perf(main): reuse team task snapshots for UI reads
This commit is contained in:
parent
cb60fca258
commit
dc3001f713
3 changed files with 141 additions and 16 deletions
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue