feat: 통일 대기 시계를 원자적 reconciliation으로 전환

This commit is contained in:
2026-09-03 10:18:44 +00:00
parent a9f23703d9
commit 69ac6028df
22 changed files with 1300 additions and 251 deletions
+141 -9
View File
@@ -56,12 +56,20 @@ import { persistUnificationFinalization } from './unificationPersistence.js';
import { buildOldNationArchiveData } from './oldNationArchive.js';
import { persistYearbookSnapshot } from './yearbookPersistence.js';
import { buildTurnWebPushEvents, captureWebPushTurnBaseline } from './webPushEvents.js';
import {
persistClockSuspensionLedgerUnderHeldLocks,
prepareClockSuspensionUnderHeldLocks,
readClockDatabaseWall,
refreshClockProjectionForFinalClockUnderHeldLocks,
} from './clockReconciliation.js';
import { applyNextClockProjection, type ClockProjectionRedis } from './clockProjectionOutbox.js';
export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks;
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
close(): Promise<void>;
applyClockProjection(redis: ClockProjectionRedis, workerId: string): Promise<boolean>;
}
export interface CommittedReadModelChangeReceipt {
@@ -1140,8 +1148,7 @@ export const createDatabaseTurnHooks = async (
lifecycleEvents.length > 0 ||
deletedGenerals.length > 0 ||
generals.some(
(general) =>
typeof general.refreshScoreTotal === 'number' && Number.isFinite(general.refreshScoreTotal)
(general) => typeof general.refreshScoreTotal === 'number' && Number.isFinite(general.refreshScoreTotal)
);
const persist = async (
prisma: GamePrisma.TransactionClient
@@ -1204,12 +1211,27 @@ export const createDatabaseTurnHooks = async (
const expectedPhase = state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL');
const expectedRevision = BigInt(state.clockRevision ?? 1);
const expectedGeneration = BigInt(state.deadlineGeneration ?? 1);
const stateMeta = asRecord(state.meta);
const unificationSuspensionId =
typeof stateMeta.unificationClockSuspensionId === 'string'
? stateMeta.unificationClockSuspensionId
: null;
const openingPhaseTransition =
durableClock.clock_phase === 'PREOPEN' &&
expectedPhase === 'RUNNING' &&
durableClock.opening_reached;
durableClock.clock_phase === 'PREOPEN' && expectedPhase === 'RUNNING' && durableClock.opening_reached;
const unificationSuspensionTransition =
durableClock.clock_phase === 'RUNNING' &&
expectedPhase === 'SUSPENDED' &&
Number(stateMeta.isunited ?? stateMeta.isUnited ?? 0) === 2 &&
Boolean(unificationSuspensionId);
const completionPhaseTransition =
durableClock.clock_phase === 'RUNNING' &&
expectedPhase === 'COMPLETED' &&
Number(stateMeta.isunited ?? stateMeta.isUnited ?? 0) >= 2;
if (
(!openingPhaseTransition && durableClock.clock_phase !== expectedPhase) ||
(!openingPhaseTransition &&
!unificationSuspensionTransition &&
!completionPhaseTransition &&
durableClock.clock_phase !== expectedPhase) ||
durableClock.clock_revision !== expectedRevision ||
durableClock.deadline_generation !== expectedGeneration
) {
@@ -1218,6 +1240,18 @@ export const createDatabaseTurnHooks = async (
`found ${durableClock.clock_phase}@${durableClock.clock_revision}/${durableClock.deadline_generation}.`
);
}
const unificationCutWallAt = unificationSuspensionTransition ? await readClockDatabaseWall(prisma) : null;
const unificationCutTick = unificationCutWallAt
? world.dateToGameTick(world.getGameNow(unificationCutWallAt))
: null;
const suspensionPreparation =
unificationCutTick !== null
? await prepareClockSuspensionUnderHeldLocks({
db: prisma,
cutTick: unificationCutTick,
cutWallAt: unificationCutWallAt!,
})
: null;
if (commandCompletion) {
const commandFence = await prisma.$queryRaw<
Array<{
@@ -1235,11 +1269,21 @@ export const createDatabaseTurnHooks = async (
FOR UPDATE
`);
const event = commandFence[0];
const unificationRevisionTransition =
commandCompletion.result.type === 'messageRespond' &&
commandCompletion.result.ok &&
commandCompletion.result.action === 'raiseInvader' &&
expectedPhase === 'RECONCILING' &&
event?.processing_clock_revision !== null &&
event?.processing_deadline_generation !== null &&
event?.processing_clock_revision + 1n === expectedRevision &&
event?.processing_deadline_generation + 1n === expectedGeneration;
if (
!event ||
event.status !== 'PROCESSING' ||
event.processing_clock_revision !== expectedRevision ||
event.processing_deadline_generation !== expectedGeneration
(!unificationRevisionTransition &&
(event.processing_clock_revision !== expectedRevision ||
event.processing_deadline_generation !== expectedGeneration))
) {
throw new Error(
`Input event processing clock fence changed before commit: ${commandCompletion.requestId}.`
@@ -1350,6 +1394,35 @@ export const createDatabaseTurnHooks = async (
END
WHERE start_tick IS NOT NULL OR end_tick IS NOT NULL
`);
await prisma.$executeRaw(GamePrisma.sql`
UPDATE select_pool
SET reserved_until = CASE
WHEN reserved_until_tick IS NULL THEN reserved_until
ELSE CAST(${baseTime} AS timestamp)
+ (reserved_until_tick / ${ticksPerSecond}) * INTERVAL '1 second'
+ (((reserved_until_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond})
* INTERVAL '1 millisecond'
END
WHERE reserved_until_tick IS NOT NULL
`);
await prisma.$executeRaw(GamePrisma.sql`
UPDATE select_npc_token
SET valid_until = CASE
WHEN valid_until_tick IS NULL THEN valid_until
ELSE CAST(${baseTime} AS timestamp)
+ (valid_until_tick / ${ticksPerSecond}) * INTERVAL '1 second'
+ (((valid_until_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond})
* INTERVAL '1 millisecond'
END,
pick_more_from = CASE
WHEN pick_more_from_tick IS NULL THEN pick_more_from
ELSE CAST(${baseTime} AS timestamp)
+ (pick_more_from_tick / ${ticksPerSecond}) * INTERVAL '1 second'
+ (((pick_more_from_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond})
* INTERVAL '1 millisecond'
END
WHERE valid_until_tick IS NOT NULL OR pick_more_from_tick IS NOT NULL
`);
}
for (const betting of pendingNationBettingOpens) {
@@ -1817,6 +1890,41 @@ export const createDatabaseTurnHooks = async (
if (options?.reservedTurns && persistedReservedTurnChanges) {
await options.reservedTurns.persistChanges(prisma, persistedReservedTurnChanges);
}
if (suspensionPreparation && unificationSuspensionId) {
const cutTick = unificationCutTick!;
await prisma.worldState.update({
where: { id: state.id },
data: {
clockTick: BigInt(cutTick),
clockWallAnchor: suspensionPreparation.cutWallAt,
},
});
await persistClockSuspensionLedgerUnderHeldLocks({
db: prisma,
suspensionId: unificationSuspensionId,
worldStateId: state.id,
profileName: options?.profileName ?? 'default',
source: 'UNIFICATION_WAIT',
cutTick,
cutWallAt: suspensionPreparation.cutWallAt,
rateTicksPerSecond: GAME_TICKS_PER_TURN / state.tickSeconds,
sourceRevision: state.clockRevision ?? 1,
});
}
if (
commandCompletion?.result.type === 'messageRespond' &&
commandCompletion.result.ok &&
commandCompletion.result.action === 'raiseInvader' &&
unificationSuspensionId &&
state.clockPhase === 'RECONCILING'
) {
await refreshClockProjectionForFinalClockUnderHeldLocks({
db: prisma,
suspensionId: unificationSuspensionId,
clockBaseTime: state.clockBaseTime ?? state.lastTurnTime,
tickSeconds: state.tickSeconds,
});
}
if (commandCompletion) {
await prisma.inputEvent.update({
where: { requestId: commandCompletion.requestId },
@@ -1902,6 +2010,10 @@ export const createDatabaseTurnHooks = async (
},
executeCommand: async (requestId, execute) => {
const committed = await prisma.$transaction(async (transaction) => {
await options?.turnDaemonLease?.assertActive(transaction);
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
const leaseToken = options?.turnDaemonLease?.getToken();
const directLogFloor =
(
await transaction.logEntry.findFirst({
@@ -1909,7 +2021,19 @@ export const createDatabaseTurnHooks = async (
select: { id: true },
})
)?.id ?? 0;
const result = await execute({ db: transaction });
const result = await execute({
db: transaction,
...(leaseToken
? {
clockOperationAuthority: {
kind: 'DAEMON' as const,
profileName: leaseToken.profile,
ownerId: leaseToken.ownerId,
fencingEpoch: leaseToken.fencingEpoch,
},
}
: {}),
});
const persisted = await persistChanges(transaction, { requestId, result }, directLogFloor);
return { result, persisted };
}, transactionOptions);
@@ -1925,6 +2049,14 @@ export const createDatabaseTurnHooks = async (
return takeCommittedReceipt()?.changes ?? null;
},
takeCommittedReadModelChangeReceipt: takeCommittedReceipt,
applyClockProjection: async (redis, workerId) => {
await applyNextClockProjection({ db: prisma, redis, workerId });
const clock = await prisma.worldState.findFirst({
orderBy: { id: 'asc' },
select: { clockPhase: true },
});
return clock?.clockPhase === 'RUNNING' || clock?.clockPhase === 'MANUAL';
},
close: () => connector.disconnect(),
};
};