오류 정지 중 가입 턴을 고정하고 화면 이동 실패 복구 지원

This commit is contained in:
2026-09-12 02:02:31 +00:00
parent 5041fc365e
commit 21d10d5dfc
15 changed files with 448 additions and 26 deletions
@@ -4,6 +4,7 @@ import { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery } from '@sammo-ts
import {
createGamePostgresConnector,
readTurnRuntimeReady,
readInputEventClockCoordinate,
createRedisConnector,
GENERAL_ACCESS_PERSISTENCE_LOCK,
CLOCK_OPERATION_PERSISTENCE_LOCK,
@@ -15,6 +16,8 @@ import {
import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js';
import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js';
import { createRuntimePauseGate } from '../src/turn/runtimePauseGate.js';
import { resolveJoinTurnTime } from '../src/turn/joinCreateGeneralService.js';
import { prepareRealtimeRecovery } from '../src/turn/prepareRealtimeRecovery.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
@@ -263,6 +266,85 @@ describeIntegration('durable clock reconciliation', () => {
}
});
it('freezes joins in a live PAUSED gate and applies outage recovery only once', async () => {
const profile = 'live-pause-join';
const base = new Date('2026-09-11T23:00:00Z');
await db.worldState.create({
data: {
scenarioCode: profile,
currentYear: 180,
currentMonth: 1,
tickSeconds: 60,
clockBaseTime: base,
clockTick: 0n,
lastTurnTick: 0n,
clockMode: 'realtime',
clockPhase: 'RUNNING',
clockWallAnchor: new Date(Date.now() - 115 * 60_000),
clockRevision: 1n,
deadlineGeneration: 1n,
},
});
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, { profile, heartbeat: false });
try {
const token = (await lease.acquire())!;
await lease.markClockReady();
const authority = {
kind: 'DAEMON' as const,
profileName: profile,
ownerId: token.ownerId,
fencingEpoch: token.fencingEpoch,
};
let phase: 'RUNNING' | 'SUSPENDED' = 'RUNNING';
const gate = createRuntimePauseGate({
assertLease: () => {},
shouldPause: async () => true,
isExplicitlyPaused: () => true,
getPhase: () => phase,
prepareRecovery: async (options) => {
await prepareRealtimeRecovery(db, authority, options);
phase = 'SUSPENDED';
},
synchronize: async () => {},
});
const beforePause = await db.$transaction((tx) => readInputEventClockCoordinate(tx));
expect(beforePause.gameTick).toBeGreaterThan(BigInt(100 * T));
await gate();
const accepted = await db.$transaction((tx) => readInputEventClockCoordinate(tx));
expect(accepted.gameTick).toBe(0n);
const pausedWorld = await db.worldState.findFirstOrThrow();
const draws = [26, 753000];
const turnTime = resolveJoinTurnTime(
{ nextRangeInt: () => draws.shift()! },
pausedWorld,
accepted.gameAt,
base,
undefined
);
expect(turnTime.toISOString()).toBe('2026-09-11T23:00:26.753Z');
await db.general.create({
data: { id: 768, name: 'pause-join', turnTick: BigInt(26_753 * 600), turnTime },
});
await gate();
expect(await db.clockSuspension.count()).toBe(1);
const suspension = await db.clockSuspension.findFirstOrThrow();
const plan = await reconcileClockSuspension({
db,
authority,
suspensionId: suspension.id,
testResumeWallAt: new Date(suspension.cutWallAt.getTime() + 115 * 60_000),
});
expect(plan.shiftTicks).toBe(108 * T);
const joined = await db.general.findUniqueOrThrow({ where: { id: 768 } });
expect(joined.turnTime.toISOString()).toBe('2026-09-12T00:48:26.753Z');
expect(joined.turnTick! - BigInt(plan.alignedTick)).toBe(BigInt(26_753 * 600));
await reconcileClockSuspension({ db, authority, suspensionId: suspension.id });
expect((await db.general.findUniqueOrThrow({ where: { id: 768 } })).turnTime).toEqual(joined.turnTime);
} 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({
@@ -239,6 +239,20 @@ describe('runtime clock shift', () => {
expect(world.getGameClockState().wallAnchor).toEqual(resumedAt);
});
it.each(['SUSPENDED', 'COMPLETED'] as const)('keeps a queued join within the frozen %s clock', (phase) => {
const base = new Date('2026-09-11T23:00:00Z');
const world = buildWorld({
clockBaseTime: base,
clockTick: 0,
clockMode: 'realtime',
clockPhase: phase,
clockWallAnchor: base,
lastTurnTick: 0,
});
expect(world.getInitialGeneralTurnTime(103 * 36_000_000)).toEqual(base);
expect(world.getInitialGeneralTurnTime(-36_000_000)).toEqual(base);
});
it('keeps runnable general scheduling at the future opening anchor during PREOPEN', () => {
const gameBase = new Date('2026-07-30T10:00:00.000Z');
const openAt = new Date('2026-09-02T23:30:00.000Z');
@@ -254,6 +268,7 @@ describe('runtime clock shift', () => {
expect(world.getGameNow(preopenAt).getTime()).toBeLessThan(gameBase.getTime());
expect(world.getRunnableGameNow(preopenAt)).toEqual(gameBase);
expect(world.getInitialGeneralTurnTime(-36_000_000)).toEqual(gameBase);
expect(world.getRunnableGameNow(openAt)).toEqual(gameBase);
expect(world.promotePreopenAtOpening(openAt)).toBe(true);
expect(world.getRunnableGameNow(new Date(openAt.getTime() + 60_000))).toEqual(
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameClockPhase } from '@sammo-ts/common';
import { createRuntimePauseGate } from '../src/turn/runtimePauseGate.js';
describe('runtime pause clock boundary', () => {
it('freezes a live error pause before commands, resumes without restart, and does not freeze twice', async () => {
let phase: GameClockPhase = 'RUNNING';
let paused = true;
const calls: string[] = [];
const gate = createRuntimePauseGate({
assertLease: () => calls.push('lease'),
shouldPause: async () => paused,
isExplicitlyPaused: () => paused,
getPhase: () => phase,
prepareRecovery: async ({ paused }) => {
calls.push(paused ? 'freeze' : 'resume');
phase = paused ? 'SUSPENDED' : 'RECONCILING';
},
synchronize: async () => calls.push('sync'),
});
expect(await gate()).toBe(true);
expect(phase).toBe('SUSPENDED');
expect(calls).toEqual(['lease', 'freeze', 'sync']);
await gate();
expect(calls.filter((call) => call === 'freeze')).toHaveLength(1);
paused = false;
expect(await gate()).toBe(false);
expect(phase).toBe('RECONCILING');
expect(calls.slice(-3)).toEqual(['lease', 'resume', 'sync']);
});
it.each(['PREOPEN', 'RUNNING', 'COMPLETED'] as const)(
'preserves the planned opening when the Gateway is PREOPEN and the clock is %s',
async (phase) => {
const prepareRecovery = vi.fn();
const gate = createRuntimePauseGate({
assertLease: () => {},
shouldPause: async () => true,
isExplicitlyPaused: () => false,
getPhase: () => phase,
prepareRecovery,
synchronize: async () => {},
});
expect(await gate()).toBe(true);
expect(prepareRecovery).not.toHaveBeenCalled();
}
);
it('does not touch the clock after lease loss', async () => {
const prepareRecovery = vi.fn();
const gate = createRuntimePauseGate({
assertLease: () => {
throw new Error('lease lost');
},
shouldPause: async () => true,
isExplicitlyPaused: () => true,
getPhase: () => 'RUNNING',
prepareRecovery,
synchronize: async () => {},
});
await expect(gate()).rejects.toThrow('lease lost');
expect(prepareRecovery).not.toHaveBeenCalled();
});
});
@@ -19,13 +19,15 @@ describe('TurnDaemonLifecycle', () => {
const now = new Date('2026-09-09T17:30:00Z');
const error = new TurnDaemonLeaseLostError('che:default');
const processor = { run: vi.fn() };
const queue = new InMemoryControlQueue();
const drain = vi.spyOn(queue, 'drain');
const onRunError = vi.fn(async () => {
if (reportFails) throw new Error('gateway unavailable');
});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(now.getTime()),
controlQueue: new InMemoryControlQueue(),
controlQueue: queue,
processor,
getNextTickTime: (value) => addMinutes(value, 5),
stateStore: {
@@ -45,6 +47,7 @@ describe('TurnDaemonLifecycle', () => {
await expect(lifecycle.start()).rejects.toBe(error);
expect(onRunError).toHaveBeenCalledExactlyOnceWith(error);
expect(processor.run).not.toHaveBeenCalled();
expect(drain).not.toHaveBeenCalled();
expect(lifecycle.getStatus()).toMatchObject({ state: 'stopping', paused: true, lastError: error.message });
});