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