feat: 서버 준비 전에 감사 초기 상태와 정책 기준을 저장

This commit is contained in:
2026-09-16 05:22:01 +00:00
parent 3d60e6122d
commit 8faab62866
20 changed files with 481 additions and 41 deletions
+45 -2
View File
@@ -46,7 +46,10 @@ export const recordAuditSettlement = (world: InMemoryTurnWorld, settlement: Audi
world.updateWorldMeta({ playAuditFlows: flows });
};
export const queueAuditMonth = (world: InMemoryTurnWorld, kind: 'MONTH_END' | 'FINAL' = 'MONTH_END'): void => {
export const queueAuditMonth = (
world: InMemoryTurnWorld,
kind: 'MONTH_END' | 'FINAL' | 'INITIAL' = 'MONTH_END'
): void => {
const state = world.getState();
const serverId = state.meta.serverId;
// identity 없는 레거시 fixture/설치에서 profile명으로 가짜 기수를 만들지 않는다.
@@ -64,12 +67,52 @@ export const queueAuditMonth = (world: InMemoryTurnWorld, kind: 'MONTH_END' | 'F
serverId,
year: state.currentYear,
month: state.currentMonth,
tick: state.lastTurnTick ?? null,
tick: kind === 'INITIAL' ? world.getGameClockState().tick : (state.lastTurnTick ?? null),
kind,
settlementsComplete: flows.complete,
});
};
/** 도입 당시 상태는 월말로 가장하지 않고 기수별 최초 기준으로 한 번 고정한다. */
export const initializeAuditCollection = (world: InMemoryTurnWorld, observedAt = new Date()): boolean => {
const state = world.getState();
const serverId = state.meta.serverId;
if (typeof serverId !== 'string' || !serverId.trim()) return false;
const previous = asRecord(state.meta.playAuditCollection);
if (previous.serverId === serverId) {
if (
previous.schemaVersion !== 1 ||
typeof previous.year !== 'number' ||
!Number.isInteger(previous.year) ||
previous.year < 0 ||
typeof previous.month !== 'number' ||
!Number.isInteger(previous.month) ||
previous.month < 1 ||
previous.month > 12 ||
typeof previous.tick !== 'number' ||
!Number.isSafeInteger(previous.tick) ||
previous.tick < 0 ||
typeof previous.observedAt !== 'string' ||
!Number.isFinite(Date.parse(previous.observedAt)) ||
previous.year * 12 + previous.month > state.currentYear * 12 + state.currentMonth
)
throw new Error('Invalid play audit collection boundary');
return false;
}
queueAuditMonth(world, 'INITIAL');
world.updateWorldMeta({
playAuditCollection: {
schemaVersion: 1,
serverId,
year: state.currentYear,
month: state.currentMonth,
tick: world.getGameClockState().tick,
observedAt: observedAt.toISOString(),
},
});
return true;
};
export const createPlayAuditHandler = (getWorld: () => InMemoryTurnWorld | null): TurnCalendarHandler => ({
beforeMonthChanged: (context) => {
const world = getWorld();
+1 -1
View File
@@ -6,7 +6,7 @@ export interface PendingAuditMonth {
serverId: string;
year: number;
month: number;
kind: 'MONTH_END' | 'FINAL';
kind: 'MONTH_END' | 'FINAL' | 'INITIAL';
tick: number | null;
settlementsComplete: boolean;
nations: AuditNationSnapshot[];
+8 -5
View File
@@ -74,6 +74,7 @@ import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js';
export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks;
flushChanges(): Promise<void>;
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
close(): Promise<void>;
@@ -2078,12 +2079,13 @@ export const createDatabaseTurnHooks = async (
};
};
const flushChanges = async (): Promise<void> => {
const committed = await persistChanges();
committed.acknowledge();
enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite);
};
const hooks: TurnDaemonHooks = {
flushChanges: async () => {
const committed = await persistChanges();
committed.acknowledge();
enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite);
},
flushChanges,
commitCommand: async (requestId, result) => {
const committed = await persistChanges(undefined, { requestId, result });
committed.acknowledge();
@@ -2127,6 +2129,7 @@ export const createDatabaseTurnHooks = async (
return {
hooks,
flushChanges,
takeCommittedReadModelChanges: () => {
return takeCommittedReceipt()?.changes ?? null;
},
@@ -1377,6 +1377,10 @@ export class InMemoryTurnWorld {
this.pendingAuditPolicies.push(structuredClone(policy));
}
hasPendingAuditRecords(): boolean {
return this.pendingAuditPolicies.length > 0 || this.pendingAuditMonths.length > 0;
}
queueAuditMonth(snapshot: PendingAuditMonth): void {
this.pendingAuditMonths.push(structuredClone(snapshot));
}
+13 -2
View File
@@ -1,6 +1,6 @@
import { initializeAuditPolicies } from '../playAudit/policy.js';
import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js';
import { createPlayAuditHandler } from '../playAudit/collection.js';
import { createPlayAuditHandler, initializeAuditCollection } from '../playAudit/collection.js';
import { randomUUID } from 'node:crypto';
import { createRuntimePauseGate } from './runtimePauseGate.js';
@@ -804,7 +804,10 @@ const createTurnDaemonRuntimeWithLease = async (
};
const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions);
worldRef = world;
initializeAuditPolicies(world);
if (!databaseFlushEnabled) {
initializeAuditPolicies(world);
initializeAuditCollection(world, new Date(clock.nowMs()));
}
const stateManager = new EngineStateManager();
stateManager.register('world', {
@@ -923,6 +926,14 @@ const createTurnDaemonRuntimeWithLease = async (
});
try {
await dbHooks.prepareRealtimeRecovery({ paused: await gatewayGate?.shouldPause() });
// 복구된 clock에서 기준을 고정하고 readiness 공개 전에 원자적으로 저장한다.
// 명령 없는 PREOPEN도 기록하며 input_event나 게임 RNG를 만들지 않는다.
initializeAuditPolicies(world);
initializeAuditCollection(world, new Date(clock.nowMs()));
if (world.hasPendingAuditRecords()) {
await dbHooks.flushChanges();
dbHooks.takeCommittedReadModelChangeReceipt();
}
} catch (error) {
await Promise.allSettled([
dbHooks.close(),