perf(main): cache no-op task activity sync
This commit is contained in:
parent
0ec8a3f962
commit
2866b10bbc
2 changed files with 208 additions and 5 deletions
|
|
@ -27,6 +27,8 @@ interface ResumeMembersCacheEntry {
|
||||||
signatureKey: string;
|
signatureKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MemberActivityNoopOperation = 'pause-member' | 'resume-member';
|
||||||
|
|
||||||
type MutableTeamTask = TeamTask & {
|
type MutableTeamTask = TeamTask & {
|
||||||
reviewIntervals?: TaskReviewInterval[];
|
reviewIntervals?: TaskReviewInterval[];
|
||||||
};
|
};
|
||||||
|
|
@ -332,11 +334,34 @@ function writeTaskFile(filePath: string, task: MutableTeamTask): void {
|
||||||
|
|
||||||
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 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`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
private mutateTeamTasksWithLock(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
run: () => ActivityIntervalResult
|
run: () => ActivityIntervalResult
|
||||||
|
|
@ -362,7 +387,53 @@ export class TeamTaskActivityIntervalService {
|
||||||
this.mutateTeamTasksUnlocked(teamName, mutate)
|
this.mutateTeamTasksUnlocked(teamName, mutate)
|
||||||
);
|
);
|
||||||
if (result.changedTasks > 0 || result.failed) {
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
@ -449,13 +520,19 @@ export class TeamTaskActivityIntervalService {
|
||||||
memberName: string,
|
memberName: string,
|
||||||
at = new Date().toISOString()
|
at = new Date().toISOString()
|
||||||
): ActivityIntervalResult {
|
): ActivityIntervalResult {
|
||||||
return this.mutateTeamTasks(teamName, (task) => {
|
const memberKey = normalizeMemberName(memberName);
|
||||||
|
const mutate = (task: MutableTeamTask): boolean => {
|
||||||
const changedWork = closeOpenWorkIntervals(task, at, memberName);
|
const changedWork = closeOpenWorkIntervals(task, at, memberName);
|
||||||
const changedReview = closeOpenReviewIntervals(task, at, memberName);
|
const changedReview = closeOpenReviewIntervals(task, at, memberName);
|
||||||
const materializedWork = materializePausedWorkInterval(task, at, memberName);
|
const materializedWork = materializePausedWorkInterval(task, at, memberName);
|
||||||
const materializedReview = materializePausedReviewInterval(task, at, memberName);
|
const materializedReview = materializePausedReviewInterval(task, at, memberName);
|
||||||
return changedWork || changedReview || materializedWork || materializedReview;
|
return changedWork || changedReview || materializedWork || materializedReview;
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (!memberKey) {
|
||||||
|
return this.mutateTeamTasks(teamName, mutate);
|
||||||
|
}
|
||||||
|
return this.mutateMemberTasksWithNoopCache(teamName, 'pause-member', memberKey, mutate);
|
||||||
}
|
}
|
||||||
|
|
||||||
resumeActiveIntervalsForMember(
|
resumeActiveIntervalsForMember(
|
||||||
|
|
@ -466,7 +543,7 @@ export class TeamTaskActivityIntervalService {
|
||||||
const memberKey = normalizeMemberName(memberName);
|
const memberKey = normalizeMemberName(memberName);
|
||||||
if (!memberKey) return { changedTasks: 0 };
|
if (!memberKey) return { changedTasks: 0 };
|
||||||
|
|
||||||
return this.mutateTeamTasks(teamName, (task) => {
|
return this.mutateMemberTasksWithNoopCache(teamName, 'resume-member', memberKey, (task) => {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -599,7 +676,9 @@ export class TeamTaskActivityIntervalService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.failed) {
|
if (result.failed) {
|
||||||
this.resumeMembersCache.delete(teamName);
|
this.clearActivityNoopCachesForTeam(teamName);
|
||||||
|
} else if (result.changedTasks > 0) {
|
||||||
|
this.clearMemberActivityNoopCacheForTeam(teamName);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -585,6 +585,130 @@ describe('TeamTaskActivityIntervalService', () => {
|
||||||
expect(mutateWithLockSpy).not.toHaveBeenCalled();
|
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 () => {
|
it('refreshes batched resume cache when a task file changes', async () => {
|
||||||
await writeTask('alpha', {
|
await writeTask('alpha', {
|
||||||
id: 'bob-task',
|
id: 'bob-task',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue