test: 감사 저장 batch의 실제 SQL 실행 횟수를 검증한다
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { writeFile } from 'node:fs/promises';
|
import { writeFile } from 'node:fs/promises';
|
||||||
import { performance } from 'node:perf_hooks';
|
import { performance } from 'node:perf_hooks';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
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 { persistAuditDecisions } from '../src/playAudit/decisionPersistence.js';
|
||||||
import { buildAuditDecisionFixture } from './fixtures/playAuditDecision.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', () => {
|
describe.skipIf(!databaseUrl)('decision storage cost probe', () => {
|
||||||
let db: GamePrismaClient;
|
let db: GamePrismaClient;
|
||||||
let close: () => Promise<void>;
|
let close: () => Promise<void>;
|
||||||
|
let recording = false;
|
||||||
|
let statements: Record<string, number> = {};
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
if (new URL(databaseUrl!).searchParams.get('schema') !== 'play_audit_cost_decision_fixture')
|
if (new URL(databaseUrl!).searchParams.get('schema') !== 'play_audit_cost_decision_fixture')
|
||||||
throw new Error('Dedicated audit cost fixture required');
|
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();
|
await connector.connect();
|
||||||
db = connector.prisma;
|
db = connector.prisma;
|
||||||
|
// connector의 반환 타입은 log generic을 지운다. 위에서 활성화한 event만 구독한다.
|
||||||
|
const observedDb = db as GamePrismaClient<GamePrisma.PrismaClientOptions, 'query'>;
|
||||||
|
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();
|
close = () => connector.disconnect();
|
||||||
await db.playAuditDecisionChunk.deleteMany();
|
await db.playAuditDecisionChunk.deleteMany();
|
||||||
await db.playAuditDecision.deleteMany();
|
await db.playAuditDecision.deleteMany();
|
||||||
@@ -27,9 +41,19 @@ describe.skipIf(!databaseUrl)('decision storage cost probe', () => {
|
|||||||
return { ...decision, tick: decision.tick + index };
|
return { ...decision, tick: decision.tick + index };
|
||||||
});
|
});
|
||||||
const payloadBytes = Buffer.byteLength(JSON.stringify(decisions));
|
const payloadBytes = Buffer.byteLength(JSON.stringify(decisions));
|
||||||
|
statements = {};
|
||||||
|
recording = true;
|
||||||
const start = performance.now();
|
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 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.playAuditDecision.count()).toBe(201);
|
||||||
expect(await db.playAuditDecisionChunk.count()).toBe(603);
|
expect(await db.playAuditDecisionChunk.count()).toBe(603);
|
||||||
const [storage] = await db.$queryRaw<
|
const [storage] = await db.$queryRaw<
|
||||||
@@ -75,7 +99,7 @@ describe.skipIf(!databaseUrl)('decision storage cost probe', () => {
|
|||||||
`;
|
`;
|
||||||
const metrics = {
|
const metrics = {
|
||||||
scope: 'synthetic 201 decisions, 302 steps each; isolated schema, warm local reads',
|
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,
|
decisions: 201,
|
||||||
steps: Number(storage!.steps),
|
steps: Number(storage!.steps),
|
||||||
chunks: 603,
|
chunks: 603,
|
||||||
@@ -89,6 +113,7 @@ describe.skipIf(!databaseUrl)('decision storage cost probe', () => {
|
|||||||
totalBytes: Number(row.totalBytes),
|
totalBytes: Number(row.totalBytes),
|
||||||
})),
|
})),
|
||||||
writeMs,
|
writeMs,
|
||||||
|
writeStatements,
|
||||||
samples: timings.length,
|
samples: timings.length,
|
||||||
readP50Ms: timings[14],
|
readP50Ms: timings[14],
|
||||||
readP95Ms: timings[28],
|
readP95Ms: timings[28],
|
||||||
|
|||||||
@@ -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,
|
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행을
|
저장677.56ms, 목록51건 p50 0.76ms/p95 1.01ms였다. 월별 장수 index scan으로51행을
|
||||||
읽었다. 반복 RNG 합성 fixture는 압축률이 높으므로 평균 운영 trace 크기의 근거가 아니다.
|
읽었다. 반복 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는 여전히 남는다.
|
||||||
|
|
||||||
|
|
||||||
### 정책·외교 사건의 요청 처리 상태
|
### 정책·외교 사건의 요청 처리 상태
|
||||||
|
|||||||
Reference in New Issue
Block a user