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),
|
this.getOpenCodeSecondaryBootstrapPendingMemberNames(snapshot),
|
||||||
});
|
});
|
||||||
const runtimeObservedAt = nowIso();
|
const runtimeObservedAt = nowIso();
|
||||||
for (const [memberName, entry] of Object.entries(nextStatuses)) {
|
const aliveMemberNames = Object.entries(nextStatuses)
|
||||||
if (entry.runtimeAlive === true) {
|
.filter(([, entry]) => entry.runtimeAlive === true)
|
||||||
this.taskActivityIntervalService.resumeActiveIntervalsForMember(
|
.map(([memberName]) => memberName);
|
||||||
teamName,
|
if (aliveMemberNames.length > 0) {
|
||||||
memberName,
|
// Resume all alive members in a single locked task-file pass per cycle
|
||||||
runtimeObservedAt
|
// instead of one synchronous lock + full task read per member.
|
||||||
);
|
this.taskActivityIntervalService.resumeActiveIntervalsForMembers(
|
||||||
}
|
teamName,
|
||||||
|
aliveMemberNames,
|
||||||
|
runtimeObservedAt
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const expectedMembers = snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined;
|
const expectedMembers = snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined;
|
||||||
const summary = expectedMembers
|
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(
|
repairStaleIntervalsAfterCrash(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
launchSnapshot?: PersistedTeamLaunchSnapshot | null
|
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 () => {
|
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