기존 외교 문서를 플레이 감사 최초 관측으로 보존
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import {
|
||||
hashAuditDiplomacyDocument,
|
||||
persistAuditDiplomacyEvents,
|
||||
projectAuditDocumentState,
|
||||
GamePrisma,
|
||||
} from '@sammo-ts/infra';
|
||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||
|
||||
export const hasAuditDocumentBaseline = (world: InMemoryTurnWorld): boolean => {
|
||||
const state = world.getState();
|
||||
const { meta } = state;
|
||||
const serverId = meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return true;
|
||||
const marker = asRecord(meta.playAuditDocuments);
|
||||
if (marker.serverId !== serverId) return false;
|
||||
if (
|
||||
marker.schemaVersion !== 1 ||
|
||||
typeof marker.documentCount !== 'number' ||
|
||||
!Number.isSafeInteger(marker.documentCount) ||
|
||||
marker.documentCount < 0 ||
|
||||
typeof marker.year !== 'number' ||
|
||||
!Number.isSafeInteger(marker.year) ||
|
||||
marker.year < 0 ||
|
||||
typeof marker.month !== 'number' ||
|
||||
!Number.isInteger(marker.month) ||
|
||||
marker.month < 1 ||
|
||||
marker.month > 12 ||
|
||||
marker.year * 12 + marker.month > state.currentYear * 12 + state.currentMonth ||
|
||||
typeof marker.tick !== 'number' ||
|
||||
!Number.isSafeInteger(marker.tick) ||
|
||||
marker.tick < 0 ||
|
||||
typeof marker.clockRevision !== 'number' ||
|
||||
!Number.isSafeInteger(marker.clockRevision) ||
|
||||
marker.clockRevision < 0 ||
|
||||
typeof marker.observedAt !== 'string' ||
|
||||
!Number.isFinite(Date.parse(marker.observedAt))
|
||||
)
|
||||
throw new Error('Invalid play audit document boundary');
|
||||
return true;
|
||||
};
|
||||
|
||||
/** CLOCK lock을 잡은 startup transaction 안에서만 호출한다. 과거 문서 사건을 복원하지 않는다. */
|
||||
export const persistAuditDocumentBaseline = async (
|
||||
db: GamePrisma.TransactionClient,
|
||||
world: InMemoryTurnWorld,
|
||||
observedAt: Date
|
||||
): Promise<void> => {
|
||||
if (hasAuditDocumentBaseline(world)) return;
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return;
|
||||
const observedAtIso = observedAt.toISOString();
|
||||
const [identity] = await db.$queryRaw<Array<{ serverId: string | null }>>(GamePrisma.sql`
|
||||
SELECT meta->>'serverId' AS "serverId" FROM world_state WHERE id = ${state.id} FOR UPDATE
|
||||
`);
|
||||
if (identity?.serverId !== serverId) throw new Error('Play audit document baseline season changed');
|
||||
const clock = world.getGameClockState();
|
||||
let cursor = 0;
|
||||
let documentCount = 0;
|
||||
while (true) {
|
||||
// 문서 본문은 불변 참조의 해시에만 필요하다. 메모리에는 한 batch만 유지한다.
|
||||
const letters = await db.diplomacyLetter.findMany({
|
||||
where: { id: { gt: cursor } },
|
||||
orderBy: { id: 'asc' },
|
||||
take: 200,
|
||||
select: {
|
||||
id: true,
|
||||
srcNationId: true,
|
||||
destNationId: true,
|
||||
prevId: true,
|
||||
textBrief: true,
|
||||
textDetail: true,
|
||||
srcSignerId: true,
|
||||
destSignerId: true,
|
||||
state: true,
|
||||
aux: true,
|
||||
date: true,
|
||||
},
|
||||
});
|
||||
await persistAuditDiplomacyEvents(
|
||||
db,
|
||||
letters.map((letter) => ({
|
||||
schemaVersion: 1,
|
||||
serverId,
|
||||
srcNationId: letter.srcNationId,
|
||||
destNationId: letter.destNationId,
|
||||
category: 'DOCUMENT',
|
||||
source: 'BASELINE',
|
||||
eventType: 'LETTER_BASELINE',
|
||||
documentId: letter.id,
|
||||
documentHash: hashAuditDiplomacyDocument(letter),
|
||||
previousDocumentId: letter.prevId,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: BigInt(clock.tick),
|
||||
clockRevision: BigInt(clock.revision),
|
||||
executionId: 'document-baseline',
|
||||
ordinal: letter.id,
|
||||
requestId: null,
|
||||
inputSequence: null,
|
||||
actor: null,
|
||||
before: null,
|
||||
after: projectAuditDocumentState(letter),
|
||||
}))
|
||||
);
|
||||
documentCount += letters.length;
|
||||
if (letters.length < 200) break;
|
||||
cursor = letters[letters.length - 1]!.id;
|
||||
}
|
||||
world.updateWorldMeta({
|
||||
playAuditDocuments: {
|
||||
schemaVersion: 1,
|
||||
serverId,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: clock.tick,
|
||||
clockRevision: clock.revision,
|
||||
observedAt: observedAtIso,
|
||||
documentCount,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { persistAuditDiplomacyEvents } from '@sammo-ts/infra';
|
||||
import { hasAuditDocumentBaseline, persistAuditDocumentBaseline } from '../playAudit/documentBaseline.js';
|
||||
import { persistAuditPolicies } from '../playAudit/policyPersistence.js';
|
||||
import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js';
|
||||
import { persistAuditMonth } from '../playAudit/persistence.js';
|
||||
@@ -76,6 +77,7 @@ import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js';
|
||||
export interface DatabaseTurnHooks {
|
||||
hooks: TurnDaemonHooks;
|
||||
flushChanges(): Promise<void>;
|
||||
flushInitialAudit(observedAt: Date, force?: boolean): Promise<void>;
|
||||
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
|
||||
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
|
||||
close(): Promise<void>;
|
||||
@@ -2087,6 +2089,31 @@ export const createDatabaseTurnHooks = async (
|
||||
committed.acknowledge();
|
||||
enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite);
|
||||
};
|
||||
const flushInitialAudit = async (observedAt: Date, force = false): Promise<void> => {
|
||||
if (hasAuditDocumentBaseline(world)) {
|
||||
if (force || world.hasPendingAuditRecords()) await flushChanges();
|
||||
return;
|
||||
}
|
||||
const checkpoint = world.captureState();
|
||||
let committed: Awaited<ReturnType<typeof persistChanges>>;
|
||||
try {
|
||||
committed = await prisma.$transaction(async (transaction) => {
|
||||
// seed/RESET과 같은 schema lock을 먼저 잡아 문서 scan 중 초기화를 막는다.
|
||||
await transaction.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(current_schema(), 0))::text AS lock_result`;
|
||||
await options?.turnDaemonLease?.assertActive(transaction);
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
||||
await synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world);
|
||||
await persistAuditDocumentBaseline(transaction, world, observedAt);
|
||||
return persistChanges(transaction);
|
||||
}, transactionOptions);
|
||||
} catch (error) {
|
||||
world.restoreState(checkpoint);
|
||||
throw error;
|
||||
}
|
||||
committed.acknowledge();
|
||||
enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite);
|
||||
};
|
||||
const hooks: TurnDaemonHooks = {
|
||||
flushChanges,
|
||||
commitCommand: async (requestId, result) => {
|
||||
@@ -2133,6 +2160,7 @@ export const createDatabaseTurnHooks = async (
|
||||
return {
|
||||
hooks,
|
||||
flushChanges,
|
||||
flushInitialAudit,
|
||||
takeCommittedReadModelChanges: () => {
|
||||
return takeCommittedReceipt()?.changes ?? null;
|
||||
},
|
||||
|
||||
@@ -933,10 +933,8 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
initializeAuditPolicies(world);
|
||||
const diplomacyInitialized = initializeAuditDiplomacy(world, new Date(clock.nowMs()));
|
||||
initializeAuditCollection(world, new Date(clock.nowMs()));
|
||||
if (world.hasPendingAuditRecords() || diplomacyInitialized) {
|
||||
await dbHooks.flushChanges();
|
||||
dbHooks.takeCommittedReadModelChangeReceipt();
|
||||
}
|
||||
await dbHooks.flushInitialAudit(new Date(clock.nowMs()), diplomacyInitialized);
|
||||
dbHooks.takeCommittedReadModelChangeReceipt();
|
||||
} catch (error) {
|
||||
await Promise.allSettled([
|
||||
dbHooks.close(),
|
||||
|
||||
Reference in New Issue
Block a user