refactor engine state transactions
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user