diff --git a/src/main/services/team/TeamMessageFeedService.ts b/src/main/services/team/TeamMessageFeedService.ts index d25cb780..fd78b910 100644 --- a/src/main/services/team/TeamMessageFeedService.ts +++ b/src/main/services/team/TeamMessageFeedService.ts @@ -449,6 +449,14 @@ export class TeamMessageFeedService { messages: cached.messages, }; } + if (cached && !cacheDirty && cacheExpired) { + this.refreshCleanExpiredCacheInBackground(teamName, cached, now); + return { + teamName, + feedRevision: cached.feedRevision, + messages: cached.messages, + }; + } const existingRequest = this.inFlightByTeam.get(teamName); const generationAtStart = this.getGeneration(teamName); @@ -479,6 +487,43 @@ export class TeamMessageFeedService { return this.generationByTeam.get(teamName) ?? 0; } + private refreshCleanExpiredCacheInBackground( + teamName: string, + cached: TeamMessageFeedCacheEntry, + now: number + ): void { + const generationAtStart = this.getGeneration(teamName); + const existingRequest = this.inFlightByTeam.get(teamName); + if (existingRequest?.generationAtStart === generationAtStart) { + return; + } + + const request = this.buildFeed(teamName, cached, now, false, true, generationAtStart).catch( + (error) => { + logger.debug( + `[${teamName}] background message feed refresh failed: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return { + teamName, + feedRevision: cached.feedRevision, + messages: cached.messages, + }; + } + ); + + const trackedRequest = request.finally(() => { + if (this.inFlightByTeam.get(teamName)?.promise === trackedRequest) { + this.inFlightByTeam.delete(teamName); + } + }); + this.inFlightByTeam.set(teamName, { + promise: trackedRequest, + generationAtStart, + }); + } + private async buildFeed( teamName: string, cached: TeamMessageFeedCacheEntry | undefined, diff --git a/test/main/services/team/TeamMessageFeedService.test.ts b/test/main/services/team/TeamMessageFeedService.test.ts index 0cbfb048..4a3f8a6e 100644 --- a/test/main/services/team/TeamMessageFeedService.test.ts +++ b/test/main/services/team/TeamMessageFeedService.test.ts @@ -167,9 +167,12 @@ Messages: ]); }); - it('refreshes the durable feed after cache expiry even when the dirty signal was missed', async () => { - let inboxMessages: InboxMessage[] = [makeMessage()]; - const getInboxMessages = vi.fn(async () => inboxMessages); + it('returns clean expired cache immediately and refreshes durable feed in the background', async () => { + const refreshRequest = createDeferred(); + const getInboxMessages = vi + .fn() + .mockResolvedValueOnce([makeMessage()]) + .mockImplementationOnce(() => refreshRequest.promise); const service = new TeamMessageFeedService({ getConfig: vi.fn(async () => config), getInboxMessages, @@ -179,7 +182,7 @@ Messages: await service.getFeed('signal-ops-4'); - inboxMessages = [ + const refreshedMessages = [ makeMessage({ from: 'jack', to: 'user', @@ -192,6 +195,15 @@ Messages: vi.setSystemTime(new Date('2026-04-19T18:46:46.500Z')); + const stale = await service.getFeed('signal-ops-4'); + expect(getInboxMessages).toHaveBeenCalledTimes(2); + expect(stale.messages).toHaveLength(1); + + refreshRequest.resolve(refreshedMessages); + await refreshRequest.promise; + await Promise.resolve(); + await Promise.resolve(); + const refreshed = await service.getFeed('signal-ops-4'); expect(getInboxMessages).toHaveBeenCalledTimes(2); expect(