감사 상세를 무손실 압축하고 이전 기수 정리 처리량을 개선한다

This commit is contained in:
Hide_D committed 2026-11-09 20:43:05 +09:00
1 parent da79013226
commit 078b9aa205
23 files changed
+504 -95

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.leaseMs = normalizePositiveInteger(options.leaseMs, 30_000);
this.retentionMs = normalizePositiveInteger(options.retentionMs, 24 * 60 * 60 * 1_000); this.retentionMs = normalizePositiveInteger(options.retentionMs, 24 * 60 * 60 * 1_000);
this.pruneIntervalMs = normalizePositiveInteger(options.pruneIntervalMs, 60_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.owner = options.owner ?? `game-api:${profileName}:${process.pid}:${randomUUID()}`;
this.now = options.now ?? (() => new Date()); this.now = options.now ?? (() => new Date());
this.onError = options.onError ?? (() => undefined); this.onError = options.onError ?? (() => undefined);
@@ -109,11 +109,17 @@ export class ReadModelOutboxWorker implements ReadModelOutboxWakeup {
const now = this.now(); const now = this.now();
if (now.getTime() >= this.nextPruneAt) { if (now.getTime() >= this.nextPruneAt) {
this.nextPruneAt = now.getTime() + this.pruneIntervalMs; // backlog가 있으면 다음 dispatch에서도 이어서 정리한다. 평상시에는 낮은 빈도로 확인한다.
await pruneDeliveredReadModelOutbox(this.db, { // 한 호출은 최대 10 batch/50ms이며 각 SQL도 최대 500행이다.
deliveredBefore: new Date(now.getTime() - this.retentionMs), const started = performance.now();
limit: this.pruneLimit, 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; return result;
} }
@@ -1,6 +1,6 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { z } from 'zod'; 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'; import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js';
const zEffectivePolicy = z.object({ const zEffectivePolicy = z.object({
@@ -275,7 +275,7 @@ export const decisionDetail = auditProcedure
decision: project(row), decision: project(row),
chunks: chunks.slice(0, input.limit).map((chunk) => ({ chunks: chunks.slice(0, input.limit).map((chunk) => ({
ordinal: chunk.ordinal, ordinal: chunk.ordinal,
steps: z.array(zStep).max(128).parse(chunk.steps), steps: z.array(zStep).max(128).parse(decodeAuditDecisionSteps(chunk.steps)),
})), })),
nextCursor: nextCursor:
chunks.length && chunks.at(-1)!.ordinal + 1 < Math.ceil(row.stepCount / 128) 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 fastify from 'fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { asRecord } from '@sammo-ts/common'; 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 { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
import { appRouter } from '../src/router.js'; import { appRouter } from '../src/router.js';
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js'; import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
@@ -255,12 +260,10 @@ integration(
include: { chunks: { orderBy: { ordinal: 'asc' } } }, include: { chunks: { orderBy: { ordinal: 'asc' } } },
}); });
const procedures = nationDecision.chunks.flatMap((chunk) => const procedures = nationDecision.chunks.flatMap((chunk) =>
Array.isArray(chunk.steps) decodeAuditDecisionSteps(chunk.steps)
? chunk.steps .map(asRecord)
.map(asRecord) .filter((step) => step.kind === 'PROCEDURE_START')
.filter((step) => step.kind === 'PROCEDURE_START') .map((step) => step.procedure)
.map((step) => step.procedure)
: []
); );
expect(procedures).toEqual(['NPC포상']); expect(procedures).toEqual(['NPC포상']);
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(withoutRecruitment)); expect(configuration(await call('getPolicy', {}))).toEqual(configuration(withoutRecruitment));
@@ -45,6 +45,28 @@ const createFixture = (rows: readonly object[]) => {
}; };
describe('ReadModelOutboxWorker', () => { 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 () => { 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 fixture = createFixture([{ id: 10n, payload: payload('front.global'), attempts: 1 }]);
const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', { 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 { encryptGameSessionToken, type GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { import {
createGamePostgresConnector, createGamePostgresConnector,
encodeAuditDecisionSteps,
GamePrisma, GamePrisma,
hashAuditDiplomacyDocument, hashAuditDiplomacyDocument,
createRedisConnector, createRedisConnector,
@@ -2345,50 +2346,52 @@ integration('game API security over HTTP transport', () => {
{ {
decisionId: decisionIds[0]!, decisionId: decisionIds[0]!,
ordinal: 0, ordinal: 0,
steps: Array.from({ length: 128 }, (_, sequence) => steps: encodeAuditDecisionSteps(
sequence === 127 Array.from({ length: 128 }, (_, sequence) =>
? { sequence === 127
...step, ? {
sequence, ...step,
kind: 'DECISION_START', sequence,
reservedAction: '휴식', kind: 'DECISION_START',
effectivePolicy: { reservedAction: '휴식',
schemaVersion: 1, effectivePolicy: {
general: { priority: ['징병'], flags: { 징병: true, 출병: false } }, schemaVersion: 1,
nation: { general: { priority: ['징병'], flags: { 징병: true, 출병: false } },
priority: ['천도'], nation: {
flags: { 천도: true }, priority: ['천도'],
values: { flags: { 천도: true },
reqNationGold: 4321, values: {
reqNationRice: 100, reqNationGold: 4321,
reqHumanWarUrgentGold: 100, reqNationRice: 100,
reqHumanWarUrgentRice: 100, reqHumanWarUrgentGold: 100,
reqHumanWarRecommandGold: 100, reqHumanWarUrgentRice: 100,
reqHumanWarRecommandRice: 100, reqHumanWarRecommandGold: 100,
reqHumanDevelGold: 100, reqHumanWarRecommandRice: 100,
reqHumanDevelRice: 100, reqHumanDevelGold: 100,
reqNpcWarGold: 100, reqHumanDevelRice: 100,
reqNpcWarRice: 100, reqNpcWarGold: 100,
reqNpcDevelGold: 100, reqNpcWarRice: 100,
reqNpcDevelRice: 100, reqNpcDevelGold: 100,
minimumResourceActionAmount: 100, reqNpcDevelRice: 100,
maximumResourceActionAmount: 100, minimumResourceActionAmount: 100,
minNpcWarLeadership: 100, maximumResourceActionAmount: 100,
minWarCrew: 100, minNpcWarLeadership: 100,
minNpcRecruitCityPopulation: 100, minWarCrew: 100,
safeRecruitCityPopulationRatio: 100, minNpcRecruitCityPopulation: 100,
properWarTrainAtmos: 100, safeRecruitCityPopulationRatio: 100,
cureThreshold: 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',
}, },
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/mapLoader.js';
export * from './scenario/scenarioSeeder.js'; export * from './scenario/scenarioSeeder.js';
export * from './scenario/gameCancellation.js'; export * from './scenario/gameCancellation.js';
export * from './playAudit/retention.js';
export * from './turn/types.js'; export * from './turn/types.js';
export * from './turn/worldLoader.js'; export * from './turn/worldLoader.js';
export * from './turn/inMemoryWorld.js'; export * from './turn/inMemoryWorld.js';
@@ -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 { auditPolicyHash } from './policy.js';
import type { PendingAuditDecision } from './decision.js'; import type { PendingAuditDecision } from './decision.js';
@@ -50,7 +50,9 @@ export const persistAuditDecisions = async (
chunks.push({ chunks.push({
decisionId: decision.id, decisionId: decision.id,
ordinal: start / AUDIT_DECISION_CHUNK_STEPS, 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) { if (chunks.length === BATCH) {
await tx.playAuditDecisionChunk.createMany({ data: chunks, skipDuplicates: true }); await tx.playAuditDecisionChunk.createMany({ data: chunks, skipDuplicates: true });
+37 -22
View File
@@ -1,5 +1,5 @@
import { asRecord } from '@sammo-ts/common'; 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 const AUDIT_RETENTION_BATCH_SIZE = 200;
export type AuditRetentionResult = { status: 'progress' | 'complete' | 'busy' | 'identityChanged'; deleted: number }; export type AuditRetentionResult = { status: 'progress' | 'complete' | 'busy' | 'identityChanged'; deleted: number };
@@ -13,6 +13,7 @@ export const prunePreviousAuditBatch = async (
return db.$transaction( return db.$transaction(
async (tx) => { async (tx) => {
await tx.$executeRaw`SET LOCAL statement_timeout = '2000ms'`; await tx.$executeRaw`SET LOCAL statement_timeout = '2000ms'`;
await tx.$executeRaw`SET LOCAL lock_timeout = '250ms'`;
// seeder와 동일한 잠금이다. RESET을 기다리게 하지 않고 다음 batch에서 재시도한다. // seeder와 동일한 잠금이다. RESET을 기다리게 하지 않고 다음 batch에서 재시도한다.
const [lock] = await tx.$queryRaw<{ locked: boolean }[]>` const [lock] = await tx.$queryRaw<{ locked: boolean }[]>`
SELECT pg_try_advisory_xact_lock(hashtextextended(current_schema(), 0)) AS locked 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 }; return { status: 'progress', deleted: deleted.count };
} }
const [decision] = await tx.$queryRaw<{ id: string }[]>` // 이미 있는 (server_id,nation_id,tick,id) 인덱스 순서로 여러 부모를 잠근다.
SELECT id FROM play_audit_decision WHERE id = COALESCE( // chunk 총량과 빈 header 삭제량의 합도 BATCH 이하로 제한한다.
(SELECT id FROM play_audit_decision WHERE server_id < ${expectedServerId} let decisions = await tx.$queryRaw<{ id: string }[]>`
ORDER BY server_id, general_id, tick, id LIMIT 1), SELECT id FROM play_audit_decision WHERE server_id < ${expectedServerId}
(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
ORDER BY server_id, general_id, tick, id LIMIT 1)
) FOR UPDATE
`; `;
if (decision) { if (!decisions.length) {
const chunks = await tx.playAuditDecisionChunk.findMany({ decisions = await tx.$queryRaw<{ id: string }[]>`
where: { decisionId: decision.id }, SELECT id FROM play_audit_decision WHERE server_id > ${expectedServerId}
orderBy: { ordinal: 'asc' }, ORDER BY server_id, nation_id, tick, id LIMIT ${AUDIT_RETENTION_BATCH_SIZE} FOR UPDATE
take: AUDIT_RETENTION_BATCH_SIZE, `;
select: { ordinal: true }, }
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 headers = await tx.playAuditDecision.deleteMany({
const deleted = await tx.playAuditDecisionChunk.deleteMany({ where: { id: { in: empty.map(({ id }) => id) } },
where: { decisionId: decision.id, ordinal: { in: chunks.map((row) => row.ordinal) } }, });
}); return { status: 'progress', deleted: count + headers.count };
return { status: 'progress', deleted: deleted.count };
}
await tx.playAuditDecision.delete({ where: { id: decision.id } });
return { status: 'progress', deleted: 1 };
} }
const policies = await tx.playAuditPolicy.findMany({ const policies = await tx.playAuditPolicy.findMany({
where: { serverId: { not: expectedServerId } }, where: { serverId: { not: expectedServerId } },
@@ -17,7 +17,7 @@ export const startAuditRetentionWorker = (options: {
timer.unref(); timer.unref();
}; };
const run = async () => { const run = async () => {
let delay = 1000; let delay = 100;
try { try {
const result = await options.prune(); const result = await options.prune();
if (result.status === 'complete' || result.status === 'identityChanged') stopped = true; if (result.status === 'complete' || result.status === 'identityChanged') stopped = true;
@@ -1,7 +1,12 @@
import { writeFile } from 'node:fs/promises'; import { writeFile } from 'node:fs/promises';
import { performance } from 'node:perf_hooks'; import { performance } from 'node:perf_hooks';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; 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 { persistAuditDecisions } from '../src/playAudit/decisionPersistence.js';
import { buildAuditDecisionFixture } from './fixtures/playAuditDecision.js'; import { buildAuditDecisionFixture } from './fixtures/playAuditDecision.js';
@@ -61,11 +66,15 @@ describe.skipIf(!databaseUrl)('decision storage cost probe', () => {
>` >`
SELECT SELECT
(SELECT count(*) FROM play_audit_decision) AS headers, (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(d)) FROM play_audit_decision d) AS "headerBytes",
(SELECT sum(pg_column_size(c)) FROM play_audit_decision_chunk c) AS "chunkBytes" (SELECT sum(pg_column_size(c)) FROM play_audit_decision_chunk c) AS "chunkBytes"
`; `;
expect(Number(storage!.steps)).toBe(201 * 302); 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< const relations = await db.$queryRaw<
{ name: string; tableBytes: bigint; indexBytes: bigint; totalBytes: bigint }[] { name: string; tableBytes: bigint; indexBytes: bigint; totalBytes: bigint }[]
>` >`
@@ -99,7 +108,8 @@ describe.skipIf(!databaseUrl)('decision storage cost probe', () => {
`; `;
const metrics = { const metrics = {
scope: 'synthetic 201 decisions, 302 steps each; isolated schema, warm local reads', 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, decisions: 201,
steps: Number(storage!.steps), steps: Number(storage!.steps),
chunks: 603, chunks: 603,
@@ -1,5 +1,5 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; 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 { persistAuditDecisions } from '../src/playAudit/decisionPersistence.js';
import { buildAuditExecutionFixture, buildAuditDecisionFixture as draft } from './fixtures/playAuditDecision.js'; import { buildAuditExecutionFixture, buildAuditDecisionFixture as draft } from './fixtures/playAuditDecision.js';
import { prunePreviousAuditBatch } from '../src/playAudit/retention.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 () => { it('rolls back gameplay/header/chunks on insert failure, retries and rejects divergent replay', async () => {
const decision = draft('decision-one'); 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.codeVersion = 'a'.repeat(40);
decision.summary.executionCoverage = 'ATTEMPTS'; decision.summary.executionCoverage = 'ATTEMPTS';
decision.steps.push({ ...decision.steps[0]!, ...buildAuditExecutionFixture(), sequence: 302 }); decision.steps.push({ ...decision.steps[0]!, ...buildAuditExecutionFixture(), sequence: 302 });
@@ -85,7 +118,7 @@ integration('decision persistence and bounded retention', () => {
orderBy: { ordinal: 'asc' }, orderBy: { ordinal: 'asc' },
}); });
expect(chunks).toHaveLength(3); 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( await expect(
db.$transaction((tx) => db.$transaction((tx) =>
persistAuditDecisions(tx, [{ ...decision, summary: { ...decision.summary, completed: true } }]) 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.playAuditDecisionChunk.count({ where: { decisionId: 'decision-one' } })).toBe(204);
expect(await db.playAuditDecision.count({ where: { id: 'decision-one' } })).toBe(1); 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: 200 });
expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'progress', deleted: 4 }); expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'progress', deleted: 5 });
expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'progress', deleted: 1 });
expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'complete', deleted: 0 }); expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'complete', deleted: 0 });
expect(await db.playAuditDecision.count()).toBe(1); expect(await db.playAuditDecision.count()).toBe(1);
expect(await db.playAuditDecisionChunk.count()).toBe(3); 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); expect(prune).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(30_001); await vi.advanceTimersByTimeAsync(30_001);
expect(prune).toHaveBeenCalledTimes(3); expect(prune).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(1000); await vi.advanceTimersByTimeAsync(100);
expect(prune).toHaveBeenCalledTimes(4); expect(prune).toHaveBeenCalledTimes(4);
await vi.advanceTimersByTimeAsync(600_000); await vi.advanceTimersByTimeAsync(600_000);
expect(prune).toHaveBeenCalledTimes(4); expect(prune).toHaveBeenCalledTimes(4);
+3
View File
@@ -2,6 +2,9 @@
## 문서 상태와 사용법 ## 문서 상태와 사용법
2026-11-09 저장 최적화: [물리 압축·정리 처리량·ZFS 운영 경계](../play-audit-storage.md).
판단 원문과 현재 기수 보존 계약을 유지하며 저장 표현과 이전 기수 정리 구현을 변경한다.
이 문서는 플레이 감사의 구조·현재 구현 경계·남은 요구사항을 관리하는 기준 문서다. 이 문서는 플레이 감사의 구조·현재 구현 경계·남은 요구사항을 관리하는 기준 문서다.
사용 방법과 DB 적용은 [운영 안내](../play-audit-operations.md)를 따른다. 사용 방법과 DB 적용은 [운영 안내](../play-audit-operations.md)를 따른다.
날짜별 구현·측정 기록은 Git 이력과 상위 작업공간의 `report/`에서 확인한다. 날짜별 구현·측정 기록은 Git 이력과 상위 작업공간의 `report/`에서 확인한다.
+5 -1
View File
@@ -5,6 +5,9 @@
관리한다. 아래 적용 절차는 각 프로필의 DB 보존 업데이트를 위한 것이며, 관리한다. 아래 적용 절차는 각 프로필의 DB 보존 업데이트를 위한 것이며,
문서에 기능이 기재되어 있다고 운영 DB에 적용된 것은 아니다. 문서에 기능이 기재되어 있다고 운영 DB에 적용된 것은 아니다.
저장량 최적화, 중지된 프로필 정리, ZFS 이전과 rollback은
[저장량과 운영 정리](play-audit-storage.md)를 따른다.
## 지금 사용할 수 있는 기능 ## 지금 사용할 수 있는 기능
| 화면 | 사용할 수 있는 정보 | 읽을 때 주의할 점 | | 화면 | 사용할 수 있는 정보 | 읽을 때 주의할 점 |
@@ -55,7 +58,7 @@
## DB migration과 적용 순서 ## DB migration과 적용 순서
정식 game migration에 감사 테이블과 인덱스가 포함되어 있다. `prisma db push`나 정식 game migration에 감사 테이블과 인덱스가 포함되어 있다. `prisma db push`나
수동 CREATE TABLE로 대신 적용하지 않는다. 현재 game chain은58개이며 다음 감사 수동 CREATE TABLE로 대신 적용하지 않는다. 현재 game chain은59개이며 다음 감사
migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세를 역산하지 않는다. migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세를 역산하지 않는다.
| migration | 준비되는 저장소/제약 | | migration | 준비되는 저장소/제약 |
@@ -69,6 +72,7 @@ migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세
| `20260916060000_widen_play_audit_ticks` | 월 표본/정책 tick을 BIGINT로 확장하여 약60개월 이후 INTEGER 초과 방지 | | `20260916060000_widen_play_audit_ticks` | 월 표본/정책 tick을 BIGINT로 확장하여 약60개월 이후 INTEGER 초과 방지 |
| `20260916070000_add_play_audit_decision` | NPC/자동턴 결정 요약과 순서별 상세 chunk, bounded 정리용 FK/index | | `20260916070000_add_play_audit_decision` | NPC/자동턴 결정 요약과 순서별 상세 chunk, bounded 정리용 FK/index |
| `20260916080000_index_play_audit_decision_month` | 기존 장수 인덱스를 월 조건 포함 인덱스로 교체하여 기수 전체 조회 방지 | | `20260916080000_index_play_audit_decision_month` | 기존 장수 인덱스를 월 조건 포함 인덱스로 교체하여 기수 전체 조회 방지 |
| `20261109000000_compact_play_audit_storage` | 무손실 압축 chunk 형식과 작은 JSON inline 압축 설정 |
운영은 [릴리스 절차](release-operations.md)의 **DB 보존 버전 업데이트**로 해당 고정 운영은 [릴리스 절차](release-operations.md)의 **DB 보존 버전 업데이트**로 해당 고정
commit을 적용한다. 이 기능을 켜기 위해 시나리오를 초기화할 필요는 없다. 수동 환경의 commit을 적용한다. 이 기능을 켜기 위해 시나리오를 초기화할 필요는 없다. 수동 환경의
+73
View File
@@ -0,0 +1,73 @@
# 플레이 감사 저장량과 운영 정리
2026-11-09 저장 최적화는 Core 전용 감사의 물리 표현과 정리 구현을 바꾼다.
현재 기수 전체 기록·판단 순서·정책 참조·hash·API 응답·권한은 유지한다.
원문 샘플링이나 보존기간 축소를 하지 않는다.
## 물리 표현
- 결정 chunk는 기존 `steps` JSONB 배열 또는 `{encoding:"gzip-json-v1",data:"base64"}`로 저장한다.
128 step 단위는 그대로이며 gzip level 3을 사용한다. 압축 envelope가 더 크면 원래 배열을 쓴다.
- replay hash는 압축 전 원문으로 계산한다. API는 공용 decoder 뒤 기존 step schema를 검증한다.
손상·알 수 없는 버전은 조회 오류이며 빈 자료로 바꾸지 않는다.
- 압축 해제는 chunk당 16MiB로 제한한다. 더 큰 원문은 기존 배열로 저장해 잘라내지 않는다.
- 장수 snapshot `data`와 결정 `summary`는 PostgreSQL `STORAGE MAIN`,
`toast_tuple_target=512`로 작은 반복 JSON도 inline 압축한다. JSON 값은 바뀌지 않는다.
- migration `20261109000000_compact_play_audit_storage`는 기존 테이블을 재작성하지 않는다.
chunk CHECK는 새 쓰기에 즉시 적용하고 대형 과거 자료 검사는 `NOT VALID`로 미룬다.
정리 후 `ALTER TABLE ... VALIDATE CONSTRAINT play_audit_decision_chunk_steps_check`를 실행한다.
기존 자료의 압축 변경은 별도 bounded backfill 또는 검증된 export/restore가 필요하다.
## 정리 처리량과 실행 수명주기
- 이전 기수 결정 부모를 기존 `(server_id,nation_id,tick,id)` 인덱스로 최대 200개 잠근다.
한 transaction의 chunk+header 삭제 합도 200행 이하이며 큰 결정 하나가 있어도 상한을 지킨다.
- 다음 성공 batch까지 100ms를 기다린다. RESET과 동일 advisory lock, identity 재검사,
2초 statement/250ms lock 제한, rollback을 유지한다. 현재 기수의 결정과 표본은 삭제하지 않는다.
- API outbox는 전달 후 24시간을 보존한다. 기존 인덱스 순서로 최대 500행씩 정리하고,
backlog가 있으면 다음 dispatch에서도 이어간다. 한 호출은 최대 10 batch/50ms이며
미전달 자료는 삭제하지 않는다. 빈 상태에서는 60초 주기로 돌아간다.
- game API/daemon이 중지된 프로필도 host scheduler가 다음 CLI로 정리한다.
CLI는 접속 URL이나 SQL 원문을 출력하지 않는다. 기본은 dry-run이다.
```sh
# 먼저 common/logic/infra/game-engine을 빌드한다. 접속 정보는 기존 process 환경으로 전달한다.
node tools/play-audit-maintenance.mjs --schemas=che,kwe,pwe,twe,nya,pya,hwe
node tools/play-audit-maintenance.mjs --schemas=che,kwe,pwe,twe,nya,pya,hwe --budget-ms=3000 --apply
```
schema마다 최신 identity를 읽고 각 삭제 transaction에서 다시 확인한다. 예상 identity가 바뀌면
그 schema의 처리를 중단한다. host timer는 다음 실행에서 새 identity를 읽는다.
장수/국가/명령/input_event 같은 gameplay 테이블은 이 CLI의 삭제 대상이 아니다.
## ZFS 운영 경계
호스트는 감사 전용 tablespace와 backup 전용 dataset을 별도로 제공한다.
hot gameplay, WAL, legacy archive를 임의로 이동하지 않는다. DB와 tablespace는 한 cluster이므로
tablespace 파일만 백업하는 것으로 복구를 보장하지 않는다.
1. 전체 DB의 일관된 압축 backup을 ZFS에 만들고 checksum, archive 읽기, 실제 복원을 확인한다.
2. DB 보존 DEPLOY로 writer/reader/migration을 함께 적용한다.
3. profile 상태를 기록하고 공식 STOP으로 쓰기를 중단한다. 원래 STOPPED인 profile은 유지한다.
4. 각 schema에서 현재 serverId의 8개 감사 테이블을 일관되게 export한다.
월 child는 현재 월 header와 join하고 결정 chunk는 현재 결정 header와 join한다.
5. 격리 DB에서 복원·건수·내용 hash를 검증한다. 이전 기수는 전체 ZFS backup에 남는다.
6. 하나의 transaction에서 감사 테이블만 TRUNCATE하고 ZFS tablespace로 옮긴 뒤
현재 기수 export를 다시 넣는다. 부모를 먼저 넣고 모든 건수/hash와 identity를 확인한 뒤 commit한다.
index도 별도로 ZFS로 옮긴다. 실패하면 전체 transaction을 rollback한다.
7. CHECK 검증과 ANALYZE를 수행하고 원래 RUNNING이었던 profile만 공식 START한다.
현재 기록, 새 기록의 decode/hash, lease/clock, 공개 HTTPS와 루트/ZFS 실제 사용량을 확인한다.
기존 데이터가 적을 때는 bounded DELETE 뒤 별도 재작성으로 공간을 회수할 수도 있다.
수천만 건 backlog에서 export/TRUNCATE/restore는 현재 자료를 보존하며 물리 공간을 즉시
회수하기 위한 계획된 유지보수 절차다. backup 검증이나 profile 정지가 빠진 자동 작업으로 쓰지 않는다.
ZFS dataset이 없을 때 루트 디렉터리에 쓰지 않도록 host mount guard와 재현 가능한 Compose 설정이 필요하다.
## rollback
이전 reader는 압축 envelope를 이해하지 못한다. 구형 버전으로 돌아가기 전 해당 기록을
새 decoder로 원래 JSON 배열로 변환하고 같은 hash/개수를 검증한다. 물리 압축 설정은
이전 애플리케이션과 호환되며 원래 TOAST 설정으로 돌릴 필요가 없다.
현재 기수 복원 중 실패하면 transaction rollback으로 기존 table/data/tablespace를 보존한다.
기수 전체 과거 자료가 필요하면 ZFS의 변경 전 backup을 별도 DB에 복원한다.
운영 DB 전체를 과거 dump로 덮어써 새 gameplay를 잃게 하지 않는다.
@@ -0,0 +1,18 @@
-- 대형 기존 기록을 migration 중 재작성/전체 검사하지 않는다. 이전 배열과 새 압축 형식을 함께 허용한다.
BEGIN;
SET LOCAL lock_timeout = '2s';
ALTER TABLE "play_audit_decision_chunk" DROP CONSTRAINT "play_audit_decision_chunk_steps_check";
ALTER TABLE "play_audit_decision_chunk" ADD CONSTRAINT "play_audit_decision_chunk_steps_check"
CHECK (COALESCE(
jsonb_typeof("steps") = 'array' OR (
jsonb_typeof("steps") = 'object'
AND "steps"->>'encoding' = 'gzip-json-v1'
AND jsonb_typeof("steps"->'data') = 'string'
), false
)) NOT VALID;
-- 기본 약 2KiB 문턱 아래의 반복 JSON도 압축하되 작은 snapshot은 TOAST 밖으로 쪼개지 않는다.
ALTER TABLE "play_audit_general" ALTER COLUMN "data" SET STORAGE MAIN;
ALTER TABLE "play_audit_general" SET (toast_tuple_target = 512);
ALTER TABLE "play_audit_decision" ALTER COLUMN "summary" SET STORAGE MAIN;
ALTER TABLE "play_audit_decision" SET (toast_tuple_target = 512);
COMMIT;
+1
View File
@@ -15,3 +15,4 @@ export * from './playAuditDiplomacy.js';
export * from './messageEnvelope.js'; export * from './messageEnvelope.js';
export * from './webPushOutbox.js'; export * from './webPushOutbox.js';
export * from './playAuditBestEffort.js'; export * from './playAuditBestEffort.js';
export * from './playAuditDecisionCodec.js';
@@ -0,0 +1,44 @@
import { gzipSync, gunzipSync } from 'node:zlib';
import type { InputJsonValue } from './turnEngineDb.js';
const ENCODING = 'gzip-json-v1';
const MAX_STEPS = 128;
const MAX_BYTES = 16 * 1024 * 1024;
/** 조회/재실행 hash에 쓰는 원문은 바꾸지 않고 저장할 때만 무손실 압축한다. */
export const encodeAuditDecisionSteps = (steps: readonly InputJsonValue[]): InputJsonValue => {
if (steps.length > MAX_STEPS) throw new Error('Audit decision chunk exceeds its step limit');
const json = JSON.stringify(steps);
// 드문 초대형 기존 payload도 버리거나 자르지 않는다. 압축 reader 상한 밖이면 원래 형식으로 저장한다.
if (Buffer.byteLength(json) > MAX_BYTES) return steps.slice();
const encoded = { encoding: ENCODING, data: gzipSync(json, { level: 3 }).toString('base64') };
return Buffer.byteLength(JSON.stringify(encoded)) < Buffer.byteLength(json) ? encoded : steps.slice();
};
/** 이전 JSONB 배열과 압축 형식을 함께 읽는다. 손상/알 수 없는 버전은 빈 기록으로 숨기지 않는다. */
export const decodeAuditDecisionSteps = (value: unknown): unknown[] => {
if (Array.isArray(value)) {
if (value.length > MAX_STEPS) throw new Error('Audit decision chunk exceeds its step limit');
return value;
}
if (!value || typeof value !== 'object' || !('encoding' in value) || !('data' in value))
throw new Error('Invalid audit decision storage');
if (
value.encoding !== ENCODING ||
typeof value.data !== 'string' ||
value.data.length > Math.ceil(MAX_BYTES / 3) * 4 + 256 ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value.data)
)
throw new Error('Invalid audit decision encoding');
let decoded: unknown;
try {
decoded = JSON.parse(
gunzipSync(Buffer.from(value.data, 'base64'), { maxOutputLength: MAX_BYTES }).toString('utf8')
);
} catch {
// DB 원문이나 압축 payload가 오류 응답/로그에 포함되지 않게 한다.
throw new Error('Corrupt audit decision storage');
}
if (!Array.isArray(decoded) || decoded.length > MAX_STEPS) throw new Error('Invalid audit decision steps');
return decoded;
};
@@ -195,8 +195,9 @@ export const pruneDeliveredReadModelOutbox = async (
SELECT "id" SELECT "id"
FROM "read_model_outbox" FROM "read_model_outbox"
WHERE "delivered_at" < ${input.deliveredBefore} WHERE "delivered_at" < ${input.deliveredBefore}
ORDER BY "id" ORDER BY "delivered_at", "available_at", "id"
LIMIT ${limit} LIMIT ${limit}
FOR UPDATE SKIP LOCKED
) )
RETURNING "id" RETURNING "id"
`); `);
@@ -0,0 +1,54 @@
import { gzipSync } from 'node:zlib';
import { describe, expect, it } from 'vitest';
import { decodeAuditDecisionSteps, encodeAuditDecisionSteps } from '../src/playAuditDecisionCodec.js';
describe('lossless audit decision storage', () => {
it('compresses repeated context while preserving every value and its order', () => {
const steps = Array.from({ length: 128 }, (_, sequence) => ({
sequence,
phase: 'general',
generalId: 51,
nationId: 3,
cityId: 12,
year: 190,
month: 7,
kind: 'RNG',
value: sequence / 128,
text: '판단·줄바꿈\n따옴표 " 그대로',
empty: null,
}));
const stored = encodeAuditDecisionSteps(steps);
expect(Array.isArray(stored)).toBe(false);
expect(Buffer.byteLength(JSON.stringify(stored))).toBeLessThan(Buffer.byteLength(JSON.stringify(steps)) / 3);
expect(decodeAuditDecisionSteps(stored)).toEqual(steps);
});
it('keeps tiny and historical uncompressed arrays readable', () => {
expect(encodeAuditDecisionSteps([])).toEqual([]);
expect(decodeAuditDecisionSteps([{ marker: 'legacy', sequence: 0 }])).toEqual([
{ marker: 'legacy', sequence: 0 },
]);
});
it('rejects unknown versions, bad base64, truncated gzip and invalid contents', () => {
for (const value of [
null,
{},
{ encoding: 'gzip-json-v2', data: '' },
{ encoding: 'gzip-json-v1', data: '%%%' },
{ encoding: 'gzip-json-v1', data: 'YQ==' },
{ encoding: 'gzip-json-v1', data: gzipSync('{}').toString('base64') },
{ encoding: 'gzip-json-v1', data: gzipSync(JSON.stringify(Array(129).fill(null))).toString('base64') },
])
expect(() => decodeAuditDecisionSteps(value)).toThrow();
expect(() => encodeAuditDecisionSteps(Array(129).fill(null))).toThrow();
});
it('bounds decompression and preserves oversized raw records rather than truncating them', () => {
const steps = [{ text: 'x'.repeat(16 * 1024 * 1024) }];
expect(encodeAuditDecisionSteps(steps)).toEqual(steps);
expect(() =>
decodeAuditDecisionSteps({
encoding: 'gzip-json-v1',
data: gzipSync(JSON.stringify(steps)).toString('base64'),
})
).toThrow('Corrupt');
});
});
+1 -1
View File
@@ -2,6 +2,6 @@
"formatVersion": 1, "formatVersion": 1,
"controllerProtocol": 2, "controllerProtocol": 2,
"gatewaySchemaHead": "20261022000000_add_open_requests", "gatewaySchemaHead": "20261022000000_add_open_requests",
"gameSchemaHead": "20260916080000_index_play_audit_decision_month", "gameSchemaHead": "20261109000000_compact_play_audit_storage",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
} }
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { asRecord, type TurnCheckpoint } from '@sammo-ts/common'; import { asRecord, type TurnCheckpoint } from '@sammo-ts/common';
import { createGamePostgresConnector } from '@sammo-ts/infra'; import { createGamePostgresConnector, decodeAuditDecisionSteps } from '@sammo-ts/infra';
import { createTurnDaemonRuntime, seedScenarioToDatabase } from '@sammo-ts/game-engine'; import { createTurnDaemonRuntime, seedScenarioToDatabase } from '@sammo-ts/game-engine';
const databaseUrl = process.env.DATABASE_URL; const databaseUrl = process.env.DATABASE_URL;
@@ -39,7 +39,7 @@ const verify = async (progress: unknown[]) => {
include: { chunks: { orderBy: { ordinal: 'asc' } } }, include: { chunks: { orderBy: { ordinal: 'asc' } } },
}); });
assert(row && row.chunks.length, `Missing actual NPC ${JSON.stringify(selection)} decision`); assert(row && row.chunks.length, `Missing actual NPC ${JSON.stringify(selection)} decision`);
const steps = row.chunks.flatMap((chunk) => (Array.isArray(chunk.steps) ? chunk.steps : [])); const steps = row.chunks.flatMap((chunk) => decodeAuditDecisionSteps(chunk.steps));
assert.equal(steps.length, row.stepCount); assert.equal(steps.length, row.stepCount);
assert.equal(asRecord(steps[0]).kind, 'DECISION_START'); assert.equal(asRecord(steps[0]).kind, 'DECISION_START');
assert(steps.some((step) => asRecord(step).kind === 'DECISION_END')); assert(steps.some((step) => asRecord(step).kind === 'DECISION_END'));
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env node
// stopped/reserved profile도 같은 fenced batch로 정리한다. 기본은 관찰만 하며 외부 scheduler가 실행한다.
import { performance } from 'node:perf_hooks';
import { setTimeout } from 'node:timers/promises';
import { createGamePostgresConnector, pruneDeliveredReadModelOutbox } from '../packages/infra/dist/index.js';
import { prunePreviousAuditBatch } from '../app/game-engine/dist/index.js';
const args = process.argv.slice(2);
const apply = args.includes('--apply');
const schemas =
args
.find((arg) => arg.startsWith('--schemas='))
?.slice(10)
.split(',') ?? [];
const budgetMs = Number(args.find((arg) => arg.startsWith('--budget-ms='))?.slice(12) ?? 3000);
if (
!schemas.length ||
schemas.some((schema) => !/^[a-z][a-z0-9_]{0,62}$/.test(schema)) ||
!Number.isSafeInteger(budgetMs) ||
budgetMs < 1 ||
budgetMs > 60000 ||
args.some((arg) => arg !== '--apply' && !arg.startsWith('--schemas=') && !arg.startsWith('--budget-ms='))
) {
throw new Error('Usage: --schemas=che,kwe,... [--budget-ms=3000] [--apply]');
}
let failed = false;
for (const schema of schemas) {
const url = process.env.DATABASE_URL ? new URL(process.env.DATABASE_URL) : new URL('postgresql://localhost');
if (!process.env.DATABASE_URL) {
url.hostname = process.env.POSTGRES_HOST ?? 'localhost';
url.port = process.env.POSTGRES_PORT ?? '5432';
url.username = process.env.POSTGRES_USER ?? 'sammo';
url.password = process.env.POSTGRES_PASSWORD ?? '';
url.pathname = `/${process.env.POSTGRES_DB ?? 'sammo'}`;
}
url.searchParams.set('schema', schema);
const connector = createGamePostgresConnector({ url: url.href, maxConnections: 1 });
try {
await connector.connect();
const db = connector.prisma;
const world = await db.worldState.findFirst({ select: { meta: true } });
const serverId =
world?.meta && typeof world.meta === 'object' && !Array.isArray(world.meta) ? world.meta.serverId : null;
if (typeof serverId !== 'string' || !serverId.trim()) {
console.log(JSON.stringify({ schema, status: 'identityMissing', deleted: 0 }));
continue;
}
if (!apply) {
console.log(JSON.stringify({ schema, status: 'dryRun', apply: false }));
continue;
}
const deadline = performance.now() + budgetMs;
let auditDeleted = 0;
let outboxDeleted = 0;
let status = 'progress';
let auditComplete = false;
let outboxComplete = false;
do {
if (!auditComplete) {
const result = await prunePreviousAuditBatch(db, serverId);
auditDeleted += result.deleted;
status = result.status;
auditComplete = result.status === 'complete';
if (result.status === 'identityChanged' || result.status === 'busy') break;
}
if (!outboxComplete) {
outboxDeleted += await db.$transaction(async (tx) => {
await tx.$executeRaw`SET LOCAL statement_timeout='2000ms'`;
await tx.$executeRaw`SET LOCAL lock_timeout='250ms'`;
const deleted = await pruneDeliveredReadModelOutbox(tx, {
deliveredBefore: new Date(Date.now() - 86400000),
limit: 500,
});
outboxComplete = deleted < 500;
return deleted;
});
}
if (auditComplete && outboxComplete) break;
await setTimeout(25);
} while (performance.now() < deadline);
console.log(
JSON.stringify({ schema, status, auditDeleted, outboxDeleted, complete: auditComplete && outboxComplete })
);
} catch {
// 연결 URL과 원문 SQL을 출력하지 않고 scheduler가 감지할 수 있는 종료 코드를 남긴다.
failed = true;
console.error(JSON.stringify({ schema, status: 'failed' }));
} finally {
await connector.disconnect();
}
}
if (failed) process.exitCode = 1;