feat(planner): implement cancellation for planning calculations

- Refactor leave-planner.worker.ts to support cancellation of ongoing calculations.
- Introduce `calculatePlanningAsyncInternal` for asynchronous planning calculations.
- Add cancellation handling in the worker message event.
- Update planning-date.ts with new utility functions for date manipulation.
- Extend translation-service.ts and translation files (en.json, de.json, es.json, fr.json) to include new strings related to planning horizon and cancellation.
- Create documentation for the cancellation feature in planner-cancellation.md.
- Add unit tests for cancellation functionality in leave-planner-cancellation.spec.ts and leave-planner.worker.spec.ts.

#57 #58
This commit is contained in:
nessar committed 2026-10-05 15:34:07 +02:00
1 parent 5c35a59805
commit 2edf2004f5
31 files changed
+2275 -304

No files matched your search

@@ -0,0 +1,235 @@
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { Worker as NodeWorker } from 'node:worker_threads';
import { promisify } from 'node:util';
import { vi } from 'vitest';
import { createPlanningCalculation } from './leave-planner.service';
describe('real planning worker cancellation', () => {
let directory: string;
let bundle: string;
let afterCheckpoint: (() => void) | undefined;
const threads = new Set<NodeWorker>();
beforeAll(async () => {
directory = mkdtempSync(join(tmpdir(), 'vac-optimizer-worker-'));
bundle = join(directory, 'worker.mjs');
const entry = join(directory, 'entry.ts');
writeFileSync(
entry,
`import { parentPort } from 'node:worker_threads';
globalThis.self = { postMessage: (message) => parentPort.postMessage(message) };
await import(${JSON.stringify(resolve('src/app/services/leave-planner.worker.ts'))});
parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
`,
);
await promisify(execFile)('bun', ['build', entry, '--target=node', '--outfile', bundle]);
});
beforeEach(() => {
afterCheckpoint = undefined;
class RealWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
private thread = new NodeWorker(bundle);
constructor() {
threads.add(this.thread);
this.thread.on('message', (data) => {
this.onmessage?.({ data } as MessageEvent);
if (data.type === 'checkpoint') afterCheckpoint?.();
});
this.thread.on('error', () => this.onerror?.());
}
postMessage(message: unknown) {
this.thread.postMessage(message);
}
terminate() {
void this.thread.terminate();
}
}
vi.stubGlobal('Worker', RealWorker);
});
afterEach(async () => {
await Promise.all([...threads].map((thread) => thread.terminate()));
threads.clear();
vi.unstubAllGlobals();
});
afterAll(() => rmSync(directory, { recursive: true, force: true }));
it('stops during calendar preparation without a plan or an obligation conflict', async () => {
const stages: string[] = [];
let preparationUpdates = 0;
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2025-01-01' },
balances: [],
rules: [],
progress: (stage) => {
stages.push(stage);
if (stage === 'preparation' && ++preparationUpdates === 2) calculation.cancel();
},
});
const outcome = await calculation.outcome;
expect(outcome).toMatchObject({
kind: 'PlanningIncomplete',
reasons: [{ code: 'user-cancelled' }],
});
if (outcome.kind !== 'PlanningIncomplete') return;
expect(outcome.incumbent).toBeUndefined();
expect(preparationUpdates).toBeGreaterThanOrEqual(2);
expect(stages).not.toContain('obligations');
expect((await calculation.trace)?.status).toBe('incomplete');
expect(await calculation.explanationError).toBeUndefined();
});
it('cancels before a displayed valid plan without reporting a queued conflict', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2025-01-01' },
balances: [],
rules: [
{
id: 'required',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-01',
endDate: '2024-12-31',
placeDays: 1,
placeType: 'cp',
},
],
progress: (stage) => {
if (stage === 'obligations') calculation.cancel();
},
});
const outcome = await calculation.outcome;
expect(outcome).toMatchObject({
kind: 'PlanningIncomplete',
reasons: [{ code: 'user-cancelled' }],
});
if (outcome.kind !== 'PlanningIncomplete') return;
expect(outcome.incumbent).toBeUndefined();
expect((await calculation.trace)?.status).toBe('incomplete');
});
it('stops an active obligation search and retains a jointly valid incumbent and its history', async () => {
let cancelledAt = 0;
afterCheckpoint = () => {
afterCheckpoint = undefined;
setTimeout(() => {
cancelledAt = performance.now();
calculation.cancel();
}, 10);
};
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2025-01-01' },
balances: [{ id: 'cp', type: 'cp', quantity: 6, expiryDate: '2024-12-31' }],
rules: ['first', 'second'].map((id) => ({
id,
type: 'time_window' as const,
force: 'obligation' as const,
startDate: '2024-01-01',
endDate: '2024-12-31',
placeDays: 3,
placeType: 'cp' as const,
})),
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('PlanningIncomplete');
if (outcome.kind !== 'PlanningIncomplete') return;
expect(outcome.reasons[0].code).toBe('user-cancelled');
expect(performance.now() - cancelledAt).toBeLessThan(2000);
expect(outcome.completedStages).not.toContain('obligations');
expect(outcome.incumbent).toBeDefined();
const incumbent = outcome.incumbent!;
for (const id of ['first', 'second'])
expect(
incumbent.placements.filter((placement) => placement.ruleIds.includes(id)),
).toHaveLength(3);
expect(
incumbent.remainingBalances.reduce((sum, balance) => sum + balance.quantity, 0) +
incumbent.placements.length,
).toBe(6);
const explanation = await calculation.explanation;
expect(explanation?.kind).toBe('PlanExplanation');
if (explanation?.kind === 'PlanExplanation') {
expect(explanation.placements.map(({ placement }) => placement)).toEqual(
incumbent.placements,
);
expect(explanation.incomplete?.reason.code).toBe('user-cancelled');
}
const trace = await calculation.trace;
expect(trace?.status).toBe('incomplete');
expect(
trace?.phases.find((phase) => phase.name === 'obligations')?.decisions.length,
).toBeGreaterThan(0);
expect(trace?.phases.find((phase) => phase.name === 'dates')?.status).toBe('not-run');
});
it('preserves balances and provenance after a valid checkpoint and drains every produced trace packet', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2025-01-01' },
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-12-31' }],
rules: [
{
id: 'fixed',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-02',
endDate: '2024-01-02',
placeDays: 1,
placeType: 'cp',
},
],
progress: (stage) => {
if (stage === 'non-working-day-gain') calculation.cancel();
},
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('PlanningIncomplete');
if (outcome.kind !== 'PlanningIncomplete') return;
expect(outcome.reasons[0].code).toBe('user-cancelled');
expect(outcome.completedStages).toContain('holiday-week-value');
expect(outcome.completedStages).not.toContain('non-working-day-gain');
const incumbent = outcome.incumbent!;
expect(incumbent.placements).toHaveLength(3);
expect(incumbent.placements).toContainEqual({
date: '2024-01-02',
type: 'cp',
balanceId: 'cp',
ruleIds: ['fixed'],
supporting: false,
});
expect(incumbent.remainingBalances).toEqual([]);
const explanation = await calculation.explanation;
expect(explanation?.kind).toBe('PlanExplanation');
if (explanation?.kind === 'PlanExplanation')
expect(explanation.placements.map(({ placement }) => placement)).toEqual(
incumbent.placements,
);
const trace = await calculation.trace;
expect(trace?.status).toBe('incomplete');
expect(trace?.error?.code).toBe('user-cancelled');
expect(trace?.phases.find((phase) => phase.name === 'non-working-day-gain')?.status).toBe(
'interrupted',
);
const frozenOutcome = structuredClone(outcome);
calculation.cancel();
expect(await calculation.outcome).toEqual(frozenOutcome);
});
it('completes the same deterministic plan as the synchronous adapter when not cancelled', async () => {
const submitted = {
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [{ id: 'cp', type: 'cp' as const, quantity: 2, expiryDate: '2024-01-31' }],
rules: [],
};
const { calculatePlanningInternal } = await import('./leave-planner.core');
const expected = calculatePlanningInternal(submitted);
const calculation = createPlanningCalculation(submitted);
expect(await calculation.outcome).toEqual(expected);
expect((await calculation.trace)?.status).toBe('ready');
expect(await calculation.explanationError).toBeUndefined();
});
});