perf(main): cache no-op task activity sync

This commit is contained in:
777genius 2026-05-31 10:59:45 +03:00
parent 0ec8a3f962
commit 2866b10bbc
2 changed files with 208 additions and 5 deletions

View file

@ -27,6 +27,8 @@ interface ResumeMembersCacheEntry {
signatureKey: string;
}
type MemberActivityNoopOperation = 'pause-member' | 'resume-member';
type MutableTeamTask = TeamTask & {
reviewIntervals?: TaskReviewInterval[];
};
@ -332,11 +334,34 @@ function writeTaskFile(filePath: string, task: MutableTeamTask): void {
export class TeamTaskActivityIntervalService {
private readonly resumeMembersCache = new Map<string, ResumeMembersCacheEntry>();
private readonly memberActivityNoopCache = new Map<string, string>();
private getBoardStateLockPath(teamName: string): string {
return `${path.join(getTeamsBasePath(), teamName, 'board-state')}.lock`;
}
private getMemberActivityNoopCacheKey(
teamName: string,
operation: MemberActivityNoopOperation,
memberKey: string
): string {
return `${teamName}\u0000${operation}\u0000${memberKey}`;
}
private clearMemberActivityNoopCacheForTeam(teamName: string): void {
const prefix = `${teamName}\u0000`;
for (const key of this.memberActivityNoopCache.keys()) {
if (key.startsWith(prefix)) {
this.memberActivityNoopCache.delete(key);
}
}
}
private clearActivityNoopCachesForTeam(teamName: string): void {
this.clearMemberActivityNoopCacheForTeam(teamName);
this.resumeMembersCache.delete(teamName);
}
private mutateTeamTasksWithLock(
teamName: string,
run: () => ActivityIntervalResult
@ -362,7 +387,53 @@ export class TeamTaskActivityIntervalService {
this.mutateTeamTasksUnlocked(teamName, mutate)
);
if (result.changedTasks > 0 || result.failed) {
this.resumeMembersCache.delete(teamName);
this.clearActivityNoopCachesForTeam(teamName);
}
return result;
}
private mutateMemberTasksWithNoopCache(
teamName: string,
operation: MemberActivityNoopOperation,
memberKey: string,
mutate: (task: MutableTeamTask) => boolean
): ActivityIntervalResult {
const cacheKey = this.getMemberActivityNoopCacheKey(teamName, operation, memberKey);
const cachedSignatureKey = this.memberActivityNoopCache.get(cacheKey);
if (cachedSignatureKey) {
const beforeLockSignature = this.readTaskDirectorySignature(teamName);
if (
beforeLockSignature &&
beforeLockSignature.key === cachedSignatureKey &&
!fs.existsSync(this.getBoardStateLockPath(teamName))
) {
return { changedTasks: 0 };
}
}
const result = this.mutateTeamTasksWithLock(teamName, () => {
const beforeSignature = this.readTaskDirectorySignature(teamName);
if (beforeSignature && this.memberActivityNoopCache.get(cacheKey) === beforeSignature.key) {
return { changedTasks: 0 };
}
const mutationResult = this.mutateTeamTasksUnlocked(teamName, mutate);
if (mutationResult.changedTasks > 0) {
this.clearActivityNoopCachesForTeam(teamName);
return mutationResult;
}
const nextSignature = beforeSignature ?? this.readTaskDirectorySignature(teamName);
if (nextSignature) {
this.memberActivityNoopCache.set(cacheKey, nextSignature.key);
} else {
this.memberActivityNoopCache.delete(cacheKey);
}
return mutationResult;
});
if (result.changedTasks > 0 || result.failed) {
this.clearActivityNoopCachesForTeam(teamName);
}
return result;
}
@ -449,13 +520,19 @@ export class TeamTaskActivityIntervalService {
memberName: string,
at = new Date().toISOString()
): ActivityIntervalResult {
return this.mutateTeamTasks(teamName, (task) => {
const memberKey = normalizeMemberName(memberName);
const mutate = (task: MutableTeamTask): boolean => {
const changedWork = closeOpenWorkIntervals(task, at, memberName);
const changedReview = closeOpenReviewIntervals(task, at, memberName);
const materializedWork = materializePausedWorkInterval(task, at, memberName);
const materializedReview = materializePausedReviewInterval(task, at, memberName);
return changedWork || changedReview || materializedWork || materializedReview;
});
};
if (!memberKey) {
return this.mutateTeamTasks(teamName, mutate);
}
return this.mutateMemberTasksWithNoopCache(teamName, 'pause-member', memberKey, mutate);
}
resumeActiveIntervalsForMember(
@ -466,7 +543,7 @@ export class TeamTaskActivityIntervalService {
const memberKey = normalizeMemberName(memberName);
if (!memberKey) return { changedTasks: 0 };
return this.mutateTeamTasks(teamName, (task) => {
return this.mutateMemberTasksWithNoopCache(teamName, 'resume-member', memberKey, (task) => {
let changed = false;
if (
@ -599,7 +676,9 @@ export class TeamTaskActivityIntervalService {
});
if (result.failed) {
this.resumeMembersCache.delete(teamName);
this.clearActivityNoopCachesForTeam(teamName);
} else if (result.changedTasks > 0) {
this.clearMemberActivityNoopCacheForTeam(teamName);
}
return result;
}

View file

@ -585,6 +585,130 @@ describe('TeamTaskActivityIntervalService', () => {
expect(mutateWithLockSpy).not.toHaveBeenCalled();
});
it('skips the task lock after an unchanged single-member resume 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.resumeActiveIntervalsForMember(
'alpha',
'bob',
'2026-05-08T10:20:00.000Z'
).changedTasks
).toBe(0);
const mutateWithLockSpy = vi.spyOn(
TeamTaskActivityIntervalService.prototype as unknown as {
mutateTeamTasksWithLock: (
teamName: string,
run: () => { changedTasks: number; failed?: boolean }
) => { changedTasks: number; failed?: boolean };
},
'mutateTeamTasksWithLock'
);
const secondResult = service.resumeActiveIntervalsForMember(
'alpha',
'bob',
'2026-05-08T10:25:00.000Z'
);
expect(secondResult.changedTasks).toBe(0);
expect(mutateWithLockSpy).not.toHaveBeenCalled();
});
it('refreshes single-member resume no-op 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.resumeActiveIntervalsForMember(
'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.resumeActiveIntervalsForMember(
'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('skips the task lock after an unchanged single-member pause no-op pass', async () => {
await writeTask('alpha', {
id: 'alice-task',
subject: 'Alice work',
owner: 'alice',
status: 'in_progress',
workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }],
historyEvents: [],
});
const service = new TeamTaskActivityIntervalService();
expect(
service.pauseActiveIntervalsForMember(
'alpha',
'bob',
'2026-05-08T10:20:00.000Z'
).changedTasks
).toBe(0);
const mutateWithLockSpy = vi.spyOn(
TeamTaskActivityIntervalService.prototype as unknown as {
mutateTeamTasksWithLock: (
teamName: string,
run: () => { changedTasks: number; failed?: boolean }
) => { changedTasks: number; failed?: boolean };
},
'mutateTeamTasksWithLock'
);
const secondResult = service.pauseActiveIntervalsForMember(
'alpha',
'bob',
'2026-05-08T10:25:00.000Z'
);
expect(secondResult.changedTasks).toBe(0);
expect(mutateWithLockSpy).not.toHaveBeenCalled();
});
it('refreshes batched resume cache when a task file changes', async () => {
await writeTask('alpha', {
id: 'bob-task',