diff --git a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts index e2f9a7dd..9b5e1a20 100644 --- a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -90,7 +90,20 @@ export class TurnDaemonLifecycle { start(): Promise { if (!this.loopPromise) { - this.loopPromise = this.runLoop(); + this.loopPromise = this.runLoop().catch(async (error: unknown) => { + // 초기화와 pause gate 실패도 관리자에게 즉시 남긴다. lease를 + // 잃은 runtime은 대기 상태로 살아 있으면 안전하게 재개할 수 없다. + this.status.state = 'stopping'; + this.status.running = false; + this.status.paused = true; + this.status.lastError = error instanceof Error ? error.message : 'Unknown lifecycle error.'; + try { + await this.hooks?.onRunError?.(error); + } catch { + // 장애 기록 실패가 원래 종료 원인을 덮어쓰지 않게 한다. + } + throw error; + }); } return this.loopPromise; } diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index d5131f94..ba86c296 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -93,7 +93,11 @@ import { createResetOfficerLockHandler, } from './monthlyCoreEventAction.js'; import { buildCommandEnv } from './reservedTurnCommands.js'; -import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js'; +import { + DatabaseTurnDaemonLease, + TurnDaemonLeaseLostError, + TurnDaemonLeaseUnavailableError, +} from '../lifecycle/databaseTurnDaemonLease.js'; import { EngineStateManager } from './engineStateManager.js'; import { applyRuntimeClockShift } from './runtimeClockShift.js'; import { applyRuntimeGameSettings } from './runtimeGameSettings.js'; @@ -1060,7 +1064,9 @@ const createTurnDaemonRuntimeWithLease = async ( hooks, pauseGate: async () => { if (turnDaemonLease?.isLost()) { - return true; + // 만료된 owner는 재개 명령도 처리할 수 없다. 현재 runtime을 + // 끝내 PM2가 새 owner와 DB snapshot으로 시작하도록 한다. + throw new TurnDaemonLeaseLostError(options.profileName ?? options.profile); } const gatewayPaused = (await pauseGate?.()) ?? false; const phase = world.getGameClockState().phase; diff --git a/app/game-engine/test/turnDaemonLifecycle.test.ts b/app/game-engine/test/turnDaemonLifecycle.test.ts index 9fa962f2..03d79c1d 100644 --- a/app/game-engine/test/turnDaemonLifecycle.test.ts +++ b/app/game-engine/test/turnDaemonLifecycle.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { ManualClock } from '@sammo-ts/common'; +import { TurnDaemonLeaseLostError } from '../src/lifecycle/databaseTurnDaemonLease.js'; import { InMemoryControlQueue, @@ -14,6 +15,39 @@ import { const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000); describe('TurnDaemonLifecycle', () => { + it.each([false, true])('reports a fatal lease gate and exits even if reporting fails (%s)', async (reportFails) => { + const now = new Date('2026-09-09T17:30:00Z'); + const error = new TurnDaemonLeaseLostError('che:default'); + const processor = { run: vi.fn() }; + const onRunError = vi.fn(async () => { + if (reportFails) throw new Error('gateway unavailable'); + }); + const lifecycle = new TurnDaemonLifecycle( + { + clock: new ManualClock(now.getTime()), + controlQueue: new InMemoryControlQueue(), + processor, + getNextTickTime: (value) => addMinutes(value, 5), + stateStore: { + loadLastTurnTime: async () => now, + loadNextGeneralTurnTime: async () => now, + saveLastTurnTime: async () => {}, + loadCheckpoint: async () => undefined, + saveCheckpoint: async () => {}, + }, + hooks: { onRunError }, + pauseGate: async () => { + throw error; + }, + }, + { profile: 'che', defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 1 } } + ); + await expect(lifecycle.start()).rejects.toBe(error); + expect(onRunError).toHaveBeenCalledExactlyOnceWith(error); + expect(processor.run).not.toHaveBeenCalled(); + expect(lifecycle.getStatus()).toMatchObject({ state: 'stopping', paused: true, lastError: error.message }); + }); + it.each(['PREOPEN', 'SUSPENDED', 'RECONCILING', 'COMPLETED'] as const)( 'does not dispatch an explicit run while the clock phase is %s', async (phase) => { diff --git a/docs/release-operations.md b/docs/release-operations.md index a811a0d3..90b60a03 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -306,6 +306,12 @@ process 복구를 시도합니다. 관리자 화면의 오류와 PM2 process 상 뒤 원인을 해결하고 실패한 작업을 재시도해 주세요. 재시도는 처음 고정된 commit을 사용합니다. +VM 중단이나 DB 연결 장애로 turn-daemon lease가 만료되면 기존 owner는 턴과 +관리자 mutation을 처리할 수 없습니다. Lifecycle은 이를 즉시 `lastError`와 +`PAUSED`로 기록하고 종료합니다. PM2가 새 runtime과 DB snapshot으로 시작한 뒤 +관리자가 `재개`를 요청합니다. 이전 owner의 lease를 연장하거나 fencing 검증을 +우회하지 않습니다. 초기화·pause gate 실패도 같은 오류 기록 경로를 사용합니다. + Turn daemon의 DB persistence interactive transaction은 기본 30초입니다. 정상 turn budget과 같은 Prisma 기본 5초를 그대로 쓰면 populated season의 flush가 경계에서 rollback되고 profile이 `PAUSED`로 전환될 수 있습니다. timeout을 늘려도 한