test scheduled state rollback boundary
This commit is contained in:
@@ -15,6 +15,13 @@ type CapturedParticipant = {
|
|||||||
snapshot: unknown;
|
snapshot: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EngineStateSavepointData = {
|
||||||
|
owner: symbol;
|
||||||
|
participants: readonly CapturedParticipant[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const savepointData = new WeakMap<EngineStateSavepoint, EngineStateSavepointData>();
|
||||||
|
|
||||||
export interface EngineStateInspection {
|
export interface EngineStateInspection {
|
||||||
revision: number;
|
revision: number;
|
||||||
transactionActive: boolean;
|
transactionActive: boolean;
|
||||||
@@ -23,13 +30,10 @@ export interface EngineStateInspection {
|
|||||||
|
|
||||||
export class EngineStateSavepoint {
|
export class EngineStateSavepoint {
|
||||||
readonly revision: number;
|
readonly revision: number;
|
||||||
readonly participants: readonly CapturedParticipant[];
|
|
||||||
readonly owner: symbol;
|
|
||||||
|
|
||||||
constructor(owner: symbol, revision: number, participants: CapturedParticipant[]) {
|
constructor(owner: symbol, revision: number, participants: CapturedParticipant[]) {
|
||||||
this.owner = owner;
|
|
||||||
this.revision = revision;
|
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}`);
|
throw new Error(`Engine state participant is already registered: ${name}`);
|
||||||
}
|
}
|
||||||
this.participants.set(name, {
|
this.participants.set(name, {
|
||||||
capture: participant.capture,
|
capture: () => participant.capture(),
|
||||||
restore: (snapshot) => participant.restore(snapshot as T),
|
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 {
|
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.');
|
throw new Error('Engine state savepoint belongs to a different manager.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private restoreParticipants(savepoint: EngineStateSavepoint): void {
|
private restoreParticipants(savepoint: EngineStateSavepoint): void {
|
||||||
this.assertOwnedSavepoint(savepoint);
|
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 currentNames = Array.from(this.participants.keys());
|
||||||
const capturedNames = savepoint.participants.map(({ name }) => name);
|
const capturedNames = capturedParticipants.map(({ name }) => name);
|
||||||
if (
|
if (
|
||||||
currentNames.length !== capturedNames.length ||
|
currentNames.length !== capturedNames.length ||
|
||||||
currentNames.some((name, index) => name !== capturedNames[index])
|
currentNames.some((name, index) => name !== capturedNames[index])
|
||||||
) {
|
) {
|
||||||
throw new Error('Engine state participant set changed after the savepoint was captured.');
|
throw new Error('Engine state participant set changed after the savepoint was captured.');
|
||||||
}
|
}
|
||||||
for (let index = savepoint.participants.length - 1; index >= 0; index -= 1) {
|
for (let index = capturedParticipants.length - 1; index >= 0; index -= 1) {
|
||||||
const captured = savepoint.participants[index];
|
const captured = capturedParticipants[index];
|
||||||
if (!captured) {
|
if (!captured) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
InMemoryControlQueue,
|
InMemoryControlQueue,
|
||||||
|
EngineStateManager,
|
||||||
ManualClock,
|
ManualClock,
|
||||||
TurnDaemonLifecycle,
|
TurnDaemonLifecycle,
|
||||||
getNextTickTime,
|
getNextTickTime,
|
||||||
@@ -13,6 +14,67 @@ import {
|
|||||||
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
|
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
|
||||||
|
|
||||||
describe('TurnDaemonLifecycle', () => {
|
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 () => {
|
it('runs scheduled turn based on queue front and checkpoint context', async () => {
|
||||||
const turnTermMinutes = 10;
|
const turnTermMinutes = 10;
|
||||||
const lastTurnTime = new Date(2026, 0, 2, 2, 0, 0, 0);
|
const lastTurnTime = new Date(2026, 0, 2, 2, 0, 0, 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user