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

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
@@ -14,6 +14,7 @@ export interface GatewayProfileGateOptions {
export interface GatewayProfileGate {
shouldPause(): Promise<boolean>;
isExplicitlyPaused(): boolean;
markPaused(error?: unknown): Promise<void>;
close(): Promise<void>;
}
@@ -30,12 +31,14 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
const prisma = connector.prisma;
let lastCheckedAt = 0;
let cachedPause = false;
let cachedStatus: GatewayProfileStatus | null = null;
const loadStatus = async (): Promise<boolean> => {
try {
const profile = await prisma.gatewayProfile.findUnique({
where: { profileName: options.profileName },
});
cachedStatus = (profile?.status as GatewayProfileStatus | undefined) ?? null;
if (!profile) {
return false;
}
@@ -46,6 +49,7 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
};
return {
isExplicitlyPaused: () => cachedStatus === 'PAUSED',
// 게이트웨이 프로필 상태를 읽어 턴 실행을 멈춰야 하는지 판단한다.
async shouldPause(): Promise<boolean> {
const now = performance.now();
@@ -57,6 +61,9 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
return cachedPause;
},
async markPaused(error?: unknown): Promise<void> {
cachedPause = true;
cachedStatus = 'PAUSED';
lastCheckedAt = performance.now();
const failure = error ? describeRuntimeError(error) : null;
const message = failure?.message ?? null;
try {
+13
View File
@@ -920,6 +920,19 @@ export class InMemoryTurnWorld {
return clock.now(wallNow);
}
getInitialGeneralTurnTime(processingGameTick: number): Date {
const clock = this.getGameClock();
const tick =
clock.phase === 'PREOPEN'
? Math.max(0, processingGameTick)
: clock.phase === 'SUSPENDED' || clock.phase === 'COMPLETED'
? clock.tick
: processingGameTick;
// 오류 정지 직전에 접수된 가입도 정지된 시각보다 미래에 배치하지 않는다.
// 접수 tick 자체는 RNG/감사 원장의 좌표로 보존한다.
return clock.tickToDate(tick);
}
dateToGameTick(date: Date): number {
return this.getGameClock().dateToTick(date);
}
@@ -0,0 +1,33 @@
import type { GameClockPhase } from '@sammo-ts/common';
/** Gateway의 실행 gate와 durable 시계를 명령 claim 전에 맞춘다. */
export const createRuntimePauseGate = (options: {
assertLease(): void;
shouldPause(): Promise<boolean>;
getPhase(): GameClockPhase;
isExplicitlyPaused(): boolean;
prepareRecovery(options: { paused: boolean }): Promise<void>;
synchronize(): Promise<unknown>;
}): (() => Promise<boolean>) => {
let lastPaused: boolean | null = null;
return async () => {
options.assertLease();
const paused = await options.shouldPause();
const phase = options.getPhase();
// 오류 정지는 Gateway 상태만 PAUSED로 바꿀 수 있다. 그대로 두면 가입은
// 흐르는 접수 시각을 쓰고, 재개 시 정수 턴 이동까지 중복 적용받는다.
// PREOPEN은 예정된 대기이므로 오픈 시각을 바꾸지 않는다.
if (paused && options.isExplicitlyPaused() && phase === 'RUNNING') {
await options.prepareRecovery({ paused: true });
} else if (!paused && phase === 'SUSPENDED') {
// 이 runtime이 만든 RECOVERY 정지는 재기동 없이도 재개한다.
// MAINTENANCE/통일 대기의 재개 권한은 기존 운영 경계에 남는다.
await options.prepareRecovery({ paused: false });
}
if (lastPaused !== paused || phase === 'SUSPENDED' || phase === 'RECONCILING') {
await options.synchronize();
}
lastPaused = paused;
return paused;
};
};
+19 -18
View File
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto';
import { createRuntimePauseGate } from './runtimePauseGate.js';
import { loadActionModuleBundle, type TurnCommandProfile, type TurnSchedule } from '@sammo-ts/logic';
import {
@@ -716,6 +717,7 @@ const createTurnDaemonRuntimeWithLease = async (
let stopClockProjectionWorker = () => {};
let applyClockProjection: DatabaseTurnHooks['applyClockProjection'] | undefined;
let synchronizeClockAuthority: DatabaseTurnHooks['synchronizeClockAuthority'] | undefined;
let prepareClockRecovery: DatabaseTurnHooks['prepareRealtimeRecovery'] | undefined;
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
const monthlyActionModules = await loadActionModuleBundle(
@@ -941,11 +943,16 @@ const createTurnDaemonRuntimeWithLease = async (
onRunError: async (error) => {
await dbHooks.hooks.onRunError?.(error);
await gatewayGate?.markPaused(error);
if (!turnDaemonLease?.isLost() && world.getGameClockState().phase === 'RUNNING') {
// 같은 command batch의 다음 가입도 정지된 시각을 보게 한다.
await dbHooks.prepareRealtimeRecovery({ paused: true });
}
},
};
takeCommittedReadModelChangeReceipt = dbHooks.takeCommittedReadModelChangeReceipt;
applyClockProjection = dbHooks.applyClockProjection;
synchronizeClockAuthority = dbHooks.synchronizeClockAuthority;
prepareClockRecovery = dbHooks.prepareRealtimeRecovery;
close = async () => {
if (auctionBidder) {
await auctionBidder.close();
@@ -1060,7 +1067,6 @@ const createTurnDaemonRuntimeWithLease = async (
maxGenerals: 200,
catchUpCap: 1,
};
let lastObservedGatewayPause: boolean | null = null;
const lifecycle = new TurnDaemonLifecycle(
{
@@ -1071,23 +1077,18 @@ const createTurnDaemonRuntimeWithLease = async (
stateStore,
processor,
hooks,
pauseGate: async () => {
if (turnDaemonLease?.isLost()) {
// 만료된 owner는 재개 명령도 처리할 수 없다. 현재 runtime을
// 끝내 PM2가 새 owner와 DB snapshot으로 시작하도록 한다.
throw turnDaemonLease.getLossError();
}
const gatewayPaused = (await pauseGate?.()) ?? false;
const phase = world.getGameClockState().phase;
const phaseNeedsSync = gatewayPaused
? phase !== 'SUSPENDED'
: phase === 'SUSPENDED' || phase === 'RECONCILING';
if (synchronizeClockAuthority && (lastObservedGatewayPause !== gatewayPaused || phaseNeedsSync)) {
await synchronizeClockAuthority();
}
lastObservedGatewayPause = gatewayPaused;
return gatewayPaused;
},
pauseGate: createRuntimePauseGate({
assertLease: () => {
if (turnDaemonLease?.isLost()) throw turnDaemonLease.getLossError();
},
shouldPause: async () => (await pauseGate?.()) ?? false,
getPhase: () => world.getGameClockState().phase,
isExplicitlyPaused: () => gatewayGate?.isExplicitlyPaused() ?? false,
prepareRecovery: async (recoveryOptions) => {
await prepareClockRecovery?.(recoveryOptions);
},
synchronize: async () => synchronizeClockAuthority?.(),
}),
commandHandler,
commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined),
// The exclusive fixture runner aborts the entire in-memory runtime
@@ -354,9 +354,7 @@ async function handleJoinCreateGeneral(
throw new Error('joinCreateGeneral requires an authoritative daemon processing game tick.');
}
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
const turnScheduleAt = ctx.world.gameTickToDate(
ctx.world.getGameClockState().phase === 'PREOPEN' ? Math.max(0, processingGameTick) : processingGameTick
);
const turnScheduleAt = ctx.world.getInitialGeneralTurnTime(processingGameTick);
try {
return {
type: 'joinCreateGeneral',
@@ -475,9 +473,7 @@ async function handleSelectPoolCreate(
}
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
// 선택 생성도 접수/RNG의 음수 tick과 실제 최초 턴의 오픈 하한을 분리한다.
const turnScheduleAt = ctx.world.gameTickToDate(
ctx.world.getGameClockState().phase === 'PREOPEN' ? Math.max(0, processingGameTick) : processingGameTick
);
const turnScheduleAt = ctx.world.getInitialGeneralTurnTime(processingGameTick);
try {
return {
type: 'selectPoolCreate',