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