감사 상세를 무손실 압축하고 이전 기수 정리 처리량을 개선한다
This commit is contained in:
1 parent
da79013226
commit
078b9aa205
23 files changed
+504
-95
No files matched your search
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user