diff --git a/app/game-engine/test/playAuditDecisionCost.integration.test.ts b/app/game-engine/test/playAuditDecisionCost.integration.test.ts index e04a0ffc..91026408 100644 --- a/app/game-engine/test/playAuditDecisionCost.integration.test.ts +++ b/app/game-engine/test/playAuditDecisionCost.integration.test.ts @@ -1,7 +1,7 @@ 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 { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { persistAuditDecisions } from '../src/playAudit/decisionPersistence.js'; import { buildAuditDecisionFixture } from './fixtures/playAuditDecision.js'; @@ -9,12 +9,26 @@ const databaseUrl = process.env.PLAY_AUDIT_COST_DATABASE_URL; describe.skipIf(!databaseUrl)('decision storage cost probe', () => { let db: GamePrismaClient; let close: () => Promise; + let recording = false; + let statements: Record = {}; 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 }); + const connector = createGamePostgresConnector({ + url: databaseUrl!, + maxConnections: 1, + log: [{ emit: 'event', level: 'query' }], + }); await connector.connect(); db = connector.prisma; + // connector의 반환 타입은 log generic을 지운다. 위에서 활성화한 event만 구독한다. + const observedDb = db as GamePrismaClient; + observedDb.$on('query', (event) => { + if (!recording) return; + // 원문 SQL/params는 보존하거나 출력하지 않고 명령 종류만 집계한다. + const command = event.query.trim().split(/\s+/, 1)[0]!.toUpperCase(); + statements[command] = (statements[command] ?? 0) + 1; + }); close = () => connector.disconnect(); await db.playAuditDecisionChunk.deleteMany(); await db.playAuditDecision.deleteMany(); @@ -27,9 +41,19 @@ describe.skipIf(!databaseUrl)('decision storage cost probe', () => { return { ...decision, tick: decision.tick + index }; }); const payloadBytes = Buffer.byteLength(JSON.stringify(decisions)); + statements = {}; + recording = true; const start = performance.now(); - await db.$transaction((tx) => persistAuditDecisions(tx, decisions), { timeout: 30_000 }); + try { + await db.$transaction((tx) => persistAuditDecisions(tx, decisions), { timeout: 30_000 }); + } finally { + recording = false; + } const writeMs = performance.now() - start; + const writeStatements = { ...statements }; + // 200결정: header1 + chunk3, 나머지1결정: header1 + chunk1. hash 조회는 batch당1. + expect(writeStatements.INSERT).toBe(6); + expect(writeStatements.SELECT).toBe(2); expect(await db.playAuditDecision.count()).toBe(201); expect(await db.playAuditDecisionChunk.count()).toBe(603); const [storage] = await db.$queryRaw< @@ -75,7 +99,7 @@ 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, SQL count, WAL or retained heap measurement. 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, @@ -89,6 +113,7 @@ describe.skipIf(!databaseUrl)('decision storage cost probe', () => { totalBytes: Number(row.totalBytes), })), writeMs, + writeStatements, samples: timings.length, readP50Ms: timings[14], readP95Ms: timings[28], diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index 7e955e83..935f635b 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -29,7 +29,12 @@ p50/p95와 EXPLAIN ANALYZE BUFFERS를 `/tmp/play-audit-decision-cost.json`에 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는 여전히 남는다. +추가 query event 계측에서는 동일201결정의 저장에 INSERT6회(헤더2/chunk4), +SELECT2회(hash 확인), COMMIT1회가 관측됐다. BEGIN 등 driver 내부 통신은 event에 +나타나지 않으므로 네트워크 왕복 전체로 해석하지 않는다. test는 INSERT/SELECT 수를 +assert하며 SQL/params 원문은 저장하지 않는다. 관측60,702단계마다 SQL을 쓰지 않고 batch당 +저장·hash 조회를 확인한 범위다. WAL·retained heap, 실제 scenario 계측 전후와 전체 +COST gate는 여전히 남는다. ### 정책·외교 사건의 요청 처리 상태