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

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.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';
@@ -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;
@@ -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);
+3
View File
@@ -2,6 +2,9 @@
## 문서 상태와 사용법
2026-11-09 저장 최적화: [물리 압축·정리 처리량·ZFS 운영 경계](../play-audit-storage.md).
판단 원문과 현재 기수 보존 계약을 유지하며 저장 표현과 이전 기수 정리 구현을 변경한다.
이 문서는 플레이 감사의 구조·현재 구현 경계·남은 요구사항을 관리하는 기준 문서다.
사용 방법과 DB 적용은 [운영 안내](../play-audit-operations.md)를 따른다.
날짜별 구현·측정 기록은 Git 이력과 상위 작업공간의 `report/`에서 확인한다.
+5 -1
View File
@@ -5,6 +5,9 @@
관리한다. 아래 적용 절차는 각 프로필의 DB 보존 업데이트를 위한 것이며,
문서에 기능이 기재되어 있다고 운영 DB에 적용된 것은 아니다.
저장량 최적화, 중지된 프로필 정리, ZFS 이전과 rollback은
[저장량과 운영 정리](play-audit-storage.md)를 따른다.
## 지금 사용할 수 있는 기능
| 화면 | 사용할 수 있는 정보 | 읽을 때 주의할 점 |
@@ -55,7 +58,7 @@
## DB migration과 적용 순서
정식 game migration에 감사 테이블과 인덱스가 포함되어 있다. `prisma db push`나
수동 CREATE TABLE로 대신 적용하지 않는다. 현재 game chain은58개이며 다음 감사
수동 CREATE TABLE로 대신 적용하지 않는다. 현재 game chain은59개이며 다음 감사
migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세를 역산하지 않는다.
| migration | 준비되는 저장소/제약 |
@@ -69,6 +72,7 @@ migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세
| `20260916060000_widen_play_audit_ticks` | 월 표본/정책 tick을 BIGINT로 확장하여 약60개월 이후 INTEGER 초과 방지 |
| `20260916070000_add_play_audit_decision` | NPC/자동턴 결정 요약과 순서별 상세 chunk, bounded 정리용 FK/index |
| `20260916080000_index_play_audit_decision_month` | 기존 장수 인덱스를 월 조건 포함 인덱스로 교체하여 기수 전체 조회 방지 |
| `20261109000000_compact_play_audit_storage` | 무손실 압축 chunk 형식과 작은 JSON inline 압축 설정 |
운영은 [릴리스 절차](release-operations.md)의 **DB 보존 버전 업데이트**로 해당 고정
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 './webPushOutbox.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"
FROM "read_model_outbox"
WHERE "delivered_at" < ${input.deliveredBefore}
ORDER BY "id"
ORDER BY "delivered_at", "available_at", "id"
LIMIT ${limit}
FOR UPDATE SKIP LOCKED
)
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,
"controllerProtocol": 2,
"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"]
}
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
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';
const databaseUrl = process.env.DATABASE_URL;
@@ -39,7 +39,7 @@ const verify = async (progress: unknown[]) => {
include: { chunks: { orderBy: { ordinal: 'asc' } } },
});
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(asRecord(steps[0]).kind, 'DECISION_START');
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;