refactor engine state transactions

This commit is contained in:
2026-07-28 01:53:48 +00:00
parent d17261f75a
commit 19a80127c6
8 changed files with 673 additions and 12 deletions
+1
View File
@@ -17,6 +17,7 @@ export * from './scenario/scenarioSeeder.js';
export * from './turn/types.js';
export * from './turn/worldLoader.js';
export * from './turn/inMemoryWorld.js';
export * from './turn/engineStateManager.js';
export * from './turn/inMemoryStateStore.js';
export * from './turn/inMemoryTurnProcessor.js';
export * from './turn/databaseHooks.js';
@@ -15,6 +15,7 @@ import type {
TurnDaemonCommandResult,
TurnDaemonCommandExecutionContext,
} from './types.js';
import type { EngineStateManager } from '../turn/engineStateManager.js';
type PendingRun = {
reason: RunReason;
@@ -43,6 +44,7 @@ export interface TurnDaemonLifecycleDeps {
commandHandler?: TurnDaemonCommandHandler;
commandResponder?: TurnDaemonCommandResponder;
pauseGate?: () => Promise<boolean>;
stateManager?: Pick<EngineStateManager, 'transaction'>;
}
export class TurnDaemonLifecycle {
@@ -56,6 +58,7 @@ export class TurnDaemonLifecycle {
private readonly commandHandler?: TurnDaemonCommandHandler;
private readonly commandResponder?: TurnDaemonCommandResponder;
private readonly pauseGate?: () => Promise<boolean>;
private readonly stateManager?: Pick<EngineStateManager, 'transaction'>;
private readonly options: TurnDaemonLifecycleOptions;
private status: TurnDaemonStatus;
@@ -75,6 +78,7 @@ export class TurnDaemonLifecycle {
this.commandHandler = deps.commandHandler;
this.commandResponder = deps.commandResponder;
this.pauseGate = deps.pauseGate;
this.stateManager = deps.stateManager;
this.options = options;
this.status = {
state: 'idle',
@@ -272,15 +276,18 @@ export class TurnDaemonLifecycle {
const executeHandler = async (
context?: TurnDaemonCommandExecutionContext
): Promise<TurnDaemonCommandResult> => {
const handled = this.commandHandler ? await this.commandHandler.handle(command, context) : null;
return (
handled ?? {
type: 'commandRejected',
ok: false,
commandType: command.type,
reason: '턴 데몬이 명령을 처리할 수 없습니다.',
}
);
const execute = async (): Promise<TurnDaemonCommandResult> => {
const handled = this.commandHandler ? await this.commandHandler.handle(command, context) : null;
return (
handled ?? {
type: 'commandRejected',
ok: false,
commandType: command.type,
reason: '턴 데몬이 명령을 처리할 수 없습니다.',
}
);
};
return this.stateManager ? this.stateManager.transaction(execute) : execute();
};
try {
if (command.requestId && this.hooks?.executeCommand) {
@@ -331,7 +338,8 @@ export class TurnDaemonLifecycle {
let result: TurnRunResult;
try {
result = await this.processor.run(targetTime, budget, checkpoint);
const runProcessor = () => this.processor.run(targetTime, budget, checkpoint);
result = this.stateManager ? await this.stateManager.transaction(runProcessor) : await runProcessor();
} catch (error) {
this.status.running = false;
this.status.state = 'paused';
@@ -0,0 +1,153 @@
export interface EngineStateParticipant<T> {
capture(): T;
restore(snapshot: T): void;
inspect?(): unknown;
}
type RegisteredParticipant = {
capture(): unknown;
restore(snapshot: unknown): void;
inspect?(): unknown;
};
type CapturedParticipant = {
name: string;
snapshot: unknown;
};
export interface EngineStateInspection {
revision: number;
transactionActive: boolean;
participants: Readonly<Record<string, unknown>>;
}
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;
}
}
/**
* Coordinates the mutable state owned by the game-engine worker.
*
* The manager deliberately knows nothing about PostgreSQL or Redis. Database
* transactions remain in databaseHooks; this boundary only prevents a failed
* calculation from leaving a partially-mutated in-memory world behind.
*/
export class EngineStateManager {
private readonly owner = Symbol('EngineStateManager');
private readonly participants = new Map<string, RegisteredParticipant>();
private revision = 0;
private transactionActive = false;
register<T>(name: string, participant: EngineStateParticipant<T>): void {
if (this.transactionActive) {
throw new Error('Cannot register engine state while a transaction is active.');
}
if (!name) {
throw new Error('Engine state participant name is required.');
}
if (this.participants.has(name)) {
throw new Error(`Engine state participant is already registered: ${name}`);
}
this.participants.set(name, {
capture: participant.capture,
restore: (snapshot) => participant.restore(snapshot as T),
inspect: participant.inspect,
});
}
getRevision(): number {
return this.revision;
}
isTransactionActive(): boolean {
return this.transactionActive;
}
capture(): EngineStateSavepoint {
const captured: CapturedParticipant[] = [];
for (const [name, participant] of this.participants) {
captured.push({ name, snapshot: participant.capture() });
}
return new EngineStateSavepoint(this.owner, this.revision, captured);
}
restore(savepoint: EngineStateSavepoint): void {
if (this.transactionActive) {
throw new Error('Cannot restore engine state while a transaction is active.');
}
this.assertOwnedSavepoint(savepoint);
this.restoreParticipants(savepoint);
this.revision += 1;
}
inspect(): EngineStateInspection {
const participants: Record<string, unknown> = {};
for (const [name, participant] of this.participants) {
participants[name] = structuredClone(participant.inspect ? participant.inspect() : participant.capture());
}
return {
revision: this.revision,
transactionActive: this.transactionActive,
participants,
};
}
async transaction<T>(operation: () => Promise<T> | T): Promise<T> {
if (this.transactionActive) {
throw new Error('Nested engine state transactions are not supported.');
}
const savepoint = this.capture();
this.transactionActive = true;
try {
const result = await operation();
this.revision += 1;
return result;
} catch (error) {
try {
this.restoreParticipants(savepoint);
} catch (restoreError) {
throw new AggregateError(
[error, restoreError],
'Engine state operation failed and its in-memory state could not be restored.',
{ cause: restoreError }
);
}
throw error;
} finally {
this.transactionActive = false;
}
}
private assertOwnedSavepoint(savepoint: EngineStateSavepoint): void {
if (savepoint.owner !== this.owner) {
throw new Error('Engine state savepoint belongs to a different manager.');
}
}
private restoreParticipants(savepoint: EngineStateSavepoint): void {
this.assertOwnedSavepoint(savepoint);
const currentNames = Array.from(this.participants.keys());
const capturedNames = savepoint.participants.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];
if (!captured) {
continue;
}
this.participants.get(captured.name)?.restore(captured.snapshot);
}
}
}
+149
View File
@@ -119,6 +119,52 @@ export interface TurnWorldChanges {
pendingNationBettingFinishes: PendingNationBettingFinish[];
}
export interface InMemoryTurnWorldStateSnapshot {
schedule: TurnSchedule;
state: TurnWorldState;
checkpoint?: TurnCheckpoint;
generals: Array<[number, TurnGeneral]>;
cities: Array<[number, City]>;
nations: Array<[number, Nation]>;
troops: Array<[number, Troop]>;
diplomacy: Array<[string, TurnDiplomacy]>;
events: Array<[number, TurnEvent]>;
dirtyGeneralIds: number[];
dirtyCityIds: number[];
dirtyNationIds: number[];
dirtyTroopIds: number[];
dirtyDiplomacyKeys: string[];
createdGeneralIds: number[];
createdNationIds: number[];
createdTroopIds: number[];
createdDiplomacyKeys: string[];
createdEventIds: number[];
deletedTroopIds: number[];
deletedGeneralIds: number[];
deletedNationIds: number[];
deletedEventIds: number[];
deletedNationSnapshots: Array<{ nation: Nation; generalIds: number[]; removedAt: Date }>;
logs: LogEntryDraft[];
messages: MessageDraft[];
lifecycleEvents: GeneralLifecycleEvent[];
pendingNeutralAuctions: PendingNeutralAuction[];
inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }>;
pendingNationBettingOpens: PendingNationBettingOpen[];
pendingNationBettingFinishes: PendingNationBettingFinish[];
}
export interface InMemoryTurnWorldInspection {
state: TurnWorldState;
checkpoint?: TurnCheckpoint;
generals: TurnGeneral[];
cities: City[];
nations: Nation[];
troops: Troop[];
diplomacy: TurnDiplomacy[];
events: TurnEvent[];
changes: TurnWorldChanges;
}
const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => {
const timeDiff = left.turnTime.getTime() - right.turnTime.getTime();
if (timeDiff !== 0) {
@@ -335,6 +381,91 @@ export class InMemoryTurnWorld {
this.ensureDiplomacyMatrix();
}
captureState(): InMemoryTurnWorldStateSnapshot {
return structuredClone({
schedule: this.schedule,
state: this.state,
checkpoint: this.checkpoint,
generals: Array.from(this.generals.entries()),
cities: Array.from(this.cities.entries()),
nations: Array.from(this.nations.entries()),
troops: Array.from(this.troops.entries()),
diplomacy: Array.from(this.diplomacy.entries()),
events: Array.from(this.events.entries()),
dirtyGeneralIds: Array.from(this.dirtyGeneralIds),
dirtyCityIds: Array.from(this.dirtyCityIds),
dirtyNationIds: Array.from(this.dirtyNationIds),
dirtyTroopIds: Array.from(this.dirtyTroopIds),
dirtyDiplomacyKeys: Array.from(this.dirtyDiplomacyKeys),
createdGeneralIds: Array.from(this.createdGeneralIds),
createdNationIds: Array.from(this.createdNationIds),
createdTroopIds: Array.from(this.createdTroopIds),
createdDiplomacyKeys: Array.from(this.createdDiplomacyKeys),
createdEventIds: Array.from(this.createdEventIds),
deletedTroopIds: Array.from(this.deletedTroopIds),
deletedGeneralIds: Array.from(this.deletedGeneralIds),
deletedNationIds: Array.from(this.deletedNationIds),
deletedEventIds: Array.from(this.deletedEventIds),
deletedNationSnapshots: this.deletedNationSnapshots,
logs: this.logs,
messages: this.messages,
lifecycleEvents: this.lifecycleEvents,
pendingNeutralAuctions: this.pendingNeutralAuctions,
inheritancePointAdjustments: this.inheritancePointAdjustments,
pendingNationBettingOpens: this.pendingNationBettingOpens,
pendingNationBettingFinishes: this.pendingNationBettingFinishes,
} satisfies InMemoryTurnWorldStateSnapshot);
}
restoreState(snapshot: InMemoryTurnWorldStateSnapshot): void {
const restored = structuredClone(snapshot);
this.schedule = restored.schedule;
this.state = restored.state;
this.checkpoint = restored.checkpoint;
this.replaceMap(this.generals, restored.generals);
this.replaceMap(this.cities, restored.cities);
this.replaceMap(this.nations, restored.nations);
this.replaceMap(this.troops, restored.troops);
this.replaceMap(this.diplomacy, restored.diplomacy);
this.replaceMap(this.events, restored.events);
this.replaceSet(this.dirtyGeneralIds, restored.dirtyGeneralIds);
this.replaceSet(this.dirtyCityIds, restored.dirtyCityIds);
this.replaceSet(this.dirtyNationIds, restored.dirtyNationIds);
this.replaceSet(this.dirtyTroopIds, restored.dirtyTroopIds);
this.replaceSet(this.dirtyDiplomacyKeys, restored.dirtyDiplomacyKeys);
this.replaceSet(this.createdGeneralIds, restored.createdGeneralIds);
this.replaceSet(this.createdNationIds, restored.createdNationIds);
this.replaceSet(this.createdTroopIds, restored.createdTroopIds);
this.replaceSet(this.createdDiplomacyKeys, restored.createdDiplomacyKeys);
this.replaceSet(this.createdEventIds, restored.createdEventIds);
this.replaceSet(this.deletedTroopIds, restored.deletedTroopIds);
this.replaceSet(this.deletedGeneralIds, restored.deletedGeneralIds);
this.replaceSet(this.deletedNationIds, restored.deletedNationIds);
this.replaceSet(this.deletedEventIds, restored.deletedEventIds);
this.replaceArray(this.deletedNationSnapshots, restored.deletedNationSnapshots);
this.replaceArray(this.logs, restored.logs);
this.replaceArray(this.messages, restored.messages);
this.replaceArray(this.lifecycleEvents, restored.lifecycleEvents);
this.replaceArray(this.pendingNeutralAuctions, restored.pendingNeutralAuctions);
this.replaceArray(this.inheritancePointAdjustments, restored.inheritancePointAdjustments);
this.replaceArray(this.pendingNationBettingOpens, restored.pendingNationBettingOpens);
this.replaceArray(this.pendingNationBettingFinishes, restored.pendingNationBettingFinishes);
}
inspectState(): InMemoryTurnWorldInspection {
return structuredClone({
state: this.state,
checkpoint: this.checkpoint,
generals: Array.from(this.generals.values()),
cities: Array.from(this.cities.values()),
nations: Array.from(this.nations.values()),
troops: Array.from(this.troops.values()),
diplomacy: Array.from(this.diplomacy.values()),
events: Array.from(this.events.values()),
changes: this.peekDirtyState(),
} satisfies InMemoryTurnWorldInspection);
}
getState(): TurnWorldState {
return { ...this.state };
}
@@ -1166,4 +1297,22 @@ export class InMemoryTurnWorld {
}
}
}
private replaceMap<K, V>(target: Map<K, V>, entries: Array<[K, V]>): void {
target.clear();
for (const [key, value] of entries) {
target.set(key, value);
}
}
private replaceSet<T>(target: Set<T>, values: T[]): void {
target.clear();
for (const value of values) {
target.add(value);
}
}
private replaceArray<T>(target: T[], values: T[]): void {
target.splice(0, target.length, ...values);
}
}
@@ -94,6 +94,17 @@ export interface ReservedTurnChanges {
nationLeaseKeys: string[];
}
export interface InMemoryReservedTurnStateSnapshot {
generalTurns: Array<[number, ReservedTurnEntry[]]>;
nationTurns: Array<[string, ReservedTurnEntry[]]>;
dirtyGeneralIds: number[];
dirtyNationKeys: string[];
pendingGeneralInitializationIds: number[];
pendingNationInitializationKeys: string[];
leasedGeneralIds: number[];
leasedNationKeys: string[];
}
export class ReservedTurnLeaseConflictError extends Error {
constructor(readonly queueKey: string) {
super(`Reserved turn queue lease conflict: ${queueKey}.`);
@@ -130,6 +141,35 @@ export class InMemoryReservedTurnStore {
this.leaseDurationMs = options.leaseDurationMs ?? DEFAULT_LEASE_DURATION_MS;
}
captureState(): InMemoryReservedTurnStateSnapshot {
return structuredClone({
generalTurns: Array.from(this.generalTurns.entries()),
nationTurns: Array.from(this.nationTurns.entries()),
dirtyGeneralIds: Array.from(this.dirtyGeneralIds),
dirtyNationKeys: Array.from(this.dirtyNationKeys),
pendingGeneralInitializationIds: Array.from(this.pendingGeneralInitializationIds),
pendingNationInitializationKeys: Array.from(this.pendingNationInitializationKeys),
leasedGeneralIds: Array.from(this.leasedGeneralIds),
leasedNationKeys: Array.from(this.leasedNationKeys),
} satisfies InMemoryReservedTurnStateSnapshot);
}
restoreState(snapshot: InMemoryReservedTurnStateSnapshot): void {
const restored = structuredClone(snapshot);
this.replaceMap(this.generalTurns, restored.generalTurns);
this.replaceMap(this.nationTurns, restored.nationTurns);
this.replaceSet(this.dirtyGeneralIds, restored.dirtyGeneralIds);
this.replaceSet(this.dirtyNationKeys, restored.dirtyNationKeys);
this.replaceSet(this.pendingGeneralInitializationIds, restored.pendingGeneralInitializationIds);
this.replaceSet(this.pendingNationInitializationKeys, restored.pendingNationInitializationKeys);
this.replaceSet(this.leasedGeneralIds, restored.leasedGeneralIds);
this.replaceSet(this.leasedNationKeys, restored.leasedNationKeys);
}
inspectState(): InMemoryReservedTurnStateSnapshot {
return this.captureState();
}
async loadAll(): Promise<void> {
const [generalRows, nationRows] = await Promise.all([
this.prisma.generalTurn.findMany(),
@@ -164,6 +204,20 @@ export class InMemoryReservedTurnStore {
}
}
private replaceMap<K, V>(target: Map<K, V>, entries: Array<[K, V]>): void {
target.clear();
for (const [key, value] of entries) {
target.set(key, value);
}
}
private replaceSet<T>(target: Set<T>, values: T[]): void {
target.clear();
for (const value of values) {
target.add(value);
}
}
private getLeaseExpiresAt(): Date {
return new Date(Date.now() + this.leaseDurationMs);
}
+36 -1
View File
@@ -75,6 +75,7 @@ import {
} from './monthlyCoreEventAction.js';
import { buildCommandEnv } from './reservedTurnCommands.js';
import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
import { EngineStateManager } from './engineStateManager.js';
export interface TurnDaemonRuntimeOptions {
profile: string;
@@ -106,6 +107,7 @@ export interface TurnDaemonRuntime {
world: InMemoryTurnWorld;
controlQueue: TurnDaemonControlQueue;
stateStore: InMemoryTurnStateStore;
stateManager: EngineStateManager;
processor: InMemoryTurnProcessor;
hooks?: TurnDaemonHooks;
close(): Promise<void>;
@@ -475,6 +477,7 @@ const createTurnDaemonRuntimeWithLease = async (
);
let occupiedAuctionUniqueItemKeys: string[] = [];
let refreshOccupiedAuctionUniqueItemKeys = async (): Promise<void> => {};
const prefetchedNationTurns = new Set<string>();
const worldOptions: InMemoryTurnWorldOptions = {
schedule,
generalTurnHandler:
@@ -496,8 +499,38 @@ const createTurnDaemonRuntimeWithLease = async (
const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions);
worldRef = world;
const stateManager = new EngineStateManager();
stateManager.register('world', {
capture: () => world.captureState(),
restore: (captured) => world.restoreState(captured),
inspect: () => world.inspectState(),
});
if (reservedTurnStoreHandle) {
stateManager.register('reservedTurns', {
capture: () => reservedTurnStoreHandle.store.captureState(),
restore: (captured) => reservedTurnStoreHandle.store.restoreState(captured),
inspect: () => reservedTurnStoreHandle.store.inspectState(),
});
}
stateManager.register('runtimeCaches', {
capture: () => ({
monthlyNationPowerRollCount,
monthlyTournamentRollConsumed,
occupiedAuctionUniqueItemKeys: [...occupiedAuctionUniqueItemKeys],
prefetchedNationTurns: Array.from(prefetchedNationTurns),
}),
restore: (captured) => {
monthlyNationPowerRollCount = captured.monthlyNationPowerRollCount;
monthlyTournamentRollConsumed = captured.monthlyTournamentRollConsumed;
occupiedAuctionUniqueItemKeys = [...captured.occupiedAuctionUniqueItemKeys];
prefetchedNationTurns.clear();
for (const key of captured.prefetchedNationTurns) {
prefetchedNationTurns.add(key);
}
},
});
const stateStore = new InMemoryTurnStateStore(world);
const prefetchedNationTurns = new Set<string>();
const processor = new InMemoryTurnProcessor(world, {
tickMinutes,
beforeExecuteGeneral: reservedTurnStoreHandle
@@ -709,6 +742,7 @@ const createTurnDaemonRuntimeWithLease = async (
pauseGate: async () => turnDaemonLease?.isLost() || ((await pauseGate?.()) ?? false),
commandHandler,
commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined),
stateManager,
},
{ profile: options.profile, defaultBudget }
);
@@ -749,6 +783,7 @@ const createTurnDaemonRuntimeWithLease = async (
world,
controlQueue: resolvedControlQueue,
stateStore,
stateManager,
processor,
hooks,
close,