From 0a0d60ef252a59a91c8f622aa217d1aea432f47a Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 3 Mar 2026 17:33:27 -0800 Subject: [PATCH] remove profile following cache and fetch directly on get profile use case --- .../services/BlueskyProfileService.ts | 69 +----------- .../services/CachedBlueskyProfileService.ts | 104 +----------------- .../useCases/queries/GetProfileUseCase.ts | 27 ++++- .../cards/domain/services/IProfileService.ts | 1 - .../cards/tests/utils/FakeProfileService.ts | 5 - .../useCases/commands/FollowTargetUseCase.ts | 12 +- .../commands/UnfollowTargetUseCase.ts | 12 +- .../domain/repositories/IFollowsRepository.ts | 18 +++ .../repositories/DrizzleFollowsRepository.ts | 32 ++++++ .../InMemoryFollowsRepository.ts | 48 ++++++++ .../http/factories/ServiceFactory.ts | 5 +- .../http/factories/UseCaseFactory.ts | 1 + 12 files changed, 135 insertions(+), 199 deletions(-) diff --git a/src/modules/atproto/infrastructure/services/BlueskyProfileService.ts b/src/modules/atproto/infrastructure/services/BlueskyProfileService.ts index 845c27ca..e13b8e76 100644 --- a/src/modules/atproto/infrastructure/services/BlueskyProfileService.ts +++ b/src/modules/atproto/infrastructure/services/BlueskyProfileService.ts @@ -6,14 +6,9 @@ import { Result, ok, err } from 'src/shared/core/Result'; import { IAgentService } from '../../application/IAgentService'; import { DID } from '../../domain/DID'; import { AuthenticationError } from 'src/shared/core/AuthenticationError'; -import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository'; -import { FollowTargetType } from 'src/modules/user/domain/value-objects/FollowTargetType'; export class BlueskyProfileService implements IProfileService { - constructor( - private readonly agentService: IAgentService, - private readonly followsRepository: IFollowsRepository, - ) {} + constructor(private readonly agentService: IAgentService) {} async getProfile( userId: string, @@ -82,67 +77,7 @@ export class BlueskyProfileService implements IProfileService { bio: profile.description, }; - // Fetch follower/following counts in parallel - const [ - followerCountResult, - followingCountResult, - collectionsCountResult, - ] = await Promise.all([ - this.followsRepository.getFollowersCount(userId, FollowTargetType.USER), - this.followsRepository.getFollowingCount(userId, FollowTargetType.USER), - this.followsRepository.getFollowingCount( - userId, - FollowTargetType.COLLECTION, - ), - ]); - - // Add counts to profile (default to 0 on error) - const followerCount = followerCountResult.isOk() - ? followerCountResult.value - : 0; - const followingCount = followingCountResult.isOk() - ? followingCountResult.value - : 0; - const followedCollectionsCount = collectionsCountResult.isOk() - ? collectionsCountResult.value - : 0; - - // Add follow status if callerId is provided - let isFollowing: boolean | undefined = undefined; - let followsYou: boolean | undefined = undefined; - if (callerDid && callerDid !== userId) { - const followResult = - await this.followsRepository.findByFollowerAndTarget( - callerDid, - userId, - FollowTargetType.USER, - ); - - if (followResult.isOk()) { - isFollowing = followResult.value !== null; - } - - // Check if the profile user follows the caller - const followsYouResult = - await this.followsRepository.findByFollowerAndTarget( - userId, - callerDid, - FollowTargetType.USER, - ); - - if (followsYouResult.isOk()) { - followsYou = followsYouResult.value !== null; - } - } - - return ok({ - ...userProfile, - isFollowing, - followsYou, - followerCount, - followingCount, - followedCollectionsCount, - }); + return ok(userProfile); } catch (error) { return err( new Error( diff --git a/src/modules/atproto/infrastructure/services/CachedBlueskyProfileService.ts b/src/modules/atproto/infrastructure/services/CachedBlueskyProfileService.ts index dfc8e91d..a02e085e 100644 --- a/src/modules/atproto/infrastructure/services/CachedBlueskyProfileService.ts +++ b/src/modules/atproto/infrastructure/services/CachedBlueskyProfileService.ts @@ -10,8 +10,6 @@ import { FollowTargetType } from 'src/modules/user/domain/value-objects/FollowTa export class CachedBlueskyProfileService implements IProfileService { private readonly CACHE_TTL_SECONDS = 3600 * 12; // 12 hours private readonly CACHE_KEY_PREFIX = 'profile:'; - private readonly COUNTS_CACHE_TTL_SECONDS = 900; // 15 minutes - private readonly COUNTS_CACHE_KEY_PREFIX = 'profile:counts:'; constructor( private readonly profileService: IProfileService, @@ -24,33 +22,22 @@ export class CachedBlueskyProfileService implements IProfileService { callerId?: string, ): Promise> { const profileCacheKey = this.getCacheKey(userId); - const countsCacheKey = this.getCountsCacheKey(userId); try { - // Fetch both profile and counts from cache in parallel - const [cachedProfile, cachedCounts] = await Promise.all([ - this.redis.get(profileCacheKey), - this.redis.get(countsCacheKey), - ]); + // Try to get profile from cache + const cachedProfile = await this.redis.get(profileCacheKey); let profile: UserProfile; - let counts: { - followerCount: number; - followingCount: number; - followedCollectionsCount: number; - }; - // Handle profile cache if (cachedProfile) { try { profile = JSON.parse(cachedProfile) as UserProfile; } catch (parseError) { - // If JSON parsing fails, continue to fetch fresh data + // If JSON parsing fails, fetch fresh data console.warn( `Failed to parse cached profile for ${userId}:`, parseError, ); - // Fall through to fetch from service const result = await this.profileService.getProfile(userId, callerId); if (result.isErr()) { return result; @@ -67,13 +54,11 @@ export class CachedBlueskyProfileService implements IProfileService { profile = result.value; - // Cache the profile (without follow status and counts) + // Cache the profile (without follow status) try { const profileToCache = { ...profile }; delete profileToCache.isFollowing; - delete profileToCache.followerCount; - delete profileToCache.followingCount; - delete profileToCache.followedCollectionsCount; + delete profileToCache.followsYou; await this.redis.setex( profileCacheKey, this.CACHE_TTL_SECONDS, @@ -85,69 +70,6 @@ export class CachedBlueskyProfileService implements IProfileService { } } - // Handle counts cache - if (cachedCounts) { - try { - counts = JSON.parse(cachedCounts) as { - followerCount: number; - followingCount: number; - followedCollectionsCount: number; - }; - } catch (parseError) { - console.warn( - `Failed to parse cached counts for ${userId}:`, - parseError, - ); - // Use counts from profile or defaults - counts = { - followerCount: profile.followerCount ?? 0, - followingCount: profile.followingCount ?? 0, - followedCollectionsCount: profile.followedCollectionsCount ?? 0, - }; - } - } else { - // Counts cache miss - use from profile or fetch fresh - if ( - profile.followerCount !== undefined && - profile.followingCount !== undefined && - profile.followedCollectionsCount !== undefined - ) { - counts = { - followerCount: profile.followerCount, - followingCount: profile.followingCount, - followedCollectionsCount: profile.followedCollectionsCount, - }; - } else { - // If profile doesn't have counts, fetch fresh - const result = await this.profileService.getProfile(userId, callerId); - if (result.isOk()) { - counts = { - followerCount: result.value.followerCount ?? 0, - followingCount: result.value.followingCount ?? 0, - followedCollectionsCount: - result.value.followedCollectionsCount ?? 0, - }; - } else { - counts = { - followerCount: 0, - followingCount: 0, - followedCollectionsCount: 0, - }; - } - } - - // Cache the counts - try { - await this.redis.setex( - countsCacheKey, - this.COUNTS_CACHE_TTL_SECONDS, - JSON.stringify(counts), - ); - } catch (cacheError) { - console.warn(`Failed to cache counts for ${userId}:`, cacheError); - } - } - // Add follow status if callerId is provided let isFollowing: boolean | undefined = undefined; let followsYou: boolean | undefined = undefined; @@ -180,7 +102,6 @@ export class CachedBlueskyProfileService implements IProfileService { ...profile, isFollowing, followsYou, - ...counts, }); } catch (redisError) { // If Redis is down, fall back to direct service call @@ -196,10 +117,6 @@ export class CachedBlueskyProfileService implements IProfileService { return `${this.CACHE_KEY_PREFIX}${userId}`; } - private getCountsCacheKey(userId: string): string { - return `${this.COUNTS_CACHE_KEY_PREFIX}${userId}`; - } - /** * Invalidate cached profile for a specific user */ @@ -211,17 +128,6 @@ export class CachedBlueskyProfileService implements IProfileService { } } - /** - * Invalidate cached counts for a specific user - */ - async invalidateCounts(userId: string): Promise { - try { - await this.redis.del(this.getCountsCacheKey(userId)); - } catch (error) { - console.warn(`Failed to invalidate counts cache for ${userId}:`, error); - } - } - /** * Warm the cache by pre-fetching a profile */ diff --git a/src/modules/cards/application/useCases/queries/GetProfileUseCase.ts b/src/modules/cards/application/useCases/queries/GetProfileUseCase.ts index 69639367..82275b36 100644 --- a/src/modules/cards/application/useCases/queries/GetProfileUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetProfileUseCase.ts @@ -4,6 +4,7 @@ import { err, ok, Result } from 'src/shared/core/Result'; import { DIDOrHandle } from 'src/modules/atproto/domain/DIDOrHandle'; import { IIdentityResolutionService } from 'src/modules/atproto/domain/services/IIdentityResolutionService'; import { ProfileMapper } from '../../mappers/ProfileMapper'; +import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository'; export interface GetMyProfileQuery { userId: string; @@ -32,6 +33,7 @@ export class GetProfileUseCase constructor( private profileService: IProfileService, private identityResolver: IIdentityResolutionService, + private followsRepository: IFollowsRepository, ) {} async execute(query: GetMyProfileQuery): Promise> { @@ -87,8 +89,31 @@ export class GetProfileUseCase const profile = profileResult.value; + // Fetch follow counts from the follows repository + const countsResult = await this.followsRepository.getProfileFollowCounts( + didResult.value.value, + ); + + if (countsResult.isErr()) { + return err( + new Error( + `Failed to fetch follow counts: ${countsResult.error instanceof Error ? countsResult.error.message : 'Unknown error'}`, + ), + ); + } + + const counts = countsResult.value; + + // Merge counts into profile + const profileWithCounts = { + ...profile, + followerCount: counts.followerCount, + followingCount: counts.followingCount, + followedCollectionsCount: counts.followedCollectionsCount, + }; + // Map profile using ProfileMapper - return ok(ProfileMapper.toUser(profile)); + return ok(ProfileMapper.toUser(profileWithCounts)); } catch (error) { return err( new Error( diff --git a/src/modules/cards/domain/services/IProfileService.ts b/src/modules/cards/domain/services/IProfileService.ts index fe845e62..f06006e3 100644 --- a/src/modules/cards/domain/services/IProfileService.ts +++ b/src/modules/cards/domain/services/IProfileService.ts @@ -2,7 +2,6 @@ import { Result } from 'src/shared/core/Result'; export interface IProfileService { getProfile(userId: string, callerId?: string): Promise>; - invalidateCounts?(userId: string): Promise; } export interface UserProfile { diff --git a/src/modules/cards/tests/utils/FakeProfileService.ts b/src/modules/cards/tests/utils/FakeProfileService.ts index be1a29d1..76d05237 100644 --- a/src/modules/cards/tests/utils/FakeProfileService.ts +++ b/src/modules/cards/tests/utils/FakeProfileService.ts @@ -21,11 +21,6 @@ export class FakeProfileService implements IProfileService { return ok(profile); } - async invalidateCounts(userId: string): Promise { - // No-op for fake implementation - // In real implementation, this would invalidate the cache - } - // Test helper methods addProfile(profile: UserProfile): void { this.profiles.set(profile.id, profile); diff --git a/src/modules/user/application/useCases/commands/FollowTargetUseCase.ts b/src/modules/user/application/useCases/commands/FollowTargetUseCase.ts index f175758c..7a7422a4 100644 --- a/src/modules/user/application/useCases/commands/FollowTargetUseCase.ts +++ b/src/modules/user/application/useCases/commands/FollowTargetUseCase.ts @@ -231,17 +231,7 @@ export class FollowTargetUseCase extends BaseUseCase< // Don't fail the operation } - // 12. Invalidate profile counts cache - // Invalidate follower's counts (followingCount increased) - if (this.profileService.invalidateCounts) { - await this.profileService.invalidateCounts(request.followerId); - } - // Invalidate target's counts (followerCount increased, only for USER targets) - if (targetType.value === 'USER' && this.profileService.invalidateCounts) { - await this.profileService.invalidateCounts(request.targetId); - } - - // 13. Return success + // 12. Return success return ok({ followId: follow.followId.toString(), }); diff --git a/src/modules/user/application/useCases/commands/UnfollowTargetUseCase.ts b/src/modules/user/application/useCases/commands/UnfollowTargetUseCase.ts index a9440ae4..70db453b 100644 --- a/src/modules/user/application/useCases/commands/UnfollowTargetUseCase.ts +++ b/src/modules/user/application/useCases/commands/UnfollowTargetUseCase.ts @@ -122,17 +122,7 @@ export class UnfollowTargetUseCase extends BaseUseCase< // Don't fail the operation } - // 9. Invalidate profile counts cache - // Invalidate follower's counts (followingCount decreased) - if (this.profileService.invalidateCounts) { - await this.profileService.invalidateCounts(request.followerId); - } - // Invalidate target's counts (followerCount decreased, only for USER targets) - if (targetType.value === 'USER' && this.profileService.invalidateCounts) { - await this.profileService.invalidateCounts(request.targetId); - } - - // 10. Return success + // 9. Return success return ok(undefined); } catch (error) { return err(AppError.UnexpectedError.create(error)); diff --git a/src/modules/user/domain/repositories/IFollowsRepository.ts b/src/modules/user/domain/repositories/IFollowsRepository.ts index a3183f18..d6726fcb 100644 --- a/src/modules/user/domain/repositories/IFollowsRepository.ts +++ b/src/modules/user/domain/repositories/IFollowsRepository.ts @@ -146,4 +146,22 @@ export interface IFollowsRepository { targetIds: string[], targetType: FollowTargetType, ): Promise>>; + + /** + * Get all follow counts for a user profile in a single query. + * + * @param userId - DID of the user + * @returns Object with followerCount, followingCount, and followedCollectionsCount + * + * Example: + * getProfileFollowCounts('did:plc:alice123') + * → { followerCount: 150, followingCount: 45, followedCollectionsCount: 12 } + */ + getProfileFollowCounts(userId: string): Promise< + Result<{ + followerCount: number; + followingCount: number; + followedCollectionsCount: number; + }> + >; } diff --git a/src/modules/user/infrastructure/repositories/DrizzleFollowsRepository.ts b/src/modules/user/infrastructure/repositories/DrizzleFollowsRepository.ts index a3a84e19..7a8a4239 100644 --- a/src/modules/user/infrastructure/repositories/DrizzleFollowsRepository.ts +++ b/src/modules/user/infrastructure/repositories/DrizzleFollowsRepository.ts @@ -452,4 +452,36 @@ export class DrizzleFollowsRepository implements IFollowsRepository { return err(error); } } + + async getProfileFollowCounts(userId: string): Promise< + Result<{ + followerCount: number; + followingCount: number; + followedCollectionsCount: number; + }> + > { + try { + // Get all three counts in a single query using subqueries + const result = await this.db.execute(sql` + SELECT + (SELECT COUNT(*)::int FROM ${follows} WHERE ${follows.targetId} = ${userId} AND ${follows.targetType} = 'USER') as follower_count, + (SELECT COUNT(*)::int FROM ${follows} WHERE ${follows.followerId} = ${userId} AND ${follows.targetType} = 'USER') as following_count, + (SELECT COUNT(*)::int FROM ${follows} WHERE ${follows.followerId} = ${userId} AND ${follows.targetType} = 'COLLECTION') as followed_collections_count + `); + + const row = result[0] as { + follower_count: number; + following_count: number; + followed_collections_count: number; + }; + + return ok({ + followerCount: row.follower_count ?? 0, + followingCount: row.following_count ?? 0, + followedCollectionsCount: row.followed_collections_count ?? 0, + }); + } catch (error: any) { + return err(error); + } + } } diff --git a/src/modules/user/tests/infrastructure/InMemoryFollowsRepository.ts b/src/modules/user/tests/infrastructure/InMemoryFollowsRepository.ts index 25950fec..09c6b85c 100644 --- a/src/modules/user/tests/infrastructure/InMemoryFollowsRepository.ts +++ b/src/modules/user/tests/infrastructure/InMemoryFollowsRepository.ts @@ -202,6 +202,54 @@ export class InMemoryFollowsRepository implements IFollowsRepository { } } + async getProfileFollowCounts(userId: string): Promise< + Result<{ + followerCount: number; + followingCount: number; + followedCollectionsCount: number; + }> + > { + try { + let followerCount = 0; + let followingCount = 0; + let followedCollectionsCount = 0; + + for (const follow of this.follows.values()) { + // Count followers (users who follow this user) + if ( + follow.targetId === userId && + follow.targetType.value === FollowTargetTypeEnum.USER + ) { + followerCount++; + } + + // Count following (users this user follows) + if ( + follow.followerId.value === userId && + follow.targetType.value === FollowTargetTypeEnum.USER + ) { + followingCount++; + } + + // Count followed collections + if ( + follow.followerId.value === userId && + follow.targetType.value === FollowTargetTypeEnum.COLLECTION + ) { + followedCollectionsCount++; + } + } + + return ok({ + followerCount, + followingCount, + followedCollectionsCount, + }); + } catch (error: any) { + return err(error); + } + } + // Helper method for testing clear(): void { this.follows.clear(); diff --git a/src/shared/infrastructure/http/factories/ServiceFactory.ts b/src/shared/infrastructure/http/factories/ServiceFactory.ts index ed2a5a94..164cec03 100644 --- a/src/shared/infrastructure/http/factories/ServiceFactory.ts +++ b/src/shared/infrastructure/http/factories/ServiceFactory.ts @@ -319,10 +319,7 @@ export class ServiceFactory { ); // Profile Service with Redis caching - const baseProfileService = new BlueskyProfileService( - atProtoAgentService, - repositories.followsRepository, - ); + const baseProfileService = new BlueskyProfileService(atProtoAgentService); let profileService: IProfileService; diff --git a/src/shared/infrastructure/http/factories/UseCaseFactory.ts b/src/shared/infrastructure/http/factories/UseCaseFactory.ts index daf2d106..96ca161a 100644 --- a/src/shared/infrastructure/http/factories/UseCaseFactory.ts +++ b/src/shared/infrastructure/http/factories/UseCaseFactory.ts @@ -205,6 +205,7 @@ export class UseCaseFactory { getProfileUseCase: new GetProfileUseCase( services.profileService, services.identityResolutionService, + repositories.followsRepository, ), refreshAccessTokenUseCase: new RefreshAccessTokenUseCase( services.tokenService, -- 2.51.2