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:
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!);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user