fix(game-engine): sync clock authority during pause
This commit is contained in:
@@ -171,7 +171,8 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
'changePermission',
|
||||
'appoint',
|
||||
'setNationSetting',
|
||||
'setNpcPolicy'
|
||||
'setNpcPolicy',
|
||||
'shiftSchedule'
|
||||
)
|
||||
)
|
||||
OR (
|
||||
|
||||
@@ -113,6 +113,41 @@ const readDbWall = async (db: GamePrisma.TransactionClient): Promise<Date> => {
|
||||
|
||||
export const readClockDatabaseWall = readDbWall;
|
||||
|
||||
const isRetryableSerializableClockError = (error: unknown): boolean => {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const value = error as { code?: unknown; message?: unknown; meta?: unknown };
|
||||
const meta =
|
||||
value.meta && typeof value.meta === 'object'
|
||||
? (value.meta as { code?: unknown; message?: unknown })
|
||||
: undefined;
|
||||
const code = typeof value.code === 'string' ? value.code : '';
|
||||
const databaseCode = typeof meta?.code === 'string' ? meta.code : '';
|
||||
const message = [value.message, meta?.message]
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.join(' ');
|
||||
return (
|
||||
code === 'P2034' ||
|
||||
code === '40001' ||
|
||||
databaseCode === '40001' ||
|
||||
message.includes('40001') ||
|
||||
message.includes('could not serialize access')
|
||||
);
|
||||
};
|
||||
|
||||
const runSerializableClockOperation = async <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
const maxAttempts = 3;
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (attempt >= maxAttempts || !isRetryableSerializableClockError(error)) {
|
||||
throw error;
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, attempt * 10));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const verifyAuthority = async (db: GamePrisma.TransactionClient, authority: ClockOperationAuthority): Promise<void> => {
|
||||
const rows = await db.$queryRaw<LeaseFenceRow[]>(GamePrisma.sql`
|
||||
SELECT owner_id AS "ownerId",
|
||||
@@ -185,64 +220,64 @@ const readParticipantSnapshots = async (
|
||||
): Promise<ParticipantSnapshot[]> => {
|
||||
const [world, generals, auctions, auctionBids, messages, inheritanceEffects, votes, pool, npcTokens, commands] =
|
||||
await Promise.all([
|
||||
db.worldState.findUniqueOrThrow({
|
||||
where: { id: worldStateId },
|
||||
select: {
|
||||
clockTick: true,
|
||||
clockRevision: true,
|
||||
deadlineGeneration: true,
|
||||
lastTurnTick: true,
|
||||
meta: true,
|
||||
},
|
||||
}),
|
||||
db.general.findMany({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, turnTick: true, recentWarTick: true, meta: true },
|
||||
}),
|
||||
db.auction.findMany({
|
||||
where: { status: { in: ['OPEN', 'FINALIZING'] } },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, status: true, openTick: true, closeTick: true },
|
||||
}),
|
||||
db.auctionBid.findMany({
|
||||
where: { auction: { status: { in: ['OPEN', 'FINALIZING'] } } },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, occurredGameTick: true },
|
||||
}),
|
||||
db.messageAction.findMany({
|
||||
where: { status: 'PENDING' },
|
||||
orderBy: { messageId: 'asc' },
|
||||
select: {
|
||||
messageId: true,
|
||||
createdGameTick: true,
|
||||
expiresGameTick: true,
|
||||
clockRevision: true,
|
||||
deadlineGeneration: true,
|
||||
},
|
||||
}),
|
||||
db.inheritanceLedger.findMany({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, appliedClockRevision: true, appliedDeadlineGeneration: true },
|
||||
}),
|
||||
db.votePoll.findMany({
|
||||
where: { closedAt: null },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, startTick: true, endTick: true },
|
||||
}),
|
||||
db.selectPoolEntry.findMany({
|
||||
where: { generalId: null },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, reservedUntilTick: true },
|
||||
}),
|
||||
db.npcSelectionToken.findMany({
|
||||
orderBy: { ownerUserId: 'asc' },
|
||||
select: { ownerUserId: true, validUntilTick: true, pickMoreFromTick: true },
|
||||
}),
|
||||
db.inputEvent.findMany({
|
||||
where: { status: { in: ['PENDING', 'PROCESSING'] } },
|
||||
orderBy: { sequence: 'asc' },
|
||||
select: { sequence: true, acceptedGameTick: true, acceptedClockRevision: true },
|
||||
}),
|
||||
db.worldState.findUniqueOrThrow({
|
||||
where: { id: worldStateId },
|
||||
select: {
|
||||
clockTick: true,
|
||||
clockRevision: true,
|
||||
deadlineGeneration: true,
|
||||
lastTurnTick: true,
|
||||
meta: true,
|
||||
},
|
||||
}),
|
||||
db.general.findMany({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, turnTick: true, recentWarTick: true, meta: true },
|
||||
}),
|
||||
db.auction.findMany({
|
||||
where: { status: { in: ['OPEN', 'FINALIZING'] } },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, status: true, openTick: true, closeTick: true },
|
||||
}),
|
||||
db.auctionBid.findMany({
|
||||
where: { auction: { status: { in: ['OPEN', 'FINALIZING'] } } },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, occurredGameTick: true },
|
||||
}),
|
||||
db.messageAction.findMany({
|
||||
where: { status: 'PENDING' },
|
||||
orderBy: { messageId: 'asc' },
|
||||
select: {
|
||||
messageId: true,
|
||||
createdGameTick: true,
|
||||
expiresGameTick: true,
|
||||
clockRevision: true,
|
||||
deadlineGeneration: true,
|
||||
},
|
||||
}),
|
||||
db.inheritanceLedger.findMany({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, appliedClockRevision: true, appliedDeadlineGeneration: true },
|
||||
}),
|
||||
db.votePoll.findMany({
|
||||
where: { closedAt: null },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, startTick: true, endTick: true },
|
||||
}),
|
||||
db.selectPoolEntry.findMany({
|
||||
where: { generalId: null },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, reservedUntilTick: true },
|
||||
}),
|
||||
db.npcSelectionToken.findMany({
|
||||
orderBy: { ownerUserId: 'asc' },
|
||||
select: { ownerUserId: true, validUntilTick: true, pickMoreFromTick: true },
|
||||
}),
|
||||
db.inputEvent.findMany({
|
||||
where: { status: { in: ['PENDING', 'PROCESSING'] } },
|
||||
orderBy: { sequence: 'asc' },
|
||||
select: { sequence: true, acceptedGameTick: true, acceptedClockRevision: true },
|
||||
}),
|
||||
]);
|
||||
const snapshot = (key: string, policy: ParticipantSnapshot['policy'], rows: unknown[]): ParticipantSnapshot => ({
|
||||
key,
|
||||
@@ -686,90 +721,97 @@ export const startClockSuspension = async (options: {
|
||||
if (!Number.isSafeInteger(catchUpTicks) || catchUpTicks < 0) {
|
||||
throw new Error('Clock suspension catch-up ticks must be a non-negative safe integer.');
|
||||
}
|
||||
return options.db.$transaction(
|
||||
async (db) => {
|
||||
await verifyAuthority(db, options.authority);
|
||||
await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
await acquireGameSchemaAdvisoryXactLock(db, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
||||
const worldStateId = await lockWorld(db);
|
||||
const existing = await db.clockSuspension.findUnique({ where: { id: options.suspensionId } });
|
||||
if (existing) {
|
||||
if (
|
||||
existing.worldStateId !== worldStateId ||
|
||||
existing.source !== options.source ||
|
||||
existing.policy !== policy
|
||||
) {
|
||||
throw new Error(
|
||||
`Clock suspension ID ${options.suspensionId} is already bound to another operation.`
|
||||
);
|
||||
return runSerializableClockOperation(() =>
|
||||
options.db.$transaction(
|
||||
async (db) => {
|
||||
await verifyAuthority(db, options.authority);
|
||||
await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
await acquireGameSchemaAdvisoryXactLock(db, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
||||
const worldStateId = await lockWorld(db);
|
||||
const existing = await db.clockSuspension.findUnique({ where: { id: options.suspensionId } });
|
||||
if (existing) {
|
||||
if (
|
||||
existing.worldStateId !== worldStateId ||
|
||||
existing.source !== options.source ||
|
||||
existing.policy !== policy
|
||||
) {
|
||||
throw new Error(
|
||||
`Clock suspension ID ${options.suspensionId} is already bound to another operation.`
|
||||
);
|
||||
}
|
||||
if (existing.status !== 'SUSPENDED') {
|
||||
throw new Error(
|
||||
`Clock suspension ${options.suspensionId} already advanced to ${existing.status}.`
|
||||
);
|
||||
}
|
||||
return {
|
||||
suspensionId: existing.id,
|
||||
phase: 'SUSPENDED' as const,
|
||||
sourceRevision: safeNumber(existing.sourceRevision, 'source revision'),
|
||||
targetRevision: safeNumber(existing.targetRevision, 'target revision'),
|
||||
cutTick: safeNumber(existing.cutTick, 'cut tick'),
|
||||
cutWallAt: existing.cutWallAt,
|
||||
};
|
||||
}
|
||||
if (existing.status !== 'SUSPENDED') {
|
||||
throw new Error(`Clock suspension ${options.suspensionId} already advanced to ${existing.status}.`);
|
||||
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
|
||||
const phase = parseGameClockPhase(world.clockPhase);
|
||||
if (phase !== 'RUNNING') {
|
||||
throw new Error(`Clock suspension can start only from RUNNING; current phase is ${phase}.`);
|
||||
}
|
||||
if (!world.clockBaseTime || world.clockTick === null || !world.clockWallAnchor) {
|
||||
throw new Error('Clock suspension requires a fully initialized logical game clock.');
|
||||
}
|
||||
const cutWallAt = await readDbWall(db);
|
||||
const storedTick = safeNumber(world.clockTick, 'world clock tick');
|
||||
const sourceRevision = safeNumber(world.clockRevision, 'world clock revision');
|
||||
const clock = new GameClock({
|
||||
baseTime: world.clockBaseTime,
|
||||
tick: storedTick,
|
||||
mode: world.clockMode === 'manual' ? 'manual' : 'realtime',
|
||||
wallAnchor: world.clockWallAnchor,
|
||||
turnSeconds: world.tickSeconds,
|
||||
phase,
|
||||
revision: sourceRevision,
|
||||
});
|
||||
const cutTick = clock.nowTick(cutWallAt);
|
||||
await lockParticipants(db, BigInt(cutTick));
|
||||
await db.worldState.update({
|
||||
where: { id: worldStateId },
|
||||
data: { clockPhase: 'SUSPENDED', clockTick: BigInt(cutTick), clockWallAnchor: cutWallAt },
|
||||
});
|
||||
const participants = await readParticipantSnapshots(db, worldStateId, BigInt(cutTick));
|
||||
await db.clockSuspension.create({
|
||||
data: {
|
||||
id: options.suspensionId,
|
||||
worldStateId,
|
||||
source: options.source,
|
||||
policy,
|
||||
status: 'SUSPENDED',
|
||||
sourceRevision: BigInt(sourceRevision),
|
||||
targetRevision: BigInt(sourceRevision + 1),
|
||||
cutTick: BigInt(cutTick),
|
||||
cutWallAt,
|
||||
rateTicksPerSecond: GAME_TICKS_PER_TURN / world.tickSeconds,
|
||||
catchUpTicks: BigInt(catchUpTicks),
|
||||
participantChecksumBefore: aggregateChecksum(participants),
|
||||
detail: asJson({
|
||||
authority: options.authority.kind,
|
||||
profileName: options.authority.profileName,
|
||||
}),
|
||||
},
|
||||
});
|
||||
await persistInitialParticipants(db, options.suspensionId, participants);
|
||||
return {
|
||||
suspensionId: existing.id,
|
||||
phase: 'SUSPENDED' as const,
|
||||
sourceRevision: safeNumber(existing.sourceRevision, 'source revision'),
|
||||
targetRevision: safeNumber(existing.targetRevision, 'target revision'),
|
||||
cutTick: safeNumber(existing.cutTick, 'cut tick'),
|
||||
cutWallAt: existing.cutWallAt,
|
||||
};
|
||||
}
|
||||
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
|
||||
const phase = parseGameClockPhase(world.clockPhase);
|
||||
if (phase !== 'RUNNING') {
|
||||
throw new Error(`Clock suspension can start only from RUNNING; current phase is ${phase}.`);
|
||||
}
|
||||
if (!world.clockBaseTime || world.clockTick === null || !world.clockWallAnchor) {
|
||||
throw new Error('Clock suspension requires a fully initialized logical game clock.');
|
||||
}
|
||||
const cutWallAt = await readDbWall(db);
|
||||
const storedTick = safeNumber(world.clockTick, 'world clock tick');
|
||||
const sourceRevision = safeNumber(world.clockRevision, 'world clock revision');
|
||||
const clock = new GameClock({
|
||||
baseTime: world.clockBaseTime,
|
||||
tick: storedTick,
|
||||
mode: world.clockMode === 'manual' ? 'manual' : 'realtime',
|
||||
wallAnchor: world.clockWallAnchor,
|
||||
turnSeconds: world.tickSeconds,
|
||||
phase,
|
||||
revision: sourceRevision,
|
||||
});
|
||||
const cutTick = clock.nowTick(cutWallAt);
|
||||
await lockParticipants(db, BigInt(cutTick));
|
||||
await db.worldState.update({
|
||||
where: { id: worldStateId },
|
||||
data: { clockPhase: 'SUSPENDED', clockTick: BigInt(cutTick), clockWallAnchor: cutWallAt },
|
||||
});
|
||||
const participants = await readParticipantSnapshots(db, worldStateId, BigInt(cutTick));
|
||||
await db.clockSuspension.create({
|
||||
data: {
|
||||
id: options.suspensionId,
|
||||
worldStateId,
|
||||
source: options.source,
|
||||
policy,
|
||||
status: 'SUSPENDED',
|
||||
sourceRevision: BigInt(sourceRevision),
|
||||
targetRevision: BigInt(sourceRevision + 1),
|
||||
cutTick: BigInt(cutTick),
|
||||
suspensionId: options.suspensionId,
|
||||
phase: 'SUSPENDED',
|
||||
sourceRevision,
|
||||
targetRevision: sourceRevision + 1,
|
||||
cutTick,
|
||||
cutWallAt,
|
||||
rateTicksPerSecond: GAME_TICKS_PER_TURN / world.tickSeconds,
|
||||
catchUpTicks: BigInt(catchUpTicks),
|
||||
participantChecksumBefore: aggregateChecksum(participants),
|
||||
detail: asJson({ authority: options.authority.kind, profileName: options.authority.profileName }),
|
||||
},
|
||||
});
|
||||
await persistInitialParticipants(db, options.suspensionId, participants);
|
||||
return {
|
||||
suspensionId: options.suspensionId,
|
||||
phase: 'SUSPENDED',
|
||||
sourceRevision,
|
||||
targetRevision: sourceRevision + 1,
|
||||
cutTick,
|
||||
cutWallAt,
|
||||
};
|
||||
},
|
||||
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
|
||||
};
|
||||
},
|
||||
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -983,18 +1025,20 @@ export const reconcileClockSuspension = async (options: {
|
||||
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
|
||||
testResumeWallAt?: Date;
|
||||
}): Promise<ClockReconciliationResult> =>
|
||||
options.db.$transaction(
|
||||
async (db) => {
|
||||
await verifyAuthority(db, options.authority);
|
||||
await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
await acquireGameSchemaAdvisoryXactLock(db, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
||||
return reconcileClockSuspensionInTransaction({
|
||||
db,
|
||||
suspensionId: options.suspensionId,
|
||||
profileName: options.authority.profileName,
|
||||
authority: options.authority,
|
||||
...(options.testResumeWallAt ? { testResumeWallAt: options.testResumeWallAt } : {}),
|
||||
});
|
||||
},
|
||||
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
|
||||
runSerializableClockOperation(() =>
|
||||
options.db.$transaction(
|
||||
async (db) => {
|
||||
await verifyAuthority(db, options.authority);
|
||||
await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
await acquireGameSchemaAdvisoryXactLock(db, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
||||
return reconcileClockSuspensionInTransaction({
|
||||
db,
|
||||
suspensionId: options.suspensionId,
|
||||
profileName: options.authority.profileName,
|
||||
authority: options.authority,
|
||||
...(options.testResumeWallAt ? { testResumeWallAt: options.testResumeWallAt } : {}),
|
||||
});
|
||||
},
|
||||
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
|
||||
)
|
||||
);
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
refreshClockProjectionForFinalClockUnderHeldLocks,
|
||||
} from './clockReconciliation.js';
|
||||
import { applyNextClockProjection, type ClockProjectionRedis } from './clockProjectionOutbox.js';
|
||||
import { synchronizeRuntimeClockAuthorityUnderHeldLock } from './runtimeClockAuthoritySync.js';
|
||||
|
||||
export interface DatabaseTurnHooks {
|
||||
hooks: TurnDaemonHooks;
|
||||
@@ -71,6 +72,7 @@ export interface DatabaseTurnHooks {
|
||||
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
|
||||
close(): Promise<void>;
|
||||
applyClockProjection(redis: ClockProjectionRedis, workerId: string): Promise<boolean>;
|
||||
synchronizeClockAuthority(): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface CommittedReadModelChangeReceipt {
|
||||
@@ -2010,6 +2012,7 @@ export const createDatabaseTurnHooks = async (
|
||||
await options?.turnDaemonLease?.assertActive(transaction);
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
||||
await synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world);
|
||||
const leaseToken = options?.turnDaemonLease?.getToken();
|
||||
const directLogFloor =
|
||||
(
|
||||
@@ -2054,6 +2057,12 @@ export const createDatabaseTurnHooks = async (
|
||||
});
|
||||
return clock?.clockPhase === 'RUNNING' || clock?.clockPhase === 'MANUAL';
|
||||
},
|
||||
synchronizeClockAuthority: () =>
|
||||
prisma.$transaction(async (transaction) => {
|
||||
await options?.turnDaemonLease?.assertActive(transaction);
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
return synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world);
|
||||
}, transactionOptions),
|
||||
close: () => connector.disconnect(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -135,6 +135,20 @@ export interface InMemoryGameClockState {
|
||||
deadlineGeneration: number;
|
||||
}
|
||||
|
||||
export interface DurableGameClockSnapshot extends InMemoryGameClockState {
|
||||
lastTurnTick: number;
|
||||
}
|
||||
|
||||
export interface DurableClockReconciliationAlignment {
|
||||
suspensionId: string;
|
||||
sourceRevision: number;
|
||||
targetRevision: number;
|
||||
deadlineGeneration: number;
|
||||
alignedTick: number;
|
||||
shiftTicks: number;
|
||||
resumeWallAt: Date;
|
||||
}
|
||||
|
||||
export type InheritancePersistencePhase = 'before_lifecycle' | 'after_lifecycle';
|
||||
|
||||
export interface PendingInheritancePointAdjustment {
|
||||
@@ -723,23 +737,14 @@ export class InMemoryTurnWorld {
|
||||
this.state = { ...this.state, clockPhase: 'COMPLETED' };
|
||||
}
|
||||
|
||||
applyClockReconciliation(input: {
|
||||
suspensionId: string;
|
||||
alignedTick: number;
|
||||
shiftTicks: number;
|
||||
targetRevision: number;
|
||||
deadlineGeneration: number;
|
||||
resumeWallAt: Date;
|
||||
}): void {
|
||||
const phase = this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual');
|
||||
if (phase !== 'SUSPENDED' || this.state.meta.unificationClockSuspensionId !== input.suspensionId) {
|
||||
throw new Error('In-memory clock reconciliation requires the matching UNIFICATION_WAIT suspension.');
|
||||
}
|
||||
private applyClockReconciliationAlignment(input: DurableClockReconciliationAlignment): void {
|
||||
if (
|
||||
!Number.isSafeInteger(input.alignedTick) ||
|
||||
!Number.isSafeInteger(input.shiftTicks) ||
|
||||
input.shiftTicks < 0 ||
|
||||
!Number.isSafeInteger(input.sourceRevision) ||
|
||||
!Number.isSafeInteger(input.targetRevision) ||
|
||||
input.targetRevision !== input.sourceRevision + 1 ||
|
||||
!Number.isSafeInteger(input.deadlineGeneration)
|
||||
) {
|
||||
throw new Error('In-memory clock reconciliation received an unsafe coordinate.');
|
||||
@@ -800,6 +805,72 @@ export class InMemoryTurnWorld {
|
||||
}
|
||||
}
|
||||
|
||||
applyClockReconciliation(input: Omit<DurableClockReconciliationAlignment, 'sourceRevision'>): void {
|
||||
const phase = this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual');
|
||||
if (phase !== 'SUSPENDED' || this.state.meta.unificationClockSuspensionId !== input.suspensionId) {
|
||||
throw new Error('In-memory clock reconciliation requires the matching UNIFICATION_WAIT suspension.');
|
||||
}
|
||||
this.applyClockReconciliationAlignment({
|
||||
...input,
|
||||
sourceRevision: this.state.clockRevision ?? 1,
|
||||
});
|
||||
}
|
||||
|
||||
applyDurableClockReconciliation(input: DurableClockReconciliationAlignment): void {
|
||||
const clock = this.getGameClockState();
|
||||
if (clock.revision === input.targetRevision && clock.phase === 'RECONCILING') {
|
||||
return;
|
||||
}
|
||||
if (clock.revision !== input.sourceRevision) {
|
||||
throw new Error(
|
||||
`Durable clock reconciliation source mismatch: memory ${clock.revision}, ledger ${input.sourceRevision}.`
|
||||
);
|
||||
}
|
||||
if (clock.phase !== 'RUNNING' && clock.phase !== 'SUSPENDED') {
|
||||
throw new Error(`Durable clock reconciliation cannot apply from in-memory phase ${clock.phase}.`);
|
||||
}
|
||||
this.applyClockReconciliationAlignment(input);
|
||||
}
|
||||
|
||||
synchronizeDurableClockSnapshot(input: DurableGameClockSnapshot): void {
|
||||
const current = this.getGameClockState();
|
||||
if (current.revision !== input.revision || current.deadlineGeneration !== input.deadlineGeneration) {
|
||||
throw new Error(
|
||||
`Durable clock snapshot generation mismatch: memory ${current.revision}/${current.deadlineGeneration}, ` +
|
||||
`database ${input.revision}/${input.deadlineGeneration}.`
|
||||
);
|
||||
}
|
||||
if ((this.state.lastTurnTick ?? 0) !== input.lastTurnTick) {
|
||||
throw new Error(
|
||||
`Durable clock snapshot turn cursor mismatch: memory ${this.state.lastTurnTick ?? 0}, database ${input.lastTurnTick}.`
|
||||
);
|
||||
}
|
||||
const currentBaseTime = this.state.clockBaseTime ?? this.state.lastTurnTime;
|
||||
if (currentBaseTime.getTime() !== input.baseTime.getTime()) {
|
||||
throw new Error(
|
||||
`Durable clock snapshot base mismatch: memory ${currentBaseTime.toISOString()}, ` +
|
||||
`database ${input.baseTime.toISOString()}.`
|
||||
);
|
||||
}
|
||||
const validTransition =
|
||||
current.phase === input.phase ||
|
||||
(current.phase === 'RUNNING' && input.phase === 'SUSPENDED') ||
|
||||
(current.phase === 'RECONCILING' && input.phase === 'RUNNING');
|
||||
if (!validTransition) {
|
||||
throw new Error(`Durable clock snapshot phase mismatch: memory ${current.phase}, database ${input.phase}.`);
|
||||
}
|
||||
this.state = {
|
||||
...this.state,
|
||||
clockBaseTime: new Date(input.baseTime.getTime()),
|
||||
clockTick: input.tick,
|
||||
clockMode: input.mode,
|
||||
clockWallAnchor: new Date(input.wallAnchor.getTime()),
|
||||
clockPhase: input.phase,
|
||||
clockRevision: input.revision,
|
||||
deadlineGeneration: input.deadlineGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
completeClockReconciliation(): void {
|
||||
if (this.state.clockPhase === 'RECONCILING') {
|
||||
this.state = { ...this.state, clockPhase: 'RUNNING' };
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { parseGameClockPhase } from '@sammo-ts/common';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
|
||||
const safeNumber = (value: bigint, label: string): number => {
|
||||
const result = Number(value);
|
||||
if (!Number.isSafeInteger(result)) {
|
||||
throw new Error(`${label} is outside the JavaScript safe integer range: ${value}.`);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replays durable suspension ledgers into the already-running daemon before it
|
||||
* handles a command or resumes scheduled turns. The caller must hold the clock
|
||||
* operation advisory lock so the world row and ledger chain are one snapshot.
|
||||
*/
|
||||
export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
|
||||
db: GamePrisma.TransactionClient,
|
||||
world: InMemoryTurnWorld
|
||||
): Promise<boolean> => {
|
||||
const durable = await db.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
clockBaseTime: true,
|
||||
clockTick: true,
|
||||
clockMode: true,
|
||||
clockWallAnchor: true,
|
||||
lastTurnTick: true,
|
||||
clockPhase: true,
|
||||
clockRevision: true,
|
||||
deadlineGeneration: true,
|
||||
},
|
||||
});
|
||||
if (
|
||||
!durable ||
|
||||
!durable.clockBaseTime ||
|
||||
durable.clockTick === null ||
|
||||
!durable.clockWallAnchor ||
|
||||
durable.lastTurnTick === null
|
||||
) {
|
||||
throw new Error('Runtime clock synchronization requires one fully initialized world clock.');
|
||||
}
|
||||
|
||||
const before = world.getGameClockState();
|
||||
const durableRevision = safeNumber(durable.clockRevision, 'durable clock revision');
|
||||
const durableGeneration = safeNumber(durable.deadlineGeneration, 'durable deadline generation');
|
||||
if (before.revision > durableRevision) {
|
||||
throw new Error(`In-memory clock revision ${before.revision} is ahead of durable revision ${durableRevision}.`);
|
||||
}
|
||||
|
||||
if (before.revision < durableRevision) {
|
||||
const ledgers = await db.clockSuspension.findMany({
|
||||
where: {
|
||||
worldStateId: durable.id,
|
||||
sourceRevision: { gte: BigInt(before.revision) },
|
||||
targetRevision: { lte: durable.clockRevision },
|
||||
status: { in: ['RECONCILING', 'APPLIED'] },
|
||||
},
|
||||
orderBy: { sourceRevision: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
sourceRevision: true,
|
||||
targetRevision: true,
|
||||
shiftTicks: true,
|
||||
alignedTick: true,
|
||||
resumeWallAt: true,
|
||||
},
|
||||
});
|
||||
let expectedRevision = before.revision;
|
||||
let expectedGeneration = before.deadlineGeneration;
|
||||
for (const ledger of ledgers) {
|
||||
const sourceRevision = safeNumber(ledger.sourceRevision, `clock suspension ${ledger.id} source revision`);
|
||||
const targetRevision = safeNumber(ledger.targetRevision, `clock suspension ${ledger.id} target revision`);
|
||||
if (sourceRevision !== expectedRevision || targetRevision !== sourceRevision + 1) {
|
||||
throw new Error(
|
||||
`Clock suspension ledger chain is discontinuous at ${ledger.id}: ` +
|
||||
`expected ${expectedRevision}->${expectedRevision + 1}, found ${sourceRevision}->${targetRevision}.`
|
||||
);
|
||||
}
|
||||
if (ledger.shiftTicks === null || ledger.alignedTick === null || !ledger.resumeWallAt) {
|
||||
throw new Error(`Clock suspension ${ledger.id} has no completed reconciliation coordinate.`);
|
||||
}
|
||||
expectedGeneration += 1;
|
||||
world.applyDurableClockReconciliation({
|
||||
suspensionId: ledger.id,
|
||||
sourceRevision,
|
||||
targetRevision,
|
||||
deadlineGeneration: expectedGeneration,
|
||||
alignedTick: safeNumber(ledger.alignedTick, `clock suspension ${ledger.id} aligned tick`),
|
||||
shiftTicks: safeNumber(ledger.shiftTicks, `clock suspension ${ledger.id} shift ticks`),
|
||||
resumeWallAt: ledger.resumeWallAt,
|
||||
});
|
||||
expectedRevision = targetRevision;
|
||||
}
|
||||
if (expectedRevision !== durableRevision || expectedGeneration !== durableGeneration) {
|
||||
throw new Error(
|
||||
`Clock suspension ledger chain ended at ${expectedRevision}/${expectedGeneration}, ` +
|
||||
`but durable clock is ${durableRevision}/${durableGeneration}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
world.synchronizeDurableClockSnapshot({
|
||||
baseTime: durable.clockBaseTime,
|
||||
tick: safeNumber(durable.clockTick, 'durable clock tick'),
|
||||
mode: durable.clockMode === 'manual' ? 'manual' : 'realtime',
|
||||
wallAnchor: durable.clockWallAnchor,
|
||||
lastTurnTick: safeNumber(durable.lastTurnTick, 'durable last turn tick'),
|
||||
phase: parseGameClockPhase(durable.clockPhase),
|
||||
revision: durableRevision,
|
||||
deadlineGeneration: durableGeneration,
|
||||
});
|
||||
|
||||
const after = world.getGameClockState();
|
||||
return (
|
||||
before.phase !== after.phase ||
|
||||
before.revision !== after.revision ||
|
||||
before.deadlineGeneration !== after.deadlineGeneration ||
|
||||
before.tick !== after.tick ||
|
||||
before.wallAnchor.getTime() !== after.wallAnchor.getTime()
|
||||
);
|
||||
};
|
||||
@@ -715,6 +715,7 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
let redisConnector: RedisConnector | null = null;
|
||||
let stopClockProjectionWorker = () => {};
|
||||
let applyClockProjection: DatabaseTurnHooks['applyClockProjection'] | undefined;
|
||||
let synchronizeClockAuthority: DatabaseTurnHooks['synchronizeClockAuthority'] | undefined;
|
||||
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
|
||||
const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
|
||||
const monthlyActionModules = await loadActionModuleBundle(
|
||||
@@ -920,6 +921,7 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
};
|
||||
takeCommittedReadModelChangeReceipt = dbHooks.takeCommittedReadModelChangeReceipt;
|
||||
applyClockProjection = dbHooks.applyClockProjection;
|
||||
synchronizeClockAuthority = dbHooks.synchronizeClockAuthority;
|
||||
close = async () => {
|
||||
if (auctionBidder) {
|
||||
await auctionBidder.close();
|
||||
@@ -1031,6 +1033,7 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
maxGenerals: 200,
|
||||
catchUpCap: 1,
|
||||
};
|
||||
let lastObservedGatewayPause: boolean | null = null;
|
||||
|
||||
const lifecycle = new TurnDaemonLifecycle(
|
||||
{
|
||||
@@ -1041,7 +1044,21 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
stateStore,
|
||||
processor,
|
||||
hooks,
|
||||
pauseGate: async () => turnDaemonLease?.isLost() || ((await pauseGate?.()) ?? false),
|
||||
pauseGate: async () => {
|
||||
if (turnDaemonLease?.isLost()) {
|
||||
return true;
|
||||
}
|
||||
const gatewayPaused = (await pauseGate?.()) ?? false;
|
||||
const phase = world.getGameClockState().phase;
|
||||
const phaseNeedsSync = gatewayPaused
|
||||
? phase !== 'SUSPENDED'
|
||||
: phase === 'SUSPENDED' || phase === 'RECONCILING';
|
||||
if (synchronizeClockAuthority && (lastObservedGatewayPause !== gatewayPaused || phaseNeedsSync)) {
|
||||
await synchronizeClockAuthority();
|
||||
}
|
||||
lastObservedGatewayPause = gatewayPaused;
|
||||
return gatewayPaused;
|
||||
},
|
||||
commandHandler,
|
||||
commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined),
|
||||
// The exclusive fixture runner aborts the entire in-memory runtime
|
||||
|
||||
Reference in New Issue
Block a user