This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

semble / src / modules / atproto / infrastructure / services / CachedBlueskyProfileService.ts
7.3 kB 231 lines
1import Redis from 'ioredis'; 2import { 3 IProfileService, 4 UserProfile, 5} from 'src/modules/cards/domain/services/IProfileService'; 6import { Result, ok } from 'src/shared/core/Result'; 7import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository'; 8import { FollowTargetType } from 'src/modules/user/domain/value-objects/FollowTargetType'; 9 10export class CachedBlueskyProfileService implements IProfileService { 11 private readonly CACHE_TTL_SECONDS = 3600 * 12; // 12 hours 12 private readonly CACHE_KEY_PREFIX = 'profile:'; 13 private readonly COUNTS_CACHE_TTL_SECONDS = 900; // 15 minutes 14 private readonly COUNTS_CACHE_KEY_PREFIX = 'profile:counts:'; 15 16 constructor( 17 private readonly profileService: IProfileService, 18 private readonly redis: Redis, 19 private readonly followsRepository: IFollowsRepository, 20 ) {} 21 22 async getProfile( 23 userId: string, 24 callerId?: string, 25 ): Promise<Result<UserProfile>> { 26 const profileCacheKey = this.getCacheKey(userId); 27 const countsCacheKey = this.getCountsCacheKey(userId); 28 29 try { 30 // Fetch both profile and counts from cache in parallel 31 const [cachedProfile, cachedCounts] = await Promise.all([ 32 this.redis.get(profileCacheKey), 33 this.redis.get(countsCacheKey), 34 ]); 35 36 let profile: UserProfile; 37 let counts: { 38 followerCount: number; 39 followingCount: number; 40 followedCollectionsCount: number; 41 }; 42 43 // Handle profile cache 44 if (cachedProfile) { 45 try { 46 profile = JSON.parse(cachedProfile) as UserProfile; 47 } catch (parseError) { 48 // If JSON parsing fails, continue to fetch fresh data 49 console.warn( 50 `Failed to parse cached profile for ${userId}:`, 51 parseError, 52 ); 53 // Fall through to fetch from service 54 const result = await this.profileService.getProfile(userId, callerId); 55 if (result.isErr()) { 56 return result; 57 } 58 profile = result.value; 59 } 60 } else { 61 // Cache miss - fetch from underlying service 62 const result = await this.profileService.getProfile(userId, callerId); 63 64 if (result.isErr()) { 65 return result; 66 } 67 68 profile = result.value; 69 70 // Cache the profile (without follow status and counts) 71 try { 72 const profileToCache = { ...profile }; 73 delete profileToCache.isFollowing; 74 delete profileToCache.followerCount; 75 delete profileToCache.followingCount; 76 delete profileToCache.followedCollectionsCount; 77 await this.redis.setex( 78 profileCacheKey, 79 this.CACHE_TTL_SECONDS, 80 JSON.stringify(profileToCache), 81 ); 82 } catch (cacheError) { 83 // Log cache error but don't fail the request 84 console.warn(`Failed to cache profile for ${userId}:`, cacheError); 85 } 86 } 87 88 // Handle counts cache 89 if (cachedCounts) { 90 try { 91 counts = JSON.parse(cachedCounts) as { 92 followerCount: number; 93 followingCount: number; 94 followedCollectionsCount: number; 95 }; 96 } catch (parseError) { 97 console.warn( 98 `Failed to parse cached counts for ${userId}:`, 99 parseError, 100 ); 101 // Use counts from profile or defaults 102 counts = { 103 followerCount: profile.followerCount ?? 0, 104 followingCount: profile.followingCount ?? 0, 105 followedCollectionsCount: profile.followedCollectionsCount ?? 0, 106 }; 107 } 108 } else { 109 // Counts cache miss - use from profile or fetch fresh 110 if ( 111 profile.followerCount !== undefined && 112 profile.followingCount !== undefined && 113 profile.followedCollectionsCount !== undefined 114 ) { 115 counts = { 116 followerCount: profile.followerCount, 117 followingCount: profile.followingCount, 118 followedCollectionsCount: profile.followedCollectionsCount, 119 }; 120 } else { 121 // If profile doesn't have counts, fetch fresh 122 const result = await this.profileService.getProfile(userId, callerId); 123 if (result.isOk()) { 124 counts = { 125 followerCount: result.value.followerCount ?? 0, 126 followingCount: result.value.followingCount ?? 0, 127 followedCollectionsCount: 128 result.value.followedCollectionsCount ?? 0, 129 }; 130 } else { 131 counts = { 132 followerCount: 0, 133 followingCount: 0, 134 followedCollectionsCount: 0, 135 }; 136 } 137 } 138 139 // Cache the counts 140 try { 141 await this.redis.setex( 142 countsCacheKey, 143 this.COUNTS_CACHE_TTL_SECONDS, 144 JSON.stringify(counts), 145 ); 146 } catch (cacheError) { 147 console.warn(`Failed to cache counts for ${userId}:`, cacheError); 148 } 149 } 150 151 // Add follow status if callerId is provided 152 let isFollowing: boolean | undefined = undefined; 153 let followsYou: boolean | undefined = undefined; 154 if (callerId && callerId !== userId) { 155 const followResult = 156 await this.followsRepository.findByFollowerAndTarget( 157 callerId, 158 userId, 159 FollowTargetType.USER, 160 ); 161 162 if (followResult.isOk()) { 163 isFollowing = followResult.value !== null; 164 } 165 166 // Check if the profile user follows the caller 167 const followsYouResult = 168 await this.followsRepository.findByFollowerAndTarget( 169 userId, 170 callerId, 171 FollowTargetType.USER, 172 ); 173 174 if (followsYouResult.isOk()) { 175 followsYou = followsYouResult.value !== null; 176 } 177 } 178 179 return ok({ 180 ...profile, 181 isFollowing, 182 followsYou, 183 ...counts, 184 }); 185 } catch (redisError) { 186 // If Redis is down, fall back to direct service call 187 console.warn( 188 `Redis error when fetching profile for ${userId}:`, 189 redisError, 190 ); 191 return this.profileService.getProfile(userId, callerId); 192 } 193 } 194 195 private getCacheKey(userId: string): string { 196 return `${this.CACHE_KEY_PREFIX}${userId}`; 197 } 198 199 private getCountsCacheKey(userId: string): string { 200 return `${this.COUNTS_CACHE_KEY_PREFIX}${userId}`; 201 } 202 203 /** 204 * Invalidate cached profile for a specific user 205 */ 206 async invalidateProfile(userId: string): Promise<void> { 207 try { 208 await this.redis.del(this.getCacheKey(userId)); 209 } catch (error) { 210 console.warn(`Failed to invalidate profile cache for ${userId}:`, error); 211 } 212 } 213 214 /** 215 * Invalidate cached counts for a specific user 216 */ 217 async invalidateCounts(userId: string): Promise<void> { 218 try { 219 await this.redis.del(this.getCountsCacheKey(userId)); 220 } catch (error) { 221 console.warn(`Failed to invalidate counts cache for ${userId}:`, error); 222 } 223 } 224 225 /** 226 * Warm the cache by pre-fetching a profile 227 */ 228 async warmCache(userId: string, callerId?: string): Promise<void> { 229 await this.getProfile(userId, callerId); 230 } 231}