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

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

+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);