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
+337 -176

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!);
});
});
});
+103 -118
View File
@@ -17,6 +17,7 @@ import type {
StaleCounterfactualAnalysis,
} from './leave-planner.core';
import { planningDateInParis } from './planning-date';
import type { WorkerRequest, WorkerResponse } from './leave-planner.worker-messages';
export type {
IfThenPlanningRule,
@@ -278,20 +279,6 @@ function invalidDeadline(request: TransportSnapshot): InvalidInputOutcome | unde
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 {
outcome: Promise<PlanningOutcome>;
explanation: Promise<PlanningExplanation | undefined>;
@@ -320,6 +307,47 @@ function interruptedOutcome(
const latestWorkerCancels = new Set<() => void>();
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 {
for (const cancel of [...latestWorkerCancels]) cancel();
latestWorkerCancels.clear();
@@ -349,9 +377,11 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
});
let cancel: (() => void) | undefined;
try {
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
});
const job = startWorkerJob(
() => supersede(),
(message) => handleMessage(message),
() => fail('worker-error', 'Planning worker failed before returning explanation data.'),
);
let outcomeSettled = false;
let cancellationRequested = false;
let cancelledRace: PlanningIncompleteOutcome | undefined;
@@ -364,9 +394,7 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
let receivedFinalState: PlanningStateSnapshot | undefined;
let livenessWatchdog: ReturnType<typeof setTimeout>;
const terminate = () => {
worker.onmessage = null;
worker.onerror = null;
worker.terminate();
job.release();
clearTimeout(livenessWatchdog);
};
const finishOutcome = (result: PlanningOutcome) => {
@@ -434,7 +462,6 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
resolveArtifacts(complete);
resolveTimings(complete.timings ?? {});
terminate();
latestWorkerCancels.delete(supersede);
};
const supersede = () => {
if (artifactsSettled) return;
@@ -456,7 +483,6 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
finalState: receivedFinalState ?? checkpointState,
});
};
latestWorkerCancels.add(supersede);
const fail = (code: string, message: string) => {
const reason = { code, message };
if (!outcomeSettled) finishOutcome(interruptedOutcome(checkpoint, code, message));
@@ -508,15 +534,11 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
cancel = () => {
if (outcomeSettled || cancellationRequested) return;
cancellationRequested = true;
try {
worker.postMessage({ type: 'cancel' });
} catch {
fail('worker-error', 'The cancellation request could not reach the worker.');
}
job.post({ type: 'cancel' }, () =>
fail('worker-error', 'The cancellation request could not reach the worker.'),
);
};
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
const message = event.data;
if (!message) return;
const handleMessage = (message: WorkerResponse) => {
if (message.type === 'heartbeat') {
scheduleLivenessWatchdog();
return;
@@ -552,13 +574,9 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
if (message.type === 'error') fail(message.code ?? 'worker-error', message.message);
else fail('worker-error', 'Planning worker returned an unexpected response.');
};
worker.onerror = () =>
fail('worker-error', 'Planning worker failed before returning explanation data.');
try {
worker.postMessage({ type: 'plan', request: { ...snapshot, progress: undefined } });
} catch {
fail('worker-error', 'Planning input could not be sent to the isolated worker.');
}
job.post({ type: 'plan', request: { ...snapshot, progress: undefined } }, () =>
fail('worker-error', 'Planning input could not be sent to the isolated worker.'),
);
} catch {
const result = interruptedOutcome(
undefined,
@@ -677,62 +695,43 @@ function counterfactualInWorker(
): Promise<CounterfactualAnalysis> {
return new Promise((resolve, reject) => {
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 {
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
});
job = startWorkerJob(
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) {
reject(error);
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;
latestWorkerCancels.add(cancel);
worker.onmessage = (
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'}).`,
),
),
job.post({ type: 'counterfactual', request: snapshot, plan, placement, state }, (error) =>
finish(() => reject(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,31 +742,19 @@ function explainCheckpointInWorker(
interruption: PlanningReason,
): Promise<Pick<PlanningArtifacts, 'explanation' | 'explanationError' | 'finalState'>> {
return new Promise((resolve, reject) => {
let worker: Worker;
try {
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
});
} catch (error) {
reject(error);
return;
}
let job: WorkerJob;
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);
job.release();
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')
try {
job = startWorkerJob(
() => finish(() => reject(new Error('Checkpoint explanation was superseded.'))),
(response) => {
if (response.type === 'explanation')
finish(() =>
resolve({
explanation: response.explanation,
@@ -775,21 +762,19 @@ function explainCheckpointInWorker(
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 {
worker.postMessage({
type: 'explain-checkpoint',
request: snapshot,
plan,
state,
interruption,
});
else if (response.type === 'error')
finish(() => reject(new Error(response.message)));
},
() => finish(() => reject(new Error('Checkpoint explanation worker failed.'))),
);
} 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" />
import type {
CounterfactualAnalysis,
LeavePlacement,
PlanOutcome,
PlanningExplanation,
PlanningOutcome,
PlanningReason,
PlanningSnapshot,
PlanningStateSnapshot,
} from './leave-planner.service';
import type { PlanningOutcome } from './leave-planner.service';
import type { WorkerRequest, WorkerResponse } from './leave-planner.worker-messages';
import {
analyzeCounterfactualInternal,
calculatePlanningAsyncInternal,
@@ -17,54 +9,6 @@ import {
type PlanningArtifacts,
} 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;
let activeCalculation: { cancelled: boolean } | undefined;