운영 안정화 변경을 최신 main과 통합한다

This commit is contained in:
Hide_D committed 2026-11-14 15:13:53 +09:00
commit 087517821e
47 files changed
+1558 -123

No files matched your search

+12 -6
View File
@@ -76,7 +76,7 @@ export class ReadModelOutboxWorker implements ReadModelOutboxWakeup {
this.leaseMs = normalizePositiveInteger(options.leaseMs, 30_000);
this.retentionMs = normalizePositiveInteger(options.retentionMs, 24 * 60 * 60 * 1_000);
this.pruneIntervalMs = normalizePositiveInteger(options.pruneIntervalMs, 60_000);
this.pruneLimit = normalizePositiveInteger(options.pruneLimit, 100);
this.pruneLimit = Math.min(500, normalizePositiveInteger(options.pruneLimit, 500));
this.owner = options.owner ?? `game-api:${profileName}:${process.pid}:${randomUUID()}`;
this.now = options.now ?? (() => new Date());
this.onError = options.onError ?? (() => undefined);
@@ -109,11 +109,17 @@ export class ReadModelOutboxWorker implements ReadModelOutboxWakeup {
const now = this.now();
if (now.getTime() >= this.nextPruneAt) {
this.nextPruneAt = now.getTime() + this.pruneIntervalMs;
await pruneDeliveredReadModelOutbox(this.db, {
deliveredBefore: new Date(now.getTime() - this.retentionMs),
limit: this.pruneLimit,
});
// backlog가 있으면 다음 dispatch에서도 이어서 정리한다. 평상시에는 낮은 빈도로 확인한다.
// 한 호출은 최대 10 batch/50ms이며 각 SQL도 최대 500행이다.
const started = performance.now();
for (let batch = 0; batch < 10; batch += 1) {
const deleted = await pruneDeliveredReadModelOutbox(this.db, {
deliveredBefore: new Date(now.getTime() - this.retentionMs),
limit: this.pruneLimit,
});
this.nextPruneAt = deleted < this.pruneLimit ? now.getTime() + this.pruneIntervalMs : 0;
if (deleted < this.pruneLimit || performance.now() - started >= 50) break;
}
}
return result;
}
@@ -1,6 +1,6 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { GamePrisma } from '@sammo-ts/infra';
import { decodeAuditDecisionSteps, type GamePrisma } from '@sammo-ts/infra';
import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js';
const zEffectivePolicy = z.object({
@@ -275,7 +275,7 @@ export const decisionDetail = auditProcedure
decision: project(row),
chunks: chunks.slice(0, input.limit).map((chunk) => ({
ordinal: chunk.ordinal,
steps: z.array(zStep).max(128).parse(chunk.steps),
steps: z.array(zStep).max(128).parse(decodeAuditDecisionSteps(chunk.steps)),
})),
nextCursor:
chunks.length && chunks.at(-1)!.ordinal + 1 < Math.ceil(row.stepCount / 128)
@@ -2,7 +2,12 @@ import { afterAll, beforeAll, expect, it } from 'vitest';
import fastify from 'fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { asRecord } from '@sammo-ts/common';
import { createGamePostgresConnector, createRedisConnector, GamePrisma } from '@sammo-ts/infra';
import {
decodeAuditDecisionSteps,
createGamePostgresConnector,
createRedisConnector,
GamePrisma,
} from '@sammo-ts/infra';
import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
import { appRouter } from '../src/router.js';
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
@@ -255,12 +260,10 @@ integration(
include: { chunks: { orderBy: { ordinal: 'asc' } } },
});
const procedures = nationDecision.chunks.flatMap((chunk) =>
Array.isArray(chunk.steps)
? chunk.steps
.map(asRecord)
.filter((step) => step.kind === 'PROCEDURE_START')
.map((step) => step.procedure)
: []
decodeAuditDecisionSteps(chunk.steps)
.map(asRecord)
.filter((step) => step.kind === 'PROCEDURE_START')
.map((step) => step.procedure)
);
expect(procedures).toEqual(['NPC포상']);
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(withoutRecruitment));
@@ -45,6 +45,28 @@ const createFixture = (rows: readonly object[]) => {
};
describe('ReadModelOutboxWorker', () => {
it('drains more than one full retention batch rather than leaving a permanent 100-per-minute backlog', async () => {
let now = new Date('2026-11-09T00:00:00Z');
const fixture = createFixture([]);
fixture.queryRaw
.mockReset()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 1n }, { id: 2n }])
.mockResolvedValueOnce([{ id: 3n }, { id: 4n }])
.mockResolvedValueOnce([]);
const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', {
intervalMs: 60000,
pruneLimit: 2,
now: () => now,
});
now = new Date('2026-11-09T00:01:00Z');
worker.start();
await vi.waitFor(() => expect(fixture.queryRaw).toHaveBeenCalledTimes(4));
await worker.stop();
const query = fixture.queryRaw.mock.calls[1]?.[0] as { sql: string };
expect(query.sql).toContain('FOR UPDATE SKIP LOCKED');
expect(query.sql).toContain('ORDER BY "delivered_at", "available_at", "id"');
});
it('publishes a survey-style global front-status invalidation without a turn daemon', async () => {
const fixture = createFixture([{ id: 10n, payload: payload('front.global'), attempts: 1 }]);
const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', {
@@ -13,6 +13,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { encryptGameSessionToken, type GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import {
createGamePostgresConnector,
encodeAuditDecisionSteps,
GamePrisma,
hashAuditDiplomacyDocument,
createRedisConnector,
@@ -2345,50 +2346,52 @@ integration('game API security over HTTP transport', () => {
{
decisionId: decisionIds[0]!,
ordinal: 0,
steps: Array.from({ length: 128 }, (_, sequence) =>
sequence === 127
? {
...step,
sequence,
kind: 'DECISION_START',
reservedAction: '휴식',
effectivePolicy: {
schemaVersion: 1,
general: { priority: ['징병'], flags: { 징병: true, 출병: false } },
nation: {
priority: ['천도'],
flags: { 천도: true },
values: {
reqNationGold: 4321,
reqNationRice: 100,
reqHumanWarUrgentGold: 100,
reqHumanWarUrgentRice: 100,
reqHumanWarRecommandGold: 100,
reqHumanWarRecommandRice: 100,
reqHumanDevelGold: 100,
reqHumanDevelRice: 100,
reqNpcWarGold: 100,
reqNpcWarRice: 100,
reqNpcDevelGold: 100,
reqNpcDevelRice: 100,
minimumResourceActionAmount: 100,
maximumResourceActionAmount: 100,
minNpcWarLeadership: 100,
minWarCrew: 100,
minNpcRecruitCityPopulation: 100,
safeRecruitCityPopulationRatio: 100,
properWarTrainAtmos: 100,
cureThreshold: 100,
steps: encodeAuditDecisionSteps(
Array.from({ length: 128 }, (_, sequence) =>
sequence === 127
? {
...step,
sequence,
kind: 'DECISION_START',
reservedAction: '휴식',
effectivePolicy: {
schemaVersion: 1,
general: { priority: ['징병'], flags: { 징병: true, 출병: false } },
nation: {
priority: ['천도'],
flags: { 천도: true },
values: {
reqNationGold: 4321,
reqNationRice: 100,
reqHumanWarUrgentGold: 100,
reqHumanWarUrgentRice: 100,
reqHumanWarRecommandGold: 100,
reqHumanWarRecommandRice: 100,
reqHumanDevelGold: 100,
reqHumanDevelRice: 100,
reqNpcWarGold: 100,
reqNpcWarRice: 100,
reqNpcDevelGold: 100,
reqNpcDevelRice: 100,
minimumResourceActionAmount: 100,
maximumResourceActionAmount: 100,
minNpcWarLeadership: 100,
minWarCrew: 100,
minNpcRecruitCityPopulation: 100,
safeRecruitCityPopulationRatio: 100,
properWarTrainAtmos: 100,
cureThreshold: 100,
},
combatForce: { '1': [2, 3] },
supportForce: [4],
developForce: [5],
secret: 'decision-secret',
},
combatForce: { '1': [2, 3] },
supportForce: [4],
developForce: [5],
secret: 'decision-secret',
},
secret: 'decision-secret',
},
}
: { ...step, sequence }
}
: { ...step, sequence }
)
),
},
{
+1
View File
@@ -13,6 +13,7 @@ export * from './scenario/databaseUrl.js';
export * from './scenario/mapLoader.js';
export * from './scenario/scenarioSeeder.js';
export * from './scenario/gameCancellation.js';
export * from './playAudit/retention.js';
export * from './turn/types.js';
export * from './turn/worldLoader.js';
export * from './turn/inMemoryWorld.js';
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import type { HostBootMonitor } from '../turn/hostBootMonitor.js';
export interface TurnDaemonLeaseToken {
profile: string;
@@ -14,6 +15,7 @@ export interface DatabaseTurnDaemonLeaseOptions {
ownerId?: string;
leaseDurationMs?: number;
heartbeat?: boolean;
hostBootMonitor?: HostBootMonitor;
}
export class TurnDaemonLeaseUnavailableError extends Error {
@@ -45,6 +47,7 @@ export class DatabaseTurnDaemonLease {
private readonly ownerId: string;
private readonly leaseDurationMs: number;
private readonly heartbeatEnabled: boolean;
private readonly hostBootMonitor?: HostBootMonitor;
private token: TurnDaemonLeaseToken | null = null;
private heartbeatTimer: NodeJS.Timeout | null = null;
private expiryTimer: NodeJS.Timeout | null = null;
@@ -63,6 +66,7 @@ export class DatabaseTurnDaemonLease {
this.ownerId = options.ownerId ?? randomUUID();
this.leaseDurationMs = normalizeLeaseDuration(options.leaseDurationMs);
this.heartbeatEnabled = options.heartbeat ?? true;
this.hostBootMonitor = options.hostBootMonitor;
}
static async connect(
@@ -75,6 +79,8 @@ export class DatabaseTurnDaemonLease {
}
async acquire(): Promise<TurnDaemonLeaseToken | null> {
await this.checkHostBoot();
if (this.lost) throw this.getLossError();
const requestStartedAt = performance.now();
const rows = await this.db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
INSERT INTO "turn_daemon_lease" (
@@ -145,6 +151,12 @@ export class DatabaseTurnDaemonLease {
return new TurnDaemonLeaseLostError(this.profile, this.lossReason);
}
async checkHostBoot(): Promise<void> {
if (!this.lost && (await this.hostBootMonitor?.hasChanged())) {
this.markLost('Host boot identity changed; restart with a fresh database snapshot.');
}
}
async renew(): Promise<boolean> {
const token = this.token;
if (!token || this.lost || this.renewalInFlight) {
@@ -153,6 +165,8 @@ export class DatabaseTurnDaemonLease {
const requestStartedAt = performance.now();
this.renewalInFlight = true;
try {
await this.checkHostBoot();
if (this.lost) return false;
const rows = await this.db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
UPDATE "turn_daemon_lease"
SET
@@ -180,6 +194,7 @@ export class DatabaseTurnDaemonLease {
}
async assertActive(transaction?: GamePrisma.TransactionClient): Promise<void> {
await this.checkHostBoot();
const token = this.token;
if (!token || this.lost) {
throw this.getLossError();
@@ -207,12 +222,13 @@ export class DatabaseTurnDaemonLease {
this.stopExpiryWatchdog();
const token = this.token;
this.token = null;
if (!token || this.lost) {
if (!token) {
return;
}
await this.db.$executeRaw(GamePrisma.sql`
UPDATE "turn_daemon_lease"
SET "lease_until" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
SET "clock_ready" = FALSE,
"lease_until" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
"heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE
"profile" = ${token.profile}
@@ -45,6 +45,7 @@ export interface TurnDaemonLifecycleDeps {
commandResponder?: TurnDaemonCommandResponder;
pauseGate?: () => Promise<boolean>;
stateManager?: Pick<EngineStateManager, 'transaction'>;
runErrorRetry?: { wait(error: unknown): Promise<boolean>; reset(): void };
}
export class TurnDaemonLifecycle {
@@ -59,6 +60,7 @@ export class TurnDaemonLifecycle {
private readonly commandResponder?: TurnDaemonCommandResponder;
private readonly pauseGate?: () => Promise<boolean>;
private readonly stateManager?: Pick<EngineStateManager, 'transaction'>;
private readonly runErrorRetry?: TurnDaemonLifecycleDeps['runErrorRetry'];
private readonly options: TurnDaemonLifecycleOptions;
private status: TurnDaemonStatus;
@@ -79,6 +81,7 @@ export class TurnDaemonLifecycle {
this.commandResponder = deps.commandResponder;
this.pauseGate = deps.pauseGate;
this.stateManager = deps.stateManager;
this.runErrorRetry = deps.runErrorRetry;
this.options = options;
this.status = {
state: 'idle',
@@ -192,8 +195,9 @@ export class TurnDaemonLifecycle {
// 수동 실행 요청도 가오픈·정지·재조정의 턴 실행 gate를 통과해야 한다.
// 사용자 명령 처리는 루프 시작에서 계속하되 시간 진행은 여기서 분리한다.
if (this.pendingRun) {
await this.runOnce(this.pendingRun);
const pending = this.pendingRun;
this.pendingRun = null;
await this.runOnce(pending);
continue;
}
@@ -447,6 +451,15 @@ export class TurnDaemonLifecycle {
};
result = this.stateManager ? await this.stateManager.transaction(runAndFlush) : await runAndFlush();
} catch (error) {
// savepoint 복원이 성공한 획득 충돌만 대기한다. 복원 실패/AggregateError나
// 저장 중 소유권 상실을 자동 재실행하면 중복 효과를 숨길 수 있다.
if (this.stateManager && (await this.runErrorRetry?.wait(error))) {
this.status.running = false;
this.status.state = 'idle';
this.pendingRun = pending;
return;
}
this.runErrorRetry?.reset();
this.status.running = false;
this.status.state = 'paused';
this.status.paused = true;
@@ -459,6 +472,7 @@ export class TurnDaemonLifecycle {
}
await this.applyRunResult(result, startMs);
this.runErrorRetry?.reset();
this.status.state = 'idle';
try {
await this.hooks?.publishEvents?.(result);
@@ -1,4 +1,4 @@
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
import { encodeAuditDecisionSteps, type GamePrisma, type InputJsonValue } from '@sammo-ts/infra';
import { auditPolicyHash } from './policy.js';
import type { PendingAuditDecision } from './decision.js';
@@ -50,7 +50,9 @@ export const persistAuditDecisions = async (
chunks.push({
decisionId: decision.id,
ordinal: start / AUDIT_DECISION_CHUNK_STEPS,
steps: decision.steps.slice(start, start + AUDIT_DECISION_CHUNK_STEPS) as InputJsonValue,
steps: encodeAuditDecisionSteps(
decision.steps.slice(start, start + AUDIT_DECISION_CHUNK_STEPS) as InputJsonValue[]
),
});
if (chunks.length === BATCH) {
await tx.playAuditDecisionChunk.createMany({ data: chunks, skipDuplicates: true });
+37 -22
View File
@@ -1,5 +1,5 @@
import { asRecord } from '@sammo-ts/common';
import type { GamePrismaClient } from '@sammo-ts/infra';
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
export const AUDIT_RETENTION_BATCH_SIZE = 200;
export type AuditRetentionResult = { status: 'progress' | 'complete' | 'busy' | 'identityChanged'; deleted: number };
@@ -13,6 +13,7 @@ export const prunePreviousAuditBatch = async (
return db.$transaction(
async (tx) => {
await tx.$executeRaw`SET LOCAL statement_timeout = '2000ms'`;
await tx.$executeRaw`SET LOCAL lock_timeout = '250ms'`;
// seeder와 동일한 잠금이다. RESET을 기다리게 하지 않고 다음 batch에서 재시도한다.
const [lock] = await tx.$queryRaw<{ locked: boolean }[]>`
SELECT pg_try_advisory_xact_lock(hashtextextended(current_schema(), 0)) AS locked
@@ -32,29 +33,43 @@ export const prunePreviousAuditBatch = async (
});
return { status: 'progress', deleted: deleted.count };
}
const [decision] = await tx.$queryRaw<{ id: string }[]>`
SELECT id FROM play_audit_decision WHERE id = COALESCE(
(SELECT id FROM play_audit_decision WHERE server_id < ${expectedServerId}
ORDER BY server_id, general_id, tick, id LIMIT 1),
(SELECT id FROM play_audit_decision WHERE server_id > ${expectedServerId}
ORDER BY server_id, general_id, tick, id LIMIT 1)
) FOR UPDATE
// 이미 있는 (server_id,nation_id,tick,id) 인덱스 순서로 여러 부모를 잠근다.
// chunk 총량과 빈 header 삭제량의 합도 BATCH 이하로 제한한다.
let decisions = await tx.$queryRaw<{ id: string }[]>`
SELECT id FROM play_audit_decision WHERE server_id < ${expectedServerId}
ORDER BY server_id, nation_id, tick, id LIMIT ${AUDIT_RETENTION_BATCH_SIZE} FOR UPDATE
`;
if (decision) {
const chunks = await tx.playAuditDecisionChunk.findMany({
where: { decisionId: decision.id },
orderBy: { ordinal: 'asc' },
take: AUDIT_RETENTION_BATCH_SIZE,
select: { ordinal: true },
if (!decisions.length) {
decisions = await tx.$queryRaw<{ id: string }[]>`
SELECT id FROM play_audit_decision WHERE server_id > ${expectedServerId}
ORDER BY server_id, nation_id, tick, id LIMIT ${AUDIT_RETENTION_BATCH_SIZE} FOR UPDATE
`;
}
if (decisions.length) {
const ids = decisions.map(({ id }) => id);
const [chunks] = await tx.$queryRaw<{ deleted: number }[]>(GamePrisma.sql`
WITH batch AS (
SELECT decision_id, ordinal FROM play_audit_decision_chunk
WHERE decision_id IN (${GamePrisma.join(ids)})
ORDER BY decision_id, ordinal LIMIT ${AUDIT_RETENTION_BATCH_SIZE}
), removed AS (
DELETE FROM play_audit_decision_chunk c USING batch b
WHERE c.decision_id=b.decision_id AND c.ordinal=b.ordinal RETURNING 1
) SELECT count(*)::integer AS deleted FROM removed
`);
const count = chunks?.deleted ?? 0;
const remaining = AUDIT_RETENTION_BATCH_SIZE - count;
if (!remaining) return { status: 'progress', deleted: count };
const empty = await tx.playAuditDecision.findMany({
where: { id: { in: ids }, chunks: { none: {} } },
select: { id: true },
orderBy: { id: 'asc' },
take: remaining,
});
if (chunks.length) {
const deleted = await tx.playAuditDecisionChunk.deleteMany({
where: { decisionId: decision.id, ordinal: { in: chunks.map((row) => row.ordinal) } },
});
return { status: 'progress', deleted: deleted.count };
}
await tx.playAuditDecision.delete({ where: { id: decision.id } });
return { status: 'progress', deleted: 1 };
const headers = await tx.playAuditDecision.deleteMany({
where: { id: { in: empty.map(({ id }) => id) } },
});
return { status: 'progress', deleted: count + headers.count };
}
const policies = await tx.playAuditPolicy.findMany({
where: { serverId: { not: expectedServerId } },
@@ -17,7 +17,7 @@ export const startAuditRetentionWorker = (options: {
timer.unref();
};
const run = async () => {
let delay = 1000;
let delay = 100;
try {
const result = await options.prune();
if (result.status === 'complete' || result.status === 'identityChanged') stopped = true;
+3
View File
@@ -8,6 +8,7 @@ import { createTurnDaemonMemoryReporter } from './turnDaemonMemoryReporter.js';
import { createGatewayProfileGate } from './gatewayProfileGate.js';
import { retryTurnDaemonLeaseStartup } from './leaseStartupRetry.js';
import { TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
import { loadHostBootMonitor } from './hostBootMonitor.js';
export interface TurnDaemonCliOptions {
profile?: string;
@@ -80,6 +81,7 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom
throw new Error(`GAME_CLOCK_MODE must be realtime or manual: ${rawGameClockMode}`);
}
const gameClockMode = rawGameClockMode as GameClockMode | undefined;
const hostBootMonitor = await loadHostBootMonitor(env);
const runtime = await retryTurnDaemonLeaseStartup(() =>
createTurnDaemonRuntime({
@@ -95,6 +97,7 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom
pauseGateIntervalMs,
adminActionIntervalMs,
gameClockMode,
hostBootMonitor,
})
).catch(async (error: unknown) => {
// 중복 starter가 정상 owner를 멈추면 안 된다. 그 밖의 초기화 실패는
+142
View File
@@ -0,0 +1,142 @@
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { performance } from 'node:perf_hooks';
import { isRecord } from '@sammo-ts/common';
export interface HostBootMonitor {
hasChanged(): Promise<boolean>;
}
const mac = (key: Buffer, value: string): string => createHmac('sha256', key).update(value).digest('hex');
/** 부팅 세대는 첫 인증 응답에 고정한다. 통신 실패는 기존 lease 복구를 막지 않는다. */
export const createHostBootMonitor = (options: {
url: string;
key: Buffer;
pollMs?: number;
timeoutMs?: number;
now?: () => number;
wallNow?: () => number;
onUnavailable?: () => void;
onVerified?: () => void;
}): HostBootMonitor => {
const url = new URL(options.url);
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash ||
url.pathname !== '/v1/boot-id'
) {
throw new Error('Host boot URL must use HTTP(S) and /v1/boot-id without credentials or query.');
}
if (options.key.length !== 32) throw new Error('Host boot key must contain exactly 32 bytes.');
const now = options.now ?? (() => performance.now());
const wallNow = options.wallNow ?? (() => Date.now());
let baseline: string | null = null;
let changed = false;
let lastAttempt = -Infinity;
let lastWall = 0;
let lastWarning = -Infinity;
let inFlight: Promise<boolean> | null = null;
const sample = async (): Promise<boolean> => {
const nonce = randomBytes(32).toString('hex');
const response = await fetch(url, {
method: 'GET',
redirect: 'error',
signal: AbortSignal.timeout(options.timeoutMs ?? 1_000),
headers: {
'x-sammo-challenge': nonce,
'x-sammo-signature': mac(options.key, `sammo-host-boot-request-v1\n${nonce}`),
},
});
if (!response.ok || !response.body) {
await response.body?.cancel();
throw new Error('Host boot response unavailable.');
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.length;
if (size > 2_048) throw new Error('Host boot response exceeds its limit.');
chunks.push(value);
}
} finally {
await reader.cancel();
}
const body: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'));
if (
!isRecord(body) ||
body.version !== 1 ||
body.challenge !== nonce ||
typeof body.bootId !== 'string' ||
!/^[a-f0-9]{64}$/.test(body.bootId) ||
typeof body.signature !== 'string' ||
!/^[a-f0-9]{64}$/.test(body.signature)
) {
throw new Error('Host boot response is invalid.');
}
const expected = mac(options.key, `sammo-host-boot-response-v1\n${nonce}\n${body.bootId}`);
if (!timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(body.signature, 'hex'))) {
throw new Error('Host boot response signature is invalid.');
}
if (baseline === null) {
baseline = body.bootId;
options.onVerified?.();
}
changed ||= body.bootId !== baseline;
return changed;
};
return {
hasChanged(): Promise<boolean> {
if (changed) return Promise.resolve(true);
if (inFlight) return inFlight;
const current = now();
const wall = wallNow();
const discontinuity = Math.abs(wall - lastWall - (current - lastAttempt)) > 1_000;
if (current - lastAttempt < (options.pollMs ?? 2_000) && !discontinuity) return Promise.resolve(false);
lastAttempt = current;
lastWall = wall;
inFlight = sample()
.catch(() => {
if (current - lastWarning >= 60_000) {
options.onUnavailable?.();
lastWarning = current;
}
return false;
})
.finally(() => {
inFlight = null;
});
return inFlight;
},
};
};
export const loadHostBootMonitor = async (env: NodeJS.ProcessEnv): Promise<HostBootMonitor | undefined> => {
const url = env.TURN_HOST_BOOT_URL?.trim();
const keyFile = env.TURN_HOST_BOOT_KEY_FILE?.trim();
if (!url && !keyFile) return undefined;
try {
if (!url || !keyFile) throw new Error('Incomplete optional configuration.');
const encoded = (await readFile(keyFile, 'utf8')).trim();
if (!/^[a-f0-9]{64}$/.test(encoded)) throw new Error('Invalid optional key file.');
return createHostBootMonitor({
url,
key: Buffer.from(encoded, 'hex'),
onVerified: () => console.info('[turn-daemon] host boot identity verified.'),
onUnavailable: () => {
console.warn('[turn-daemon] host boot identity unavailable; ordinary lease recovery remains active.');
},
});
} catch {
// 보조 관측기의 설정/파일 부재도 게임 기동의 필수 의존성이 되지 않는다.
console.warn('[turn-daemon] host boot observation disabled; check optional configuration and key file.');
return undefined;
}
};
@@ -0,0 +1,38 @@
import { performance } from 'node:perf_hooks';
import { setTimeout } from 'node:timers/promises';
import { ReservedTurnLeaseBusyError } from './reservedTurnStore.js';
/** rollback 완료 뒤에만 대기한다. 다음 loop에서 pause/lease gate와 최신 큐를 다시 읽는다. */
export const createReservedTurnLeaseRetry = (
options: {
now?: () => number;
sleep?: (ms: number) => Promise<void>;
maxWaitMs?: number;
onWait?: (queueKey: string) => void;
} = {}
) => {
const now = options.now ?? (() => performance.now());
const sleep = options.sleep ?? ((ms: number) => setTimeout(ms));
const maxWaitMs = options.maxWaitMs ?? 5 * 60_000 + 1_000;
let startedAt: number | null = null;
let lastReportedAt = -Infinity;
return {
reset(): void {
startedAt = null;
lastReportedAt = -Infinity;
},
async wait(error: unknown): Promise<boolean> {
if (!(error instanceof ReservedTurnLeaseBusyError)) return false;
const current = now();
startedAt ??= current;
const remaining = maxWaitMs - (current - startedAt);
if (remaining <= 0) return false;
if (current - lastReportedAt >= 30_000) {
options.onWait?.(error.queueKey);
lastReportedAt = current;
}
await sleep(Math.min(1_000, remaining));
return true;
},
};
};
+36 -8
View File
@@ -129,6 +129,14 @@ export class ReservedTurnLeaseConflictError extends Error {
}
}
/** 실행 전 획득 충돌만 재시도한다. flush/finalize의 소유권 상실과 구분한다. */
export class ReservedTurnLeaseBusyError extends ReservedTurnLeaseConflictError {
constructor(queueKey: string) {
super(queueKey);
this.name = 'ReservedTurnLeaseBusyError';
}
}
export class InMemoryReservedTurnStore {
private readonly generalTurns = new Map<number, ReservedTurnEntry[]>();
private readonly nationTurns = new Map<string, ReservedTurnEntry[]>();
@@ -249,11 +257,7 @@ export class InMemoryReservedTurnStore {
}
};
pruneNationKeys(Array.from(this.nationTurns.keys()), (key) => this.nationTurns.delete(key));
for (const keys of [
this.dirtyNationKeys,
this.pendingNationInitializationKeys,
this.leasedNationKeys,
]) {
for (const keys of [this.dirtyNationKeys, this.pendingNationInitializationKeys, this.leasedNationKeys]) {
for (const key of Array.from(keys)) {
const nationId = Number(key.split(':', 1)[0]);
if (deletedNations.has(nationId)) {
@@ -376,7 +380,7 @@ export class InMemoryReservedTurnStore {
}
}
if (claimed.count !== 1) {
throw new ReservedTurnLeaseConflictError(`general:${generalId}`);
throw new ReservedTurnLeaseBusyError(`general:${generalId}`);
}
this.leasedGeneralIds.add(generalId);
return !retainedExistingLease;
@@ -427,7 +431,7 @@ export class InMemoryReservedTurnStore {
}
}
if (claimed.count !== 1) {
throw new ReservedTurnLeaseConflictError(`nation:${nationId}:${officerLevel}`);
throw new ReservedTurnLeaseBusyError(`nation:${nationId}:${officerLevel}`);
}
this.leasedNationKeys.add(buildNationKey(nationId, officerLevel));
return !retainedExistingLease;
@@ -904,6 +908,24 @@ export class InMemoryReservedTurnStore {
await this.persistChanges(this.prisma, changes);
this.acknowledgeDirtyState(changes);
}
/** 실행 루프 종료 뒤 호출한다. rollback으로 journal에서 빠진 DB 임대도 해제한다. */
async releaseOwnedLeases(): Promise<void> {
const results = await Promise.allSettled([
this.prisma.generalTurnRevision?.updateMany({
where: { leaseOwner: this.leaseOwner },
data: { leaseOwner: null, leaseExpiresAt: null },
}),
this.prisma.nationTurnRevision?.updateMany({
where: { leaseOwner: this.leaseOwner },
data: { leaseOwner: null, leaseExpiresAt: null },
}),
]);
const errors = results.flatMap((result) => (result.status === 'rejected' ? [result.reason] : []));
if (errors.length) throw new AggregateError(errors, 'Reserved turn lease cleanup failed.');
this.leasedGeneralIds.clear();
this.leasedNationKeys.clear();
}
}
export const createReservedTurnStore = async (options: ReservedTurnStoreOptions): Promise<ReservedTurnStoreHandle> => {
@@ -918,6 +940,12 @@ export const createReservedTurnStore = async (options: ReservedTurnStoreOptions)
await store.loadAll();
return {
store,
close: () => connector.disconnect(),
close: async () => {
try {
await store.releaseOwnedLeases();
} finally {
await connector.disconnect();
}
},
};
};
+2 -2
View File
@@ -2,7 +2,7 @@ import type { GameClockPhase } from '@sammo-ts/common';
/** Gateway의 실행 gate와 durable 시계를 명령 claim 전에 맞춘다. */
export const createRuntimePauseGate = (options: {
assertLease(): void;
assertLease(): void | Promise<void>;
shouldPause(): Promise<boolean>;
getPhase(): GameClockPhase;
isExplicitlyPaused(): boolean;
@@ -11,7 +11,7 @@ export const createRuntimePauseGate = (options: {
}): (() => Promise<boolean>) => {
let lastPaused: boolean | null = null;
return async () => {
options.assertLease();
await options.assertLease();
const paused = await options.shouldPause();
const phase = options.getPhase();
// 오류 정지는 Gateway 상태만 PAUSED로 바꿀 수 있다. 그대로 두면 가입은
+34 -14
View File
@@ -108,6 +108,8 @@ import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lif
import { EngineStateManager } from './engineStateManager.js';
import { applyRuntimeClockShift } from './runtimeClockShift.js';
import { applyRuntimeGameSettings } from './runtimeGameSettings.js';
import { createReservedTurnLeaseRetry } from './reservedTurnLeaseRetry.js';
import type { HostBootMonitor } from './hostBootMonitor.js';
export interface TurnDaemonRuntimeOptions {
profile: string;
@@ -134,6 +136,7 @@ export interface TurnDaemonRuntimeOptions {
leaseDurationMs?: number;
leaseOwnerId?: string;
enableLeaseHeartbeat?: boolean;
hostBootMonitor?: HostBootMonitor;
/**
* Isolated, single-process fixture acceleration only. Reserved turns are
* loaded once and no concurrent API writer may touch this database.
@@ -985,6 +988,7 @@ const createTurnDaemonRuntimeWithLease = async (
prepareClockRecovery = dbHooks.prepareRealtimeRecovery;
prunePreviousAudit = dbHooks.prunePreviousAudit;
close = async () => {
await adminActionConsumer?.stop();
if (auctionBidder) {
await auctionBidder.close();
}
@@ -995,12 +999,15 @@ const createTurnDaemonRuntimeWithLease = async (
await tournamentRewardFinalizer.close();
}
await dbHooks.close();
if (reservedTurnStoreHandle) {
await reservedTurnStoreHandle.close();
try {
await reservedTurnStoreHandle?.close();
} finally {
try {
await gatewayGate?.close();
} finally {
await turnDaemonLease?.close();
}
}
await gatewayGate?.close();
await adminActionConsumer?.stop();
await turnDaemonLease?.close();
};
} else if (reservedTurnStoreHandle) {
hooks = {
@@ -1009,9 +1016,12 @@ const createTurnDaemonRuntimeWithLease = async (
},
};
close = async () => {
await reservedTurnStoreHandle.close();
await gatewayGate?.close();
await adminActionConsumer?.stop();
try {
await reservedTurnStoreHandle.close();
} finally {
await gatewayGate?.close();
}
};
} else if (gatewayGate) {
hooks = {
@@ -1070,12 +1080,17 @@ const createTurnDaemonRuntimeWithLease = async (
const baseClose = close;
close = async () => {
stopClockProjectionWorker();
await baseClose();
await neutralAuctionRegistrar.close();
if (redisConnector) {
await redisConnector.disconnect();
}
await commandConnector?.disconnect();
const baseResults = await Promise.allSettled([baseClose()]);
// queue 임대 정리 실패도 다른 연결/worker의 종료를 막지 않게 한다.
const results = await Promise.allSettled([
neutralAuctionRegistrar.close(),
redisConnector?.disconnect(),
commandConnector?.disconnect(),
]);
const failures = [...baseResults, ...results].flatMap((result) =>
result.status === 'rejected' ? [result.reason] : []
);
if (failures.length) throw new AggregateError(failures, 'Turn daemon connection cleanup failed.');
};
const resolvedControlQueue = options.controlQueue ?? databaseCommandQueue ?? controlQueue;
@@ -1109,7 +1124,8 @@ const createTurnDaemonRuntimeWithLease = async (
processor,
hooks,
pauseGate: createRuntimePauseGate({
assertLease: () => {
assertLease: async () => {
await turnDaemonLease?.checkHostBoot();
if (turnDaemonLease?.isLost()) throw turnDaemonLease.getLossError();
},
shouldPause: async () => (await pauseGate?.()) ?? false,
@@ -1127,6 +1143,9 @@ const createTurnDaemonRuntimeWithLease = async (
// accumulated world before every due tick in that isolated mode;
// production and gateway-managed runtimes keep rollback savepoints.
stateManager: options.exclusiveFastForward ? undefined : stateManager,
runErrorRetry: createReservedTurnLeaseRetry({
onWait: (queueKey) => console.info(`[turn-daemon] waiting for reserved turn lease: ${queueKey}`),
}),
},
{ profile: options.profile, defaultBudget }
);
@@ -1179,6 +1198,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
ownerId: options.leaseOwnerId,
leaseDurationMs: options.leaseDurationMs,
heartbeat: options.enableLeaseHeartbeat,
hostBootMonitor: options.hostBootMonitor,
})
: null;
if (turnDaemonLease && !(await turnDaemonLease.acquire())) {
@@ -0,0 +1,134 @@
import { createHmac, randomBytes } from 'node:crypto';
import { once } from 'node:events';
import { createServer } from 'node:http';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createHostBootMonitor, loadHostBootMonitor } from '../src/turn/hostBootMonitor.js';
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()));
});
const fixture = async () => {
const key = randomBytes(32);
let bootId = 'a'.repeat(64);
let replay: string | null = null;
let mode: 'valid' | 'replay' | 'forged' | 'oversized' | 'offline' | 'hang' = 'valid';
const requests: string[] = [];
const server = createServer((request, response) => {
const nonce = String(request.headers['x-sammo-challenge']);
requests.push(nonce);
expect(request.headers['x-sammo-signature']).toBe(
createHmac('sha256', key).update(`sammo-host-boot-request-v1\n${nonce}`).digest('hex')
);
if (mode === 'hang') return;
if (mode === 'offline') {
response.writeHead(503).end();
return;
}
if (mode === 'oversized') {
response.end('x'.repeat(4096));
return;
}
if (mode === 'replay') {
response.end(replay);
return;
}
const body = JSON.stringify({
version: 1,
challenge: nonce,
bootId,
signature:
mode === 'forged'
? '0'.repeat(64)
: createHmac('sha256', key)
.update(`sammo-host-boot-response-v1\n${nonce}\n${bootId}`)
.digest('hex'),
});
replay ??= body;
response.end(body);
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
cleanups.push(
() =>
new Promise<void>((resolve) => {
server.close(() => resolve());
server.closeAllConnections();
})
);
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Missing fixture listener');
return {
key,
requests,
url: `http://127.0.0.1:${address.port}/v1/boot-id`,
setBoot: (value: string) => {
bootId = value;
},
setMode: (value: typeof mode) => {
mode = value;
},
};
};
describe('Windows boot observation', () => {
it('uses a stable baseline, survives outages, and latches a signed boot change', async () => {
const f = await fixture();
const monitor = createHostBootMonitor({ ...f, pollMs: 0 });
expect(await monitor.hasChanged()).toBe(false);
f.setMode('offline');
expect(await monitor.hasChanged()).toBe(false);
f.setBoot('b'.repeat(64));
f.setMode('valid');
expect(await monitor.hasChanged()).toBe(true);
f.setMode('offline');
expect(await monitor.hasChanged()).toBe(true);
});
it.each(['replay', 'forged', 'oversized', 'hang'] as const)(
'rejects %s without changing the baseline',
async (mode) => {
const f = await fixture();
const onUnavailable = vi.fn();
const monitor = createHostBootMonitor({ ...f, pollMs: 0, timeoutMs: 100, onUnavailable });
expect(await monitor.hasChanged()).toBe(false);
f.setBoot('b'.repeat(64));
f.setMode(mode);
expect(await monitor.hasChanged()).toBe(false);
expect(onUnavailable).toHaveBeenCalledOnce();
f.setMode('valid');
expect(await monitor.hasChanged()).toBe(true);
}
);
it('samples immediately after a wall-clock jump despite a frozen monotonic cache and coalesces requests', async () => {
const f = await fixture();
let wall = 10_000;
const monitor = createHostBootMonitor({ ...f, now: () => 0, wallNow: () => wall });
expect(await monitor.hasChanged()).toBe(false);
expect(await monitor.hasChanged()).toBe(false);
expect(f.requests).toHaveLength(1);
wall += 300_000;
f.setBoot('b'.repeat(64));
expect(await Promise.all([monitor.hasChanged(), monitor.hasChanged()])).toEqual([true, true]);
expect(f.requests).toHaveLength(2);
expect(new Set(f.requests).size).toBe(2);
});
it('does not block game startup when optional configuration or its key file is absent', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(await loadHostBootMonitor({})).toBeUndefined();
expect(await loadHostBootMonitor({ TURN_HOST_BOOT_URL: 'http://host/v1/boot-id' })).toBeUndefined();
expect(
await loadHostBootMonitor({
TURN_HOST_BOOT_URL: 'http://host/v1/boot-id',
TURN_HOST_BOOT_KEY_FILE: '/nonexistent-host-boot-key',
})
).toBeUndefined();
expect(() =>
createHostBootMonitor({ url: 'http://user:password@host/v1/boot-id', key: randomBytes(32) })
).toThrow('without credentials');
warning.mockRestore();
});
});
@@ -1,7 +1,12 @@
import { writeFile } from 'node:fs/promises';
import { performance } from 'node:perf_hooks';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import {
decodeAuditDecisionSteps,
createGamePostgresConnector,
type GamePrisma,
type GamePrismaClient,
} from '@sammo-ts/infra';
import { persistAuditDecisions } from '../src/playAudit/decisionPersistence.js';
import { buildAuditDecisionFixture } from './fixtures/playAuditDecision.js';
@@ -61,11 +66,15 @@ describe.skipIf(!databaseUrl)('decision storage cost probe', () => {
>`
SELECT
(SELECT count(*) FROM play_audit_decision) AS headers,
(SELECT sum(jsonb_array_length(steps)) FROM play_audit_decision_chunk) AS steps,
(SELECT sum(step_count) FROM play_audit_decision) AS steps,
(SELECT sum(pg_column_size(d)) FROM play_audit_decision d) AS "headerBytes",
(SELECT sum(pg_column_size(c)) FROM play_audit_decision_chunk c) AS "chunkBytes"
`;
expect(Number(storage!.steps)).toBe(201 * 302);
const chunks = await db.playAuditDecisionChunk.findMany({ select: { steps: true } });
expect(chunks.reduce((total, chunk) => total + decodeAuditDecisionSteps(chunk.steps).length, 0)).toBe(
201 * 302
);
const relations = await db.$queryRaw<
{ name: string; tableBytes: bigint; indexBytes: bigint; totalBytes: bigint }[]
>`
@@ -99,7 +108,8 @@ describe.skipIf(!databaseUrl)('decision storage cost probe', () => {
`;
const metrics = {
scope: 'synthetic 201 decisions, 302 steps each; isolated schema, warm local reads',
limitations: 'Not production p95; no gameplay baseline, WAL or retained heap measurement. SQL counts cover only the persistence transaction. Repetitive synthetic steps compress well. Relation allocation can retain space from previous runs.',
limitations:
'Not production p95; no gameplay baseline, WAL or retained heap measurement. SQL counts cover only the persistence transaction. Repetitive synthetic steps compress well. Relation allocation can retain space from previous runs.',
decisions: 201,
steps: Number(storage!.steps),
chunks: 603,
@@ -1,5 +1,5 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { decodeAuditDecisionSteps, createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { persistAuditDecisions } from '../src/playAudit/decisionPersistence.js';
import { buildAuditExecutionFixture, buildAuditDecisionFixture as draft } from './fixtures/playAuditDecision.js';
import { prunePreviousAuditBatch } from '../src/playAudit/retention.js';
@@ -36,7 +36,40 @@ integration('decision persistence and bounded retention', () => {
});
it('rolls back gameplay/header/chunks on insert failure, retries and rejects divergent replay', async () => {
const decision = draft('decision-one');
if (decision.steps[0]?.kind === 'DECISION_START') decision.steps[0].effectivePolicy = {"schemaVersion":1,"general":{"priority":["징병"],"flags":{"징병":true,"출병":false}},"nation":{"priority":["천도"],"flags":{"천도":true},"values":{"reqNationGold":4321,"reqNationRice":100,"reqHumanWarUrgentGold":100,"reqHumanWarUrgentRice":100,"reqHumanWarRecommandGold":100,"reqHumanWarRecommandRice":100,"reqHumanDevelGold":100,"reqHumanDevelRice":100,"reqNpcWarGold":100,"reqNpcWarRice":100,"reqNpcDevelGold":100,"reqNpcDevelRice":100,"minimumResourceActionAmount":100,"maximumResourceActionAmount":100,"minNpcWarLeadership":100,"minWarCrew":100,"minNpcRecruitCityPopulation":100,"safeRecruitCityPopulationRatio":100,"properWarTrainAtmos":100,"cureThreshold":100},"combatForce":{"1":[2,3]},"supportForce":[4],"developForce":[5]}};
if (decision.steps[0]?.kind === 'DECISION_START')
decision.steps[0].effectivePolicy = {
schemaVersion: 1,
general: { priority: ['징병'], flags: { 징병: true, 출병: false } },
nation: {
priority: ['천도'],
flags: { 천도: true },
values: {
reqNationGold: 4321,
reqNationRice: 100,
reqHumanWarUrgentGold: 100,
reqHumanWarUrgentRice: 100,
reqHumanWarRecommandGold: 100,
reqHumanWarRecommandRice: 100,
reqHumanDevelGold: 100,
reqHumanDevelRice: 100,
reqNpcWarGold: 100,
reqNpcWarRice: 100,
reqNpcDevelGold: 100,
reqNpcDevelRice: 100,
minimumResourceActionAmount: 100,
maximumResourceActionAmount: 100,
minNpcWarLeadership: 100,
minWarCrew: 100,
minNpcRecruitCityPopulation: 100,
safeRecruitCityPopulationRatio: 100,
properWarTrainAtmos: 100,
cureThreshold: 100,
},
combatForce: { '1': [2, 3] },
supportForce: [4],
developForce: [5],
},
};
decision.summary.codeVersion = 'a'.repeat(40);
decision.summary.executionCoverage = 'ATTEMPTS';
decision.steps.push({ ...decision.steps[0]!, ...buildAuditExecutionFixture(), sequence: 302 });
@@ -85,7 +118,7 @@ integration('decision persistence and bounded retention', () => {
orderBy: { ordinal: 'asc' },
});
expect(chunks).toHaveLength(3);
expect(chunks.flatMap((chunk) => chunk.steps)).toEqual(decision.steps);
expect(chunks.flatMap((chunk) => decodeAuditDecisionSteps(chunk.steps))).toEqual(decision.steps);
await expect(
db.$transaction((tx) =>
persistAuditDecisions(tx, [{ ...decision, summary: { ...decision.summary, completed: true } }])
@@ -109,10 +142,34 @@ integration('decision persistence and bounded retention', () => {
expect(await db.playAuditDecisionChunk.count({ where: { decisionId: 'decision-one' } })).toBe(204);
expect(await db.playAuditDecision.count({ where: { id: 'decision-one' } })).toBe(1);
expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'progress', deleted: 200 });
expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'progress', deleted: 4 });
expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'progress', deleted: 1 });
expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'progress', deleted: 5 });
expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'complete', deleted: 0 });
expect(await db.playAuditDecision.count()).toBe(1);
expect(await db.playAuditDecisionChunk.count()).toBe(3);
});
it('removes many small decisions in one bounded batch and rolls chunks back on header failure', async () => {
const decisions = Array.from({ length: 100 }, (_, index) => {
const row = draft(`batch-old-${index}`, 'batch-old');
return { ...row, steps: [row.steps[0]!, row.steps.at(-1)!] };
});
await db.$transaction((tx) => persistAuditDecisions(tx, decisions));
await db.$executeRawUnsafe(
`CREATE OR REPLACE FUNCTION decision_header_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'header fixture rollback'; END $$`
);
await db.$executeRawUnsafe(
`CREATE TRIGGER decision_header_failure BEFORE DELETE ON play_audit_decision FOR EACH ROW EXECUTE FUNCTION decision_header_failure()`
);
try {
await expect(prunePreviousAuditBatch(db, 'decision-current')).rejects.toThrow();
expect(
await db.playAuditDecisionChunk.count({ where: { decisionId: { in: decisions.map(({ id }) => id) } } })
).toBe(100);
} finally {
await db.$executeRawUnsafe('DROP TRIGGER decision_header_failure ON play_audit_decision');
await db.$executeRawUnsafe('DROP FUNCTION decision_header_failure()');
}
expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'progress', deleted: 200 });
expect(await db.playAuditDecision.count()).toBe(1);
expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'complete', deleted: 0 });
});
});
@@ -20,7 +20,7 @@ describe('audit retention scheduling', () => {
expect(prune).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(30_001);
expect(prune).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(100);
expect(prune).toHaveBeenCalledTimes(4);
await vi.advanceTimersByTimeAsync(600_000);
expect(prune).toHaveBeenCalledTimes(4);
@@ -4,7 +4,9 @@ import { createGamePostgresConnector } from '@sammo-ts/infra';
import {
InMemoryReservedTurnStore,
ReservedTurnLeaseConflictError,
createReservedTurnStore,
} from '../src/turn/reservedTurnStore.js';
import { createReservedTurnLeaseRetry } from '../src/turn/reservedTurnLeaseRetry.js';
const databaseUrl = process.env.RESERVED_TURN_DATABASE_URL;
const describeIntegration = databaseUrl ? describe : describe.skip;
@@ -132,4 +134,98 @@ describeIntegration('reserved turn daemon/API lease integration', () => {
});
expect(after.map((turn) => turn.actionCode)).toEqual(before.map((turn) => turn.actionCode));
});
it('cleans persisted ownership after rollback loses the journal, preserves other writers, and reloads once', async () => {
if (!connector || !databaseUrl) throw new Error('integration connector is unavailable');
await connector.prisma.generalTurnRevision.update({
where: { generalId: GENERAL_ID },
data: { leaseOwner: null, leaseExpiresAt: null },
});
const before = await connector.prisma.generalTurn.findMany({
where: { generalId: GENERAL_ID },
orderBy: { turnIdx: 'asc' },
});
const revision = await connector.prisma.generalTurnRevision.findUniqueOrThrow({
where: { generalId: GENERAL_ID },
});
const old = await createReservedTurnStore({ databaseUrl, leaseOwner: 'interrupted-daemon' });
const savepoint = old.store.captureTransactionState();
await old.store.prepareTurnsForExecution(GENERAL_ID);
old.store.shiftGeneralTurns(GENERAL_ID, -1);
old.store.restoreState(savepoint);
expect(old.store.getQueueCounts().leasedGeneralQueues).toBe(0);
const next = await createReservedTurnStore({ databaseUrl, leaseOwner: 'new-daemon' });
try {
await expect(next.store.prepareTurnsForExecution(GENERAL_ID)).rejects.toBeInstanceOf(
ReservedTurnLeaseConflictError
);
await old.close();
const released = await connector.prisma.generalTurnRevision.findUniqueOrThrow({
where: { generalId: GENERAL_ID },
});
expect(released).toMatchObject({ leaseOwner: null, leaseExpiresAt: null, revision: revision.revision });
await next.store.prepareTurnsForExecution(GENERAL_ID);
// A delayed close of an old owner cannot clear the new owner's lease.
const delayedCleaner = new InMemoryReservedTurnStore(connector.prisma, {
maxGeneralTurns: 2,
maxNationTurns: 1,
leaseOwner: 'interrupted-daemon',
});
await delayedCleaner.releaseOwnedLeases();
expect(
await connector.prisma.generalTurnRevision.findUniqueOrThrow({ where: { generalId: GENERAL_ID } })
).toMatchObject({ leaseOwner: 'new-daemon', revision: revision.revision });
await next.store.releaseOwnedLeases();
const actual = await connector.prisma.generalTurn.findMany({
where: { generalId: GENERAL_ID },
orderBy: { turnIdx: 'asc' },
});
expect(actual.map((row) => row.actionCode)).toEqual(before.map((row) => row.actionCode));
} finally {
await next.close();
}
});
it('waits out an orphaned lease without deleting it and reloads an intervening API edit', async () => {
if (!connector) throw new Error('integration connector is unavailable');
await connector.prisma.generalTurnRevision.update({
where: { generalId: GENERAL_ID },
data: { leaseOwner: 'killed-daemon', leaseExpiresAt: new Date(Date.now() + 300_000) },
});
const store = new InMemoryReservedTurnStore(connector.prisma, {
maxGeneralTurns: 2,
maxNationTurns: 1,
leaseOwner: 'recovery-daemon',
});
await store.loadAll();
const retry = createReservedTurnLeaseRetry({
sleep: async () => {
// Simulate the recorded expiry, then an authorized API edit before the next claim.
expect(
await connector.prisma.generalTurnRevision.findUniqueOrThrow({ where: { generalId: GENERAL_ID } })
).toMatchObject({ leaseOwner: 'killed-daemon' });
await connector.prisma.generalTurnRevision.update({
where: { generalId: GENERAL_ID },
data: { leaseExpiresAt: new Date(0) },
});
await connector.prisma.generalTurn.updateMany({
where: { generalId: GENERAL_ID, turnIdx: 0 },
data: { actionCode: 'che_수련' },
});
},
});
try {
let failure: unknown;
try {
await store.prepareTurnsForExecution(GENERAL_ID);
} catch (error) {
failure = error;
}
expect(await retry.wait(failure)).toBe(true);
await store.prepareTurnsForExecution(GENERAL_ID);
expect(store.getGeneralTurns(GENERAL_ID)[0]?.action).toBe('che_수련');
} finally {
await store.releaseOwnedLeases();
}
});
});
@@ -169,6 +169,18 @@ const buildHarness = (initialRevision: RevisionRow | null = null) => {
};
describe('reserved turn daemon lease', () => {
it('cleans both persisted lease kinds even after their journal is rolled back', async () => {
const harness = buildHarness();
const snapshot = harness.store.captureTransactionState();
await harness.store.prepareTurnsForExecution(7, { nationId: 3, officerLevel: 12 });
harness.store.restoreState(snapshot);
expect(harness.store.getQueueCounts()).toMatchObject({ leasedGeneralQueues: 0, leasedNationQueues: 0 });
await harness.store.releaseOwnedLeases();
expect(harness.getRevision()).toMatchObject({ leaseOwner: null, leaseExpiresAt: null, revision: 0 });
expect(harness.getNationRevision()).toMatchObject({ leaseOwner: null, leaseExpiresAt: null, revision: 0 });
expect(harness.generalDeleteMany).not.toHaveBeenCalled();
});
it('prunes deleted general and nation queues together with their journal state', () => {
const harness = buildHarness();
harness.store.ensureGeneralTurns(7);
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from 'vitest';
import { createReservedTurnLeaseRetry } from '../src/turn/reservedTurnLeaseRetry.js';
import { ReservedTurnLeaseBusyError, ReservedTurnLeaseConflictError } from '../src/turn/reservedTurnStore.js';
describe('reserved turn acquisition retry', () => {
it('bounds the entire contention episode across different queues and resets after progress', async () => {
let now = 0;
const onWait = vi.fn();
const retry = createReservedTurnLeaseRetry({
now: () => now,
sleep: async (ms) => {
now += ms;
},
maxWaitMs: 2_500,
onWait,
});
expect(await retry.wait(new ReservedTurnLeaseBusyError('general:1'))).toBe(true);
expect(await retry.wait(new ReservedTurnLeaseBusyError('nation:2:12'))).toBe(true);
expect(await retry.wait(new ReservedTurnLeaseBusyError('general:3'))).toBe(true);
expect(now).toBe(2_500);
expect(await retry.wait(new ReservedTurnLeaseBusyError('general:1'))).toBe(false);
expect(onWait).toHaveBeenCalledTimes(1);
retry.reset();
expect(await retry.wait(new ReservedTurnLeaseBusyError('general:1'))).toBe(true);
});
it('never retries flush ownership loss, calculation errors, or failed savepoint restoration', async () => {
const sleep = vi.fn();
const retry = createReservedTurnLeaseRetry({ sleep });
for (const error of [
new ReservedTurnLeaseConflictError('general:1'),
new Error('calculation failed'),
new AggregateError([new ReservedTurnLeaseBusyError('general:1')], 'restore failed'),
])
expect(await retry.wait(error)).toBe(false);
expect(sleep).not.toHaveBeenCalled();
});
});
+25 -1
View File
@@ -3,12 +3,36 @@ import type { GameClockPhase } from '@sammo-ts/common';
import { createRuntimePauseGate } from '../src/turn/runtimePauseGate.js';
describe('runtime pause clock boundary', () => {
it('awaits a host authority check before reading profile state or changing the clock', async () => {
const calls: string[] = [];
const gate = createRuntimePauseGate({
assertLease: async () => {
await Promise.resolve();
calls.push('host');
throw new Error('host changed');
},
shouldPause: async () => {
calls.push('profile');
return false;
},
isExplicitlyPaused: () => false,
getPhase: () => 'RUNNING',
prepareRecovery: async () => {
calls.push('recovery');
},
synchronize: async () => {},
});
await expect(gate()).rejects.toThrow('host changed');
expect(calls).toEqual(['host']);
});
it('freezes a live error pause before commands, resumes without restart, and does not freeze twice', async () => {
let phase: GameClockPhase = 'RUNNING';
let paused = true;
const calls: string[] = [];
const gate = createRuntimePauseGate({
assertLease: () => calls.push('lease'),
assertLease: () => {
calls.push('lease');
},
shouldPause: async () => paused,
isExplicitlyPaused: () => paused,
getPhase: () => phase,
@@ -207,6 +207,39 @@ integration('database turn daemon lease and fencing', () => {
expect(row.ownerId).toBe(firstToken ? 'owner-a' : 'owner-b');
});
it('fences a host boot change inside a transaction before mutation, without expiring the DB lease', async () => {
const profile = `${profilePrefix}host-boot`;
let changed = false;
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, {
profile,
ownerId: 'saved-vm-owner',
heartbeat: false,
leaseDurationMs: 60_000,
hostBootMonitor: { hasChanged: async () => changed },
});
leases.push(lease);
await lease.acquire();
await lease.markClockReady();
changed = true;
let committed = false;
await expect(
db.$transaction(async (transaction) => {
await lease.assertActive(transaction);
committed = true;
})
).rejects.toBeInstanceOf(TurnDaemonLeaseLostError);
expect(committed).toBe(false);
expect(lease.isLost()).toBe(true);
expect(await lease.renew()).toBe(false);
// The monitor does not force-clear ownership or bypass the successor's fencing.
expect((await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } })).ownerId).toBe('saved-vm-owner');
const next = await createLease(profile, 'fresh-vm-owner');
expect(await next.acquire()).toBeNull();
await lease.release();
expect((await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } })).clockReady).toBe(false);
expect((await next.acquire())?.fencingEpoch).toBe(2n);
});
it('persists and evaluates lease wall time as UTC under a non-UTC database session', async () => {
const profile = `${profilePrefix}utc-wall`;
const zonedUrl = new URL(databaseUrl!);
@@ -1,6 +1,8 @@
import { describe, expect, it, vi } from 'vitest';
import { ManualClock } from '@sammo-ts/common';
import { TurnDaemonLeaseLostError } from '../src/lifecycle/databaseTurnDaemonLease.js';
import { ReservedTurnLeaseBusyError } from '../src/turn/reservedTurnStore.js';
import { createReservedTurnLeaseRetry } from '../src/turn/reservedTurnLeaseRetry.js';
import {
InMemoryControlQueue,
@@ -15,6 +17,68 @@ import {
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
describe('TurnDaemonLifecycle', () => {
it('rolls back a busy queue, checks the operator gate, and retries an explicit run exactly once', async () => {
const now = new Date('2026-11-12T00:00:00Z');
const queue = new InMemoryControlQueue();
queue.enqueue({ type: 'run', reason: 'manual', targetTime: now.toISOString() });
let resource = 10;
let runs = 0;
const stateManager = new EngineStateManager();
stateManager.register('resource', {
capture: () => resource,
restore: (value) => {
resource = value;
},
});
const onRunError = vi.fn();
const flushChanges = vi.fn();
const gate = vi.fn(async () => false);
const retry = createReservedTurnLeaseRetry({
sleep: async () => {
expect(resource).toBe(10);
},
});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(now.getTime()),
controlQueue: queue,
stateManager,
runErrorRetry: retry,
getNextTickTime: (value) => addMinutes(value, 5),
pauseGate: gate,
stateStore: {
loadLastTurnTime: async () => now,
loadNextGeneralTurnTime: async () => null,
saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
},
processor: {
run: async () => {
resource -= 2;
if (++runs === 1) throw new ReservedTurnLeaseBusyError('general:7');
queue.enqueue({ type: 'shutdown' });
return {
lastTurnTime: now.toISOString(),
processedGenerals: 1,
processedTurns: 0,
durationMs: 0,
partial: false,
};
},
},
hooks: { onRunError, flushChanges },
},
{ profile: 'che', defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 } }
);
await lifecycle.start();
expect(runs).toBe(2);
expect(resource).toBe(8);
expect(gate.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(flushChanges).toHaveBeenCalledOnce();
expect(onRunError).not.toHaveBeenCalled();
});
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');