From 4457c6f61b75c666615326a83a4f6f60610ffcfc Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 31 May 2026 09:44:12 +0300 Subject: [PATCH] perf(main): cache team inbox reads --- src/main/services/team/TeamInboxReader.ts | 88 +++++++++++++++++- .../services/team/TeamInboxReader.test.ts | 91 +++++++++++++++++++ 2 files changed, 178 insertions(+), 1 deletion(-) diff --git a/src/main/services/team/TeamInboxReader.ts b/src/main/services/team/TeamInboxReader.ts index 032b6d47..3326e318 100644 --- a/src/main/services/team/TeamInboxReader.ts +++ b/src/main/services/team/TeamInboxReader.ts @@ -9,6 +9,44 @@ import type { InboxMessage } from '@shared/types'; const MAX_INBOX_FILE_BYTES = 10 * 1024 * 1024; // 10MB — skip corrupt/oversized inbox files const INBOX_READ_CONCURRENCY = process.platform === 'win32' ? 4 : 12; +const INBOX_FILE_CACHE_MAX_ENTRIES = 1_024; + +interface InboxFileSignature { + size: number; + mtimeMs: number; + ctimeMs: number; + dev: number; + ino: number; +} + +interface CachedInboxFile { + signature: InboxFileSignature; + messages: InboxMessage[]; +} + +function buildInboxFileSignature(stat: fs.Stats): InboxFileSignature { + return { + size: stat.size, + mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs, + dev: stat.dev, + ino: stat.ino, + }; +} + +function inboxFileSignaturesEqual(a: InboxFileSignature, b: InboxFileSignature): boolean { + return ( + a.size === b.size && + a.mtimeMs === b.mtimeMs && + a.ctimeMs === b.ctimeMs && + a.dev === b.dev && + a.ino === b.ino + ); +} + +function cloneInboxMessages(messages: readonly InboxMessage[]): InboxMessage[] { + return structuredClone([...messages]); +} async function mapLimit( items: readonly T[], @@ -30,6 +68,43 @@ async function mapLimit( } export class TeamInboxReader { + private readonly inboxFileCache = new Map(); + + private getCachedMessages( + inboxPath: string, + signature: InboxFileSignature + ): InboxMessage[] | undefined { + const cached = this.inboxFileCache.get(inboxPath); + if (!cached) { + return undefined; + } + if (!inboxFileSignaturesEqual(cached.signature, signature)) { + this.inboxFileCache.delete(inboxPath); + return undefined; + } + return cloneInboxMessages(cached.messages); + } + + private setCachedMessages( + inboxPath: string, + signature: InboxFileSignature, + messages: readonly InboxMessage[] + ): void { + if ( + !this.inboxFileCache.has(inboxPath) && + this.inboxFileCache.size >= INBOX_FILE_CACHE_MAX_ENTRIES + ) { + const oldestKey = this.inboxFileCache.keys().next().value; + if (oldestKey) { + this.inboxFileCache.delete(oldestKey); + } + } + this.inboxFileCache.set(inboxPath, { + signature, + messages: cloneInboxMessages(messages), + }); + } + async listInboxNames(teamName: string): Promise { const inboxDir = path.join(getTeamsBasePath(), teamName, 'inboxes'); @@ -53,15 +128,23 @@ export class TeamInboxReader { const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${member}.json`); let raw: string; + let signature: InboxFileSignature; try { const stat = await fs.promises.stat(inboxPath); // Avoid hangs on non-regular files (FIFO, sockets) and unbounded memory usage on huge files. if (!stat.isFile() || stat.size > MAX_INBOX_FILE_BYTES) { + this.inboxFileCache.delete(inboxPath); return []; } + signature = buildInboxFileSignature(stat); + const cached = this.getCachedMessages(inboxPath, signature); + if (cached) { + return cached; + } raw = await readFileUtf8WithTimeout(inboxPath, 5_000); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + this.inboxFileCache.delete(inboxPath); return []; } if (error instanceof FileReadTimeoutError) { @@ -74,9 +157,11 @@ export class TeamInboxReader { try { parsed = JSON.parse(raw) as unknown; } catch { + this.setCachedMessages(inboxPath, signature, []); return []; } if (!Array.isArray(parsed)) { + this.setCachedMessages(inboxPath, signature, []); return []; } @@ -199,7 +284,8 @@ export class TeamInboxReader { return bt - at; }); - return messages; + this.setCachedMessages(inboxPath, signature, messages); + return cloneInboxMessages(messages); } async getMessages(teamName: string): Promise { diff --git a/test/main/services/team/TeamInboxReader.test.ts b/test/main/services/team/TeamInboxReader.test.ts index 063fa4c4..40f8e481 100644 --- a/test/main/services/team/TeamInboxReader.test.ts +++ b/test/main/services/team/TeamInboxReader.test.ts @@ -14,9 +14,14 @@ const hoisted = vi.hoisted(() => { error.code = 'ENOENT'; throw error; } + const signatureValue = Array.from(data).reduce((sum, char) => sum + char.charCodeAt(0), 0); return { isFile: () => true, size: Buffer.byteLength(data, 'utf8'), + mtimeMs: signatureValue, + ctimeMs: signatureValue, + dev: 1, + ino: signatureValue, }; }); @@ -69,6 +74,7 @@ describe('TeamInboxReader', () => { beforeEach(() => { hoisted.files.clear(); hoisted.dirs.clear(); + hoisted.stat.mockClear(); hoisted.readdir.mockClear(); hoisted.readFile.mockClear(); }); @@ -119,6 +125,91 @@ describe('TeamInboxReader', () => { expect(merged[1].text).toBe('older'); }); + it('caches getMessagesFor results while the inbox file signature is unchanged', async () => { + hoisted.files.set( + '/mock/teams/my-team/inboxes/alice.json', + JSON.stringify([ + { + from: 'alice', + text: 'cached', + timestamp: '2026-01-01T00:00:00.000Z', + read: false, + messageId: 'm-1', + }, + ]) + ); + + const first = await reader.getMessagesFor('my-team', 'alice'); + first[0]!.to = 'mutated'; + const second = await reader.getMessagesFor('my-team', 'alice'); + + expect(hoisted.stat).toHaveBeenCalledTimes(2); + expect(hoisted.readFile).toHaveBeenCalledTimes(1); + expect(second).toEqual([ + expect.objectContaining({ + messageId: 'm-1', + text: 'cached', + to: undefined, + }), + ]); + }); + + it('re-reads getMessagesFor results when the inbox file signature changes', async () => { + const inboxPath = '/mock/teams/my-team/inboxes/alice.json'; + hoisted.files.set( + inboxPath, + JSON.stringify([ + { + from: 'alice', + text: 'first', + timestamp: '2026-01-01T00:00:00.000Z', + read: false, + messageId: 'm-1', + }, + ]) + ); + expect((await reader.getMessagesFor('my-team', 'alice'))[0]?.text).toBe('first'); + + hoisted.files.set( + inboxPath, + JSON.stringify([ + { + from: 'alice', + text: 'second', + timestamp: '2026-01-01T00:00:01.000Z', + read: false, + messageId: 'm-2', + }, + ]) + ); + + expect((await reader.getMessagesFor('my-team', 'alice'))[0]?.text).toBe('second'); + expect(hoisted.readFile).toHaveBeenCalledTimes(2); + }); + + it('does not let getMessages recipient backfill mutate cached member inbox rows', async () => { + hoisted.dirs.set(inboxDir, ['alice.json']); + hoisted.files.set( + '/mock/teams/my-team/inboxes/alice.json', + JSON.stringify([ + { + from: 'alice', + text: 'legacy recipient', + timestamp: '2026-01-01T00:00:00.000Z', + read: false, + messageId: 'm-1', + }, + ]) + ); + + const merged = await reader.getMessages('my-team'); + const direct = await reader.getMessagesFor('my-team', 'alice'); + + expect(merged[0]?.to).toBe('alice'); + expect(direct[0]?.to).toBeUndefined(); + expect(hoisted.readFile).toHaveBeenCalledTimes(1); + }); + it('generates deterministic messageId for legacy inbox rows without messageId', async () => { hoisted.files.set( '/mock/teams/my-team/inboxes/alice.json',