test scheduled state rollback boundary
This commit is contained in:
@@ -15,6 +15,13 @@ type CapturedParticipant = {
|
||||
snapshot: unknown;
|
||||
};
|
||||
|
||||
type EngineStateSavepointData = {
|
||||
owner: symbol;
|
||||
participants: readonly CapturedParticipant[];
|
||||
};
|
||||
|
||||
const savepointData = new WeakMap<EngineStateSavepoint, EngineStateSavepointData>();
|
||||
|
||||
export interface EngineStateInspection {
|
||||
revision: number;
|
||||
transactionActive: boolean;
|
||||
@@ -23,13 +30,10 @@ export interface EngineStateInspection {
|
||||
|
||||
export class EngineStateSavepoint {
|
||||
readonly revision: number;
|
||||
readonly participants: readonly CapturedParticipant[];
|
||||
readonly owner: symbol;
|
||||
|
||||
constructor(owner: symbol, revision: number, participants: CapturedParticipant[]) {
|
||||
this.owner = owner;
|
||||
this.revision = revision;
|
||||
this.participants = participants;
|
||||
savepointData.set(this, { owner, participants });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,9 +61,9 @@ export class EngineStateManager {
|
||||
throw new Error(`Engine state participant is already registered: ${name}`);
|
||||
}
|
||||
this.participants.set(name, {
|
||||
capture: participant.capture,
|
||||
capture: () => participant.capture(),
|
||||
restore: (snapshot) => participant.restore(snapshot as T),
|
||||
inspect: participant.inspect,
|
||||
inspect: participant.inspect ? () => participant.inspect?.() : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -127,23 +131,27 @@ export class EngineStateManager {
|
||||
}
|
||||
|
||||
private assertOwnedSavepoint(savepoint: EngineStateSavepoint): void {
|
||||
if (savepoint.owner !== this.owner) {
|
||||
if (savepointData.get(savepoint)?.owner !== this.owner) {
|
||||
throw new Error('Engine state savepoint belongs to a different manager.');
|
||||
}
|
||||
}
|
||||
|
||||
private restoreParticipants(savepoint: EngineStateSavepoint): void {
|
||||
this.assertOwnedSavepoint(savepoint);
|
||||
const capturedParticipants = savepointData.get(savepoint)?.participants;
|
||||
if (!capturedParticipants) {
|
||||
throw new Error('Engine state savepoint data is unavailable.');
|
||||
}
|
||||
const currentNames = Array.from(this.participants.keys());
|
||||
const capturedNames = savepoint.participants.map(({ name }) => name);
|
||||
const capturedNames = capturedParticipants.map(({ name }) => name);
|
||||
if (
|
||||
currentNames.length !== capturedNames.length ||
|
||||
currentNames.some((name, index) => name !== capturedNames[index])
|
||||
) {
|
||||
throw new Error('Engine state participant set changed after the savepoint was captured.');
|
||||
}
|
||||
for (let index = savepoint.participants.length - 1; index >= 0; index -= 1) {
|
||||
const captured = savepoint.participants[index];
|
||||
for (let index = capturedParticipants.length - 1; index >= 0; index -= 1) {
|
||||
const captured = capturedParticipants[index];
|
||||
if (!captured) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
InMemoryControlQueue,
|
||||
EngineStateManager,
|
||||
ManualClock,
|
||||
TurnDaemonLifecycle,
|
||||
getNextTickTime,
|
||||
@@ -13,6 +14,67 @@ import {
|
||||
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
|
||||
|
||||
describe('TurnDaemonLifecycle', () => {
|
||||
it('restores engine state when a scheduled calculation throws', async () => {
|
||||
const now = new Date('2026-01-01T00:10:00.000Z');
|
||||
const queue = new InMemoryControlQueue();
|
||||
let engineState = { value: 'before' };
|
||||
const stateManager = new EngineStateManager();
|
||||
stateManager.register('test', {
|
||||
capture: () => structuredClone(engineState),
|
||||
restore: (snapshot) => {
|
||||
engineState = snapshot;
|
||||
},
|
||||
});
|
||||
let resolveError: (() => void) | undefined;
|
||||
const errorObserved = new Promise<void>((resolve) => {
|
||||
resolveError = resolve;
|
||||
});
|
||||
const lifecycle = new TurnDaemonLifecycle(
|
||||
{
|
||||
clock: new ManualClock(now.getTime()),
|
||||
controlQueue: queue,
|
||||
getNextTickTime: () => new Date('2026-01-01T00:05:00.000Z'),
|
||||
stateStore: {
|
||||
loadLastTurnTime: async () => new Date('2026-01-01T00:00:00.000Z'),
|
||||
loadNextGeneralTurnTime: async () => null,
|
||||
saveLastTurnTime: async () => {},
|
||||
loadCheckpoint: async () => undefined,
|
||||
saveCheckpoint: async () => {},
|
||||
},
|
||||
processor: {
|
||||
run: async () => {
|
||||
engineState.value = 'partial';
|
||||
throw new Error('scheduled calculation failed');
|
||||
},
|
||||
},
|
||||
stateManager,
|
||||
hooks: {
|
||||
onRunError: async () => {
|
||||
resolveError?.();
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
profile: 'test',
|
||||
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
|
||||
}
|
||||
);
|
||||
|
||||
const loop = lifecycle.start();
|
||||
await errorObserved;
|
||||
|
||||
expect(engineState).toEqual({ value: 'before' });
|
||||
expect(stateManager.getRevision()).toBe(0);
|
||||
expect(lifecycle.getStatus()).toMatchObject({
|
||||
state: 'paused',
|
||||
paused: true,
|
||||
lastError: 'scheduled calculation failed',
|
||||
});
|
||||
|
||||
await lifecycle.stop('done');
|
||||
await loop;
|
||||
});
|
||||
|
||||
it('runs scheduled turn based on queue front and checkpoint context', async () => {
|
||||
const turnTermMinutes = 10;
|
||||
const lastTurnTime = new Date(2026, 0, 2, 2, 0, 0, 0);
|
||||
|
||||
Reference in New Issue
Block a user