오픈 직후 DB 시계 전환을 데몬 메모리에 안전하게 반영
This commit is contained in:
@@ -870,8 +870,22 @@ export class InMemoryTurnWorld {
|
||||
`database ${input.baseTime.toISOString()}.`
|
||||
);
|
||||
}
|
||||
// Gateway가 같은 clock lock 아래 DB의 오픈 phase를 먼저 전환할 수 있다.
|
||||
// 메모리의 가오픈을 따라잡되, 턴 처리나 일정 변경을 오픈으로 오인하지 않는다.
|
||||
const openingTransition =
|
||||
current.phase === 'PREOPEN' &&
|
||||
input.phase === 'RUNNING' &&
|
||||
current.mode === 'realtime' &&
|
||||
input.mode === 'realtime' &&
|
||||
current.tick === 0 &&
|
||||
input.tick === 0 &&
|
||||
current.lastTurnTick === 0 &&
|
||||
current.wallAnchor.getTime() === input.wallAnchor.getTime() &&
|
||||
current.recovery === null &&
|
||||
input.recovery === null;
|
||||
const validTransition =
|
||||
current.phase === input.phase ||
|
||||
openingTransition ||
|
||||
(current.phase === 'RUNNING' && input.phase === 'SUSPENDED') ||
|
||||
(current.phase === 'RECONCILING' && input.phase === 'RUNNING');
|
||||
if (!validTransition) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
readTurnRuntimeReady,
|
||||
createRedisConnector,
|
||||
GENERAL_ACCESS_PERSISTENCE_LOCK,
|
||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
||||
GamePrisma,
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
type GamePrismaClient,
|
||||
@@ -15,6 +16,8 @@ import {
|
||||
import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js';
|
||||
import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js';
|
||||
import { prepareRealtimeRecovery } from '../src/turn/prepareRealtimeRecovery.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
||||
import { DatabaseTurnDaemonLease } from '../src/lifecycle/databaseTurnDaemonLease.js';
|
||||
|
||||
const databaseUrl = process.env.CLOCK_RECONCILIATION_DATABASE_URL;
|
||||
@@ -67,6 +70,139 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
await clean();
|
||||
});
|
||||
|
||||
it('serializes Gateway opening with daemon sync and keeps the opening cursor intact', async () => {
|
||||
const baseTime = new Date('2026-09-12T00:00:00Z');
|
||||
const row = await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode: 'opening-sync',
|
||||
currentYear: 180,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 60,
|
||||
clockBaseTime: baseTime,
|
||||
clockWallAnchor: baseTime,
|
||||
clockTick: 0n,
|
||||
lastTurnTick: 0n,
|
||||
clockMode: 'realtime',
|
||||
clockPhase: 'PREOPEN',
|
||||
clockRevision: 3n,
|
||||
deadlineGeneration: 5n,
|
||||
},
|
||||
});
|
||||
const world = new InMemoryTurnWorld(
|
||||
{
|
||||
id: row.id,
|
||||
currentYear: 180,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 60,
|
||||
lastTurnTime: baseTime,
|
||||
clockBaseTime: baseTime,
|
||||
clockWallAnchor: baseTime,
|
||||
clockTick: 0,
|
||||
lastTurnTick: 0,
|
||||
clockMode: 'realtime',
|
||||
clockPhase: 'PREOPEN',
|
||||
clockRevision: 3,
|
||||
deadlineGeneration: 5,
|
||||
meta: {},
|
||||
},
|
||||
{
|
||||
generals: [],
|
||||
cities: [],
|
||||
nations: [],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
map: {
|
||||
id: 'opening',
|
||||
name: 'opening',
|
||||
cities: [],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
},
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: 'test', unitSet: 'default' },
|
||||
},
|
||||
},
|
||||
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 1 }] } }
|
||||
);
|
||||
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, {
|
||||
profile: 'opening-sync',
|
||||
heartbeat: false,
|
||||
});
|
||||
expect(await lease.acquire()).not.toBeNull();
|
||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { turnDaemonLease: lease });
|
||||
let releaseOpening!: () => void;
|
||||
let openingLocked!: () => void;
|
||||
const release = new Promise<void>((resolve) => {
|
||||
releaseOpening = resolve;
|
||||
});
|
||||
const locked = new Promise<void>((resolve) => {
|
||||
openingLocked = resolve;
|
||||
});
|
||||
const opening = db.$transaction(
|
||||
async (tx) => {
|
||||
await acquireGameSchemaAdvisoryXactLock(tx, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
await tx.worldState.update({ where: { id: row.id }, data: { clockPhase: 'RUNNING' } });
|
||||
openingLocked();
|
||||
await release;
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
let sync: Promise<boolean> | undefined;
|
||||
try {
|
||||
await locked;
|
||||
sync = hooks.synchronizeClockAuthority();
|
||||
// 실제 별도 connection의 advisory lock 대기를 관찰한 뒤에만 DB 오픈을 commit한다.
|
||||
const deadline = Date.now() + 5_000;
|
||||
let waiting = false;
|
||||
while (!waiting && Date.now() < deadline) {
|
||||
const rows = await db.$queryRaw<Array<{ waiting: boolean }>>(GamePrisma.sql`
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM pg_locks held JOIN pg_locks pending
|
||||
ON held.locktype = pending.locktype AND held.database = pending.database
|
||||
AND held.classid = pending.classid AND held.objid = pending.objid
|
||||
AND held.objsubid = pending.objsubid
|
||||
JOIN pg_stat_activity activity ON activity.pid = held.pid
|
||||
WHERE held.locktype = 'advisory' AND held.granted AND NOT pending.granted
|
||||
AND activity.datname = current_database()
|
||||
) AS waiting
|
||||
`);
|
||||
waiting = rows[0]?.waiting ?? false;
|
||||
if (!waiting) await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
expect(waiting).toBe(true);
|
||||
expect(world.getGameClockState().phase).toBe('PREOPEN');
|
||||
releaseOpening();
|
||||
await opening;
|
||||
await expect(sync).resolves.toBe(true);
|
||||
await expect(hooks.synchronizeClockAuthority()).resolves.toBe(false);
|
||||
expect(world.getGameClockState()).toMatchObject({
|
||||
phase: 'RUNNING',
|
||||
tick: 0,
|
||||
lastTurnTick: 0,
|
||||
revision: 3,
|
||||
deadlineGeneration: 5,
|
||||
baseTime,
|
||||
wallAnchor: baseTime,
|
||||
});
|
||||
expect(await db.worldState.findUniqueOrThrow({ where: { id: row.id } })).toMatchObject({
|
||||
clockPhase: 'RUNNING',
|
||||
clockTick: 0n,
|
||||
lastTurnTick: 0n,
|
||||
clockRevision: 3n,
|
||||
});
|
||||
} finally {
|
||||
releaseOpening();
|
||||
await Promise.allSettled([opening, ...(sync ? [sync] : [])]);
|
||||
await hooks.close();
|
||||
await lease.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts partial starts while rejecting incomplete and off-boundary DB windows', async () => {
|
||||
const row = await db.worldState.create({
|
||||
data: {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn
|
||||
|
||||
const baseTime = new Date('2026-09-03T10:00:00.000Z');
|
||||
|
||||
const buildWorld = (phase: 'RUNNING' | 'SUSPENDED' = 'RUNNING'): InMemoryTurnWorld => {
|
||||
const buildWorld = (phase: 'PREOPEN' | 'RUNNING' | 'SUSPENDED' = 'RUNNING'): InMemoryTurnWorld => {
|
||||
const general = {
|
||||
id: 1,
|
||||
name: 'clock-sync-general',
|
||||
@@ -83,6 +83,59 @@ const buildDb = (worldState: Record<string, unknown>, ledgers: unknown[] = []):
|
||||
}) as unknown as GamePrisma.TransactionClient;
|
||||
|
||||
describe('runtime clock authority synchronization', () => {
|
||||
const openingSnapshot = () => ({
|
||||
id: 1,
|
||||
clockBaseTime: baseTime,
|
||||
clockTick: 0n,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: baseTime,
|
||||
lastTurnTick: 0n,
|
||||
clockPhase: 'RUNNING',
|
||||
clockRevision: 3n,
|
||||
deadlineGeneration: 5n,
|
||||
});
|
||||
|
||||
it('adopts the Gateway opening once without changing schedules or consuming ticks', async () => {
|
||||
const world = buildWorld('PREOPEN');
|
||||
const general = structuredClone(world.getGeneralById(1));
|
||||
const db = buildDb(openingSnapshot());
|
||||
|
||||
await expect(synchronizeRuntimeClockAuthorityUnderHeldLock(db, world)).resolves.toBe(true);
|
||||
expect(world.getGameClockState()).toMatchObject({ phase: 'RUNNING', tick: 0, revision: 3 });
|
||||
expect(world.getGeneralById(1)).toEqual(general);
|
||||
expect(db.clockSuspension.findMany).not.toHaveBeenCalled();
|
||||
await expect(synchronizeRuntimeClockAuthorityUnderHeldLock(db, world)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ clockTick: 1n },
|
||||
{ clockMode: 'manual' },
|
||||
{ clockWallAnchor: new Date(baseTime.getTime() + 1) },
|
||||
{ clockRecoveryStartTick: 0n, clockRecoveryEndTick: 36_000_000n, clockRecoveryStartWallAt: baseTime },
|
||||
{ lastTurnTick: 1n },
|
||||
{ clockBaseTime: new Date(baseTime.getTime() + 1) },
|
||||
{ deadlineGeneration: 6n },
|
||||
{ clockRevision: 4n },
|
||||
{ clockPhase: 'SUSPENDED' },
|
||||
{ clockPhase: 'COMPLETED' },
|
||||
])('rejects an opening with incompatible durable coordinates: %#', async (patch) => {
|
||||
const world = buildWorld('PREOPEN');
|
||||
const before = world.getGameClockState();
|
||||
await expect(
|
||||
synchronizeRuntimeClockAuthorityUnderHeldLock(buildDb({ ...openingSnapshot(), ...patch }), world)
|
||||
).rejects.toThrow();
|
||||
expect(world.getGameClockState()).toEqual(before);
|
||||
});
|
||||
|
||||
it('rejects reversing a completed opening back to PREOPEN', async () => {
|
||||
await expect(
|
||||
synchronizeRuntimeClockAuthorityUnderHeldLock(
|
||||
buildDb({ ...openingSnapshot(), clockPhase: 'PREOPEN' }),
|
||||
buildWorld('RUNNING')
|
||||
)
|
||||
).rejects.toThrow(/phase mismatch/);
|
||||
});
|
||||
|
||||
it('adopts a maintenance suspension cut without advancing the game schedule', async () => {
|
||||
const world = buildWorld('RUNNING');
|
||||
const cutWallAt = new Date('2026-09-03T10:02:00.000Z');
|
||||
|
||||
Reference in New Issue
Block a user