lease 상실 데몬을 종료하고 관리자에게 즉시 오류 기록

This commit is contained in:
2026-09-09 23:04:02 +00:00
parent d191960ea1
commit 09f4e60df4
4 changed files with 62 additions and 3 deletions
@@ -90,7 +90,20 @@ export class TurnDaemonLifecycle {
start(): Promise<void> { start(): Promise<void> {
if (!this.loopPromise) { 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; return this.loopPromise;
} }
+8 -2
View File
@@ -93,7 +93,11 @@ import {
createResetOfficerLockHandler, createResetOfficerLockHandler,
} from './monthlyCoreEventAction.js'; } from './monthlyCoreEventAction.js';
import { buildCommandEnv } from './reservedTurnCommands.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 { EngineStateManager } from './engineStateManager.js';
import { applyRuntimeClockShift } from './runtimeClockShift.js'; import { applyRuntimeClockShift } from './runtimeClockShift.js';
import { applyRuntimeGameSettings } from './runtimeGameSettings.js'; import { applyRuntimeGameSettings } from './runtimeGameSettings.js';
@@ -1060,7 +1064,9 @@ const createTurnDaemonRuntimeWithLease = async (
hooks, hooks,
pauseGate: async () => { pauseGate: async () => {
if (turnDaemonLease?.isLost()) { if (turnDaemonLease?.isLost()) {
return true; // 만료된 owner는 재개 명령도 처리할 수 없다. 현재 runtime을
// 끝내 PM2가 새 owner와 DB snapshot으로 시작하도록 한다.
throw new TurnDaemonLeaseLostError(options.profileName ?? options.profile);
} }
const gatewayPaused = (await pauseGate?.()) ?? false; const gatewayPaused = (await pauseGate?.()) ?? false;
const phase = world.getGameClockState().phase; const phase = world.getGameClockState().phase;
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { ManualClock } from '@sammo-ts/common'; import { ManualClock } from '@sammo-ts/common';
import { TurnDaemonLeaseLostError } from '../src/lifecycle/databaseTurnDaemonLease.js';
import { import {
InMemoryControlQueue, InMemoryControlQueue,
@@ -14,6 +15,39 @@ import {
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000); const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
describe('TurnDaemonLifecycle', () => { 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)( it.each(['PREOPEN', 'SUSPENDED', 'RECONCILING', 'COMPLETED'] as const)(
'does not dispatch an explicit run while the clock phase is %s', 'does not dispatch an explicit run while the clock phase is %s',
async (phase) => { async (phase) => {
+6
View File
@@ -306,6 +306,12 @@ process 복구를 시도합니다. 관리자 화면의 오류와 PM2 process 상
뒤 원인을 해결하고 실패한 작업을 재시도해 주세요. 재시도는 처음 고정된 commit을 뒤 원인을 해결하고 실패한 작업을 재시도해 주세요. 재시도는 처음 고정된 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 Turn daemon의 DB persistence interactive transaction은 기본 30초입니다. 정상 turn
budget과 같은 Prisma 기본 5초를 그대로 쓰면 populated season의 flush가 경계에서 budget과 같은 Prisma 기본 5초를 그대로 쓰면 populated season의 flush가 경계에서
rollback되고 profile이 `PAUSED`로 전환될 수 있습니다. timeout을 늘려도 한 rollback되고 profile이 `PAUSED`로 전환될 수 있습니다. timeout을 늘려도 한