perf: batch per-member task-interval resume into one locked pass
During launch the live-status loop resumes every alive member every audit cycle. resumeActiveIntervalsForMember runs a synchronous file-lock + full read of every task file, so for an N-member team with M task files it did N locked passes x M readFileSync per cycle (e.g. 6 members x 20 task files), blocking the main event loop. Profiling a 6-member mixed launch showed mutateTeamTasks/withFileLockSync as a top main-thread cost (~14%). Add resumeActiveIntervalsForMembers that applies the identical per-member resume logic against a member set in a single locked pass, and use it in the live-status loop. Same mutations, but one lock + task read per cycle instead of one per member. Adds a test covering multi-member resume in one pass.
This commit is contained in:
parent
aa9a1bba8c
commit
f79ea145d7
3 changed files with 119 additions and 8 deletions
|
|
@ -13895,14 +13895,17 @@ export class TeamProvisioningService {
|
|||
this.getOpenCodeSecondaryBootstrapPendingMemberNames(snapshot),
|
||||
});
|
||||
const runtimeObservedAt = nowIso();
|
||||
for (const [memberName, entry] of Object.entries(nextStatuses)) {
|
||||
if (entry.runtimeAlive === true) {
|
||||
this.taskActivityIntervalService.resumeActiveIntervalsForMember(
|
||||
teamName,
|
||||
memberName,
|
||||
runtimeObservedAt
|
||||
);
|
||||
}
|
||||
const aliveMemberNames = Object.entries(nextStatuses)
|
||||
.filter(([, entry]) => entry.runtimeAlive === true)
|
||||
.map(([memberName]) => memberName);
|
||||
if (aliveMemberNames.length > 0) {
|
||||
// Resume all alive members in a single locked task-file pass per cycle
|
||||
// instead of one synchronous lock + full task read per member.
|
||||
this.taskActivityIntervalService.resumeActiveIntervalsForMembers(
|
||||
teamName,
|
||||
aliveMemberNames,
|
||||
runtimeObservedAt
|
||||
);
|
||||
}
|
||||
const expectedMembers = snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined;
|
||||
const summary = expectedMembers
|
||||
|
|
|
|||
|
|
@ -443,6 +443,62 @@ export class TeamTaskActivityIntervalService {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched equivalent of resumeActiveIntervalsForMember for several members in a
|
||||
* single task-file pass. During launch the live-status loop resumes every alive
|
||||
* member every audit cycle; doing that per member meant one synchronous
|
||||
* 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
|
||||
* the mutations are exactly the same but the lock + reads happen once per cycle
|
||||
* instead of once per member.
|
||||
*/
|
||||
resumeActiveIntervalsForMembers(
|
||||
teamName: string,
|
||||
memberNames: readonly string[],
|
||||
at = new Date().toISOString()
|
||||
): ActivityIntervalResult {
|
||||
const memberKeys = new Set(
|
||||
memberNames.map((name) => normalizeMemberName(name)).filter((key): key is string => !!key)
|
||||
);
|
||||
if (memberKeys.size === 0) return { changedTasks: 0 };
|
||||
|
||||
return this.mutateTeamTasks(teamName, (task) => {
|
||||
let changed = false;
|
||||
|
||||
if (
|
||||
task.status === 'in_progress' &&
|
||||
memberKeys.has(normalizeMemberName(task.owner)) &&
|
||||
!hasOpenWorkInterval(task)
|
||||
) {
|
||||
const activeStartedAt = getActiveWorkStartedAt(task);
|
||||
task.workIntervals = [
|
||||
...(Array.isArray(task.workIntervals) ? task.workIntervals : []),
|
||||
{ startedAt: resumeStartIso(activeStartedAt, at) },
|
||||
];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const activeReview = getActiveReviewStart(task);
|
||||
if (
|
||||
task.status === 'completed' &&
|
||||
activeReview &&
|
||||
memberKeys.has(normalizeMemberName(activeReview.reviewer)) &&
|
||||
!hasOpenReviewInterval(task, activeReview.reviewer)
|
||||
) {
|
||||
task.reviewIntervals = [
|
||||
...(Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []),
|
||||
{
|
||||
reviewer: activeReview.reviewer,
|
||||
startedAt: resumeStartIso(activeReview.startedAt, at),
|
||||
},
|
||||
];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
});
|
||||
}
|
||||
|
||||
repairStaleIntervalsAfterCrash(
|
||||
teamName: string,
|
||||
launchSnapshot?: PersistedTeamLaunchSnapshot | null
|
||||
|
|
|
|||
|
|
@ -465,6 +465,58 @@ describe('TeamTaskActivityIntervalService', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('resumes active intervals for multiple members in a single pass', async () => {
|
||||
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: [],
|
||||
});
|
||||
await writeTask('alpha', {
|
||||
id: 'alice-task',
|
||||
subject: 'Alice work',
|
||||
owner: 'alice',
|
||||
status: 'in_progress',
|
||||
workIntervals: [
|
||||
{ startedAt: '2026-05-08T11:00:00.000Z', completedAt: '2026-05-08T11:05:00.000Z' },
|
||||
],
|
||||
historyEvents: [],
|
||||
});
|
||||
await writeTask('alpha', {
|
||||
id: 'carol-task',
|
||||
subject: 'Carol work',
|
||||
owner: 'carol',
|
||||
status: 'in_progress',
|
||||
workIntervals: [
|
||||
{ startedAt: '2026-05-08T12:00:00.000Z', completedAt: '2026-05-08T12:05:00.000Z' },
|
||||
],
|
||||
historyEvents: [],
|
||||
});
|
||||
|
||||
const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMembers(
|
||||
'alpha',
|
||||
['bob', 'alice'],
|
||||
'2026-05-08T10:20:00.000Z'
|
||||
);
|
||||
const bobTask = await readTask('alpha', 'bob-task');
|
||||
const aliceTask = await readTask('alpha', 'alice-task');
|
||||
const carolTask = await readTask('alpha', 'carol-task');
|
||||
|
||||
// Both listed members resumed in one pass; a member outside the set is untouched.
|
||||
expect(result.changedTasks).toBe(2);
|
||||
expect((bobTask.workIntervals as unknown[]).at(-1)).toEqual({
|
||||
startedAt: '2026-05-08T10:20:00.000Z',
|
||||
});
|
||||
expect((aliceTask.workIntervals as unknown[]).at(-1)).toEqual({
|
||||
startedAt: '2026-05-08T10:20:00.000Z',
|
||||
});
|
||||
expect(carolTask.workIntervals).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reopens and closes lead work intervals across activity changes', async () => {
|
||||
await writeTask('alpha', {
|
||||
id: 'lead-task',
|
||||
|
|
|
|||
Loading…
Reference in a new issue