perf(main): cache task activity file reads
This commit is contained in:
parent
2866b10bbc
commit
c85b0d94ce
2 changed files with 174 additions and 10 deletions
|
|
@ -22,6 +22,19 @@ interface TaskDirectorySignature {
|
||||||
key: string;
|
key: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TaskFileSignature {
|
||||||
|
size: number;
|
||||||
|
mtimeMs: number;
|
||||||
|
ctimeMs: number;
|
||||||
|
dev: number;
|
||||||
|
ino: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedActivityTaskFile {
|
||||||
|
signature: TaskFileSignature;
|
||||||
|
task: MutableTeamTask | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface ResumeMembersCacheEntry {
|
interface ResumeMembersCacheEntry {
|
||||||
memberKey: string;
|
memberKey: string;
|
||||||
signatureKey: string;
|
signatureKey: string;
|
||||||
|
|
@ -34,6 +47,7 @@ type MutableTeamTask = TeamTask & {
|
||||||
};
|
};
|
||||||
|
|
||||||
const CRASH_REPAIR_GRACE_MS = 5_000;
|
const CRASH_REPAIR_GRACE_MS = 5_000;
|
||||||
|
const TASK_FILE_CACHE_MAX_ENTRIES = 8_192;
|
||||||
const logger = createLogger('Service:TeamTaskActivityIntervalService');
|
const logger = createLogger('Service:TeamTaskActivityIntervalService');
|
||||||
|
|
||||||
function normalizeMemberName(value: string | null | undefined): string {
|
function normalizeMemberName(value: string | null | undefined): string {
|
||||||
|
|
@ -317,24 +331,36 @@ function materializePausedReviewInterval(
|
||||||
return true;
|
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 {
|
function writeTaskFile(filePath: string, task: MutableTeamTask): void {
|
||||||
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||||
fs.writeFileSync(tempPath, JSON.stringify(task, null, 2));
|
fs.writeFileSync(tempPath, JSON.stringify(task, null, 2));
|
||||||
fs.renameSync(tempPath, filePath);
|
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 {
|
export class TeamTaskActivityIntervalService {
|
||||||
private readonly resumeMembersCache = new Map<string, ResumeMembersCacheEntry>();
|
private readonly resumeMembersCache = new Map<string, ResumeMembersCacheEntry>();
|
||||||
private readonly memberActivityNoopCache = new Map<string, string>();
|
private readonly memberActivityNoopCache = new Map<string, string>();
|
||||||
|
private readonly taskFileCache = new Map<string, CachedActivityTaskFile>();
|
||||||
|
|
||||||
private getBoardStateLockPath(teamName: string): string {
|
private getBoardStateLockPath(teamName: string): string {
|
||||||
return `${path.join(getTeamsBasePath(), teamName, 'board-state')}.lock`;
|
return `${path.join(getTeamsBasePath(), teamName, 'board-state')}.lock`;
|
||||||
|
|
@ -362,6 +388,81 @@ export class TeamTaskActivityIntervalService {
|
||||||
this.resumeMembersCache.delete(teamName);
|
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(
|
private mutateTeamTasksWithLock(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
run: () => ActivityIntervalResult
|
run: () => ActivityIntervalResult
|
||||||
|
|
@ -457,10 +558,11 @@ export class TeamTaskActivityIntervalService {
|
||||||
for (const fileName of entries) {
|
for (const fileName of entries) {
|
||||||
if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue;
|
if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue;
|
||||||
const filePath = path.join(tasksDir, fileName);
|
const filePath = path.join(tasksDir, fileName);
|
||||||
const task = readTaskFile(filePath);
|
const task = this.readTaskFile(filePath);
|
||||||
if (!task) continue;
|
if (!task) continue;
|
||||||
if (!mutate(task)) continue;
|
if (!mutate(task)) continue;
|
||||||
writeTaskFile(filePath, task);
|
writeTaskFile(filePath, task);
|
||||||
|
this.cacheWrittenTaskFile(filePath, task);
|
||||||
changedTasks += 1;
|
changedTasks += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -585,6 +585,68 @@ describe('TeamTaskActivityIntervalService', () => {
|
||||||
expect(mutateWithLockSpy).not.toHaveBeenCalled();
|
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 () => {
|
it('skips the task lock after an unchanged single-member resume no-op pass', async () => {
|
||||||
await writeTask('alpha', {
|
await writeTask('alpha', {
|
||||||
id: 'bob-task',
|
id: 'bob-task',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue