fix: 10분 중단 즉시 복구와 대기 중 토너먼트 참가 허용
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user