fix: 10분 중단 즉시 복구와 대기 중 토너먼트 참가 허용

This commit is contained in:
2026-09-15 23:36:50 +00:00
parent 4fb1343943
commit ab5a1d240c
12 changed files with 447 additions and 87 deletions
@@ -231,7 +231,7 @@ describeIntegration('durable clock reconciliation', () => {
expect((await db.worldState.findUniqueOrThrow({ where: { id: row.id } })).clockRecoveryStartTick).toBe(1n);
});
it.each([300, 420])('applies startup recovery after %i seconds on a 60-minute server', async (delay) => {
it.each([180, 420, 590, 610])('applies startup recovery after %i seconds on a 60-minute server', async (delay) => {
const profile = 'short-startup';
await db.worldState.create({
data: {
@@ -259,8 +259,8 @@ describeIntegration('durable clock reconciliation', () => {
fencingEpoch: token.fencingEpoch,
});
const world = await db.worldState.findFirstOrThrow();
expect(world.clockPhase).toBe(delay < 360 ? 'RUNNING' : 'RECONCILING');
expect(readTurnRecovery(world) === null).toBe(delay < 360);
expect(world.clockPhase).toBe(delay <= 600 ? 'RUNNING' : 'RECONCILING');
expect(readTurnRecovery(world) === null).toBe(delay <= 600);
} finally {
await lease.close();
}
@@ -345,6 +345,61 @@ describeIntegration('durable clock reconciliation', () => {
}
});
it.each([300, 1200, 3600])(
'keeps pending turns and operator pause across a short VM outage (%i second turn)',
async (turnSeconds) => {
const profile = 'short-vm-outage';
const anchor = new Date(Date.now() - 590_000);
await db.worldState.create({
data: {
scenarioCode: profile,
currentYear: 199,
currentMonth: 1,
tickSeconds: turnSeconds,
clockBaseTime: anchor,
clockWallAnchor: anchor,
clockTick: 0n,
lastTurnTick: 0n,
clockMode: 'realtime',
clockPhase: 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
},
});
const general = await db.general.create({
data: { id: 901, name: 'pending-turn', turnTick: 12345n, turnTime: anchor },
});
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, { profile, heartbeat: false });
try {
const token = await lease.acquire();
const authority = {
kind: 'DAEMON' as const,
profileName: profile,
ownerId: token!.ownerId,
fencingEpoch: token!.fencingEpoch,
};
await prepareRealtimeRecovery(db, authority);
expect(await db.worldState.findFirstOrThrow()).toMatchObject({
clockPhase: 'RUNNING',
clockRevision: 1n,
clockTick: 0n,
lastTurnTick: 0n,
clockRecoveryStartTick: null,
});
expect(await db.general.findUniqueOrThrow({ where: { id: general.id } })).toMatchObject({
turnTick: general.turnTick,
turnTime: general.turnTime,
});
expect(await db.clockSuspension.count()).toBe(0);
// 짧은 host 중단도 운영자가 정지한 서버를 자동 재개하는 근거가 되지 않는다.
await prepareRealtimeRecovery(db, authority, { paused: true });
expect(await db.worldState.findFirstOrThrow()).toMatchObject({ clockPhase: 'SUSPENDED' });
} finally {
await lease.close();
}
}
);
it.each([false, true])('fences outage recovery and reuses its window; repeated outage=%s', async (repeated) => {
const profile = 'recovery-startup';
await db.worldState.create({
@@ -513,8 +568,8 @@ describeIntegration('durable clock reconciliation', () => {
}
);
it.each([359999, 360000, 360001, 840000, 12 * 3600000 + 1000])(
'persists strict recovery boundaries for %i ms',
it.each([180000, 599999, 600000, 600001, 840000, 12 * 3600000 + 1000])(
'persists the inclusive ten-minute recovery boundary for %i ms',
async (gap) => {
const observed = T / 6;
const future = new Date(Date.now() + 3600000);
@@ -549,7 +604,7 @@ describeIntegration('durable clock reconciliation', () => {
authority,
testResumeWallAt: now,
});
expect(plan.recovery === null).toBe(gap < 360000);
expect(plan.recovery === null).toBe(gap <= 600000);
expect(await db.message.count()).toBe(0);
// Redis 장애 후에도 알림은 DB의 RUNNING 전이와 함께 한 번만 저장한다.
await expect(
@@ -84,6 +84,52 @@ const buildWorld = (stateOverride: Partial<TurnWorldState> = {}): InMemoryTurnWo
};
describe('runtime clock shift', () => {
it('executes ten minutes of pending work in bounded batches without skipping or repeating generals', async () => {
const base = new Date('2026-07-30T10:00:00Z');
const resumed = new Date(base.getTime() + 600_000);
const plan = planTurnRecovery({
observedTick: 0,
normalTick: GAME_TICKS_PER_TURN,
wallNow: resumed,
turnSeconds: 600,
});
expect(plan).toMatchObject({ skippedTurns: 0, recovery: null });
const world = buildWorld({
clockBaseTime: base,
clockTick: 0,
clockWallAnchor: base,
clockMode: 'realtime',
clockPhase: 'RUNNING',
lastTurnTick: 0,
});
for (const [id, offset] of [
[1, 19_902],
[2, 42_001],
] as const) {
world.updateGeneral(id, { turnTime: new Date(base.getTime() + offset), turnTick: offset * 60 });
}
const executed: number[] = [];
const processor = new InMemoryTurnProcessor(world, {
afterExecuteGeneral: async (general) => {
executed.push(general.id);
},
});
const target = world.getGameNow(resumed);
world.advanceGameClockTo(target, resumed);
const budget = { budgetMs: 10_000, maxGenerals: 1, catchUpCap: 1 };
let result = await processor.run(target, budget);
expect(result.processedGenerals).toBe(1);
for (let batch = 0; result.partial && batch < 4; batch++) {
result = await processor.run(target, budget, result.checkpoint);
expect(result.processedGenerals).toBeLessThanOrEqual(1);
}
expect(result.partial).toBe(false);
expect(executed).toEqual([1, 2]);
expect(world.getState().currentMonth).toBe(2);
expect(world.getGeneralById(1)!.turnTick).toBe(GAME_TICKS_PER_TURN + 19_902 * 60);
expect(world.getGeneralById(2)!.turnTick).toBe(GAME_TICKS_PER_TURN + 42_001 * 60);
});
it('runs two real monthly cycles per normal interval, survives reload, and returns to one cycle', async () => {
const base = new Date('2026-07-30T10:00:00Z');
const wallAt = (minutes: number) => new Date(base.getTime() + minutes * 60_000);