From 7e099326ec33ac5f53baba8f3eae472e64ded837 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 16 Aug 2026 18:31:14 +0000 Subject: [PATCH] =?UTF-8?q?perf:=201200=EC=9E=A5=EC=88=98=20=EC=9A=A9?= =?UTF-8?q?=EB=9F=89=20fixture=EC=99=80=20E1=20profile=EC=9D=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 900 NPC와 300 synthetic 사용자를 5분 턴으로 처리하는 결정론적 engine profile을 추가하고, 전용 PostgreSQL schema와 Redis DB에 300개 인증 session을 준비하는 격리 load fixture를 제공한다. --- app/game-engine/package.json | 1 + .../scripts/profile-npc-capacity-1200.mjs | 45 ++ .../helpers/npcUnificationTimingProfiler.ts | 32 +- .../npcScenarioUnificationBenchmark.test.ts | 111 ++++- tools/load-tests/.gitignore | 2 +- tools/load-tests/README.md | 104 ++++- tools/load-tests/compose.capacity.yml | 42 ++ .../config/300-users-900-npcs-5m.json | 4 +- tools/load-tests/config/load-test.schema.json | 6 +- tools/load-tests/package.json | 3 + tools/load-tests/src/cli.ts | 43 +- tools/load-tests/src/config.ts | 14 +- tools/load-tests/src/fixture.ts | 389 ++++++++++++++++++ tools/load-tests/test/config.test.ts | 3 + tools/load-tests/test/fixture.test.ts | 46 +++ tools/load-tests/tsconfig.json | 2 +- 16 files changed, 808 insertions(+), 39 deletions(-) create mode 100644 app/game-engine/scripts/profile-npc-capacity-1200.mjs create mode 100644 tools/load-tests/compose.capacity.yml create mode 100644 tools/load-tests/src/fixture.ts create mode 100644 tools/load-tests/test/fixture.test.ts diff --git a/app/game-engine/package.json b/app/game-engine/package.json index e6bb87d8..525cb580 100644 --- a/app/game-engine/package.json +++ b/app/game-engine/package.json @@ -99,6 +99,7 @@ "lint:fix": "eslint . --fix", "profile:npc-unification-memory": "node scripts/profile-npc-unification-memory.mjs", "profile:npc-unification-timing": "node scripts/profile-npc-unification-timing.mjs", + "profile:npc-capacity-1200": "node scripts/profile-npc-capacity-1200.mjs", "test": "vitest run --config vitest.config.ts", "typecheck": "pnpm -w tsc7 -b app/game-engine/tsconfig.json" }, diff --git a/app/game-engine/scripts/profile-npc-capacity-1200.mjs b/app/game-engine/scripts/profile-npc-capacity-1200.mjs new file mode 100644 index 00000000..6060799d --- /dev/null +++ b/app/game-engine/scripts/profile-npc-capacity-1200.mjs @@ -0,0 +1,45 @@ +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const vitestPath = path.join(packageRoot, 'node_modules', 'vitest', 'vitest.mjs'); + +const child = spawn( + process.execPath, + [ + '--expose-gc', + vitestPath, + 'run', + '--config', + 'vitest.config.ts', + '--pool=threads', + '--maxWorkers=1', + 'test/npcScenarioUnificationBenchmark.test.ts', + ], + { + cwd: packageRoot, + env: { + ...process.env, + NPC_UNIFICATION_BENCHMARK: '1', + NPC_UNIFICATION_BENCHMARK_PROFILE: '1200-generals-5m', + NPC_UNIFICATION_BENCHMARK_CONVERGENCE_ASSIST: 'none', + NPC_UNIFICATION_BENCHMARK_FIXED_MONTHS: process.env.NPC_UNIFICATION_BENCHMARK_FIXED_MONTHS ?? '1', + }, + stdio: 'inherit', + } +); + +child.once('error', (error) => { + console.error('[npc-capacity-1200] failed to start benchmark', error); + process.exitCode = 1; +}); + +child.once('exit', (code, signal) => { + if (signal) { + console.error(`[npc-capacity-1200] benchmark terminated by ${signal}`); + process.exitCode = 1; + return; + } + process.exitCode = code ?? 1; +}); diff --git a/app/game-engine/test/helpers/npcUnificationTimingProfiler.ts b/app/game-engine/test/helpers/npcUnificationTimingProfiler.ts index 5e9f01fc..64bcf0b9 100644 --- a/app/game-engine/test/helpers/npcUnificationTimingProfiler.ts +++ b/app/game-engine/test/helpers/npcUnificationTimingProfiler.ts @@ -54,6 +54,7 @@ const summarizeSeries = (series: DurationSeries) => ({ const monthKey = (year: number, month: number): string => `${year}-${String(month).padStart(2, '0')}`; export class NpcUnificationTimingProfiler { + private readonly allTurnSeries = createSeries(); private readonly commandSeries = new Map(); private readonly commandAiSeries = new Map(); private readonly decisionSeries = new Map<'chief' | 'ordinary', DurationSeries>([ @@ -120,6 +121,8 @@ export class NpcUnificationTimingProfiler { observeGeneralTurn(input: { year: number; month: number; officerLevel: number; durationNs: bigint }): void { const durationNs = Number(input.durationNs); + this.allTurnSeries.durationsNs.push(durationNs); + this.allTurnSeries.totalNs += durationNs; const officerGroup = input.officerLevel >= 5 ? 'chief' : 'ordinary'; const series = this.turnSeries.get(officerGroup)!; series.durationsNs.push(durationNs); @@ -165,6 +168,14 @@ export class NpcUnificationTimingProfiler { unificationReached: boolean; convergenceAssist: string; discardedDrafts: { logs: number; messages: number; neutralAuctions: number }; + capacity?: { + profile: string; + expectedNpcGenerals: number; + expectedHumanGenerals: number; + turnMinutes: number; + fixedMonths: number; + finalStateSha256: string; + }; }) { const commandKeys = Array.from(this.commandSeries.keys()).sort(); const commands = commandKeys.map((key) => ({ @@ -199,6 +210,12 @@ export class NpcUnificationTimingProfiler { const startIndex = input.startYear * 12 + input.startMonth - 1; const finalIndex = input.finalYear * 12 + input.finalMonth - 1; + const wallDurationMs = Number(process.hrtime.bigint() - input.startedAtNs) / 1_000_000; + const monthWallSeries = { + durationsNs: Array.from(this.monthWallMs.values(), (value) => value * 1_000_000), + totalNs: Array.from(this.monthWallMs.values()).reduce((total, value) => total + value * 1_000_000, 0), + }; + const capacityWindowMs = monthWallSeries.totalNs / 1_000_000; return { schemaVersion: 1, runtime: { @@ -227,7 +244,7 @@ export class NpcUnificationTimingProfiler { finalGeneralCount: input.finalGeneralCount, foundedNationCount: input.foundedNationCount, finalNationCount: input.finalNationCount, - wallDurationMs: Number(process.hrtime.bigint() - input.startedAtNs) / 1_000_000, + wallDurationMs, discardedDrafts: input.discardedDrafts, }, npcDecisionByOfficerGroup: { @@ -238,6 +255,19 @@ export class NpcUnificationTimingProfiler { chief: summarizeSeries(this.turnSeries.get('chief')!), ordinary: summarizeSeries(this.turnSeries.get('ordinary')!), }, + ...(input.capacity + ? { + capacity: { + ...input.capacity, + generalTurns: summarizeSeries(this.allTurnSeries), + monthWall: summarizeSeries(monthWallSeries), + generalTurnsPerSecond: + capacityWindowMs > 0 + ? this.allTurnSeries.durationsNs.length / (capacityWindowMs / 1_000) + : 0, + }, + } + : {}), memory: { maxObservedHeapUsedBytes: this.maxHeapUsedBytes, maxObservedRssBytes: this.maxRssBytes, diff --git a/app/game-engine/test/npcScenarioUnificationBenchmark.test.ts b/app/game-engine/test/npcScenarioUnificationBenchmark.test.ts index b0bcb7ba..bb45ad4e 100644 --- a/app/game-engine/test/npcScenarioUnificationBenchmark.test.ts +++ b/app/game-engine/test/npcScenarioUnificationBenchmark.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; @@ -18,7 +19,65 @@ const benchmarkEnabled = process.env.NPC_UNIFICATION_BENCHMARK === '1'; const benchmarkDescribe = describe.runIf(benchmarkEnabled); const SCENARIO_ID = 2601; const HIDDEN_SEED = 'scenario-2601-npc-unification-benchmark-v1'; -const TURN_MINUTES = 10; +const CAPACITY_PROFILE = '1200-generals-5m'; + +const canonicalize = (value: unknown): string => { + if (value instanceof Date) return JSON.stringify(value.toISOString()); + if (typeof value === 'bigint') return JSON.stringify(value.toString()); + if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`; + if (value && typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalize(record[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +}; + +const hashWorldState = (world: InMemoryTurnWorld): string => + createHash('sha256') + .update( + canonicalize({ + state: world.getState(), + generals: world.listGenerals().sort((left, right) => left.id - right.id), + cities: world.listCities().sort((left, right) => left.id - right.id), + nations: world.listNations().sort((left, right) => left.id - right.id), + }) + ) + .digest('hex'); + +const buildCapacityGenerals = ( + sourceGenerals: readonly TurnGeneral[], + npcCount: number, + humanCount: number +): TurnGeneral[] => { + if (sourceGenerals.length === 0) throw new Error('capacity benchmark requires scenario generals'); + const ordered = [...sourceGenerals].sort((left, right) => left.id - right.id); + const clone = (source: TurnGeneral, id: number, humanIndex: number | null): TurnGeneral => ({ + ...source, + id, + name: id === source.id ? source.name : `${source.name}#L${id}`, + userId: humanIndex === null ? null : `load-user-${String(humanIndex + 1).padStart(4, '0')}`, + npcState: humanIndex === null ? Math.max(2, source.npcState) : 0, + turnTime: new Date(source.turnTime), + recentWarTime: source.recentWarTime ? new Date(source.recentWarTime) : null, + lastTurn: source.lastTurn ? { ...source.lastTurn } : undefined, + meta: { ...source.meta }, + penalty: source.penalty && typeof source.penalty === 'object' ? { ...source.penalty } : source.penalty, + inheritancePoints: source.inheritancePoints ? { ...source.inheritancePoints } : undefined, + }); + const result: TurnGeneral[] = []; + for (let index = 0; index < npcCount; index += 1) { + const source = ordered[index % ordered.length]!; + result.push(clone(source, index + 1, null)); + } + for (let index = 0; index < humanCount; index += 1) { + const source = ordered[(npcCount + index) % ordered.length]!; + result.push(clone(source, npcCount + index + 1, index)); + } + return result; +}; const createGameDate = (year: number, month: number): Date => { const date = new Date(0); @@ -117,6 +176,9 @@ benchmarkDescribe('scenario 2601 NPC 완전 인메모리 천통 벤치마크', ( const unitSet = await loadUnitSetDefinitionByName(scenario.config.environment.unitSet); const startYear = scenario.startYear ?? 180; const startMonth = 1; + const capacityProfile = process.env.NPC_UNIFICATION_BENCHMARK_PROFILE ?? ''; + const capacityMode = capacityProfile === CAPACITY_PROFILE; + const turnMinutes = capacityMode ? 5 : 10; const startTime = createGameDate(startYear, startMonth); const bootstrap = buildScenarioBootstrap({ scenario, @@ -126,7 +188,7 @@ benchmarkDescribe('scenario 2601 NPC 완전 인메모리 천통 벤치마크', ( hiddenSeed: HIDDEN_SEED, initialYear: startYear, initialMonth: startMonth, - turnTermMinutes: TURN_MINUTES, + turnTermMinutes: turnMinutes, includeNeutralNationInSeed: true, }, }); @@ -136,11 +198,16 @@ benchmarkDescribe('scenario 2601 NPC 완전 인메모리 천통 벤치마크', ( buildTurnCity ); const domainGeneralById = new Map(bootstrap.snapshot.generals.map((general) => [general.id, general])); - const generals = bootstrap.seed.generals.map((seedGeneral) => { + const scenarioGenerals = bootstrap.seed.generals.map((seedGeneral) => { const domainGeneral = domainGeneralById.get(seedGeneral.id); if (!domainGeneral) throw new Error(`missing domain general ${seedGeneral.id}`); return buildTurnGeneral(domainGeneral, seedGeneral, startTime, startYear, startMonth); }); + const expectedNpcGenerals = capacityMode ? 900 : scenarioGenerals.length; + const expectedHumanGenerals = capacityMode ? 300 : 0; + const generals = capacityMode + ? buildCapacityGenerals(scenarioGenerals, expectedNpcGenerals, expectedHumanGenerals) + : scenarioGenerals; const snapshot: TurnWorldSnapshot = { scenarioConfig: bootstrap.snapshot.scenarioConfig, @@ -148,7 +215,7 @@ benchmarkDescribe('scenario 2601 NPC 완전 인메모리 천통 벤치마크', ( worldConfig: { fiction: scenario.fiction, npcMode: 2, - turnTermMinutes: TURN_MINUTES, + turnTermMinutes: turnMinutes, tournamentTrig: false, }, map, @@ -174,7 +241,7 @@ benchmarkDescribe('scenario 2601 NPC 완전 인메모리 천통 벤치마크', ( id: 1, currentYear: startYear, currentMonth: startMonth, - tickSeconds: TURN_MINUTES * 60, + tickSeconds: turnMinutes * 60, lastTurnTime: startTime, clockBaseTime: startTime, clockTick: 0, @@ -189,7 +256,7 @@ benchmarkDescribe('scenario 2601 NPC 완전 인메모리 천통 벤치마크', ( initYear: startYear, initMonth: startMonth, fiction: scenario.fiction, - killturn: 4800 / TURN_MINUTES, + killturn: 4800 / turnMinutes, develcost: 20, isUnited: 0, isunited: 0, @@ -198,7 +265,7 @@ benchmarkDescribe('scenario 2601 NPC 완전 인메모리 천통 벤치마크', ( serverId: 'benchmark-scenario-2601', }, }; - const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: TURN_MINUTES }] }; + const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: turnMinutes }] }; const worldRef = { current: null as InMemoryTurnWorld | null }; const profiler = new NpcUnificationTimingProfiler(); const turnStartedAt = new Map(); @@ -220,7 +287,7 @@ benchmarkDescribe('scenario 2601 NPC 완전 인메모리 천통 벤치마크', ( extraCalendarHandlers: [unification.handler], onActionProfiled: (payload) => profiler.observeAction(payload), turnProcessorOptions: { - tickMinutes: TURN_MINUTES, + tickMinutes: turnMinutes, beforeExecuteGeneral: async (general) => { turnStartedAt.set(general.id, process.hrtime.bigint()); }, @@ -241,6 +308,8 @@ benchmarkDescribe('scenario 2601 NPC 완전 인메모리 천통 벤치마크', ( }); const maximumYear = Number(process.env.NPC_UNIFICATION_BENCHMARK_MAX_YEAR ?? 300); + const fixedCapacityMonths = Number(process.env.NPC_UNIFICATION_BENCHMARK_FIXED_MONTHS ?? 1); + let simulatedCapacityMonths = 0; while (true) { const before = harness.world.getState(); const monthStartedAtNs = process.hrtime.bigint(); @@ -263,8 +332,10 @@ benchmarkDescribe('scenario 2601 NPC 완전 인메모리 천통 벤치마크', ( harness.reservedTurnStore.acknowledgeDirtyState(reservedChanges); const current = harness.world.getState(); + simulatedCapacityMonths += 1; const meta = current.meta as Record; if ((meta.isUnited ?? meta.isunited ?? 0) !== 0) break; + if (capacityMode && simulatedCapacityMonths >= fixedCapacityMonths) break; if (current.currentYear >= maximumYear) break; } @@ -289,6 +360,18 @@ benchmarkDescribe('scenario 2601 NPC 완전 인메모리 천통 벤치마크', ( unificationReached, convergenceAssist, discardedDrafts, + ...(capacityMode + ? { + capacity: { + profile: capacityProfile, + expectedNpcGenerals, + expectedHumanGenerals, + turnMinutes, + fixedMonths: fixedCapacityMonths, + finalStateSha256: hashWorldState(harness.world), + }, + } + : {}), }); const reportPath = resolve( process.env.NPC_UNIFICATION_BENCHMARK_REPORT_PATH ?? 'test-results/npc-scenario-unification-benchmark.json' @@ -306,8 +389,16 @@ benchmarkDescribe('scenario 2601 NPC 완전 인메모리 천통 벤치마크', ( })}` ); - expect(generals.length).toBeGreaterThanOrEqual(600); + if (capacityMode) { + expect(generals).toHaveLength(1_200); + expect(generals.filter((general) => general.npcState >= 2)).toHaveLength(900); + expect(generals.filter((general) => general.npcState === 0 && general.userId)).toHaveLength(300); + expect(report.capacity?.generalTurns.count).toBeGreaterThanOrEqual(1_200); + expect(report.capacity?.finalStateSha256).toMatch(/^[a-f0-9]{64}$/u); + } else { + expect(generals.length).toBeGreaterThanOrEqual(600); + expect(unificationReached).toBe(true); + } expect(cities.length).toBeGreaterThanOrEqual(90); - expect(unificationReached).toBe(true); }, 1_800_000); }); diff --git a/tools/load-tests/.gitignore b/tools/load-tests/.gitignore index 5bd280dd..0e46da19 100644 --- a/tools/load-tests/.gitignore +++ b/tools/load-tests/.gitignore @@ -1,4 +1,4 @@ -secrets/*.json +secrets/* results/*.json !secrets/.gitkeep !results/.gitkeep diff --git a/tools/load-tests/README.md b/tools/load-tests/README.md index 847902bf..a89ce096 100644 --- a/tools/load-tests/README.md +++ b/tools/load-tests/README.md @@ -15,6 +15,10 @@ unchanged/snapshot/patch 경로를 구분하며 raw JSON에는 종류별 count guard를 우회하는 CLI flag는 없다. - 전용 PostgreSQL schema는 `load_`로, Redis prefix는 `load-tests:`로 시작해야 한다. fixture/runtime을 기동하는 외부 orchestration에도 같은 값을 주어 공유 개발·운영 profile과 분리한다. +- fixture CLI는 config와 DB URL의 schema가 정확히 같고, Redis DB가 config의 전용 `1..15` DB와 정확히 + 같을 때만 동작한다. 둘 다 loopback/private host만 허용한다. cleanup은 전용 Redis manifest와 schema명을 + 다시 확인한 뒤 그 `load_` schema와 해당 profile의 access token만 지운다. Redis `FLUSHDB`와 공유 schema + 삭제는 하지 않는다. - driver는 query만 허용한다. own/global phase 이름은 invalidation 뒤 viewer read fan-out을 뜻하며 mutation을 만들지 않는다. 실제 own/global change stimulus는 격리 runtime에서 별도 orchestration으로 발생시킨다. - token 파일은 이 workspace 안의 Git ignored path여야 하고 정확히 `0600`이어야 한다. 권장 위치는 @@ -24,13 +28,64 @@ unchanged/snapshot/patch 경로를 구분하며 raw JSON에는 종류별 count ## 재현 명령 -먼저 sample의 `runtimeMetadata` placeholder를 실제 fixture SHA-256, image digest, PostgreSQL/Redis -version으로 바꾼 복사본을 만든다. secret이나 ID를 config에 넣지 않는다. +### 1. 격리 PostgreSQL/Redis와 1,200장수 fixture + +아래 Compose는 loopback에만 포트를 열고 PostgreSQL 18.4, Redis 8.2.7을 고정한다. 실제 password와 URL은 +Git ignored `secrets/`에 두며 명령행이나 결과 JSON에는 기록하지 않는다. `capacity.env`에는 최소 +`LOAD_TEST_DATABASE_URL`(query의 `schema=load_capacity_300_900_5m` 포함), +`LOAD_TEST_REDIS_URL`(`/15` 포함), API 기동에 필요한 `GAME_TOKEN_SECRET`, +`GAME_IMAGE_UPLOAD_SECRET_FILE`을 넣는다. URL의 password는 percent-encoding한다. ```sh -install -m 600 /dev/null tools/load-tests/secrets/game-tokens.json -# 편집기로 300개 synthetic game bearer token을 tokens 배열에 입력 +install -m 600 /dev/null tools/load-tests/secrets/postgres-password.txt +install -m 600 /dev/null tools/load-tests/secrets/capacity.env +# 두 파일은 로컬 편집기로 채우고 내용을 stdout에 출력하지 않는다. +docker compose -f tools/load-tests/compose.capacity.yml config --quiet +docker compose -f tools/load-tests/compose.capacity.yml up -d --wait + +set -a +source tools/load-tests/secrets/capacity.env +set +a + +pnpm --filter @sammo-ts/load-tests seed \ + --config tools/load-tests/config/300-users-900-npcs-5m.json \ + --tokens tools/load-tests/secrets/game-tokens.json +pnpm --filter @sammo-ts/load-tests verify-fixture \ + --config tools/load-tests/config/300-users-900-npcs-5m.json +``` + +`seed`는 해당 `load_` schema에 migration을 적용하고 scenario 2601을 고정 seed/time으로 설치한 뒤 정확히 +900 NPC + 300 synthetic 사용자 장수로 재구성한다. 각 사용자의 24시간 access token은 Redis 전용 DB와 +새 `0600` JSON에만 저장한다. stdout에는 token, user/general ID, DB/Redis URL을 내보내지 않고 count와 +비밀값을 제외한 fixture SHA-256만 기록한다. token 파일이 이미 있으면 DB 작업 전에 실패한다. + +fixture와 같은 환경으로 API를 띄울 때 핵심 namespace는 다음과 같다. `capacity.env` 값을 다시 명령행에 +풀어 쓰지 않는다. + +```sh +export DATABASE_URL="$LOAD_TEST_DATABASE_URL" +export REDIS_URL="$LOAD_TEST_REDIS_URL" +export PROFILE=load_capacity_300_900_5m +export SCENARIO=2601 +export GAME_PROFILE_NAME=load-tests:capacity-300-900-5m +export GAME_API_HOST=127.0.0.1 +export GAME_API_PORT=15001 +export GAME_TRPC_PATH=/api/trpc +export GAME_API_EVENTS_PATH=/events +pnpm --filter @sammo-ts/game-api start +``` + +API와 driver는 별도 terminal/process로 실행한다. dev-sam2026와 같은 판정이 필요하면 API/engine container에 +4 CPU/8 GiB 제한을 주고 driver는 그 cgroup 밖에서 실행하며, image digest를 아래 run config에 기록한다. +공개 `dev-sam2026.hided.net` profile에는 이 fixture나 driver를 연결하지 않는다. + +### 2. 인증 HTTP/SSE driver + +먼저 sample의 `runtimeMetadata` placeholder를 실제 fixture SHA-256, image digest, PostgreSQL/Redis +version으로 바꾼 ignored 복사본을 만든다. secret이나 ID를 config에 넣지 않는다. + +```sh pnpm --filter @sammo-ts/load-tests validate --config tools/load-tests/config/300-users-900-npcs-5m.json pnpm --filter @sammo-ts/load-tests dry-run \ --config tools/load-tests/config/300-users-900-npcs-5m.json \ @@ -53,19 +108,42 @@ fixture/image/PostgreSQL/Redis metadata, phase별 metric을 포함한다. 이 아니다. `runtimeMetadata` placeholder가 남은 run과 외부 stimulus가 없던 own/global phase를 capacity pass로 보고하지 않는다. -E1의 DB-free 자연 통일 계산은 기존 실행 가능한 benchmark를 그대로 사용한다. +### 3. E1 결정론적 engine capacity profile + +E1은 DB/Redis 없이 scenario 2601을 정확히 900 NPC + 300 synthetic 사용자 장수로 확장해 5분 턴 한 달을 +실행한다. report에는 정확한 초기 count, 전체 장수턴 처리량, 장수턴 및 월 wall-time p50/p95/p99/max, +RSS/heap과 최종 상태 SHA-256이 들어간다. 같은 commit/Node/fixture에서 상태 hash가 같아야 한다. ```sh -NPC_UNIFICATION_BENCHMARK_CONVERGENCE_ASSIST=none \ -NPC_UNIFICATION_BENCHMARK_MAX_YEAR=300 \ -NPC_UNIFICATION_BENCHMARK_REPORT_PATH=/dev/shm/npc-unification.json \ -pnpm --filter @sammo-ts/game-engine profile:npc-unification-timing +NPC_UNIFICATION_BENCHMARK_FIXED_MONTHS=1 \ +NPC_UNIFICATION_BENCHMARK_REPORT_PATH=/dev/shm/npc-capacity-1200.json \ +pnpm --filter @sammo-ts/game-engine profile:npc-capacity-1200 ``` -현재 E1 command의 기본 fixture는 문서상 880 NPC/10분 턴이므로 900 NPC/5분 또는 총 1,200장수 E1이라고 -바꿔 부르지 않는다. E2는 실제 daemon fast-forward, PostgreSQL flush, Redis publish와 schedule-lag/DB -statement 계측을 한 lifecycle로 묶는 안전한 fixture API가 아직 없어 stub을 추가하지 않았다. 따라서 이 -package 단독 실행은 E2나 M1 전체 합격 근거가 아니다. +이 프로필은 자연 통일 소요시간 시험이 아니라 고정 1개월 engine 처리량 시험이다. 기존 +`profile:npc-unification-timing`의 무보정 자연 진행 의미는 바꾸지 않는다. + +### 4. 명시적 cleanup + +token 파일은 별도로 안전하게 삭제하고, fixture schema/Redis token은 schema명을 그대로 확인 인자로 주어 +정리한다. named volume은 보존한다. 데이터 폐기가 필요하지 않으면 이 명령을 실행하지 않는다. + +```sh +pnpm --filter @sammo-ts/load-tests cleanup \ + --config tools/load-tests/config/300-users-900-npcs-5m.json \ + --confirm load_capacity_300_900_5m +docker compose -f tools/load-tests/compose.capacity.yml down +``` + +## 아직 남은 측정 경계 + +- `seed`/`verify-fixture`는 실제 PostgreSQL schema와 Redis access-token 상태를 만든다. 그러나 E2의 daemon + fast-forward, 한 달치 PostgreSQL flush/outbox publish, schedule lag와 DB statement count를 하나로 + 계측하는 실행기는 아직 없다. 따라서 E1이나 API/SSE driver 결과를 E2 합격으로 대체하지 않는다. +- own/global phase의 mutation stimulus는 driver가 만들지 않는다. 격리 runtime의 실제 engine/API mutation과 + 함께 실행하지 않았다면 A2/A3/M1 전체 합격으로 보고하지 않는다. +- 이 repository에서 실행한 로컬 E1 수치는 source-tree 회귀 근거다. dev-sam2026 동급 4 CPU/8 GiB container + 결과로 부르려면 동일 image/fixture와 cgroup에서 다시 측정해야 한다. ## 도구 자체 검증 diff --git a/tools/load-tests/compose.capacity.yml b/tools/load-tests/compose.capacity.yml new file mode 100644 index 00000000..feb2448c --- /dev/null +++ b/tools/load-tests/compose.capacity.yml @@ -0,0 +1,42 @@ +name: sammo-capacity-fixture + +services: + postgres: + image: postgres:18.4-bookworm + environment: + POSTGRES_DB: sammo_capacity + POSTGRES_USER: sammo_capacity + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password + secrets: + - postgres_password + ports: + - "127.0.0.1:${CAPACITY_POSTGRES_PORT:-15442}:5432" + volumes: + # PostgreSQL 18 declares the versioned data directory below this root. + - capacity_postgres_data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U sammo_capacity -d sammo_capacity"] + interval: 2s + timeout: 3s + retries: 30 + restart: "no" + + redis: + image: redis:8.2.7-bookworm + command: ["redis-server", "--save", "", "--appendonly", "no", "--databases", "16"] + ports: + - "127.0.0.1:${CAPACITY_REDIS_PORT:-16379}:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 3s + retries: 30 + restart: "no" + +secrets: + postgres_password: + file: ./secrets/postgres-password.txt + +volumes: + capacity_postgres_data: + name: sammo_capacity_fixture_postgres_data diff --git a/tools/load-tests/config/300-users-900-npcs-5m.json b/tools/load-tests/config/300-users-900-npcs-5m.json index 92c18103..8399cc08 100644 --- a/tools/load-tests/config/300-users-900-npcs-5m.json +++ b/tools/load-tests/config/300-users-900-npcs-5m.json @@ -11,7 +11,9 @@ }, "isolation": { "postgresSchema": "load_capacity_300_900_5m", - "redisPrefix": "load-tests:capacity-300-900-5m:" + "redisPrefix": "load-tests:capacity-300-900-5m:", + "redisDatabase": 15, + "profileName": "load-tests:capacity-300-900-5m" }, "capacity": { "authenticatedViewers": 300, diff --git a/tools/load-tests/config/load-test.schema.json b/tools/load-tests/config/load-test.schema.json index 2ca4606b..ab580533 100644 --- a/tools/load-tests/config/load-test.schema.json +++ b/tools/load-tests/config/load-test.schema.json @@ -29,10 +29,12 @@ "isolation": { "type": "object", "additionalProperties": false, - "required": ["postgresSchema", "redisPrefix"], + "required": ["postgresSchema", "redisPrefix", "redisDatabase", "profileName"], "properties": { "postgresSchema": { "type": "string", "pattern": "^load_[a-z0-9_]+$" }, - "redisPrefix": { "type": "string", "pattern": "^load-tests:[a-z0-9:_-]+:$" } + "redisPrefix": { "type": "string", "pattern": "^load-tests:[a-z0-9:_-]+:$" }, + "redisDatabase": { "type": "integer", "minimum": 1, "maximum": 15 }, + "profileName": { "type": "string", "pattern": "^load-tests:[a-z0-9:_-]+$" } } }, "capacity": { diff --git a/tools/load-tests/package.json b/tools/load-tests/package.json index 02572403..23349a3c 100644 --- a/tools/load-tests/package.json +++ b/tools/load-tests/package.json @@ -7,6 +7,9 @@ "run": "pnpm -w exec tsx tools/load-tests/src/cli.ts run", "dry-run": "pnpm -w exec tsx tools/load-tests/src/cli.ts dry-run", "validate": "pnpm -w exec tsx tools/load-tests/src/cli.ts validate", + "seed": "pnpm -w exec tsx tools/load-tests/src/cli.ts seed", + "verify-fixture": "pnpm -w exec tsx tools/load-tests/src/cli.ts verify-fixture", + "cleanup": "pnpm -w exec tsx tools/load-tests/src/cli.ts cleanup", "test": "pnpm -w exec tsx --test tools/load-tests/test/*.test.ts", "typecheck": "pnpm -w tsc7 -p tools/load-tests/tsconfig.json --noEmit" } diff --git a/tools/load-tests/src/cli.ts b/tools/load-tests/src/cli.ts index 7a5d5bf2..12d40417 100644 --- a/tools/load-tests/src/cli.ts +++ b/tools/load-tests/src/cli.ts @@ -2,35 +2,41 @@ import { mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { assertRuntimeMetadataFinalized, loadConfig, loadTokens } from './config.js'; +import { cleanupCapacityFixture, seedCapacityFixture, verifyCapacityFixture } from './fixture.js'; import { describeDryRun, runLoadTest } from './runner.js'; -type Command = 'run' | 'dry-run' | 'validate'; +type Command = 'run' | 'dry-run' | 'validate' | 'seed' | 'verify-fixture' | 'cleanup'; const usage = (): never => { - process.stderr.write('usage: cli.ts --config [--tokens <0600-gitignored-file>] [--output ]\n'); + process.stderr.write('usage: cli.ts --config [--tokens <0600-gitignored-file>] [--output ] [--confirm ]\n'); process.exit(64); }; -const parseArguments = (argv: readonly string[]): { command: Command; config: string; tokens?: string; output?: string } => { +const parseArguments = (argv: readonly string[]): { command: Command; config: string; tokens?: string; output?: string; confirm?: string } => { const command = argv[0]; - if (!['run', 'dry-run', 'validate'].includes(command ?? '')) usage(); + if (!['run', 'dry-run', 'validate', 'seed', 'verify-fixture', 'cleanup'].includes(command ?? '')) usage(); const values = new Map(); for (let index = 1; index < argv.length; index += 2) { const flag = argv[index]; const value = argv[index + 1]; - if (!flag || !['--config', '--tokens', '--output'].includes(flag) || !value) usage(); + if (!flag || !['--config', '--tokens', '--output', '--confirm'].includes(flag) || !value) usage(); values.set(flag, value); } const config = values.get('--config'); if (!config) usage(); if (command === 'run' && (!values.get('--tokens') || !values.get('--output'))) usage(); - if (command === 'validate' && (values.has('--tokens') || values.has('--output'))) usage(); - if (command === 'dry-run' && values.has('--output')) usage(); + if (command === 'seed' && (!values.get('--tokens') || values.has('--output') || values.has('--confirm'))) usage(); + if (command === 'cleanup' && (!values.get('--confirm') || values.has('--tokens') || values.has('--output'))) usage(); + if (command === 'verify-fixture' && (values.has('--tokens') || values.has('--output') || values.has('--confirm'))) usage(); + if (command === 'validate' && (values.has('--tokens') || values.has('--output') || values.has('--confirm'))) usage(); + if (command === 'dry-run' && (values.has('--output') || values.has('--confirm'))) usage(); + if (command === 'run' && values.has('--confirm')) usage(); return { command: command as Command, config: config!, ...(values.get('--tokens') ? { tokens: values.get('--tokens')! } : {}), ...(values.get('--output') ? { output: values.get('--output')! } : {}), + ...(values.get('--confirm') ? { confirm: values.get('--confirm')! } : {}), }; }; @@ -42,6 +48,23 @@ const main = async (): Promise => { process.stdout.write(`${JSON.stringify({ valid: true, name: config.name, configSha256: sha256 })}\n`); return; } + if (args.command === 'seed') { + const result = await seedCapacityFixture({ + config, + tokenPath: args.tokens!, + workspaceRoot, + }); + process.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + if (args.command === 'verify-fixture') { + process.stdout.write(`${JSON.stringify(await verifyCapacityFixture(config))}\n`); + return; + } + if (args.command === 'cleanup') { + process.stdout.write(`${JSON.stringify(await cleanupCapacityFixture(config, args.confirm!))}\n`); + return; + } const tokens = args.tokens ? await loadTokens(args.tokens, workspaceRoot, config.capacity.authenticatedViewers) : null; if (args.command === 'dry-run') { process.stdout.write(`${JSON.stringify({ valid: true, tokenFileValidated: tokens !== null, configSha256: sha256, plan: describeDryRun(config) }, null, 2)}\n`); @@ -57,7 +80,11 @@ const main = async (): Promise => { }; main().catch((error: unknown) => { - const message = error instanceof Error ? error.message : 'unknown load-test error'; + let message = error instanceof Error ? error.message : 'unknown load-test error'; + for (const secret of [process.env.LOAD_TEST_DATABASE_URL, process.env.LOAD_TEST_REDIS_URL]) { + if (secret) message = message.replaceAll(secret, '[redacted-url]'); + } + message = message.replace(/(?:postgres(?:ql)?|rediss?):\/\/[^\s]+/giu, '[redacted-url]'); process.stderr.write(`${message}\n`); process.exitCode = 1; }); diff --git a/tools/load-tests/src/config.ts b/tools/load-tests/src/config.ts index bb4c8160..0ecd6791 100644 --- a/tools/load-tests/src/config.ts +++ b/tools/load-tests/src/config.ts @@ -39,6 +39,8 @@ export interface LoadConfig { isolation: { postgresSchema: string; redisPrefix: string; + redisDatabase: number; + profileName: string; }; capacity: { authenticatedViewers: number; @@ -64,7 +66,7 @@ const integerAtLeast = (value: unknown, minimum: number): boolean => const hasOnlyKeys = (value: Record, allowed: readonly string[]): boolean => Object.keys(value).every((key) => allowed.includes(key)); -const isPrivateTargetHost = (hostname: string): boolean => { +export const isPrivateTargetHost = (hostname: string): boolean => { const normalized = hostname.toLowerCase().replace(/^\[|\]$/gu, ''); if (normalized === 'localhost' || normalized === '::1' || normalized.endsWith('.localhost')) return true; if (normalized.endsWith('.internal') || normalized.endsWith('.local')) return true; @@ -116,13 +118,21 @@ export const validateLoadConfig = (raw: unknown): LoadConfig => { if (!isRecord(isolation)) { issues.push('isolation must be an object'); } else { - if (!hasOnlyKeys(isolation, ['postgresSchema', 'redisPrefix'])) issues.push('isolation contains unknown fields'); + if (!hasOnlyKeys(isolation, ['postgresSchema', 'redisPrefix', 'redisDatabase', 'profileName'])) issues.push('isolation contains unknown fields'); if (typeof isolation.postgresSchema !== 'string' || !/^load_[a-z0-9_]+$/u.test(isolation.postgresSchema)) { issues.push('isolation.postgresSchema must start with load_'); } if (typeof isolation.redisPrefix !== 'string' || !/^load-tests:[a-z0-9:_-]+:$/u.test(isolation.redisPrefix)) { issues.push('isolation.redisPrefix must be load-tests scoped and end with a colon'); } + if (!integerAtLeast(isolation.redisDatabase, 1) || Number(isolation.redisDatabase) > 15) { + issues.push('isolation.redisDatabase must be a dedicated Redis database in the range 1..15'); + } + if (typeof isolation.profileName !== 'string' || !/^load-tests:[a-z0-9:_-]+$/u.test(isolation.profileName)) { + issues.push('isolation.profileName must be load-tests scoped'); + } else if (typeof isolation.redisPrefix === 'string' && `${isolation.profileName}:` !== isolation.redisPrefix) { + issues.push('isolation.profileName must match isolation.redisPrefix without the trailing colon'); + } } const capacity = raw.capacity; diff --git a/tools/load-tests/src/fixture.ts b/tools/load-tests/src/fixture.ts new file mode 100644 index 00000000..07fd3e1c --- /dev/null +++ b/tools/load-tests/src/fixture.ts @@ -0,0 +1,389 @@ +import { execFile } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { lstat, mkdir, open, realpath } from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +import { seedScenarioToDatabase } from '@sammo-ts/game-engine'; +import { + createGamePostgresConnector, + createRedisConnector, + type GamePrisma, + type GamePrismaClient, +} from '@sammo-ts/infra'; + +import { canonicalJson, isPrivateTargetHost, sha256, type LoadConfig } from './config.js'; + +const execFileAsync = promisify(execFile); +const FIXED_NOW = new Date('2026-08-16T00:00:00.000Z'); +const SCENARIO_ID = 2601; + +type FixtureEnvironment = { databaseUrl: string; redisUrl: string }; + +const requireEnvironment = (env: NodeJS.ProcessEnv): FixtureEnvironment => { + const databaseUrl = env.LOAD_TEST_DATABASE_URL; + const redisUrl = env.LOAD_TEST_REDIS_URL; + if (!databaseUrl || !redisUrl) { + throw new Error('LOAD_TEST_DATABASE_URL and LOAD_TEST_REDIS_URL are required'); + } + return { databaseUrl, redisUrl }; +}; + +const assertPrivateUrl = (value: string, protocols: readonly string[], label: string): URL => { + const parsed = new URL(value); + if (!protocols.includes(parsed.protocol)) throw new Error(`${label} uses an unsupported protocol`); + if (!isPrivateTargetHost(parsed.hostname)) throw new Error(`${label} must use a loopback or private/internal host`); + return parsed; +}; + +export const assertFixtureIsolation = (config: LoadConfig, env: FixtureEnvironment): void => { + const database = assertPrivateUrl(env.databaseUrl, ['postgres:', 'postgresql:'], 'LOAD_TEST_DATABASE_URL'); + if (database.searchParams.get('schema') !== config.isolation.postgresSchema) { + throw new Error('LOAD_TEST_DATABASE_URL schema must exactly match isolation.postgresSchema'); + } + const redis = assertPrivateUrl(env.redisUrl, ['redis:', 'rediss:'], 'LOAD_TEST_REDIS_URL'); + const redisDatabase = Number(redis.pathname.replace(/^\//u, '') || '0'); + if (redisDatabase !== config.isolation.redisDatabase) { + throw new Error('LOAD_TEST_REDIS_URL database must exactly match isolation.redisDatabase'); + } +}; + +const accessKeyPrefix = (config: LoadConfig): string => `sammo:game:access:${config.isolation.profileName}:`; +const manifestKey = (config: LoadConfig): string => `${config.isolation.redisPrefix}fixture-manifest`; + +const deleteMatchingRedisKeys = async ( + client: ReturnType['client'], + pattern: string +): Promise => { + let deleted = 0; + for await (const keys of client.scanIterator({ MATCH: pattern, COUNT: 500 })) { + if (keys.length === 0) continue; + deleted += await client.del(keys); + } + return deleted; +}; + +const countMatchingRedisKeys = async ( + client: ReturnType['client'], + pattern: string +): Promise => { + let count = 0; + for await (const keys of client.scanIterator({ MATCH: pattern, COUNT: 500 })) count += keys.length; + return count; +}; + +const migrateDedicatedSchema = async (workspaceRoot: string, databaseUrl: string): Promise => { + await execFileAsync('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'], { + cwd: workspaceRoot, + env: { ...process.env, DATABASE_URL: databaseUrl }, + maxBuffer: 10 * 1024 * 1024, + }); +}; + +type GeneralRow = Awaited>[number]; + +const cloneGeneral = ( + source: GeneralRow, + input: { id: number; userId: string | null; npcState: number } +): GamePrisma.GeneralCreateManyInput => + ({ + ...source, + id: input.id, + name: `${source.name}#L${input.id}`, + userId: input.userId, + npcState: input.npcState, + turnTime: new Date(source.turnTime), + recentWarTime: source.recentWarTime ? new Date(source.recentWarTime) : null, + createdAt: FIXED_NOW, + updatedAt: FIXED_NOW, + meta: source.meta as GamePrisma.InputJsonValue, + lastTurn: source.lastTurn as GamePrisma.InputJsonValue, + penalty: source.penalty as GamePrisma.InputJsonValue, + }) satisfies GamePrisma.GeneralCreateManyInput; + +const projectFixtureState = async (db: GamePrismaClient) => { + const [world, generals, cities, nations] = await Promise.all([ + db.worldState.findFirst({ + select: { + scenarioCode: true, + currentYear: true, + currentMonth: true, + tickSeconds: true, + config: true, + meta: true, + }, + }), + db.general.findMany({ + orderBy: { id: 'asc' }, + select: { + id: true, + userId: true, + name: true, + nationId: true, + cityId: true, + npcState: true, + leadership: true, + strength: true, + intel: true, + officerLevel: true, + turnTime: true, + meta: true, + }, + }), + db.city.findMany({ orderBy: { id: 'asc' }, select: { id: true, nationId: true, level: true, meta: true } }), + db.nation.findMany({ orderBy: { id: 'asc' }, select: { id: true, level: true, meta: true } }), + ]); + return { world, generals, cities, nations }; +}; + +const resizeSeededGenerals = async (db: GamePrismaClient, config: LoadConfig): Promise => { + const source = await db.general.findMany({ orderBy: { id: 'asc' } }); + if (source.length === 0) throw new Error('scenario seed produced no generals'); + const expectedNpc = config.capacity.npcGenerals; + const expectedHuman = config.capacity.humanGenerals; + if (expectedHuman !== config.capacity.authenticatedViewers) { + throw new Error('fixture requires one human general per authenticated viewer'); + } + await db.$transaction(async (transaction) => { + await transaction.general.deleteMany(); + const rows: GamePrisma.GeneralCreateManyInput[] = []; + for (let index = 0; index < expectedNpc; index += 1) { + rows.push(cloneGeneral(source[index % source.length]!, { id: index + 1, userId: null, npcState: 2 })); + } + for (let index = 0; index < expectedHuman; index += 1) { + rows.push( + cloneGeneral(source[(expectedNpc + index) % source.length]!, { + id: expectedNpc + index + 1, + userId: `load-user-${String(index + 1).padStart(4, '0')}`, + npcState: 0, + }) + ); + } + await transaction.general.createMany({ data: rows }); + const world = await transaction.worldState.findFirstOrThrow({ select: { id: true, meta: true, config: true } }); + await transaction.worldState.update({ + where: { id: world.id }, + data: { + tickSeconds: Math.trunc(config.capacity.turnIntervalMs / 1_000), + meta: { ...(world.meta as Record), lastGeneralId: rows.length } as GamePrisma.InputJsonValue, + config: { + ...(world.config as Record), + maxUserCnt: expectedHuman, + turnTermMinutes: config.capacity.turnIntervalMs / 60_000, + } as GamePrisma.InputJsonValue, + }, + }); + }); +}; + +const assertNewTokenPath = async (tokenPath: string, workspaceRoot: string): Promise => { + const secretRoot = await realpath(path.join(workspaceRoot, 'tools/load-tests/secrets')); + const output = path.resolve(tokenPath); + const relative = path.relative(secretRoot, output); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative) || path.extname(output) !== '.json') { + throw new Error('seed token output must be a new JSON file inside tools/load-tests/secrets'); + } + await mkdir(path.dirname(output), { recursive: true, mode: 0o700 }); + const parent = await realpath(path.dirname(output)); + if (parent !== secretRoot) throw new Error('seed token output must not traverse a symbolic-link directory'); + try { + await lstat(output); + throw new Error('seed token output already exists'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + return output; +}; + +const writeTokens = async (tokenPath: string, tokens: readonly string[]): Promise => { + const handle = await open(tokenPath, 'wx', 0o600); + try { + await handle.writeFile(`${JSON.stringify({ tokens })}\n`, { encoding: 'utf8' }); + } finally { + await handle.close(); + } +}; + +export const seedCapacityFixture = async (options: { + config: LoadConfig; + tokenPath: string; + workspaceRoot: string; + env?: NodeJS.ProcessEnv; +}) => { + const environment = requireEnvironment(options.env ?? process.env); + assertFixtureIsolation(options.config, environment); + const tokenPath = await assertNewTokenPath(options.tokenPath, options.workspaceRoot); + await migrateDedicatedSchema(options.workspaceRoot, environment.databaseUrl); + + const previousSeed = process.env.INTEGRATION_WORLD_SEED; + process.env.INTEGRATION_WORLD_SEED = `load-capacity-${options.config.name}-v1`; + try { + await seedScenarioToDatabase({ + scenarioId: SCENARIO_ID, + databaseUrl: environment.databaseUrl, + now: FIXED_NOW, + gameClockMode: 'manual', + installOptions: { + turnTermMinutes: options.config.capacity.turnIntervalMs / 60_000, + npcMode: 2, + tournamentTrig: false, + serverId: `load-${options.config.name}`, + }, + }); + } finally { + if (previousSeed === undefined) delete process.env.INTEGRATION_WORLD_SEED; + else process.env.INTEGRATION_WORLD_SEED = previousSeed; + } + + const postgres = createGamePostgresConnector({ url: environment.databaseUrl }); + const redis = createRedisConnector({ url: environment.redisUrl }); + await postgres.connect(); + try { + await resizeSeededGenerals(postgres.prisma, options.config); + const state = await projectFixtureState(postgres.prisma); + const fixtureSha256 = `sha256:${sha256(canonicalJson(state))}`; + await redis.connect(); + try { + await deleteMatchingRedisKeys(redis.client, `${accessKeyPrefix(options.config)}ga_*`); + const issuedAt = new Date().toISOString(); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1_000).toISOString(); + const tokens = Array.from({ length: options.config.capacity.authenticatedViewers }, () => + `ga_${randomUUID()}` + ); + await Promise.all( + tokens.map((token, index) => { + const userNo = String(index + 1).padStart(4, '0'); + return redis.client.set( + `${accessKeyPrefix(options.config)}${token}`, + JSON.stringify({ + version: 1, + profile: options.config.isolation.profileName, + issuedAt, + expiresAt, + sessionId: `load-session-${userNo}`, + user: { + id: `load-user-${userNo}`, + username: `load_user_${userNo}`, + displayName: `부하장수${userNo}`, + roles: [], + }, + sanctions: {}, + identity: { + kakaoVerified: true, + canCreateGeneral: false, + requiresKakaoVerification: false, + graceEndsAt: null, + }, + }), + { EX: 24 * 60 * 60 } + ); + }) + ); + await redis.client.set( + manifestKey(options.config), + JSON.stringify({ + schema: options.config.isolation.postgresSchema, + profile: options.config.isolation.profileName, + fixtureSha256, + viewers: options.config.capacity.authenticatedViewers, + }), + { EX: 48 * 60 * 60 } + ); + await writeTokens(tokenPath, tokens); + return { + seeded: true, + fixtureSha256, + generals: state.generals.length, + npcGenerals: state.generals.filter((general) => general.npcState >= 2).length, + humanGenerals: state.generals.filter((general) => general.npcState === 0 && general.userId).length, + tokensWritten: tokens.length, + }; + } finally { + await redis.disconnect(); + } + } finally { + await postgres.disconnect(); + } +}; + +export const verifyCapacityFixture = async (config: LoadConfig, env: NodeJS.ProcessEnv = process.env) => { + const environment = requireEnvironment(env); + assertFixtureIsolation(config, environment); + const postgres = createGamePostgresConnector({ url: environment.databaseUrl }); + const redis = createRedisConnector({ url: environment.redisUrl }); + await postgres.connect(); + try { + const state = await projectFixtureState(postgres.prisma); + const fixtureSha256 = `sha256:${sha256(canonicalJson(state))}`; + await redis.connect(); + try { + const rawManifest = await redis.client.get(manifestKey(config)); + let manifestFixtureSha256: string | null = null; + if (rawManifest) { + try { + const parsed = JSON.parse(rawManifest) as Record; + manifestFixtureSha256 = + typeof parsed.fixtureSha256 === 'string' ? parsed.fixtureSha256 : null; + } catch { + manifestFixtureSha256 = null; + } + } + const accessTokens = await countMatchingRedisKeys(redis.client, `${accessKeyPrefix(config)}ga_*`); + const npcGenerals = state.generals.filter((general) => general.npcState >= 2).length; + const humanGenerals = state.generals.filter( + (general) => general.npcState === 0 && general.userId + ).length; + const valid = + state.generals.length === config.capacity.npcGenerals + config.capacity.humanGenerals && + npcGenerals === config.capacity.npcGenerals && + humanGenerals === config.capacity.humanGenerals && + accessTokens === config.capacity.authenticatedViewers && + manifestFixtureSha256 === fixtureSha256; + return { + valid, + fixtureSha256, + generals: state.generals.length, + npcGenerals, + humanGenerals, + accessTokens, + redisManifestPresent: rawManifest !== null, + redisManifestMatches: manifestFixtureSha256 === fixtureSha256, + }; + } finally { + await redis.disconnect(); + } + } finally { + await postgres.disconnect(); + } +}; + +export const cleanupCapacityFixture = async ( + config: LoadConfig, + confirmation: string, + env: NodeJS.ProcessEnv = process.env +) => { + if (confirmation !== config.isolation.postgresSchema) { + throw new Error('cleanup confirmation must exactly equal isolation.postgresSchema'); + } + const environment = requireEnvironment(env); + assertFixtureIsolation(config, environment); + const redis = createRedisConnector({ url: environment.redisUrl }); + await redis.connect(); + try { + if (!(await redis.client.get(manifestKey(config)))) { + throw new Error('refusing cleanup because the dedicated fixture manifest is absent'); + } + const postgres = createGamePostgresConnector({ url: environment.databaseUrl }); + await postgres.connect(); + try { + await postgres.prisma.$executeRawUnsafe(`DROP SCHEMA "${config.isolation.postgresSchema}" CASCADE`); + } finally { + await postgres.disconnect(); + } + const accessTokensDeleted = await deleteMatchingRedisKeys(redis.client, `${accessKeyPrefix(config)}ga_*`); + await redis.client.del(manifestKey(config)); + return { cleaned: true, schemaDropped: true, accessTokensDeleted }; + } finally { + await redis.disconnect(); + } +}; diff --git a/tools/load-tests/test/config.test.ts b/tools/load-tests/test/config.test.ts index 06c8706c..f82d2c32 100644 --- a/tools/load-tests/test/config.test.ts +++ b/tools/load-tests/test/config.test.ts @@ -11,7 +11,10 @@ void test('the 300 viewer, 900 NPC, five-minute sample validates', async () => { const config = validateLoadConfig(JSON.parse(await readFile(samplePath, 'utf8'))); assert.equal(config.capacity.authenticatedViewers, 300); assert.equal(config.capacity.npcGenerals, 900); + assert.equal(config.capacity.humanGenerals, 300); assert.equal(config.capacity.turnIntervalMs, 300_000); + assert.equal(config.isolation.redisDatabase, 15); + assert.equal(config.isolation.profileName, 'load-tests:capacity-300-900-5m'); assert.deepEqual(new Set(config.phases.map((phase) => phase.kind)), new Set(['idle', 'own', 'global', 'mixed'])); }); diff --git a/tools/load-tests/test/fixture.test.ts b/tools/load-tests/test/fixture.test.ts new file mode 100644 index 00000000..9bf6172f --- /dev/null +++ b/tools/load-tests/test/fixture.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { validateLoadConfig } from '../src/config.js'; +import { assertFixtureIsolation } from '../src/fixture.js'; + +const samplePath = new URL('../config/300-users-900-npcs-5m.json', import.meta.url); + +void test('fixture accepts only the configured private schema and dedicated Redis database', async () => { + const config = validateLoadConfig(JSON.parse(await readFile(samplePath, 'utf8'))); + assert.doesNotThrow(() => + assertFixtureIsolation(config, { + databaseUrl: 'postgresql://fixture:secret@127.0.0.1:15432/sammo?schema=load_capacity_300_900_5m', + redisUrl: 'redis://127.0.0.1:16379/15', + }) + ); +}); + +void test('fixture refuses a shared schema, shared Redis database, and public host', async () => { + const config = validateLoadConfig(JSON.parse(await readFile(samplePath, 'utf8'))); + assert.throws( + () => + assertFixtureIsolation(config, { + databaseUrl: 'postgresql://fixture:secret@127.0.0.1:15432/sammo?schema=public', + redisUrl: 'redis://127.0.0.1:16379/15', + }), + /schema must exactly match/u + ); + assert.throws( + () => + assertFixtureIsolation(config, { + databaseUrl: 'postgresql://fixture:secret@127.0.0.1:15432/sammo?schema=load_capacity_300_900_5m', + redisUrl: 'redis://127.0.0.1:16379/0', + }), + /database must exactly match/u + ); + assert.throws( + () => + assertFixtureIsolation(config, { + databaseUrl: 'postgresql://fixture:secret@database.example.com:5432/sammo?schema=load_capacity_300_900_5m', + redisUrl: 'redis://127.0.0.1:16379/15', + }), + /loopback or private/u + ); +}); diff --git a/tools/load-tests/tsconfig.json b/tools/load-tests/tsconfig.json index c0e398f2..9eeb931a 100644 --- a/tools/load-tests/tsconfig.json +++ b/tools/load-tests/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.base.json", + "extends": "../../tsconfig.paths.json", "compilerOptions": { "noEmit": true, "types": ["node"]