perf: cache unchanged team task reads
This commit is contained in:
parent
28a55416ca
commit
127d31ba88
2 changed files with 155 additions and 2 deletions
|
|
@ -23,6 +23,7 @@ import type {
|
||||||
const logger = createLogger('Service:TeamTaskReader');
|
const logger = createLogger('Service:TeamTaskReader');
|
||||||
const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024;
|
const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024;
|
||||||
const ALL_TASKS_CACHE_TTL_MS = 5_000;
|
const ALL_TASKS_CACHE_TTL_MS = 5_000;
|
||||||
|
const TASK_FILE_CACHE_MAX_ENTRIES = 8_192;
|
||||||
|
|
||||||
interface CachedAllTasks {
|
interface CachedAllTasks {
|
||||||
value: (TeamTask & { teamName: string })[];
|
value: (TeamTask & { teamName: string })[];
|
||||||
|
|
@ -34,10 +35,47 @@ interface InFlightAllTasks {
|
||||||
generationAtStart: number;
|
generationAtStart: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TaskFileSignature {
|
||||||
|
size: number;
|
||||||
|
mtimeMs: number;
|
||||||
|
ctimeMs: number;
|
||||||
|
dev: number;
|
||||||
|
ino: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedTaskFile {
|
||||||
|
signature: TaskFileSignature;
|
||||||
|
task: TeamTask | null;
|
||||||
|
}
|
||||||
|
|
||||||
function cloneTasks<T>(tasks: T[]): T[] {
|
function cloneTasks<T>(tasks: T[]): T[] {
|
||||||
return structuredClone(tasks);
|
return structuredClone(tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cloneTask(task: TeamTask): TeamTask {
|
||||||
|
return structuredClone(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalise escaped newline sequences (`\\n`) that some MCP/CLI sources
|
* Normalise escaped newline sequences (`\\n`) that some MCP/CLI sources
|
||||||
* write as literal two-character strings instead of real line-breaks.
|
* write as literal two-character strings instead of real line-breaks.
|
||||||
|
|
@ -82,12 +120,49 @@ export class TeamTaskReader {
|
||||||
private static allTasksCache: CachedAllTasks | null = null;
|
private static allTasksCache: CachedAllTasks | null = null;
|
||||||
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>();
|
||||||
|
|
||||||
static invalidateAllTasksCache(): void {
|
static invalidateAllTasksCache(): void {
|
||||||
TeamTaskReader.allTasksCache = null;
|
TeamTaskReader.allTasksCache = null;
|
||||||
|
TeamTaskReader.taskFileCache.clear();
|
||||||
TeamTaskReader.allTasksGeneration += 1;
|
TeamTaskReader.allTasksGeneration += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static getCachedTaskFile(
|
||||||
|
taskPath: string,
|
||||||
|
signature: TaskFileSignature
|
||||||
|
): TeamTask | null | undefined {
|
||||||
|
const cached = TeamTaskReader.taskFileCache.get(taskPath);
|
||||||
|
if (!cached) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!taskFileSignaturesEqual(cached.signature, signature)) {
|
||||||
|
TeamTaskReader.taskFileCache.delete(taskPath);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return cached.task ? cloneTask(cached.task) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static setCachedTaskFile(
|
||||||
|
taskPath: string,
|
||||||
|
signature: TaskFileSignature,
|
||||||
|
task: TeamTask | null
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
!TeamTaskReader.taskFileCache.has(taskPath) &&
|
||||||
|
TeamTaskReader.taskFileCache.size >= TASK_FILE_CACHE_MAX_ENTRIES
|
||||||
|
) {
|
||||||
|
const oldestKey = TeamTaskReader.taskFileCache.keys().next().value;
|
||||||
|
if (oldestKey) {
|
||||||
|
TeamTaskReader.taskFileCache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TeamTaskReader.taskFileCache.set(taskPath, {
|
||||||
|
signature,
|
||||||
|
task: task ? cloneTask(task) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the next available numeric task ID by scanning ALL task files
|
* Returns the next available numeric task ID by scanning ALL task files
|
||||||
* (including _internal ones) to avoid ID collisions.
|
* (including _internal ones) to avoid ID collisions.
|
||||||
|
|
@ -147,6 +222,15 @@ export class TeamTaskReader {
|
||||||
const fileStat = await fs.promises.stat(taskPath);
|
const fileStat = await fs.promises.stat(taskPath);
|
||||||
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);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const signature = buildTaskFileSignature(fileStat);
|
||||||
|
const cachedTask = TeamTaskReader.getCachedTaskFile(taskPath, signature);
|
||||||
|
if (cachedTask !== undefined) {
|
||||||
|
if (cachedTask) {
|
||||||
|
tasks.push(cachedTask);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const raw = await readFileUtf8WithTimeout(taskPath, 5_000);
|
const raw = await readFileUtf8WithTimeout(taskPath, 5_000);
|
||||||
|
|
@ -154,6 +238,7 @@ export class TeamTaskReader {
|
||||||
// Skip internal CLI tracking entries (spawned subagent bookkeeping)
|
// Skip internal CLI tracking entries (spawned subagent bookkeeping)
|
||||||
const metadata = parsed.metadata as Record<string, unknown> | undefined;
|
const metadata = parsed.metadata as Record<string, unknown> | undefined;
|
||||||
if (metadata?._internal === true) {
|
if (metadata?._internal === true) {
|
||||||
|
TeamTaskReader.setCachedTaskFile(taskPath, signature, null);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const subject = typeof parsed.subject === 'string' ? parsed.subject : '';
|
const subject = typeof parsed.subject === 'string' ? parsed.subject : '';
|
||||||
|
|
@ -361,10 +446,13 @@ export class TeamTaskReader {
|
||||||
: undefined,
|
: undefined,
|
||||||
} satisfies Record<keyof TeamTask, unknown>;
|
} satisfies Record<keyof TeamTask, unknown>;
|
||||||
if (task.status === 'deleted') {
|
if (task.status === 'deleted') {
|
||||||
|
TeamTaskReader.setCachedTaskFile(taskPath, signature, null);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
TeamTaskReader.setCachedTaskFile(taskPath, signature, task);
|
||||||
tasks.push(task);
|
tasks.push(task);
|
||||||
} catch {
|
} catch {
|
||||||
|
TeamTaskReader.taskFileCache.delete(taskPath);
|
||||||
logger.debug(`Skipping invalid task file: ${taskPath}`);
|
logger.debug(`Skipping invalid task file: ${taskPath}`);
|
||||||
}
|
}
|
||||||
processed++;
|
processed++;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as fsp from 'fs/promises';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
|
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
|
||||||
|
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
||||||
|
|
||||||
import type { TeamTask } from '../../../../src/shared/types/team';
|
import type { TeamTask } from '../../../../src/shared/types/team';
|
||||||
|
|
||||||
|
|
@ -27,16 +32,43 @@ function makeTask(id: string): TeamTask & { teamName: string } {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('TeamTaskReader', () => {
|
describe('TeamTaskReader', () => {
|
||||||
afterEach(() => {
|
let tmpDir: string | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
TeamTaskReader.invalidateAllTasksCache();
|
TeamTaskReader.invalidateAllTasksCache();
|
||||||
|
setClaudeBasePathOverride(null);
|
||||||
|
if (tmpDir) {
|
||||||
|
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
tmpDir = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function setupTasksRoot(): Promise<string> {
|
||||||
|
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'team-task-reader-'));
|
||||||
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
await fsp.mkdir(path.join(tmpDir, 'tasks'), { recursive: true });
|
||||||
|
return tmpDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeTaskFile(teamName: string, task: Record<string, unknown>): Promise<string> {
|
||||||
|
const tasksDir = path.join(tmpDir!, 'tasks', teamName);
|
||||||
|
await fsp.mkdir(tasksDir, { recursive: true });
|
||||||
|
const taskPath = path.join(tasksDir, `${String(task.id)}.json`);
|
||||||
|
await fsp.writeFile(taskPath, JSON.stringify(task, null, 2), 'utf8');
|
||||||
|
return taskPath;
|
||||||
|
}
|
||||||
|
|
||||||
it('does not reuse or cache a stale in-flight getAllTasks scan after invalidation', async () => {
|
it('does not reuse or cache a stale in-flight getAllTasks scan after invalidation', async () => {
|
||||||
const firstRead = createDeferred<(TeamTask & { teamName: string })[]>();
|
const firstRead = createDeferred<(TeamTask & { teamName: string })[]>();
|
||||||
const secondRead = createDeferred<(TeamTask & { teamName: string })[]>();
|
const secondRead = createDeferred<(TeamTask & { teamName: string })[]>();
|
||||||
const readAllTasksUncached = vi
|
const readAllTasksUncached = vi
|
||||||
.spyOn(TeamTaskReader.prototype as unknown as { readAllTasksUncached: () => Promise<(TeamTask & { teamName: string })[]> }, 'readAllTasksUncached')
|
.spyOn(
|
||||||
|
TeamTaskReader.prototype as unknown as {
|
||||||
|
readAllTasksUncached: () => Promise<(TeamTask & { teamName: string })[]>;
|
||||||
|
},
|
||||||
|
'readAllTasksUncached'
|
||||||
|
)
|
||||||
.mockImplementationOnce(() => firstRead.promise)
|
.mockImplementationOnce(() => firstRead.promise)
|
||||||
.mockImplementationOnce(() => secondRead.promise);
|
.mockImplementationOnce(() => secondRead.promise);
|
||||||
|
|
||||||
|
|
@ -59,4 +91,37 @@ describe('TeamTaskReader', () => {
|
||||||
await expect(reader.getAllTasks()).resolves.toEqual([makeTask('fresh-task')]);
|
await expect(reader.getAllTasks()).resolves.toEqual([makeTask('fresh-task')]);
|
||||||
expect(readAllTasksUncached).toHaveBeenCalledTimes(2);
|
expect(readAllTasksUncached).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reuses parsed task files until their file signature changes', async () => {
|
||||||
|
await setupTasksRoot();
|
||||||
|
await writeTaskFile('atlas-hq', {
|
||||||
|
id: '1',
|
||||||
|
subject: '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.getTasks('atlas-hq');
|
||||||
|
expect(firstRead).toMatchObject([{ id: '1', subject: 'Cached task' }]);
|
||||||
|
firstRead[0]!.subject = 'Mutated caller copy';
|
||||||
|
await expect(reader.getTasks('atlas-hq')).resolves.toMatchObject([
|
||||||
|
{ id: '1', subject: 'Cached task' },
|
||||||
|
]);
|
||||||
|
expect(readFileSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await writeTaskFile('atlas-hq', {
|
||||||
|
id: '1',
|
||||||
|
subject: 'Changed cached task',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: '2026-05-02T12:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(reader.getTasks('atlas-hq')).resolves.toMatchObject([
|
||||||
|
{ id: '1', subject: 'Changed cached task' },
|
||||||
|
]);
|
||||||
|
expect(readFileSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue