test: NPC 감사 저장량과 조회 비용 probe를 추가한다

This commit is contained in:
2026-09-16 09:08:46 +00:00
parent 718cc05d15
commit ef1ba055b5
2 changed files with 122 additions and 0 deletions
@@ -0,0 +1,99 @@
import { writeFile } from 'node:fs/promises';
import { performance } from 'node:perf_hooks';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { persistAuditDecisions } from '../src/playAudit/decisionPersistence.js';
import { buildAuditDecisionFixture } from './fixtures/playAuditDecision.js';
const databaseUrl = process.env.PLAY_AUDIT_COST_DATABASE_URL;
describe.skipIf(!databaseUrl)('decision storage cost probe', () => {
let db: GamePrismaClient;
let close: () => Promise<void>;
beforeAll(async () => {
if (new URL(databaseUrl!).searchParams.get('schema') !== 'play_audit_cost_decision_fixture')
throw new Error('Dedicated audit cost fixture required');
const connector = createGamePostgresConnector({ url: databaseUrl!, maxConnections: 1 });
await connector.connect();
db = connector.prisma;
close = () => connector.disconnect();
await db.playAuditDecisionChunk.deleteMany();
await db.playAuditDecision.deleteMany();
});
afterAll(async () => close?.());
it('measures a batch boundary without truncating stored steps', async () => {
const decisions = Array.from({ length: 201 }, (_, index) => {
const decision = buildAuditDecisionFixture(`cost-${index}`, 'cost-current');
return { ...decision, tick: decision.tick + index };
});
const payloadBytes = Buffer.byteLength(JSON.stringify(decisions));
const start = performance.now();
await db.$transaction((tx) => persistAuditDecisions(tx, decisions), { timeout: 30_000 });
const writeMs = performance.now() - start;
expect(await db.playAuditDecision.count()).toBe(201);
expect(await db.playAuditDecisionChunk.count()).toBe(603);
const [storage] = await db.$queryRaw<
{ headers: bigint; steps: bigint; headerBytes: bigint; chunkBytes: bigint }[]
>`
SELECT
(SELECT count(*) FROM play_audit_decision) AS headers,
(SELECT sum(jsonb_array_length(steps)) FROM play_audit_decision_chunk) 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 relations = await db.$queryRaw<
{ name: string; tableBytes: bigint; indexBytes: bigint; totalBytes: bigint }[]
>`
SELECT relname AS name, pg_table_size(oid) AS "tableBytes",
pg_indexes_size(oid) AS "indexBytes", pg_total_relation_size(oid) AS "totalBytes"
FROM pg_class WHERE relnamespace = current_schema()::regnamespace
AND relname IN ('play_audit_decision', 'play_audit_decision_chunk')
ORDER BY relname
`;
expect(relations).toHaveLength(2);
await db.$executeRawUnsafe('ANALYZE play_audit_decision');
const timings: number[] = [];
for (let sample = 0; sample < 31; sample += 1) {
const queryStart = performance.now();
const rows = await db.playAuditDecision.findMany({
where: { serverId: 'cost-current', generalId: 990321, year: 190, month: 1 },
orderBy: [{ tick: 'desc' }, { id: 'desc' }],
take: 51,
select: { id: true, tick: true, summary: true, stepCount: true },
});
if (sample > 0) timings.push(performance.now() - queryStart);
expect(rows).toHaveLength(51);
expect(rows[0]!.id).toBe('cost-200');
}
timings.sort((a, b) => a - b);
const plan = await db.$queryRaw`
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT id, tick, summary, step_count FROM play_audit_decision
WHERE server_id = 'cost-current' AND general_id = 990321 AND year = 190 AND month = 1
ORDER BY tick DESC, id DESC LIMIT 51
`;
const metrics = {
scope: 'synthetic 201 decisions, 302 steps each; isolated schema, warm local reads',
limitations: 'Not production p95; no gameplay baseline, SQL count, WAL or retained heap measurement. Repetitive synthetic steps compress well. Relation allocation can retain space from previous runs.',
decisions: 201,
steps: Number(storage!.steps),
chunks: 603,
payloadBytes,
headerRowBytes: Number(storage!.headerBytes),
chunkRowBytes: Number(storage!.chunkBytes),
relations: relations.map((row) => ({
name: row.name,
tableBytes: Number(row.tableBytes),
indexBytes: Number(row.indexBytes),
totalBytes: Number(row.totalBytes),
})),
writeMs,
samples: timings.length,
readP50Ms: timings[14],
readP95Ms: timings[28],
plan,
};
await writeFile('/tmp/play-audit-decision-cost.json', JSON.stringify(metrics, null, 2));
}, 60_000);
});
+23
View File
@@ -9,6 +9,29 @@ NPC 결정 trace와 조사 A~F의 완성, 전체 종료 경계 및 COST gate는
## 현재 구현
### NPC 저장 비용 probe
`app/game-engine/test/playAuditDecisionCost.integration.test.ts`는 별도
`PLAY_AUDIT_COST_DATABASE_URL`이 있을 때만 실행한다. schema는 정확히
`play_audit_cost_decision_fixture`여야 하며 그 schema의 결정/chunk를 비운 뒤 측정한다.
운영 URL을 사용하지 않는다. 정식 game migration을 먼저 적용하고 다음 명령을 실행한다.
```sh
pnpm --filter @sammo-ts/game-engine test playAuditDecisionCost.integration.test.ts --no-file-parallelism
```
201개 결정×302step을 실제 persistence로 저장해 200개 batch 경계를 넘긴다.
201header/603chunk/60,702step을 검사하고 JSON bytes, pg_column_size 합계,
테이블·TOAST 포함 할당과 index bytes, 저장 transaction 시간, warm 목록30회
p50/p95와 EXPLAIN ANALYZE BUFFERS를 `/tmp/play-audit-decision-cost.json`에 기록한다.
비밀·payload 본문·DB URL은 출력하지 않는다. 반복 실행에서는 이전 할당 공간이 남을 수 있다.
2026-09-16 격리 PG 결과는 JSON12,271,836B, header행101,304B/chunk행666,924B,
저장677.56ms, 목록51건 p50 0.76ms/p95 1.01ms였다. 월별 장수 index scan으로51행을
읽었다. 반복 RNG 합성 fixture는 압축률이 높으므로 평균 운영 trace 크기의 근거가 아니다.
SQL 왕복·WAL·retained heap, 실제 scenario 계측 전후와 전체 COST gate는 여전히 남는다.
### 정책·외교 사건의 요청 처리 상태
`playAudit.requestState`는 현재 기수의 정책 버전 또는 외교 사건 ID만 받는다.