test: NPC 감사 저장량과 조회 비용 probe를 추가한다
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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만 받는다.
|
||||
|
||||
Reference in New Issue
Block a user