오픈 직후 DB 시계 전환을 데몬 메모리에 안전하게 반영
This commit is contained in:
@@ -870,8 +870,22 @@ export class InMemoryTurnWorld {
|
|||||||
`database ${input.baseTime.toISOString()}.`
|
`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 =
|
const validTransition =
|
||||||
current.phase === input.phase ||
|
current.phase === input.phase ||
|
||||||
|
openingTransition ||
|
||||||
(current.phase === 'RUNNING' && input.phase === 'SUSPENDED') ||
|
(current.phase === 'RUNNING' && input.phase === 'SUSPENDED') ||
|
||||||
(current.phase === 'RECONCILING' && input.phase === 'RUNNING');
|
(current.phase === 'RECONCILING' && input.phase === 'RUNNING');
|
||||||
if (!validTransition) {
|
if (!validTransition) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
readTurnRuntimeReady,
|
readTurnRuntimeReady,
|
||||||
createRedisConnector,
|
createRedisConnector,
|
||||||
GENERAL_ACCESS_PERSISTENCE_LOCK,
|
GENERAL_ACCESS_PERSISTENCE_LOCK,
|
||||||
|
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
||||||
GamePrisma,
|
GamePrisma,
|
||||||
acquireGameSchemaAdvisoryXactLock,
|
acquireGameSchemaAdvisoryXactLock,
|
||||||
type GamePrismaClient,
|
type GamePrismaClient,
|
||||||
@@ -15,6 +16,8 @@ import {
|
|||||||
import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js';
|
import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js';
|
||||||
import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js';
|
import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js';
|
||||||
import { prepareRealtimeRecovery } from '../src/turn/prepareRealtimeRecovery.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';
|
import { DatabaseTurnDaemonLease } from '../src/lifecycle/databaseTurnDaemonLease.js';
|
||||||
|
|
||||||
const databaseUrl = process.env.CLOCK_RECONCILIATION_DATABASE_URL;
|
const databaseUrl = process.env.CLOCK_RECONCILIATION_DATABASE_URL;
|
||||||
@@ -67,6 +70,139 @@ describeIntegration('durable clock reconciliation', () => {
|
|||||||
await clean();
|
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 () => {
|
it('accepts partial starts while rejecting incomplete and off-boundary DB windows', async () => {
|
||||||
const row = await db.worldState.create({
|
const row = await db.worldState.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn
|
|||||||
|
|
||||||
const baseTime = new Date('2026-09-03T10:00:00.000Z');
|
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 = {
|
const general = {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'clock-sync-general',
|
name: 'clock-sync-general',
|
||||||
@@ -83,6 +83,59 @@ const buildDb = (worldState: Record<string, unknown>, ledgers: unknown[] = []):
|
|||||||
}) as unknown as GamePrisma.TransactionClient;
|
}) as unknown as GamePrisma.TransactionClient;
|
||||||
|
|
||||||
describe('runtime clock authority synchronization', () => {
|
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 () => {
|
it('adopts a maintenance suspension cut without advancing the game schedule', async () => {
|
||||||
const world = buildWorld('RUNNING');
|
const world = buildWorld('RUNNING');
|
||||||
const cutWallAt = new Date('2026-09-03T10:02:00.000Z');
|
const cutWallAt = new Date('2026-09-03T10:02:00.000Z');
|
||||||
|
|||||||
Reference in New Issue
Block a user