관리자 서버 진단에 lease 상태와 영속 장애 이력 추가

This commit is contained in:
2026-09-09 23:36:16 +00:00
parent 9c73085353
commit a00f46dfc8
19 changed files with 627 additions and 43 deletions
@@ -24,8 +24,8 @@ export class TurnDaemonLeaseUnavailableError extends Error {
}
export class TurnDaemonLeaseLostError extends Error {
constructor(profile: string) {
super(`Turn daemon lease was lost for profile "${profile}".`);
constructor(profile: string, reason?: string) {
super(`Turn daemon lease was lost for profile "${profile}".${reason ? ` ${reason}` : ''}`);
this.name = 'TurnDaemonLeaseLostError';
}
}
@@ -50,6 +50,7 @@ export class DatabaseTurnDaemonLease {
private expiryTimer: NodeJS.Timeout | null = null;
private renewalInFlight = false;
private lost = false;
private lossReason: string | undefined;
private constructor(
db: GamePrismaClient,
@@ -116,6 +117,7 @@ export class DatabaseTurnDaemonLease {
fencingEpoch: BigInt(row.fencing_epoch),
};
this.lost = false;
this.lossReason = undefined;
this.scheduleExpiryWatchdog(requestStartedAt);
if (this.heartbeatEnabled) {
this.startHeartbeat();
@@ -139,6 +141,10 @@ export class DatabaseTurnDaemonLease {
return this.lost;
}
getLossError(): TurnDaemonLeaseLostError {
return new TurnDaemonLeaseLostError(this.profile, this.lossReason);
}
async renew(): Promise<boolean> {
const token = this.token;
if (!token || this.lost || this.renewalInFlight) {
@@ -160,7 +166,7 @@ export class DatabaseTurnDaemonLease {
RETURNING "profile", "owner_id", "fencing_epoch"
`);
if (rows.length === 0) {
this.markLost();
this.markLost('Heartbeat renewal rejected: lease expired or owner/epoch changed.');
return false;
}
if (this.lost) {
@@ -176,7 +182,7 @@ export class DatabaseTurnDaemonLease {
async assertActive(transaction?: GamePrisma.TransactionClient): Promise<void> {
const token = this.token;
if (!token || this.lost) {
throw new TurnDaemonLeaseLostError(this.profile);
throw this.getLossError();
}
const db = transaction ?? this.db;
const rows = await db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
@@ -190,8 +196,8 @@ export class DatabaseTurnDaemonLease {
FOR UPDATE
`);
if (rows.length === 0) {
this.markLost();
throw new TurnDaemonLeaseLostError(this.profile);
this.markLost('Transaction fencing rejected: lease expired or owner/epoch changed.');
throw this.getLossError();
}
}
@@ -228,8 +234,10 @@ export class DatabaseTurnDaemonLease {
}
const intervalMs = Math.max(250, Math.floor(this.leaseDurationMs / 3));
this.heartbeatTimer = setInterval(() => {
void this.renew().catch(() => {
this.markLost();
void this.renew().catch((error: unknown) => {
this.markLost(
`Heartbeat database request failed (${error instanceof Error ? error.name : 'unknown error'}).`
);
});
}, intervalMs);
this.heartbeatTimer.unref();
@@ -248,7 +256,9 @@ export class DatabaseTurnDaemonLease {
this.stopExpiryWatchdog();
const remainingMs = Math.max(0, this.leaseDurationMs - (performance.now() - requestStartedAt));
this.expiryTimer = setTimeout(() => {
this.markLost();
this.markLost(
`Heartbeat deadline exceeded (${this.leaseDurationMs}ms; renewal in flight: ${this.renewalInFlight}).`
);
}, remainingMs);
this.expiryTimer.unref();
}
@@ -260,7 +270,9 @@ export class DatabaseTurnDaemonLease {
}
}
private markLost(): void {
private markLost(reason: string): void {
if (this.lost) return;
this.lossReason = reason;
this.lost = true;
this.stopHeartbeat();
this.stopExpiryWatchdog();
+23
View File
@@ -5,6 +5,8 @@ import type { TurnRunBudget } from '../lifecycle/types.js';
import { resolveDatabaseUrl } from '../scenario/databaseUrl.js';
import { createTurnDaemonRuntime } from './turnDaemon.js';
import { createTurnDaemonMemoryReporter } from './turnDaemonMemoryReporter.js';
import { createGatewayProfileGate } from './gatewayProfileGate.js';
import { TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
export interface TurnDaemonCliOptions {
profile?: string;
@@ -89,6 +91,27 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom
pauseGateIntervalMs,
adminActionIntervalMs,
gameClockMode,
}).catch(async (error: unknown) => {
// 중복 starter가 정상 owner를 멈추면 안 된다. 그 밖의 초기화 실패는
// lifecycle hook이 아직 없으므로 여기서 별도로 관리자에게 기록한다.
if (!(error instanceof TurnDaemonLeaseUnavailableError)) {
try {
const gate = await createGatewayProfileGate({
databaseUrl,
gatewayDatabaseUrl,
profileName,
incidentContext: () => ({ stage: 'startup' }),
});
try {
await gate.markPaused(error);
} finally {
await gate.close();
}
} catch {
/* 원래 시작 실패를 보존한다. */
}
}
throw error;
});
const memoryReporter = createTurnDaemonMemoryReporter({
+39 -11
View File
@@ -1,6 +1,7 @@
import { performance } from 'node:perf_hooks';
import { randomUUID } from 'node:crypto';
import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common';
import { describeRuntimeError, gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common';
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
export interface GatewayProfileGateOptions {
@@ -8,6 +9,7 @@ export interface GatewayProfileGateOptions {
gatewayDatabaseUrl?: string;
profileName: string;
cacheMs?: number;
incidentContext?: () => Record<string, string | number | boolean | null>;
}
export interface GatewayProfileGate {
@@ -22,6 +24,7 @@ const PROFILE_STATUSES_MARKABLE_AS_PAUSED = ['PREOPEN', 'RUNNING', 'PAUSED'] as
export const createGatewayProfileGate = async (options: GatewayProfileGateOptions): Promise<GatewayProfileGate> => {
const connector = createGatewayPostgresConnector({
url: options.gatewayDatabaseUrl ?? options.databaseUrl,
connectionTimeoutMillis: 3000,
});
await connector.connect();
const prisma = connector.prisma;
@@ -54,19 +57,44 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
return cachedPause;
},
async markPaused(error?: unknown): Promise<void> {
const message = error instanceof Error ? error.message : error ? String(error) : null;
const failure = error ? describeRuntimeError(error) : null;
const message = failure?.message ?? null;
try {
await prisma.gatewayProfile.updateMany({
where: {
profileName: options.profileName,
status: { in: [...PROFILE_STATUSES_MARKABLE_AS_PAUSED] },
},
data: {
status: 'PAUSED',
lastError: message,
},
await prisma.$transaction(async (tx) => {
const updated = await tx.gatewayProfile.updateMany({
where: {
profileName: options.profileName,
status: { in: [...PROFILE_STATUSES_MARKABLE_AS_PAUSED] },
OR: [{ status: { not: 'PAUSED' } }, { lastError: { not: message } }, { lastError: null }],
},
data: {
status: 'PAUSED',
lastError: message,
},
});
if (updated.count && failure) {
// 상태와 이력을 함께 commit한다. 재개가 lastError를 지워도
// 당시 원인과 실행 좌표는 관리자 감사 저장소에 남는다.
await tx.adminAuditEvent.create({
data: {
correlationId: randomUUID(),
actorUserId: 'system:turn-daemon',
actorUsername: 'turn-daemon',
credentialKind: 'DAEMON',
action: 'runtime.failure',
targetType: 'profile-runtime',
targetId: options.profileName,
profileName: options.profileName,
outcome: 'FAILED',
errorCode: failure.code,
errorMessage: failure.message,
summary: { frames: failure.frames, ...options.incidentContext?.() },
},
});
}
});
} catch {
if (failure) console.error('[turn-daemon] failed to persist runtime incident', failure);
return;
}
},
+15 -6
View File
@@ -93,11 +93,7 @@ import {
createResetOfficerLockHandler,
} from './monthlyCoreEventAction.js';
import { buildCommandEnv } from './reservedTurnCommands.js';
import {
DatabaseTurnDaemonLease,
TurnDaemonLeaseLostError,
TurnDaemonLeaseUnavailableError,
} from '../lifecycle/databaseTurnDaemonLease.js';
import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
import { EngineStateManager } from './engineStateManager.js';
import { applyRuntimeClockShift } from './runtimeClockShift.js';
import { applyRuntimeGameSettings } from './runtimeGameSettings.js';
@@ -892,6 +888,19 @@ const createTurnDaemonRuntimeWithLease = async (
gatewayDatabaseUrl: options.gatewayDatabaseUrl,
profileName: options.profileName,
cacheMs: options.pauseGateIntervalMs,
incidentContext: () => {
const state = world.getState();
const clock = world.getGameClockState();
const token = turnDaemonLease?.getToken();
return {
year: state.currentYear,
month: state.currentMonth,
clockPhase: clock.phase,
clockTick: clock.tick,
ownerId: token?.ownerId ?? null,
fencingEpoch: token?.fencingEpoch.toString() ?? null,
};
},
})
: null;
if (gatewayGate) {
@@ -1066,7 +1075,7 @@ const createTurnDaemonRuntimeWithLease = async (
if (turnDaemonLease?.isLost()) {
// 만료된 owner는 재개 명령도 처리할 수 없다. 현재 runtime을
// 끝내 PM2가 새 owner와 DB snapshot으로 시작하도록 한다.
throw new TurnDaemonLeaseLostError(options.profileName ?? options.profile);
throw turnDaemonLease.getLossError();
}
const gatewayPaused = (await pauseGate?.()) ?? false;
const phase = world.getGameClockState().phase;
@@ -112,6 +112,7 @@ integration('gateway runtime action consumer', () => {
});
it('does not overwrite a terminal operator status while reporting a daemon error', async () => {
const existingIncidents = await db.adminAuditEvent.count({ where: { profileName, action: 'runtime.failure' } });
const gate = await createGatewayProfileGate({
databaseUrl: databaseUrl!,
gatewayDatabaseUrl: databaseUrl!,
@@ -127,6 +128,19 @@ integration('gateway runtime action consumer', () => {
status: 'PAUSED',
lastError: 'running failure',
});
await gate.markPaused(new Error('running failure'));
const incidents = await db.adminAuditEvent.findMany({ where: { profileName, action: 'runtime.failure' } });
expect(incidents).toHaveLength(existingIncidents + 1);
expect(incidents[0]).toMatchObject({
credentialKind: 'DAEMON',
errorCode: 'Error',
errorMessage: 'running failure',
});
await db.gatewayProfile.update({ where: { profileName }, data: { status: 'RUNNING', lastError: null } });
expect(await db.adminAuditEvent.count({ where: { profileName, action: 'runtime.failure' } })).toBe(
existingIncidents + 1
);
await db.gatewayProfile.update({
where: { profileName },
@@ -137,6 +151,9 @@ integration('gateway runtime action consumer', () => {
status: 'STOPPED',
lastError: null,
});
expect(await db.adminAuditEvent.count({ where: { profileName, action: 'runtime.failure' } })).toBe(
existingIncidents + 1
);
} finally {
await gate.close();
}