refactor(planning): give planning workers one lifetime owner

Planning, counterfactual analysis and checkpoint explanation now share
one internal worker-job owner for creation, handler cleanup,
termination and supersession, and one request/response message
contract between the coordinator and the worker. Job-specific progress,
checkpoint recovery and completion rules stay explicit.

Refs #277

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nessarandClaude Opus 5.5 committed 2026-10-17 11:11:37 +02:00
1 parent 7fc2b33587
commit b69b3c3690
4 files changed
+344 -183

No files matched your search

@@ -0,0 +1,172 @@
import { vi } from 'vitest';
import {
createPlanningCalculation,
type LeavePlacement,
type PlanningCalculationContext,
} from './leave-planner.service';
const request = { horizon: { start: '2024-01-01', end: '2025-01-01' }, balances: [], rules: [] };
const plan = {
kind: 'Plan',
placements: [{ date: '2024-01-05', balanceId: 'cp' } as unknown as LeavePlacement],
completedStages: [],
};
const state = { marker: 'state' };
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
class FakeWorker {
static all: FakeWorker[] = [];
static constructThrows = false;
static postThrows = false;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
terminate = vi.fn();
postMessage = vi.fn(() => {
if (this.throwOnPost) throw new Error('post failed');
});
private throwOnPost = FakeWorker.postThrows;
constructor() {
if (FakeWorker.constructThrows) throw new Error('no worker');
FakeWorker.all.push(this);
}
emit(data: unknown) {
this.onmessage?.({ data } as MessageEvent);
}
}
interface Started {
worker?: FakeWorker;
succeeded: Promise<boolean>;
deliver: () => void;
late: () => void;
}
type Flags = { construct?: boolean; post?: boolean };
const arm = (flags: Flags) => {
FakeWorker.constructThrows = !!flags.construct;
FakeWorker.postThrows = !!flags.post;
};
const jobs: Record<string, (flags: Flags) => Promise<Started>> = {
plan: async (flags) => {
arm(flags);
const progress = vi.fn();
const calculation = createPlanningCalculation({ ...request, progress });
const worker = FakeWorker.all[0];
return {
worker,
succeeded: calculation.outcome.then((outcome) => outcome.kind === 'Plan'),
deliver: () => {
worker?.emit({ type: 'result', outcome: plan });
worker?.emit({ type: 'artifacts', artifacts: {} });
},
late: () => {
worker?.emit({ type: 'progress', stage: 'late' });
expect(progress).not.toHaveBeenCalled();
},
};
},
counterfactual: async (flags) => {
const calculation = createPlanningCalculation(request);
FakeWorker.all[0].emit({ type: 'result', outcome: plan });
FakeWorker.all[0].emit({ type: 'artifacts', artifacts: {} });
await calculation.timings;
arm(flags);
const analysis = (calculation as PlanningCalculationContext).analyzeCounterfactual(
plan.placements[0],
);
const succeeded = analysis.then(
(result) => result.kind !== 'CounterfactualStale',
() => false,
);
await flush();
const worker = FakeWorker.all[1];
return {
worker,
succeeded,
deliver: () =>
worker?.emit({ type: 'counterfactual-result', analysis: { results: [] } }),
late: () => worker?.emit({ type: 'error', message: 'late' }),
};
},
'explain-checkpoint': async (flags) => {
const calculation = createPlanningCalculation(request);
const first = FakeWorker.all[0];
first.emit({ type: 'checkpoint', plan, state });
arm(flags);
first.emit({ type: 'error', message: 'planner crashed' });
const worker = FakeWorker.all[1];
return {
worker,
succeeded: calculation.explanation.then((explanation) => explanation !== undefined),
deliver: () =>
worker?.emit({
type: 'explanation',
explanation: { kind: 'IncompleteExplanation' },
}),
late: () => worker?.emit({ type: 'error', message: 'late' }),
};
},
};
describe('worker job lifetime', () => {
beforeEach(() => {
FakeWorker.all = [];
arm({});
vi.stubGlobal('window', {});
vi.stubGlobal('Worker', FakeWorker);
});
afterEach(() => {
arm({});
vi.unstubAllGlobals();
});
describe.each(Object.keys(jobs))('%s job', (name) => {
const start = jobs[name];
const released = (worker: FakeWorker) => {
expect(worker.terminate).toHaveBeenCalledOnce();
expect(worker.onmessage).toBeNull();
expect(worker.onerror).toBeNull();
};
it('releases the worker once on success and ignores late messages', async () => {
const job = await start({});
job.deliver();
expect(await job.succeeded).toBe(true);
released(job.worker!);
job.late();
job.deliver();
released(job.worker!);
});
it('fails without a worker when construction throws', async () => {
const job = await start({ construct: true });
expect(job.worker).toBeUndefined();
expect(await job.succeeded).toBe(false);
});
it('releases the worker once when postMessage throws', async () => {
const job = await start({ post: true });
expect(await job.succeeded).toBe(false);
released(job.worker!);
});
it('releases the worker once on a runtime error', async () => {
const job = await start({});
job.worker!.onerror!();
expect(await job.succeeded).toBe(false);
released(job.worker!);
job.deliver();
expect(await job.succeeded).toBe(false);
released(job.worker!);
});
it('releases the worker once when a newer calculation supersedes it', async () => {
const job = await start({});
createPlanningCalculation(request);
expect(await job.succeeded).toBe(false);
released(job.worker!);
job.deliver();
released(job.worker!);
});
});
});
+110 -125
View File
@@ -17,6 +17,7 @@ import type {
StaleCounterfactualAnalysis, StaleCounterfactualAnalysis,
} from './leave-planner.core'; } from './leave-planner.core';
import { planningDateInParis } from './planning-date'; import { planningDateInParis } from './planning-date';
import type { WorkerRequest, WorkerResponse } from './leave-planner.worker-messages';
export type { export type {
IfThenPlanningRule, IfThenPlanningRule,
@@ -278,20 +279,6 @@ function invalidDeadline(request: TransportSnapshot): InvalidInputOutcome | unde
return undefined; return undefined;
} }
type PlannerWorkerResponse =
| { type: 'heartbeat' }
| { type: 'progress'; stage: string }
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningStateSnapshot }
| { type: 'result'; outcome: PlanningOutcome }
| { type: 'artifacts'; artifacts: PlanningArtifacts }
| {
type: 'explanation';
explanation?: PlanningExplanation;
explanationError?: PlanningReason;
finalState?: PlanningStateSnapshot;
}
| { type: 'error'; code?: string; message: string };
interface PlanningRun { interface PlanningRun {
outcome: Promise<PlanningOutcome>; outcome: Promise<PlanningOutcome>;
explanation: Promise<PlanningExplanation | undefined>; explanation: Promise<PlanningExplanation | undefined>;
@@ -320,6 +307,47 @@ function interruptedOutcome(
const latestWorkerCancels = new Set<() => void>(); const latestWorkerCancels = new Set<() => void>();
let latestCounterfactualCancel: (() => void) | undefined; let latestCounterfactualCancel: (() => void) | undefined;
interface WorkerJob {
post(message: WorkerRequest, onFailure: (error: unknown) => void): void;
release(): void;
}
// Single owner of a worker's lifetime: creation, handlers, cancellation registration and an idempotent release.
function startWorkerJob(
supersede: () => void,
onMessage: (message: WorkerResponse) => void,
onError: () => void,
): WorkerJob {
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
});
let released = false;
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
if (!released && event.data) onMessage(event.data);
};
worker.onerror = () => {
if (!released) onError();
};
latestWorkerCancels.add(supersede);
return {
post(message, onFailure) {
try {
worker.postMessage(message);
} catch (error) {
onFailure(error);
}
},
release() {
if (released) return;
released = true;
worker.onmessage = null;
worker.onerror = null;
worker.terminate();
latestWorkerCancels.delete(supersede);
},
};
}
function cancelLatestWorkers(): void { function cancelLatestWorkers(): void {
for (const cancel of [...latestWorkerCancels]) cancel(); for (const cancel of [...latestWorkerCancels]) cancel();
latestWorkerCancels.clear(); latestWorkerCancels.clear();
@@ -349,9 +377,11 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
}); });
let cancel: (() => void) | undefined; let cancel: (() => void) | undefined;
try { try {
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), { const job = startWorkerJob(
type: 'module', () => supersede(),
}); (message) => handleMessage(message),
() => fail('worker-error', 'Planning worker failed before returning explanation data.'),
);
let outcomeSettled = false; let outcomeSettled = false;
let cancellationRequested = false; let cancellationRequested = false;
let cancelledRace: PlanningIncompleteOutcome | undefined; let cancelledRace: PlanningIncompleteOutcome | undefined;
@@ -364,9 +394,7 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
let receivedFinalState: PlanningStateSnapshot | undefined; let receivedFinalState: PlanningStateSnapshot | undefined;
let livenessWatchdog: ReturnType<typeof setTimeout>; let livenessWatchdog: ReturnType<typeof setTimeout>;
const terminate = () => { const terminate = () => {
worker.onmessage = null; job.release();
worker.onerror = null;
worker.terminate();
clearTimeout(livenessWatchdog); clearTimeout(livenessWatchdog);
}; };
const finishOutcome = (result: PlanningOutcome) => { const finishOutcome = (result: PlanningOutcome) => {
@@ -434,7 +462,6 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
resolveArtifacts(complete); resolveArtifacts(complete);
resolveTimings(complete.timings ?? {}); resolveTimings(complete.timings ?? {});
terminate(); terminate();
latestWorkerCancels.delete(supersede);
}; };
const supersede = () => { const supersede = () => {
if (artifactsSettled) return; if (artifactsSettled) return;
@@ -456,7 +483,6 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
finalState: receivedFinalState ?? checkpointState, finalState: receivedFinalState ?? checkpointState,
}); });
}; };
latestWorkerCancels.add(supersede);
const fail = (code: string, message: string) => { const fail = (code: string, message: string) => {
const reason = { code, message }; const reason = { code, message };
if (!outcomeSettled) finishOutcome(interruptedOutcome(checkpoint, code, message)); if (!outcomeSettled) finishOutcome(interruptedOutcome(checkpoint, code, message));
@@ -508,15 +534,11 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
cancel = () => { cancel = () => {
if (outcomeSettled || cancellationRequested) return; if (outcomeSettled || cancellationRequested) return;
cancellationRequested = true; cancellationRequested = true;
try { job.post({ type: 'cancel' }, () =>
worker.postMessage({ type: 'cancel' }); fail('worker-error', 'The cancellation request could not reach the worker.'),
} catch { );
fail('worker-error', 'The cancellation request could not reach the worker.');
}
}; };
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => { const handleMessage = (message: WorkerResponse) => {
const message = event.data;
if (!message) return;
if (message.type === 'heartbeat') { if (message.type === 'heartbeat') {
scheduleLivenessWatchdog(); scheduleLivenessWatchdog();
return; return;
@@ -552,13 +574,9 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
if (message.type === 'error') fail(message.code ?? 'worker-error', message.message); if (message.type === 'error') fail(message.code ?? 'worker-error', message.message);
else fail('worker-error', 'Planning worker returned an unexpected response.'); else fail('worker-error', 'Planning worker returned an unexpected response.');
}; };
worker.onerror = () => job.post({ type: 'plan', request: { ...snapshot, progress: undefined } }, () =>
fail('worker-error', 'Planning worker failed before returning explanation data.'); fail('worker-error', 'Planning input could not be sent to the isolated worker.'),
try { );
worker.postMessage({ type: 'plan', request: { ...snapshot, progress: undefined } });
} catch {
fail('worker-error', 'Planning input could not be sent to the isolated worker.');
}
} catch { } catch {
const result = interruptedOutcome( const result = interruptedOutcome(
undefined, undefined,
@@ -677,62 +695,43 @@ function counterfactualInWorker(
): Promise<CounterfactualAnalysis> { ): Promise<CounterfactualAnalysis> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
latestCounterfactualCancel?.(); latestCounterfactualCancel?.();
let worker: Worker; let job: WorkerJob;
const finish = (callback: () => void) => {
if (settled) return;
settled = true;
job.release();
if (latestCounterfactualCancel === cancel) latestCounterfactualCancel = undefined;
callback();
};
let settled = false;
const cancel = () =>
finish(() => reject(new Error('Counterfactual analysis was superseded.')));
try { try {
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), { job = startWorkerJob(
type: 'module', cancel,
}); (message) => {
if (message.type === 'counterfactual-result' && message.analysis)
finish(() => resolve(message.analysis));
else if (message.type === 'error')
finish(() =>
reject(
new Error(
message.message ??
`Counterfactual analysis failed (${message.code ?? 'worker-error'}).`,
),
),
);
},
() => finish(() => reject(new Error('Counterfactual worker failed.'))),
);
} catch (error) { } catch (error) {
reject(error); reject(error);
return; return;
} }
let settled = false;
const finish = (callback: () => void) => {
if (settled) return;
settled = true;
worker.onmessage = null;
worker.onerror = null;
worker.terminate();
if (cancel) latestWorkerCancels.delete(cancel);
if (latestCounterfactualCancel === cancel) latestCounterfactualCancel = undefined;
callback();
};
const cancel = () =>
finish(() => reject(new Error('Counterfactual analysis was superseded.')));
latestCounterfactualCancel = cancel; latestCounterfactualCancel = cancel;
latestWorkerCancels.add(cancel); job.post({ type: 'counterfactual', request: snapshot, plan, placement, state }, (error) =>
worker.onmessage = ( finish(() => reject(error)),
event: MessageEvent<{ );
type: string;
analysis?: CounterfactualAnalysis;
code?: string;
message?: string;
}>,
) => {
if (event.data?.type === 'counterfactual-result' && event.data.analysis)
finish(() => resolve(event.data.analysis!));
else if (event.data?.type === 'error')
finish(() =>
reject(
new Error(
event.data.message ??
`Counterfactual analysis failed (${event.data.code ?? 'worker-error'}).`,
),
),
);
};
worker.onerror = () => finish(() => reject(new Error('Counterfactual worker failed.')));
try {
worker.postMessage({
type: 'counterfactual',
request: snapshot,
plan,
placement,
state,
});
} catch (error) {
finish(() => reject(error));
}
}); });
} }
@@ -743,53 +742,39 @@ function explainCheckpointInWorker(
interruption: PlanningReason, interruption: PlanningReason,
): Promise<Pick<PlanningArtifacts, 'explanation' | 'explanationError' | 'finalState'>> { ): Promise<Pick<PlanningArtifacts, 'explanation' | 'explanationError' | 'finalState'>> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let worker: Worker; let job: WorkerJob;
try {
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
});
} catch (error) {
reject(error);
return;
}
let settled = false; let settled = false;
const finish = (callback: () => void) => { const finish = (callback: () => void) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
worker.onmessage = null; job.release();
worker.onerror = null;
worker.terminate();
if (cancel) latestWorkerCancels.delete(cancel);
callback(); callback();
}; };
const cancel = () =>
finish(() => reject(new Error('Checkpoint explanation was superseded.')));
latestWorkerCancels.add(cancel);
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
const response = event.data;
if (response?.type === 'explanation')
finish(() =>
resolve({
explanation: response.explanation,
explanationError: response.explanationError,
finalState: response.finalState,
}),
);
else if (response?.type === 'error') finish(() => reject(new Error(response.message)));
};
worker.onerror = () =>
finish(() => reject(new Error('Checkpoint explanation worker failed.')));
try { try {
worker.postMessage({ job = startWorkerJob(
type: 'explain-checkpoint', () => finish(() => reject(new Error('Checkpoint explanation was superseded.'))),
request: snapshot, (response) => {
plan, if (response.type === 'explanation')
state, finish(() =>
interruption, resolve({
}); explanation: response.explanation,
explanationError: response.explanationError,
finalState: response.finalState,
}),
);
else if (response.type === 'error')
finish(() => reject(new Error(response.message)));
},
() => finish(() => reject(new Error('Checkpoint explanation worker failed.'))),
);
} catch (error) { } catch (error) {
finish(() => reject(error)); reject(error);
return;
} }
job.post(
{ type: 'explain-checkpoint', request: snapshot, plan, state, interruption },
(error) => finish(() => reject(error)),
);
}); });
} }
@@ -0,0 +1,60 @@
import type {
CounterfactualAnalysis,
LeavePlacement,
PlanOutcome,
PlanningExplanation,
PlanningOutcome,
PlanningReason,
PlanningSnapshot,
PlanningStateSnapshot,
} from './leave-planner.service';
import type { PlanningArtifacts } from './leave-planner.core';
// Message contract shared by the planning coordinator and its worker; types only.
export type WorkerPlanningRequest = {
calculationDate?: string;
declaredDays?: unknown;
horizon?: { start?: unknown; end?: unknown };
balances?: unknown;
rules?: unknown;
identities?: unknown;
labels?: unknown;
deadlineMs?: unknown;
progress?: unknown;
};
export type WorkerRequest =
| {
type: 'plan';
request: WorkerPlanningRequest;
}
| { type: 'cancel' }
| {
type: 'counterfactual';
request: PlanningSnapshot;
plan: PlanOutcome;
placement: LeavePlacement;
state?: PlanningStateSnapshot;
}
| {
type: 'explain-checkpoint';
request: PlanningSnapshot;
plan: PlanOutcome;
state: PlanningStateSnapshot;
interruption: PlanningReason;
};
export type WorkerResponse =
| { type: 'heartbeat' }
| { type: 'progress'; stage: string }
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningStateSnapshot }
| { type: 'result'; outcome: PlanningOutcome }
| { type: 'artifacts'; artifacts: PlanningArtifacts }
| {
type: 'explanation';
explanation?: PlanningExplanation;
explanationError?: PlanningReason;
finalState?: PlanningStateSnapshot;
}
| { type: 'counterfactual-result'; analysis: CounterfactualAnalysis }
| { type: 'error'; code?: string; message: string };
+2 -58
View File
@@ -1,15 +1,7 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
import type { import type { PlanningOutcome } from './leave-planner.service';
CounterfactualAnalysis, import type { WorkerRequest, WorkerResponse } from './leave-planner.worker-messages';
LeavePlacement,
PlanOutcome,
PlanningExplanation,
PlanningOutcome,
PlanningReason,
PlanningSnapshot,
PlanningStateSnapshot,
} from './leave-planner.service';
import { import {
analyzeCounterfactualInternal, analyzeCounterfactualInternal,
calculatePlanningAsyncInternal, calculatePlanningAsyncInternal,
@@ -17,54 +9,6 @@ import {
type PlanningArtifacts, type PlanningArtifacts,
} from './leave-planner.core'; } from './leave-planner.core';
type WorkerPlanningRequest = {
calculationDate?: string;
declaredDays?: unknown;
horizon?: { start?: unknown; end?: unknown };
balances?: unknown;
rules?: unknown;
identities?: unknown;
labels?: unknown;
deadlineMs?: unknown;
progress?: unknown;
};
type WorkerRequest =
| {
type: 'plan';
request: WorkerPlanningRequest;
}
| { type: 'cancel' }
| {
type: 'counterfactual';
request: PlanningSnapshot;
plan: PlanOutcome;
placement: LeavePlacement;
state?: PlanningStateSnapshot;
}
| {
type: 'explain-checkpoint';
request: PlanningSnapshot;
plan: PlanOutcome;
state: PlanningStateSnapshot;
interruption: PlanningReason;
};
type WorkerResponse =
| { type: 'heartbeat' }
| { type: 'progress'; stage: string }
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningStateSnapshot }
| { type: 'result'; outcome: PlanningOutcome }
| { type: 'artifacts'; artifacts: PlanningArtifacts }
| {
type: 'explanation';
explanation?: PlanningExplanation;
explanationError?: PlanningReason;
finalState?: PlanningStateSnapshot;
}
| { type: 'counterfactual-result'; analysis: CounterfactualAnalysis }
| { type: 'error'; code?: string; message: string };
const worker = self as DedicatedWorkerGlobalScope; const worker = self as DedicatedWorkerGlobalScope;
let activeCalculation: { cancelled: boolean } | undefined; let activeCalculation: { cancelled: boolean } | undefined;