fix(clock): preserve UTC leases and unification recovery

This commit is contained in:
2026-09-03 19:40:14 +00:00
parent 62006fb2d4
commit a2b0b997a2
9 changed files with 351 additions and 21 deletions
@@ -86,9 +86,9 @@ export class DatabaseTurnDaemonLease {
VALUES (
${this.profile},
${this.ownerId},
CURRENT_TIMESTAMP + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
1,
CURRENT_TIMESTAMP
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
)
ON CONFLICT ("profile") DO UPDATE
SET
@@ -99,10 +99,10 @@ export class DatabaseTurnDaemonLease {
THEN "turn_daemon_lease"."fencing_epoch"
ELSE "turn_daemon_lease"."fencing_epoch" + 1
END,
"heartbeat_at" = CURRENT_TIMESTAMP
"heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE
"turn_daemon_lease"."owner_id" = EXCLUDED."owner_id"
OR "turn_daemon_lease"."lease_until" <= CURRENT_TIMESTAMP
OR "turn_daemon_lease"."lease_until" <= CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
RETURNING "profile", "owner_id", "fencing_epoch"
`);
const row = rows[0];
@@ -141,13 +141,13 @@ export class DatabaseTurnDaemonLease {
const rows = await this.db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
UPDATE "turn_daemon_lease"
SET
"lease_until" = CURRENT_TIMESTAMP + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
"heartbeat_at" = CURRENT_TIMESTAMP
"lease_until" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
"heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE
"profile" = ${token.profile}
AND "owner_id" = ${token.ownerId}
AND "fencing_epoch" = ${token.fencingEpoch}
AND "lease_until" > CURRENT_TIMESTAMP
AND "lease_until" > CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
RETURNING "profile", "owner_id", "fencing_epoch"
`);
if (rows.length === 0) {
@@ -177,7 +177,7 @@ export class DatabaseTurnDaemonLease {
"profile" = ${token.profile}
AND "owner_id" = ${token.ownerId}
AND "fencing_epoch" = ${token.fencingEpoch}
AND "lease_until" > CURRENT_TIMESTAMP
AND "lease_until" > CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
FOR UPDATE
`);
if (rows.length === 0) {
@@ -196,7 +196,8 @@ export class DatabaseTurnDaemonLease {
}
await this.db.$executeRaw(GamePrisma.sql`
UPDATE "turn_daemon_lease"
SET "lease_until" = CURRENT_TIMESTAMP, "heartbeat_at" = CURRENT_TIMESTAMP
SET "lease_until" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
"heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE
"profile" = ${token.profile}
AND "owner_id" = ${token.ownerId}
@@ -98,11 +98,12 @@ const invalidateMessageIds = async (
db: GamePrisma.TransactionClient,
world: InMemoryTurnWorld,
ids: number[],
now: Date
now: Date,
authoritativeGameTick?: bigint
): Promise<void> => {
const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))];
if (uniqueIds.length === 0) return;
const resolvedGameTick = BigInt(world.dateToGameTick(now));
const resolvedGameTick = authoritativeGameTick ?? BigInt(world.dateToGameTick(now));
await db.messageAction.updateMany({
where: { messageId: { in: uniqueIds }, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedGameTick },
@@ -348,7 +349,27 @@ const respondToRaiseInvader = async (options: {
},
event
);
await invalidateMessageIds(db, world, [row.id], world.gameTickToDate(alignment.alignedTick));
const resolvedGameTick = BigInt(alignment.alignedTick);
if (resolvedGameTick < row.createdGameTick) {
throw new Error(
`RaiseInvader resolved tick ${resolvedGameTick} precedes prompt tick ${row.createdGameTick}; clock authority is inconsistent.`
);
}
const promptRows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
SELECT message_id AS id
FROM message_action
WHERE action_type = 'raiseInvader'
AND status = 'PENDING'
AND created_game_tick = ${row.createdGameTick}
FOR UPDATE
`);
await invalidateMessageIds(
db,
world,
promptRows.map(({ id }) => id),
world.gameTickToDate(alignment.alignedTick),
resolvedGameTick
);
return { ok: true, action: 'raiseInvader', reason: 'success' };
};