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