From c85b0d94cec7d751c37db90f74d3b73207b7c24b Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 31 May 2026 11:11:01 +0300 Subject: [PATCH] perf(main): cache task activity file reads --- .../team/TeamTaskActivityIntervalService.ts | 122 ++++++++++++++++-- .../TeamTaskActivityIntervalService.test.ts | 62 +++++++++ 2 files changed, 174 insertions(+), 10 deletions(-) diff --git a/src/main/services/team/TeamTaskActivityIntervalService.ts b/src/main/services/team/TeamTaskActivityIntervalService.ts index e51415cd..3298f3f3 100644 --- a/src/main/services/team/TeamTaskActivityIntervalService.ts +++ b/src/main/services/team/TeamTaskActivityIntervalService.ts @@ -22,6 +22,19 @@ interface TaskDirectorySignature { key: string; } +interface TaskFileSignature { + size: number; + mtimeMs: number; + ctimeMs: number; + dev: number; + ino: number; +} + +interface CachedActivityTaskFile { + signature: TaskFileSignature; + task: MutableTeamTask | null; +} + interface ResumeMembersCacheEntry { memberKey: string; signatureKey: string; @@ -34,6 +47,7 @@ type MutableTeamTask = TeamTask & { }; const CRASH_REPAIR_GRACE_MS = 5_000; +const TASK_FILE_CACHE_MAX_ENTRIES = 8_192; const logger = createLogger('Service:TeamTaskActivityIntervalService'); function normalizeMemberName(value: string | null | undefined): string { @@ -317,24 +331,36 @@ function materializePausedReviewInterval( return true; } -function readTaskFile(filePath: string): MutableTeamTask | null { - try { - const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; - return parsed && typeof parsed === 'object' ? (parsed as MutableTeamTask) : null; - } catch { - return null; - } -} - function writeTaskFile(filePath: string, task: MutableTeamTask): void { const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; fs.writeFileSync(tempPath, JSON.stringify(task, null, 2)); fs.renameSync(tempPath, filePath); } +function buildTaskFileSignature(stat: fs.Stats): TaskFileSignature { + return { + size: stat.size, + mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs, + dev: stat.dev, + ino: stat.ino, + }; +} + +function taskFileSignaturesEqual(a: TaskFileSignature, b: TaskFileSignature): boolean { + return ( + a.size === b.size && + a.mtimeMs === b.mtimeMs && + a.ctimeMs === b.ctimeMs && + a.dev === b.dev && + a.ino === b.ino + ); +} + export class TeamTaskActivityIntervalService { private readonly resumeMembersCache = new Map(); private readonly memberActivityNoopCache = new Map(); + private readonly taskFileCache = new Map(); private getBoardStateLockPath(teamName: string): string { return `${path.join(getTeamsBasePath(), teamName, 'board-state')}.lock`; @@ -362,6 +388,81 @@ export class TeamTaskActivityIntervalService { this.resumeMembersCache.delete(teamName); } + private getCachedTaskFile( + filePath: string, + signature: TaskFileSignature + ): MutableTeamTask | null | undefined { + const cached = this.taskFileCache.get(filePath); + if (!cached) return undefined; + if (!taskFileSignaturesEqual(cached.signature, signature)) { + this.taskFileCache.delete(filePath); + return undefined; + } + return cached.task ? structuredClone(cached.task) : null; + } + + private setCachedTaskFile( + filePath: string, + signature: TaskFileSignature, + task: MutableTeamTask | null + ): void { + if ( + !this.taskFileCache.has(filePath) && + this.taskFileCache.size >= TASK_FILE_CACHE_MAX_ENTRIES + ) { + const oldestKey = this.taskFileCache.keys().next().value; + if (oldestKey) { + this.taskFileCache.delete(oldestKey); + } + } + this.taskFileCache.set(filePath, { + signature, + task: task ? structuredClone(task) : null, + }); + } + + private readTaskFile(filePath: string): MutableTeamTask | null { + let signature: TaskFileSignature; + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) { + this.taskFileCache.delete(filePath); + return null; + } + signature = buildTaskFileSignature(stat); + const cached = this.getCachedTaskFile(filePath, signature); + if (cached !== undefined) { + return cached; + } + } catch { + this.taskFileCache.delete(filePath); + return null; + } + + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; + const task = parsed && typeof parsed === 'object' ? (parsed as MutableTeamTask) : null; + this.setCachedTaskFile(filePath, signature, task); + return task; + } catch { + this.setCachedTaskFile(filePath, signature, null); + return null; + } + } + + private cacheWrittenTaskFile(filePath: string, task: MutableTeamTask): void { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) { + this.taskFileCache.delete(filePath); + return; + } + this.setCachedTaskFile(filePath, buildTaskFileSignature(stat), task); + } catch { + this.taskFileCache.delete(filePath); + } + } + private mutateTeamTasksWithLock( teamName: string, run: () => ActivityIntervalResult @@ -457,10 +558,11 @@ export class TeamTaskActivityIntervalService { for (const fileName of entries) { if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue; const filePath = path.join(tasksDir, fileName); - const task = readTaskFile(filePath); + const task = this.readTaskFile(filePath); if (!task) continue; if (!mutate(task)) continue; writeTaskFile(filePath, task); + this.cacheWrittenTaskFile(filePath, task); changedTasks += 1; } diff --git a/test/main/services/team/TeamTaskActivityIntervalService.test.ts b/test/main/services/team/TeamTaskActivityIntervalService.test.ts index c5fccfef..da36b85f 100644 --- a/test/main/services/team/TeamTaskActivityIntervalService.test.ts +++ b/test/main/services/team/TeamTaskActivityIntervalService.test.ts @@ -585,6 +585,68 @@ describe('TeamTaskActivityIntervalService', () => { expect(mutateWithLockSpy).not.toHaveBeenCalled(); }); + it('reuses cached task file reads across unchanged team-wide scans', async () => { + await writeTask('alpha', { + id: 'bob-task', + subject: 'Bob work', + owner: 'bob', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }], + historyEvents: [], + }); + await writeTask('alpha', { + id: 'alice-task', + subject: 'Alice work', + owner: 'alice', + status: 'completed', + reviewIntervals: [{ reviewer: 'bob', startedAt: '2026-05-08T10:00:00.000Z' }], + historyEvents: [], + }); + + const service = new TeamTaskActivityIntervalService(); + expect(service.pauseActiveIntervalsForTeam('alpha', '2026-05-08T10:20:00.000Z').changedTasks) + .toBe(2); + + const jsonParseSpy = vi.spyOn(JSON, 'parse'); + const secondResult = service.pauseActiveIntervalsForTeam('alpha', '2026-05-08T10:25:00.000Z'); + + expect(secondResult.changedTasks).toBe(0); + expect(jsonParseSpy).not.toHaveBeenCalled(); + }); + + it('refreshes cached task file reads when a task file changes', async () => { + await writeTask('alpha', { + id: 'bob-task', + subject: 'Bob work', + owner: 'bob', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }], + historyEvents: [], + }); + + const service = new TeamTaskActivityIntervalService(); + expect(service.pauseActiveIntervalsForTeam('alpha', '2026-05-08T10:20:00.000Z').changedTasks) + .toBe(1); + + await writeTask('alpha', { + id: 'bob-task', + subject: 'Bob work', + owner: 'bob', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-08T10:30:00.000Z' }], + historyEvents: [], + signaturePadding: 'changed-file-signature', + }); + + const result = service.pauseActiveIntervalsForTeam('alpha', '2026-05-08T10:35:00.000Z'); + const task = await readTask('alpha', 'bob-task'); + + expect(result.changedTasks).toBe(1); + expect(task.workIntervals).toEqual([ + { startedAt: '2026-05-08T10:30:00.000Z', completedAt: '2026-05-08T10:35:00.000Z' }, + ]); + }); + it('skips the task lock after an unchanged single-member resume no-op pass', async () => { await writeTask('alpha', { id: 'bob-task',