오류 정지 중 가입 턴을 고정하고 화면 이동 실패 복구 지원
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user