lease 상실 데몬을 종료하고 관리자에게 즉시 오류 기록
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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을 늘려도 한
|
||||||
|
|||||||
Reference in New Issue
Block a user