perf(main): reuse team task snapshots for UI reads

This commit is contained in:
777genius 2026-05-31 10:22:11 +03:00
parent cb60fca258
commit dc3001f713
3 changed files with 141 additions and 16 deletions

View file

@ -510,6 +510,15 @@ export class TeamDataService {
TeamTaskReader.invalidateAllTasksCache(); TeamTaskReader.invalidateAllTasksCache();
} }
private async readTasksForUiSnapshot(teamName: string): Promise<readonly TeamTask[]> {
const snapshotReader = this.taskReader as TeamTaskReader & {
getTasksProjectionSnapshot?: (teamName: string) => Promise<readonly TeamTask[]>;
};
return typeof snapshotReader.getTasksProjectionSnapshot === 'function'
? snapshotReader.getTasksProjectionSnapshot(teamName)
: this.taskReader.getTasks(teamName);
}
private getController(teamName: string): AgentTeamsController { private getController(teamName: string): AgentTeamsController {
return this.controllerFactory(teamName); return this.controllerFactory(teamName);
} }
@ -1013,7 +1022,7 @@ export class TeamDataService {
: null; : null;
const [tasks, kanbanState, presenceIndex] = await Promise.all([ const [tasks, kanbanState, presenceIndex] = await Promise.all([
this.taskReader.getTasks(teamName).catch(() => [] as TeamTask[]), this.readTasksForUiSnapshot(teamName).catch(() => [] as readonly TeamTask[]),
this.kanbanManager this.kanbanManager
.getState(teamName) .getState(teamName)
.catch(() => ({ teamName, reviewers: [], tasks: {} }) as KanbanState), .catch(() => ({ teamName, reviewers: [], tasks: {} }) as KanbanState),
@ -1387,7 +1396,7 @@ export class TeamDataService {
label: 'tasks', label: 'tasks',
createFallback: () => [], createFallback: () => [],
warningText: 'Tasks failed to load', warningText: 'Tasks failed to load',
load: () => this.taskReader.getTasks(teamName), load: () => this.readTasksForUiSnapshot(teamName),
}) })
); );
const [ const [
@ -1424,7 +1433,7 @@ export class TeamDataService {
if (launchStateStepResult.warning) warnings.push(launchStateStepResult.warning); if (launchStateStepResult.warning) warnings.push(launchStateStepResult.warning);
if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning); if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning);
const tasks: TeamTask[] = tasksStepResult.value; const tasks: readonly TeamTask[] = tasksStepResult.value;
const inboxNames: string[] = inboxNamesStepResult.value; const inboxNames: string[] = inboxNamesStepResult.value;
mark('postStart'); mark('postStart');

View file

@ -48,12 +48,19 @@ interface CachedTaskFile {
task: TeamTask | null; task: TeamTask | null;
} }
function cloneTasks<T>(tasks: readonly T[]): T[] { interface CachedTeamTasks {
return structuredClone([...tasks]); signaturesByFile: Map<string, TaskFileSignature>;
value: TeamTask[];
} }
function cloneTask(task: TeamTask): TeamTask { interface ScannedTaskFile {
return structuredClone(task); file: string;
taskPath: string;
signature: TaskFileSignature;
}
function cloneTasks<T>(tasks: readonly T[]): T[] {
return structuredClone([...tasks]);
} }
function buildTaskFileSignature(stat: fs.Stats): TaskFileSignature { function buildTaskFileSignature(stat: fs.Stats): TaskFileSignature {
@ -121,14 +128,16 @@ export class TeamTaskReader {
private static allTasksInFlight: InFlightAllTasks | null = null; private static allTasksInFlight: InFlightAllTasks | null = null;
private static allTasksGeneration = 0; private static allTasksGeneration = 0;
private static taskFileCache = new Map<string, CachedTaskFile>(); private static taskFileCache = new Map<string, CachedTaskFile>();
private static teamTasksCache = new Map<string, CachedTeamTasks>();
static invalidateAllTasksCache(): void { static invalidateAllTasksCache(): void {
TeamTaskReader.allTasksCache = null; TeamTaskReader.allTasksCache = null;
TeamTaskReader.taskFileCache.clear(); TeamTaskReader.taskFileCache.clear();
TeamTaskReader.teamTasksCache.clear();
TeamTaskReader.allTasksGeneration += 1; TeamTaskReader.allTasksGeneration += 1;
} }
private static getCachedTaskFile( private static getCachedTaskFileSnapshot(
taskPath: string, taskPath: string,
signature: TaskFileSignature signature: TaskFileSignature
): TeamTask | null | undefined { ): TeamTask | null | undefined {
@ -140,7 +149,7 @@ export class TeamTaskReader {
TeamTaskReader.taskFileCache.delete(taskPath); TeamTaskReader.taskFileCache.delete(taskPath);
return undefined; return undefined;
} }
return cached.task ? cloneTask(cached.task) : null; return cached.task;
} }
private static setCachedTaskFile( private static setCachedTaskFile(
@ -159,7 +168,37 @@ export class TeamTaskReader {
} }
TeamTaskReader.taskFileCache.set(taskPath, { TeamTaskReader.taskFileCache.set(taskPath, {
signature, signature,
task: task ? cloneTask(task) : null, task,
});
}
private static getCachedTeamTasks(
teamName: string,
scannedFiles: readonly ScannedTaskFile[]
): readonly TeamTask[] | null {
const cached = TeamTaskReader.teamTasksCache.get(teamName);
if (!cached || cached.signaturesByFile.size !== scannedFiles.length) {
return null;
}
for (const file of scannedFiles) {
const cachedSignature = cached.signaturesByFile.get(file.file);
if (!cachedSignature || !taskFileSignaturesEqual(cachedSignature, file.signature)) {
return null;
}
}
return cached.value;
}
private static setCachedTeamTasks(
teamName: string,
scannedFiles: readonly ScannedTaskFile[],
tasks: TeamTask[]
): void {
TeamTaskReader.teamTasksCache.set(teamName, {
signaturesByFile: new Map(scannedFiles.map((file) => [file.file, file.signature] as const)),
value: tasks,
}); });
} }
@ -193,6 +232,11 @@ export class TeamTaskReader {
} }
async getTasks(teamName: string): Promise<TeamTask[]> { async getTasks(teamName: string): Promise<TeamTask[]> {
const tasks = await this.getTasksProjectionSnapshot(teamName);
return cloneTasks(tasks);
}
async getTasksProjectionSnapshot(teamName: string): Promise<readonly TeamTask[]> {
const tasksDir = path.join(getTasksBasePath(), teamName); const tasksDir = path.join(getTasksBasePath(), teamName);
let entries: string[]; let entries: string[];
@ -205,8 +249,8 @@ export class TeamTaskReader {
throw error; throw error;
} }
const tasks: TeamTask[] = []; const scannedFiles: ScannedTaskFile[] = [];
let processed = 0; let canCacheTeamSnapshot = true;
for (const file of entries) { for (const file of entries) {
if ( if (
!file.endsWith('.json') || !file.endsWith('.json') ||
@ -223,10 +267,34 @@ export class TeamTaskReader {
if (!fileStat.isFile() || fileStat.size > MAX_TASK_FILE_BYTES) { if (!fileStat.isFile() || fileStat.size > MAX_TASK_FILE_BYTES) {
logger.debug(`Skipping suspicious task file: ${taskPath}`); logger.debug(`Skipping suspicious task file: ${taskPath}`);
TeamTaskReader.taskFileCache.delete(taskPath); TeamTaskReader.taskFileCache.delete(taskPath);
TeamTaskReader.teamTasksCache.delete(teamName);
canCacheTeamSnapshot = false;
continue; continue;
} }
const signature = buildTaskFileSignature(fileStat); scannedFiles.push({
const cachedTask = TeamTaskReader.getCachedTaskFile(taskPath, signature); file,
taskPath,
signature: buildTaskFileSignature(fileStat),
});
} catch {
TeamTaskReader.taskFileCache.delete(taskPath);
TeamTaskReader.teamTasksCache.delete(teamName);
canCacheTeamSnapshot = false;
logger.debug(`Skipping invalid task file: ${taskPath}`);
}
}
const cachedTeamTasks = TeamTaskReader.getCachedTeamTasks(teamName, scannedFiles);
if (cachedTeamTasks) {
return cachedTeamTasks;
}
const tasks: TeamTask[] = [];
let processed = 0;
for (const scannedFile of scannedFiles) {
const { taskPath, signature } = scannedFile;
try {
const cachedTask = TeamTaskReader.getCachedTaskFileSnapshot(taskPath, signature);
if (cachedTask !== undefined) { if (cachedTask !== undefined) {
if (cachedTask) { if (cachedTask) {
tasks.push(cachedTask); tasks.push(cachedTask);
@ -245,7 +313,7 @@ export class TeamTaskReader {
const createdAt = typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined; const createdAt = typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined;
let updatedAt: string | undefined; let updatedAt: string | undefined;
try { try {
updatedAt = fileStat.mtime.toISOString(); updatedAt = new Date(signature.mtimeMs).toISOString();
} catch { } catch {
/* leave undefined */ /* leave undefined */
} }
@ -453,6 +521,8 @@ export class TeamTaskReader {
tasks.push(task); tasks.push(task);
} catch { } catch {
TeamTaskReader.taskFileCache.delete(taskPath); TeamTaskReader.taskFileCache.delete(taskPath);
TeamTaskReader.teamTasksCache.delete(teamName);
canCacheTeamSnapshot = false;
logger.debug(`Skipping invalid task file: ${taskPath}`); logger.debug(`Skipping invalid task file: ${taskPath}`);
} }
processed++; processed++;
@ -478,6 +548,10 @@ export class TeamTaskReader {
return a.id.localeCompare(b.id, undefined, { numeric: true, sensitivity: 'base' }); return a.id.localeCompare(b.id, undefined, { numeric: true, sensitivity: 'base' });
}); });
if (canCacheTeamSnapshot) {
TeamTaskReader.setCachedTeamTasks(teamName, scannedFiles, tasks);
}
return tasks; return tasks;
} }
@ -667,7 +741,7 @@ export class TeamTaskReader {
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory()) continue; if (!entry.isDirectory()) continue;
try { try {
const tasks = await this.getTasks(entry.name); const tasks = await this.getTasksProjectionSnapshot(entry.name);
for (const task of tasks) { for (const task of tasks) {
result.push({ ...task, teamName: entry.name }); result.push({ ...task, teamName: entry.name });
} }

View file

@ -142,4 +142,46 @@ describe('TeamTaskReader', () => {
]); ]);
expect(readFileSpy).toHaveBeenCalledTimes(2); expect(readFileSpy).toHaveBeenCalledTimes(2);
}); });
it('reuses read-only team task projection snapshots until a file signature changes', async () => {
await setupTasksRoot();
const taskPath = await writeTaskFile('atlas-hq', {
id: '1',
subject: 'Projection cached task',
status: 'pending',
createdAt: '2026-05-02T12:00:00.000Z',
});
const readFileSpy = vi.spyOn(fs.promises, 'readFile');
const reader = new TeamTaskReader();
const firstRead = await reader.getTasksProjectionSnapshot('atlas-hq');
const secondRead = await reader.getTasksProjectionSnapshot('atlas-hq');
expect(secondRead).toBe(firstRead);
expect(secondRead).toMatchObject([{ id: '1', subject: 'Projection cached task' }]);
expect(readFileSpy).toHaveBeenCalledTimes(1);
await fsp.writeFile(
taskPath,
JSON.stringify(
{
id: '1',
subject: 'Projection changed task',
status: 'pending',
createdAt: '2026-05-02T12:00:00.000Z',
},
null,
2
),
'utf8'
);
const changedTime = new Date(Date.now() + 2_000);
await fsp.utimes(taskPath, changedTime, changedTime);
const thirdRead = await reader.getTasksProjectionSnapshot('atlas-hq');
expect(thirdRead).not.toBe(firstRead);
expect(thirdRead).toMatchObject([{ id: '1', subject: 'Projection changed task' }]);
expect(readFileSpy).toHaveBeenCalledTimes(2);
});
}); });