From ef1ba055b5530123805a0df0c87d924efe286ac2 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 16 Sep 2026 09:08:46 +0000 Subject: [PATCH] =?UTF-8?q?test:=20NPC=20=EA=B0=90=EC=82=AC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EB=9F=89=EA=B3=BC=20=EC=A1=B0=ED=9A=8C=20=EB=B9=84?= =?UTF-8?q?=EC=9A=A9=20probe=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../playAuditDecisionCost.integration.test.ts | 99 +++++++++++++++++++ docs/design/play-audit-implementation.md | 23 +++++ 2 files changed, 122 insertions(+) create mode 100644 app/game-engine/test/playAuditDecisionCost.integration.test.ts diff --git a/app/game-engine/test/playAuditDecisionCost.integration.test.ts b/app/game-engine/test/playAuditDecisionCost.integration.test.ts new file mode 100644 index 00000000..e04a0ffc --- /dev/null +++ b/app/game-engine/test/playAuditDecisionCost.integration.test.ts @@ -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; + 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); +}); diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index 41db9452..7e955e83 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -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만 받는다.