From 6eca64a8f778d6843669963ec85ecedab43b209a Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 30 Aug 2026 22:16:14 +0200 Subject: [PATCH] fix(user): stop invalidating user cache on profile reads (#2142) --- .../infrastructure/UserCacheService.test.ts | 15 +++++++ .../api/infrastructure/UserCacheService.ts | 6 +++ fluxer_api/src/api/user/UserCacheHelpers.ts | 8 ++-- .../services/UserAccountRequestService.ts | 16 +++---- .../UserProfileCacheInvalidation.test.ts | 44 +++++++++++++++++++ 5 files changed, 76 insertions(+), 13 deletions(-) create mode 100644 fluxer_api/src/api/user/tests/UserProfileCacheInvalidation.test.ts diff --git a/fluxer_api/src/api/infrastructure/UserCacheService.test.ts b/fluxer_api/src/api/infrastructure/UserCacheService.test.ts index 498bcc249..23f435dab 100644 --- a/fluxer_api/src/api/infrastructure/UserCacheService.test.ts +++ b/fluxer_api/src/api/infrastructure/UserCacheService.test.ts @@ -136,6 +136,21 @@ describe('UserCacheService', () => { expect(usersServiceClient.requests).toEqual([[userId]]); }); + it('seeds only the request cache when mapping a user on a read path', () => { + const userId = createUserID(4501n); + const usersServiceClient = new FakeUsersServiceClient(); + const service = new UserCacheService(usersServiceClient); + const user = createUser(userId, 'ReadUser'); + const requestCache = createRequestCache(); + + const partial = service.setUserPartialResponseFromUserInRequestCache(user, requestCache); + + expect(partial.username).toBe('ReadUser'); + expect(requestCache.userPartials.get(userId)).toBe(partial); + expect(usersServiceClient.invalidated).toEqual([]); + expect(usersServiceClient.requests).toEqual([]); + }); + it('invalidates the users service cache when seeding a partial from a user update', async () => { const userId = createUserID(4001n); const usersServiceClient = new FakeUsersServiceClient(); diff --git a/fluxer_api/src/api/infrastructure/UserCacheService.ts b/fluxer_api/src/api/infrastructure/UserCacheService.ts index 6740525cc..68247023f 100644 --- a/fluxer_api/src/api/infrastructure/UserCacheService.ts +++ b/fluxer_api/src/api/infrastructure/UserCacheService.ts @@ -94,6 +94,12 @@ export class UserCacheService { return response; } + setUserPartialResponseFromUserInRequestCache(user: User, requestCache: RequestCache): UserPartialResponse { + const response = mapUserToPartialResponse(user); + requestCache.userPartials.set(user.id, response); + return response; + } + private createDeletedUserPartialFallback(userId: UserID): UserPartialResponse { return { id: userId.toString(), diff --git a/fluxer_api/src/api/user/UserCacheHelpers.ts b/fluxer_api/src/api/user/UserCacheHelpers.ts index b3f06bff2..93ec2c0ff 100644 --- a/fluxer_api/src/api/user/UserCacheHelpers.ts +++ b/fluxer_api/src/api/user/UserCacheHelpers.ts @@ -24,17 +24,17 @@ export async function getCachedUserPartialResponses(params: { return await userCacheService.getUserPartialResponses(userIds, requestCache); } -export async function mapUserToPartialResponseWithCache(params: { +export function mapUserToPartialResponseWithCache(params: { user: User; - userCacheService: Pick; + userCacheService: Pick; requestCache: RequestCache; -}): Promise { +}): UserPartialResponse { const {user, userCacheService, requestCache} = params; const cached = requestCache.userPartials.get(user.id); if (cached) { return cached; } - return userCacheService.setUserPartialResponseFromUserInBackground(user, requestCache); + return userCacheService.setUserPartialResponseFromUserInRequestCache(user, requestCache); } export async function invalidateUserCache(params: { diff --git a/fluxer_api/src/api/user/services/UserAccountRequestService.ts b/fluxer_api/src/api/user/services/UserAccountRequestService.ts index b3655dfb8..d2fabb64a 100644 --- a/fluxer_api/src/api/user/services/UserAccountRequestService.ts +++ b/fluxer_api/src/api/user/services/UserAccountRequestService.ts @@ -479,19 +479,17 @@ export class UserAccountRequestService { const guildMemberProfile = mapGuildMemberToProfileResponse(profile.guildMemberDomain ?? null, {restrictProfile}); const timezoneOffset = profile.timezoneVisible ? getCurrentTimeZoneOffsetMinutes(profileUser.timezone) : null; const mutualFriends = profile.mutualFriends - ? await Promise.all( - profile.mutualFriends.map((user) => - mapUserToPartialResponseWithCache({ - user, - userCacheService: this.userCacheService, - requestCache: params.requestCache, - }), - ), + ? profile.mutualFriends.map((user) => + mapUserToPartialResponseWithCache({ + user, + userCacheService: this.userCacheService, + requestCache: params.requestCache, + }), ) : undefined; const connectedAccounts = profile.connections ? this.mapConnectionsToResponse(profile.connections) : undefined; return { - user: await mapUserToPartialResponseWithCache({ + user: mapUserToPartialResponseWithCache({ user: profileUser, userCacheService: this.userCacheService, requestCache: params.requestCache, diff --git a/fluxer_api/src/api/user/tests/UserProfileCacheInvalidation.test.ts b/fluxer_api/src/api/user/tests/UserProfileCacheInvalidation.test.ts new file mode 100644 index 000000000..2410b9f7d --- /dev/null +++ b/fluxer_api/src/api/user/tests/UserProfileCacheInvalidation.test.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {UserProfileFullResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; +import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; +import {createTestAccount} from '../../auth/tests/AuthTestUtils'; +import {createFriendship} from '../../channel/tests/ChannelTestUtils'; +import {UserCacheService} from '../../infrastructure/UserCacheService'; +import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; +import {createBuilder} from '../../test/TestRequestBuilder'; + +describe('User Profile Cache Invalidation', () => { + let harness: ApiTestHarness; + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + beforeEach(async () => { + await harness.reset(); + }); + afterAll(async () => { + await harness?.shutdown(); + }); + + it('resolves the profile subject and mutual friends without invalidating the users service cache', async () => { + const viewerAccount = await createTestAccount(harness); + const targetAccount = await createTestAccount(harness); + const mutualAccount = await createTestAccount(harness); + await createFriendship(harness, viewerAccount, targetAccount); + await createFriendship(harness, viewerAccount, mutualAccount); + await createFriendship(harness, targetAccount, mutualAccount); + const invalidateUserCache = vi.spyOn(UserCacheService.prototype, 'invalidateUserCache'); + + try { + const profile = await createBuilder(harness, viewerAccount.token) + .get(`/users/${targetAccount.userId}/profile?with_mutual_friends=true`) + .execute(); + + expect(profile.user.id).toBe(targetAccount.userId); + expect(profile.mutual_friends?.map((friend) => friend.id)).toEqual([mutualAccount.userId]); + expect(invalidateUserCache).not.toHaveBeenCalled(); + } finally { + invalidateUserCache.mockRestore(); + } + }); +});