perf(main): skip redundant task interval resume scans
This commit is contained in:
parent
e7d7b3014e
commit
4575255c28
2 changed files with 200 additions and 33 deletions
|
|
@ -18,6 +18,15 @@ interface ActivityIntervalResult {
|
||||||
failed?: boolean;
|
failed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TaskDirectorySignature {
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResumeMembersCacheEntry {
|
||||||
|
memberKey: string;
|
||||||
|
signatureKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
type MutableTeamTask = TeamTask & {
|
type MutableTeamTask = TeamTask & {
|
||||||
reviewIntervals?: TaskReviewInterval[];
|
reviewIntervals?: TaskReviewInterval[];
|
||||||
};
|
};
|
||||||
|
|
@ -322,13 +331,15 @@ function writeTaskFile(filePath: string, task: MutableTeamTask): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TeamTaskActivityIntervalService {
|
export class TeamTaskActivityIntervalService {
|
||||||
private mutateTeamTasks(
|
private readonly resumeMembersCache = new Map<string, ResumeMembersCacheEntry>();
|
||||||
|
|
||||||
|
private mutateTeamTasksWithLock(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
mutate: (task: MutableTeamTask) => boolean
|
run: () => ActivityIntervalResult
|
||||||
): ActivityIntervalResult {
|
): ActivityIntervalResult {
|
||||||
const lockScope = path.join(getTeamsBasePath(), teamName, 'board-state');
|
const lockScope = path.join(getTeamsBasePath(), teamName, 'board-state');
|
||||||
try {
|
try {
|
||||||
return withFileLockSync(lockScope, () => this.mutateTeamTasksUnlocked(teamName, mutate));
|
return withFileLockSync(lockScope, run);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[${teamName}] Failed to update task activity intervals: ${
|
`[${teamName}] Failed to update task activity intervals: ${
|
||||||
|
|
@ -339,6 +350,19 @@ export class TeamTaskActivityIntervalService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private mutateTeamTasks(
|
||||||
|
teamName: string,
|
||||||
|
mutate: (task: MutableTeamTask) => boolean
|
||||||
|
): ActivityIntervalResult {
|
||||||
|
const result = this.mutateTeamTasksWithLock(teamName, () =>
|
||||||
|
this.mutateTeamTasksUnlocked(teamName, mutate)
|
||||||
|
);
|
||||||
|
if (result.changedTasks > 0 || result.failed) {
|
||||||
|
this.resumeMembersCache.delete(teamName);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private mutateTeamTasksUnlocked(
|
private mutateTeamTasksUnlocked(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
mutate: (task: MutableTeamTask) => boolean
|
mutate: (task: MutableTeamTask) => boolean
|
||||||
|
|
@ -371,6 +395,38 @@ export class TeamTaskActivityIntervalService {
|
||||||
return { changedTasks };
|
return { changedTasks };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readTaskDirectorySignature(teamName: string): TaskDirectorySignature | null {
|
||||||
|
const tasksDir = path.join(getTasksBasePath(), teamName);
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = fs
|
||||||
|
.readdirSync(tasksDir)
|
||||||
|
.filter((fileName) => fileName.endsWith('.json') && !fileName.startsWith('.'))
|
||||||
|
.sort();
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return { key: 'missing' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const fileName of entries) {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(path.join(tasksDir, fileName));
|
||||||
|
if (!stat.isFile()) continue;
|
||||||
|
parts.push([fileName, stat.size, stat.mtimeMs, stat.ctimeMs].join('\0'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { key: parts.join('\0\0') };
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeMemberSetKey(memberKeys: ReadonlySet<string>): string {
|
||||||
|
return [...memberKeys].sort().join('\0');
|
||||||
|
}
|
||||||
|
|
||||||
pauseActiveIntervalsForTeam(
|
pauseActiveIntervalsForTeam(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
at = new Date().toISOString()
|
at = new Date().toISOString()
|
||||||
|
|
@ -450,7 +506,8 @@ export class TeamTaskActivityIntervalService {
|
||||||
* file-lock + read of every task file PER member PER cycle. This applies the
|
* file-lock + read of every task file PER member PER cycle. This applies the
|
||||||
* identical per-member resume logic against a member set in one locked pass, so
|
* identical per-member resume logic against a member set in one locked pass, so
|
||||||
* the mutations are exactly the same but the lock + reads happen once per cycle
|
* the mutations are exactly the same but the lock + reads happen once per cycle
|
||||||
* instead of once per member.
|
* instead of once per member. After a no-op pass, a task-file signature skips
|
||||||
|
* unchanged repeat cycles without parsing every task JSON again.
|
||||||
*/
|
*/
|
||||||
resumeActiveIntervalsForMembers(
|
resumeActiveIntervalsForMembers(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
|
|
@ -461,8 +518,20 @@ export class TeamTaskActivityIntervalService {
|
||||||
memberNames.map((name) => normalizeMemberName(name)).filter((key): key is string => !!key)
|
memberNames.map((name) => normalizeMemberName(name)).filter((key): key is string => !!key)
|
||||||
);
|
);
|
||||||
if (memberKeys.size === 0) return { changedTasks: 0 };
|
if (memberKeys.size === 0) return { changedTasks: 0 };
|
||||||
|
const memberKey = this.makeMemberSetKey(memberKeys);
|
||||||
|
|
||||||
return this.mutateTeamTasks(teamName, (task) => {
|
const result = this.mutateTeamTasksWithLock(teamName, () => {
|
||||||
|
const beforeSignature = this.readTaskDirectorySignature(teamName);
|
||||||
|
const cached = this.resumeMembersCache.get(teamName);
|
||||||
|
if (
|
||||||
|
beforeSignature &&
|
||||||
|
cached?.memberKey === memberKey &&
|
||||||
|
cached.signatureKey === beforeSignature.key
|
||||||
|
) {
|
||||||
|
return { changedTasks: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutationResult = this.mutateTeamTasksUnlocked(teamName, (task) => {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -497,6 +566,26 @@ export class TeamTaskActivityIntervalService {
|
||||||
|
|
||||||
return changed;
|
return changed;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nextSignature =
|
||||||
|
mutationResult.changedTasks > 0
|
||||||
|
? this.readTaskDirectorySignature(teamName)
|
||||||
|
: beforeSignature;
|
||||||
|
if (nextSignature) {
|
||||||
|
this.resumeMembersCache.set(teamName, {
|
||||||
|
memberKey,
|
||||||
|
signatureKey: nextSignature.key,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.resumeMembersCache.delete(teamName);
|
||||||
|
}
|
||||||
|
return mutationResult;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.failed) {
|
||||||
|
this.resumeMembersCache.delete(teamName);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
repairStaleIntervalsAfterCrash(
|
repairStaleIntervalsAfterCrash(
|
||||||
|
|
|
||||||
|
|
@ -517,6 +517,84 @@ describe('TeamTaskActivityIntervalService', () => {
|
||||||
expect(carolTask.workIntervals).toHaveLength(1);
|
expect(carolTask.workIntervals).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('skips unchanged batched resume task reads after a no-op pass', 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.resumeActiveIntervalsForMembers(
|
||||||
|
'alpha',
|
||||||
|
['bob'],
|
||||||
|
'2026-05-08T10:20:00.000Z'
|
||||||
|
).changedTasks
|
||||||
|
).toBe(0);
|
||||||
|
|
||||||
|
const jsonParseSpy = vi.spyOn(JSON, 'parse');
|
||||||
|
const secondResult = service.resumeActiveIntervalsForMembers(
|
||||||
|
'alpha',
|
||||||
|
['bob'],
|
||||||
|
'2026-05-08T10:25:00.000Z'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(secondResult.changedTasks).toBe(0);
|
||||||
|
expect(jsonParseSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes batched resume cache 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.resumeActiveIntervalsForMembers(
|
||||||
|
'alpha',
|
||||||
|
['bob'],
|
||||||
|
'2026-05-08T10:20:00.000Z'
|
||||||
|
).changedTasks
|
||||||
|
).toBe(0);
|
||||||
|
|
||||||
|
await writeTask('alpha', {
|
||||||
|
id: 'bob-task',
|
||||||
|
subject: 'Bob work',
|
||||||
|
owner: 'bob',
|
||||||
|
status: 'in_progress',
|
||||||
|
workIntervals: [
|
||||||
|
{
|
||||||
|
startedAt: '2026-05-08T10:00:00.000Z',
|
||||||
|
completedAt: '2026-05-08T10:05:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
historyEvents: [],
|
||||||
|
signaturePadding: 'changed-file-signature',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = service.resumeActiveIntervalsForMembers(
|
||||||
|
'alpha',
|
||||||
|
['bob'],
|
||||||
|
'2026-05-08T10:30:00.000Z'
|
||||||
|
);
|
||||||
|
const task = await readTask('alpha', 'bob-task');
|
||||||
|
|
||||||
|
expect(result.changedTasks).toBe(1);
|
||||||
|
expect(task.workIntervals).toEqual([
|
||||||
|
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
|
||||||
|
{ startedAt: '2026-05-08T10:30:00.000Z' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('reopens and closes lead work intervals across activity changes', async () => {
|
it('reopens and closes lead work intervals across activity changes', async () => {
|
||||||
await writeTask('alpha', {
|
await writeTask('alpha', {
|
||||||
id: 'lead-task',
|
id: 'lead-task',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue