feat: 시계 reconciliation 워커와 명령 경계 완성
This commit is contained in:
@@ -0,0 +1,763 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
GAME_TICKS_PER_TURN,
|
||||
MAX_SAFE_GAME_TICK,
|
||||
GameClock,
|
||||
buildClockAlignmentPlan,
|
||||
parseClockAlignmentPolicy,
|
||||
parseGameClockPhase,
|
||||
type ClockAlignmentPolicy,
|
||||
} from '@sammo-ts/common';
|
||||
import {
|
||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
||||
GENERAL_ACCESS_PERSISTENCE_LOCK,
|
||||
GamePrisma,
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
type GamePrismaClient,
|
||||
} from '@sammo-ts/infra';
|
||||
|
||||
export type ClockSuspensionSource = 'MAINTENANCE' | 'OPEN_DELAY' | 'UNIFICATION_WAIT' | 'RECOVERY';
|
||||
|
||||
export type ClockOperationAuthority =
|
||||
| { kind: 'DAEMON'; profileName: string; ownerId: string; fencingEpoch: bigint }
|
||||
| { kind: 'OFFLINE'; profileName: string; reason: string };
|
||||
|
||||
export interface ClockSuspensionResult {
|
||||
suspensionId: string;
|
||||
phase: 'SUSPENDED';
|
||||
sourceRevision: number;
|
||||
targetRevision: number;
|
||||
cutTick: number;
|
||||
cutWallAt: Date;
|
||||
}
|
||||
|
||||
export interface ClockReconciliationResult {
|
||||
suspensionId: string;
|
||||
phase: 'RECONCILING';
|
||||
sourceRevision: number;
|
||||
targetRevision: number;
|
||||
deadlineGeneration: number;
|
||||
gapTicks: number;
|
||||
catchUpTicks: number;
|
||||
shiftTicks: number;
|
||||
alignedTick: number;
|
||||
resumeWallAt: Date;
|
||||
}
|
||||
|
||||
interface DbWallRow {
|
||||
wallNow: Date;
|
||||
}
|
||||
|
||||
interface LeaseFenceRow {
|
||||
ownerId: string;
|
||||
fencingEpoch: bigint;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
interface IdRow {
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface TextIdRow {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface ParticipantSnapshot {
|
||||
key: string;
|
||||
policy: 'SHIFT' | 'KEEP' | 'REBUILD';
|
||||
checksum: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const canonicalize = (value: unknown): unknown => {
|
||||
if (typeof value === 'bigint') return value.toString();
|
||||
if (Array.isArray(value)) return value.map(canonicalize);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, canonicalize(item)])
|
||||
);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const stableJson = (value: unknown): string => JSON.stringify(canonicalize(value));
|
||||
|
||||
const checksum = (value: unknown): string => createHash('sha256').update(stableJson(value)).digest('hex');
|
||||
|
||||
const aggregateChecksum = (participants: readonly ParticipantSnapshot[]): string =>
|
||||
checksum(participants.map(({ key, policy, checksum: value, count }) => ({ key, policy, checksum: value, count })));
|
||||
|
||||
const readDbWall = async (db: GamePrisma.TransactionClient): Promise<Date> => {
|
||||
const rows = await db.$queryRaw<DbWallRow[]>(GamePrisma.sql`
|
||||
SELECT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3) AS "wallNow"
|
||||
`);
|
||||
const wallNow = rows[0]?.wallNow;
|
||||
if (!wallNow || Number.isNaN(wallNow.getTime())) {
|
||||
throw new Error('Failed to read the PostgreSQL wall clock.');
|
||||
}
|
||||
return wallNow;
|
||||
};
|
||||
|
||||
const verifyAuthority = async (
|
||||
db: GamePrisma.TransactionClient,
|
||||
authority: ClockOperationAuthority
|
||||
): Promise<void> => {
|
||||
const rows = await db.$queryRaw<LeaseFenceRow[]>(GamePrisma.sql`
|
||||
SELECT owner_id AS "ownerId",
|
||||
fencing_epoch AS "fencingEpoch",
|
||||
lease_until > (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') AS valid
|
||||
FROM turn_daemon_lease
|
||||
WHERE profile = ${authority.profileName}
|
||||
FOR UPDATE
|
||||
`);
|
||||
const lease = rows[0];
|
||||
if (authority.kind === 'OFFLINE') {
|
||||
if (!authority.reason.trim()) {
|
||||
throw new Error('Offline clock operations require an audit reason.');
|
||||
}
|
||||
if (lease?.valid) {
|
||||
throw new Error(`Clock operation requires the ${authority.profileName} daemon lease to be offline.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!lease?.valid ||
|
||||
lease.ownerId !== authority.ownerId ||
|
||||
lease.fencingEpoch !== authority.fencingEpoch
|
||||
) {
|
||||
throw new Error(`Stale turn-daemon fencing authority for profile ${authority.profileName}.`);
|
||||
}
|
||||
};
|
||||
|
||||
const lockWorld = async (db: GamePrisma.TransactionClient): Promise<number> => {
|
||||
const rows = await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||
SELECT id FROM world_state ORDER BY id LIMIT 2 FOR UPDATE
|
||||
`);
|
||||
if (rows.length !== 1) {
|
||||
throw new Error(`Clock reconciliation requires exactly one world_state row; found ${rows.length}.`);
|
||||
}
|
||||
return rows[0]!.id;
|
||||
};
|
||||
|
||||
const lockParticipants = async (db: GamePrisma.TransactionClient, cutTick: bigint): Promise<void> => {
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`SELECT id FROM general ORDER BY id FOR UPDATE`);
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||
SELECT id FROM auction
|
||||
WHERE status IN ('OPEN'::auction_status, 'FINALIZING'::auction_status)
|
||||
ORDER BY id FOR UPDATE
|
||||
`);
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||
SELECT id FROM message
|
||||
WHERE valid_until_tick IS NOT NULL AND valid_until_tick >= ${cutTick}
|
||||
ORDER BY id FOR UPDATE
|
||||
`);
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||
SELECT id FROM vote_poll WHERE closed_at IS NULL ORDER BY id FOR UPDATE
|
||||
`);
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||
SELECT id FROM select_pool WHERE general_id IS NULL ORDER BY id FOR UPDATE
|
||||
`);
|
||||
await db.$queryRaw<TextIdRow[]>(GamePrisma.sql`
|
||||
SELECT owner_user_id AS id FROM select_npc_token ORDER BY owner_user_id FOR UPDATE
|
||||
`);
|
||||
};
|
||||
|
||||
const readParticipantSnapshots = async (
|
||||
db: GamePrisma.TransactionClient,
|
||||
worldStateId: number,
|
||||
cutTick: bigint
|
||||
): Promise<ParticipantSnapshot[]> => {
|
||||
const [world, generals, auctions, messages, 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 },
|
||||
}),
|
||||
db.auction.findMany({
|
||||
where: { status: { in: ['OPEN', 'FINALIZING'] } },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, status: true, openTick: true, closeTick: true },
|
||||
}),
|
||||
db.message.findMany({
|
||||
where: { validUntilTick: { not: null, gte: cutTick } },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, timeTick: true, validUntilTick: 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,
|
||||
policy,
|
||||
checksum: checksum(rows),
|
||||
count: rows.length,
|
||||
});
|
||||
const meta = world.meta && typeof world.meta === 'object' && !Array.isArray(world.meta) ? world.meta : {};
|
||||
return [
|
||||
snapshot('world-clock', 'REBUILD', [
|
||||
{
|
||||
clockTick: world.clockTick,
|
||||
clockRevision: world.clockRevision,
|
||||
deadlineGeneration: world.deadlineGeneration,
|
||||
},
|
||||
]),
|
||||
snapshot('turn-cursor', 'SHIFT', [{ lastTurnTick: world.lastTurnTick }]),
|
||||
snapshot(
|
||||
'general-next-turn',
|
||||
'SHIFT',
|
||||
generals.map(({ id, turnTick }) => ({ id, turnTick }))
|
||||
),
|
||||
snapshot(
|
||||
'general-recent-war-occurrence',
|
||||
'KEEP',
|
||||
generals.map(({ id, recentWarTick }) => ({ id, recentWarTick }))
|
||||
),
|
||||
snapshot(
|
||||
'auction-open-occurrence',
|
||||
'KEEP',
|
||||
auctions.map(({ id, openTick }) => ({ id, openTick }))
|
||||
),
|
||||
snapshot(
|
||||
'auction-deadline',
|
||||
'SHIFT',
|
||||
auctions.map(({ id, status, closeTick }) => ({ id, status, closeTick }))
|
||||
),
|
||||
snapshot(
|
||||
'auction-finalizing-recovery',
|
||||
'REBUILD',
|
||||
auctions.map(({ id, status }) => ({ id, status }))
|
||||
),
|
||||
snapshot(
|
||||
'message-occurrence',
|
||||
'KEEP',
|
||||
messages.map(({ id, timeTick }) => ({ id, timeTick }))
|
||||
),
|
||||
snapshot(
|
||||
'message-expiry',
|
||||
'SHIFT',
|
||||
messages.map(({ id, validUntilTick }) => ({ id, validUntilTick }))
|
||||
),
|
||||
snapshot(
|
||||
'vote-start-occurrence',
|
||||
'KEEP',
|
||||
votes.map(({ id, startTick }) => ({ id, startTick }))
|
||||
),
|
||||
snapshot(
|
||||
'vote-end-deadline',
|
||||
'SHIFT',
|
||||
votes.map(({ id, endTick }) => ({ id, endTick }))
|
||||
),
|
||||
snapshot('select-pool-reservation', 'SHIFT', pool),
|
||||
snapshot('npc-selection-window', 'SHIFT', npcTokens),
|
||||
snapshot('accepted-command-coordinate', 'KEEP', commands),
|
||||
snapshot('movable-json-rule-anchors', 'SHIFT', [
|
||||
{
|
||||
lastTurnTime: Reflect.get(meta, 'lastTurnTime'),
|
||||
turntime: Reflect.get(meta, 'turntime'),
|
||||
starttime: Reflect.get(meta, 'starttime'),
|
||||
tnmt_time: Reflect.get(meta, 'tnmt_time'),
|
||||
},
|
||||
]),
|
||||
];
|
||||
};
|
||||
|
||||
const persistInitialParticipants = async (
|
||||
db: GamePrisma.TransactionClient,
|
||||
suspensionId: string,
|
||||
participants: readonly ParticipantSnapshot[]
|
||||
): Promise<void> => {
|
||||
for (const participant of participants) {
|
||||
await db.clockReconciliationParticipant.create({
|
||||
data: {
|
||||
suspensionId,
|
||||
participantKey: participant.key,
|
||||
policy: participant.policy,
|
||||
beforeChecksum: participant.checksum,
|
||||
afterChecksum: participant.checksum,
|
||||
affectedCount: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const shiftMetaDate = (value: unknown, deltaMilliseconds: number): unknown => {
|
||||
if (typeof value !== 'string' || !value.trim()) return value;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return new Date(parsed.getTime() + deltaMilliseconds).toISOString();
|
||||
};
|
||||
|
||||
const shiftedMeta = (value: GamePrisma.JsonValue, deltaMilliseconds: number): GamePrisma.InputJsonValue => {
|
||||
const meta = value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
|
||||
for (const key of ['lastTurnTime', 'turntime', 'starttime', 'tnmt_time'] as const) {
|
||||
if (Object.hasOwn(meta, key)) {
|
||||
Reflect.set(meta, key, shiftMetaDate(Reflect.get(meta, key), deltaMilliseconds));
|
||||
}
|
||||
}
|
||||
return asJson(meta);
|
||||
};
|
||||
|
||||
const assertShiftFits = (participants: readonly ParticipantSnapshot[], shiftTicks: number): void => {
|
||||
if (!Number.isSafeInteger(shiftTicks) || shiftTicks < 0) {
|
||||
throw new Error(`Invalid reconciliation shift: ${shiftTicks}`);
|
||||
}
|
||||
// Checksums retain stringified values for audit; actual row ranges are
|
||||
// checked by PostgreSQL BIGINT and the world aligned tick is checked by the
|
||||
// shared GameClock plan. The sentinel expiry is deliberately never shifted.
|
||||
if (participants.some((participant) => !participant.checksum)) {
|
||||
throw new Error('Participant snapshot is incomplete.');
|
||||
}
|
||||
};
|
||||
|
||||
const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise<void> => {
|
||||
const shift = BigInt(shiftTicks);
|
||||
const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift;
|
||||
const [general, auction, message, vote, pool, npcValid, npcMore] = await Promise.all([
|
||||
db.general.aggregate({ _max: { turnTick: true }, where: { turnTick: { not: null } } }),
|
||||
db.auction.aggregate({
|
||||
_max: { closeTick: true },
|
||||
where: { status: { in: ['OPEN', 'FINALIZING'] }, closeTick: { not: null } },
|
||||
}),
|
||||
db.message.aggregate({
|
||||
_max: { validUntilTick: true },
|
||||
where: { validUntilTick: { not: null, lt: BigInt(MAX_SAFE_GAME_TICK) } },
|
||||
}),
|
||||
db.votePoll.aggregate({ _max: { endTick: true }, where: { closedAt: null, endTick: { not: null } } }),
|
||||
db.selectPoolEntry.aggregate({
|
||||
_max: { reservedUntilTick: true },
|
||||
where: { generalId: null, reservedUntilTick: { not: null } },
|
||||
}),
|
||||
db.npcSelectionToken.aggregate({ _max: { validUntilTick: true }, where: { validUntilTick: { not: null } } }),
|
||||
db.npcSelectionToken.aggregate({
|
||||
_max: { pickMoreFromTick: true },
|
||||
where: { pickMoreFromTick: { not: null } },
|
||||
}),
|
||||
]);
|
||||
const values: Array<[string, bigint | null]> = [
|
||||
['general.turn_tick', general._max.turnTick],
|
||||
['auction.close_tick', auction._max.closeTick],
|
||||
['message.valid_until_tick', message._max.validUntilTick],
|
||||
['vote_poll.end_tick', vote._max.endTick],
|
||||
['select_pool.reserved_until_tick', pool._max.reservedUntilTick],
|
||||
['select_npc_token.valid_until_tick', npcValid._max.validUntilTick],
|
||||
['select_npc_token.pick_more_from_tick', npcMore._max.pickMoreFromTick],
|
||||
];
|
||||
for (const [label, value] of values) {
|
||||
if (value !== null && value > maximum) {
|
||||
throw new Error(`${label} would exceed the safe game tick range after reconciliation.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const applyParticipantShift = async (
|
||||
db: GamePrisma.TransactionClient,
|
||||
worldStateId: number,
|
||||
cutTick: bigint,
|
||||
alignedTick: bigint,
|
||||
targetRevision: bigint,
|
||||
targetGeneration: bigint,
|
||||
shiftTicks: bigint,
|
||||
projectionDeltaMilliseconds: number,
|
||||
resumeWallAt: Date
|
||||
): Promise<Map<string, number>> => {
|
||||
const affected = new Map<string, number>();
|
||||
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId }, select: { meta: true } });
|
||||
const cursor = await db.worldState.updateMany({
|
||||
where: { id: worldStateId, lastTurnTick: { not: null } },
|
||||
data: { lastTurnTick: { increment: shiftTicks } },
|
||||
});
|
||||
affected.set('turn-cursor', cursor.count);
|
||||
affected.set(
|
||||
'general-next-turn',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE general
|
||||
SET turn_tick = turn_tick + ${shiftTicks},
|
||||
turn_time = turn_time + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
|
||||
WHERE turn_tick IS NOT NULL
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
'auction-deadline',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET close_tick = close_tick + ${shiftTicks},
|
||||
close_at = close_at + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
|
||||
WHERE status IN ('OPEN'::auction_status, 'FINALIZING'::auction_status)
|
||||
AND close_tick IS NOT NULL
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
'message-expiry',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE message
|
||||
SET valid_until_tick = valid_until_tick + ${shiftTicks},
|
||||
valid_until = valid_until + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
|
||||
WHERE valid_until_tick IS NOT NULL
|
||||
AND valid_until_tick >= ${cutTick}
|
||||
AND valid_until_tick < ${BigInt(MAX_SAFE_GAME_TICK)}
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
'vote-end-deadline',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE vote_poll
|
||||
SET end_tick = end_tick + ${shiftTicks},
|
||||
end_at = end_at + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
|
||||
WHERE closed_at IS NULL AND end_tick IS NOT NULL
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
'select-pool-reservation',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE select_pool
|
||||
SET reserved_until_tick = reserved_until_tick + ${shiftTicks},
|
||||
reserved_until = reserved_until + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
|
||||
WHERE general_id IS NULL AND reserved_until_tick IS NOT NULL
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
'npc-selection-window',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE select_npc_token
|
||||
SET valid_until_tick = CASE
|
||||
WHEN valid_until_tick IS NULL THEN NULL ELSE valid_until_tick + ${shiftTicks} END,
|
||||
valid_until = CASE
|
||||
WHEN valid_until_tick IS NULL THEN valid_until
|
||||
ELSE valid_until + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond' END,
|
||||
pick_more_from_tick = CASE
|
||||
WHEN pick_more_from_tick IS NULL THEN NULL ELSE pick_more_from_tick + ${shiftTicks} END,
|
||||
pick_more_from = CASE
|
||||
WHEN pick_more_from_tick IS NULL THEN pick_more_from
|
||||
ELSE pick_more_from + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond' END
|
||||
WHERE valid_until_tick IS NOT NULL OR pick_more_from_tick IS NOT NULL
|
||||
`)
|
||||
);
|
||||
await db.worldState.update({
|
||||
where: { id: worldStateId },
|
||||
data: {
|
||||
clockTick: alignedTick,
|
||||
clockWallAnchor: resumeWallAt,
|
||||
clockPhase: 'RECONCILING',
|
||||
clockRevision: targetRevision,
|
||||
deadlineGeneration: targetGeneration,
|
||||
meta: shiftedMeta(world.meta, projectionDeltaMilliseconds),
|
||||
},
|
||||
});
|
||||
affected.set('world-clock', 1);
|
||||
affected.set('movable-json-rule-anchors', 1);
|
||||
affected.set('auction-finalizing-recovery', 0);
|
||||
return affected;
|
||||
};
|
||||
|
||||
export const startClockSuspension = async (options: {
|
||||
db: GamePrismaClient;
|
||||
suspensionId: string;
|
||||
source: ClockSuspensionSource;
|
||||
authority: ClockOperationAuthority;
|
||||
policy?: ClockAlignmentPolicy;
|
||||
catchUpTicks?: number;
|
||||
}): Promise<ClockSuspensionResult> => {
|
||||
if (!options.suspensionId.trim() || options.suspensionId.length > 64) {
|
||||
throw new Error('Clock suspension ID must contain 1-64 characters.');
|
||||
}
|
||||
const policy = options.policy ?? 'EXACT';
|
||||
const catchUpTicks = options.catchUpTicks ?? 0;
|
||||
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.`);
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
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: options.suspensionId,
|
||||
phase: 'SUSPENDED',
|
||||
sourceRevision,
|
||||
targetRevision: sourceRevision + 1,
|
||||
cutTick,
|
||||
cutWallAt,
|
||||
};
|
||||
},
|
||||
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
|
||||
);
|
||||
};
|
||||
|
||||
export const reconcileClockSuspension = async (options: {
|
||||
db: GamePrismaClient;
|
||||
suspensionId: string;
|
||||
authority: ClockOperationAuthority;
|
||||
/** 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);
|
||||
const worldStateId = await lockWorld(db);
|
||||
const suspension = await db.clockSuspension.findUniqueOrThrow({ where: { id: options.suspensionId } });
|
||||
if (suspension.worldStateId !== worldStateId) {
|
||||
throw new Error('Clock suspension belongs to another world state.');
|
||||
}
|
||||
if (suspension.status === 'RECONCILING' || suspension.status === 'APPLIED') {
|
||||
if (
|
||||
suspension.gapTicks === null ||
|
||||
suspension.shiftTicks === null ||
|
||||
suspension.alignedTick === null ||
|
||||
!suspension.resumeWallAt
|
||||
) {
|
||||
throw new Error('Persisted clock reconciliation result is incomplete.');
|
||||
}
|
||||
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
|
||||
return {
|
||||
suspensionId: suspension.id,
|
||||
phase: 'RECONCILING' as const,
|
||||
sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'),
|
||||
targetRevision: safeNumber(suspension.targetRevision, 'target revision'),
|
||||
deadlineGeneration: safeNumber(world.deadlineGeneration, 'deadline generation'),
|
||||
gapTicks: safeNumber(suspension.gapTicks, 'gap ticks'),
|
||||
catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'),
|
||||
shiftTicks: safeNumber(suspension.shiftTicks, 'shift ticks'),
|
||||
alignedTick: safeNumber(suspension.alignedTick, 'aligned tick'),
|
||||
resumeWallAt: suspension.resumeWallAt,
|
||||
};
|
||||
}
|
||||
if (suspension.status !== 'SUSPENDED') {
|
||||
throw new Error(`Clock suspension cannot reconcile from status ${suspension.status}.`);
|
||||
}
|
||||
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
|
||||
const phase = parseGameClockPhase(world.clockPhase);
|
||||
if (phase !== 'SUSPENDED' || world.clockRevision !== suspension.sourceRevision) {
|
||||
throw new Error('Clock reconciliation phase or source revision fence failed.');
|
||||
}
|
||||
const worldMeta =
|
||||
world.meta && typeof world.meta === 'object' && !Array.isArray(world.meta)
|
||||
? (world.meta as Record<string, unknown>)
|
||||
: {};
|
||||
const united = Number(worldMeta.isunited ?? worldMeta.isUnited ?? 0);
|
||||
if (suspension.source === 'UNIFICATION_WAIT' || united >= 2) {
|
||||
throw new Error(
|
||||
'Unification wait requires the atomic alignment-and-invader workflow; generic resume is forbidden.'
|
||||
);
|
||||
}
|
||||
const cutTick = safeNumber(suspension.cutTick, 'cut tick');
|
||||
await lockParticipants(db, suspension.cutTick);
|
||||
if (options.testResumeWallAt && process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('A clock reconciliation wall override is allowed only in tests.');
|
||||
}
|
||||
const resumeWallAt = options.testResumeWallAt
|
||||
? new Date(options.testResumeWallAt.getTime())
|
||||
: await readDbWall(db);
|
||||
const plan = buildClockAlignmentPlan({
|
||||
policy: parseClockAlignmentPolicy(suspension.policy),
|
||||
sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'),
|
||||
cutTick,
|
||||
cutWall: suspension.cutWallAt,
|
||||
resumeWall: resumeWallAt,
|
||||
ticksPerSecond: suspension.rateTicksPerSecond,
|
||||
catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'),
|
||||
});
|
||||
const before = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
|
||||
assertShiftFits(before, plan.shiftTicks);
|
||||
await assertScheduleRanges(db, plan.shiftTicks);
|
||||
const projectionDeltaMilliseconds = Math.trunc(
|
||||
(plan.shiftTicks * 1_000) / suspension.rateTicksPerSecond
|
||||
);
|
||||
if (!Number.isSafeInteger(projectionDeltaMilliseconds)) {
|
||||
throw new Error('Clock reconciliation projection delta is outside the safe integer range.');
|
||||
}
|
||||
const targetGeneration = world.deadlineGeneration + 1n;
|
||||
const affected = await applyParticipantShift(
|
||||
db,
|
||||
worldStateId,
|
||||
suspension.cutTick,
|
||||
BigInt(plan.alignedTick),
|
||||
BigInt(plan.targetRevision),
|
||||
targetGeneration,
|
||||
BigInt(plan.shiftTicks),
|
||||
projectionDeltaMilliseconds,
|
||||
resumeWallAt
|
||||
);
|
||||
const after = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
|
||||
const afterByKey = new Map(after.map((participant) => [participant.key, participant]));
|
||||
for (const participant of before) {
|
||||
const next = afterByKey.get(participant.key);
|
||||
if (!next) throw new Error(`Missing post-reconciliation participant: ${participant.key}`);
|
||||
if (participant.policy === 'KEEP' && participant.checksum !== next.checksum) {
|
||||
throw new Error(`KEEP participant changed during reconciliation: ${participant.key}`);
|
||||
}
|
||||
await db.clockReconciliationParticipant.upsert({
|
||||
where: {
|
||||
suspensionId_participantKey: {
|
||||
suspensionId: suspension.id,
|
||||
participantKey: participant.key,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
suspensionId: suspension.id,
|
||||
participantKey: participant.key,
|
||||
policy: participant.policy,
|
||||
beforeChecksum: participant.checksum,
|
||||
afterChecksum: next.checksum,
|
||||
affectedCount: affected.get(participant.key) ?? 0,
|
||||
},
|
||||
update: {
|
||||
policy: participant.policy,
|
||||
beforeChecksum: participant.checksum,
|
||||
afterChecksum: next.checksum,
|
||||
affectedCount: affected.get(participant.key) ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
const outboxPayload = {
|
||||
version: 1,
|
||||
profileName: options.authority.profileName,
|
||||
suspensionId: suspension.id,
|
||||
sourceRevision: plan.sourceRevision,
|
||||
targetRevision: plan.targetRevision,
|
||||
deadlineGeneration: safeNumber(targetGeneration, 'deadline generation'),
|
||||
shiftTicks: plan.shiftTicks,
|
||||
projectionDeltaMilliseconds,
|
||||
clockBaseTime: world.clockBaseTime!.toISOString(),
|
||||
ticksPerSecond: suspension.rateTicksPerSecond,
|
||||
};
|
||||
await db.clockProjectionOutbox.create({
|
||||
data: {
|
||||
worldStateId,
|
||||
suspensionId: suspension.id,
|
||||
targetRevision: BigInt(plan.targetRevision),
|
||||
status: 'PENDING',
|
||||
payload: asJson(outboxPayload),
|
||||
checksum: checksum(outboxPayload),
|
||||
},
|
||||
});
|
||||
await db.clockSuspension.update({
|
||||
where: { id: suspension.id },
|
||||
data: {
|
||||
status: 'RECONCILING',
|
||||
resumeWallAt,
|
||||
gapTicks: BigInt(plan.gapTicks),
|
||||
shiftTicks: BigInt(plan.shiftTicks),
|
||||
alignedTick: BigInt(plan.alignedTick),
|
||||
participantChecksumBefore: aggregateChecksum(before),
|
||||
participantChecksumAfter: aggregateChecksum(after),
|
||||
},
|
||||
});
|
||||
return {
|
||||
suspensionId: suspension.id,
|
||||
phase: 'RECONCILING',
|
||||
sourceRevision: plan.sourceRevision,
|
||||
targetRevision: plan.targetRevision,
|
||||
deadlineGeneration: safeNumber(targetGeneration, 'deadline generation'),
|
||||
gapTicks: plan.gapTicks,
|
||||
catchUpTicks: plan.catchUpTicks,
|
||||
shiftTicks: plan.shiftTicks,
|
||||
alignedTick: plan.alignedTick,
|
||||
resumeWallAt,
|
||||
};
|
||||
},
|
||||
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
|
||||
);
|
||||
Reference in New Issue
Block a user