perf: 1200장수 용량 fixture와 E1 profile을 추가

900 NPC와 300 synthetic 사용자를 5분 턴으로 처리하는 결정론적 engine profile을 추가하고, 전용 PostgreSQL schema와 Redis DB에 300개 인증 session을 준비하는 격리 load fixture를 제공한다.
This commit is contained in:
2026-08-16 18:31:14 +00:00
parent 299470590a
commit 7e099326ec
16 changed files with 808 additions and 39 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
secrets/*.json
secrets/*
results/*.json
!secrets/.gitkeep
!results/.gitkeep
+91 -13
View File
@@ -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에서 다시 측정해야 한다.
## 도구 자체 검증
+42
View File
@@ -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
@@ -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,
@@ -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": {
+3
View File
@@ -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"
}
+35 -8
View File
@@ -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 <validate|dry-run|run> --config <file> [--tokens <0600-gitignored-file>] [--output <new-json-file>]\n');
process.stderr.write('usage: cli.ts <validate|dry-run|run|seed|verify-fixture|cleanup> --config <file> [--tokens <0600-gitignored-file>] [--output <new-json-file>] [--confirm <load_schema>]\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<string, string>();
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<void> => {
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<void> => {
};
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;
});
+12 -2
View File
@@ -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<string, unknown>, 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;
+389
View File
@@ -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<typeof createRedisConnector>['client'],
pattern: string
): Promise<number> => {
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<typeof createRedisConnector>['client'],
pattern: string
): Promise<number> => {
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<void> => {
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<ReturnType<GamePrismaClient['general']['findMany']>>[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<void> => {
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<string, unknown>), lastGeneralId: rows.length } as GamePrisma.InputJsonValue,
config: {
...(world.config as Record<string, unknown>),
maxUserCnt: expectedHuman,
turnTermMinutes: config.capacity.turnIntervalMs / 60_000,
} as GamePrisma.InputJsonValue,
},
});
});
};
const assertNewTokenPath = async (tokenPath: string, workspaceRoot: string): Promise<string> => {
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<void> => {
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<string, unknown>;
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();
}
};
+3
View File
@@ -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']));
});
+46
View File
@@ -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
);
});
+1 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../../tsconfig.paths.json",
"compilerOptions": {
"noEmit": true,
"types": ["node"]