From a4274c64b66d1ebd061b61246c3538cf024168a1 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 16 Aug 2026 19:03:54 +0000 Subject: [PATCH] =?UTF-8?q?test:=20source=20revision=20capacity=20?= =?UTF-8?q?=EA=B3=84=EC=B8=A1=EC=9D=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/load-tests/README.md | 14 ++++- tools/load-tests/package.json | 1 + tools/load-tests/src/cli.ts | 76 ++++++++++++++++++++++----- tools/load-tests/src/fixture.ts | 63 +++++++++++++++++----- tools/load-tests/src/metrics.ts | 18 ++++++- tools/load-tests/src/runner.ts | 35 +++++++++--- tools/load-tests/src/trpc.ts | 62 ++++++++++++++++++---- tools/load-tests/test/fixture.test.ts | 15 +++--- tools/load-tests/test/metrics.test.ts | 4 ++ tools/load-tests/test/trpc.test.ts | 17 ++++-- 10 files changed, 252 insertions(+), 53 deletions(-) diff --git a/tools/load-tests/README.md b/tools/load-tests/README.md index d63ad4cb..4297f8c6 100644 --- a/tools/load-tests/README.md +++ b/tools/load-tests/README.md @@ -5,8 +5,9 @@ phase에서 실제 game API tRPC query를 실행한다. HTTP latency p50/p95/p99, 성공/오류, SSE open/close/reconnect/event와 public payload 금지 field 수, driver CPU/RSS/event-loop lag를 raw JSON에 남긴다. token 값, 사용자/장수/도시/국가 ID와 response/event payload는 출력하지 않는다. -dashboard query의 opaque revision은 viewer별 메모리에서만 다음 `known` 입력으로 이어서 -unchanged/snapshot/patch 경로를 구분하며 raw JSON에는 종류별 count만 남긴다. +dashboard query의 opaque content/source revision은 viewer별 메모리에서만 다음 `known`/`knownSource` +입력으로 이어서 unchanged/snapshot/patch와 source-revision fast-path 조건을 구분한다. raw JSON에는 +종류별 count와 source revision 관측/전송/일치-unchanged aggregate만 남긴다. ## 안전 경계 @@ -52,6 +53,10 @@ pnpm --filter @sammo-ts/load-tests seed \ --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 + +pnpm --filter @sammo-ts/load-tests activate-coverage \ + --config tools/load-tests/config/300-users-900-npcs-5m.json \ + --confirm load_capacity_300_900_5m ``` `seed`는 해당 `load_` schema에 migration을 적용하고 scenario 2601을 고정 seed/time으로 설치한 뒤 정확히 @@ -62,6 +67,11 @@ pnpm --filter @sammo-ts/load-tests verify-fixture \ fixture와 같은 환경으로 API를 띄울 때 핵심 namespace는 다음과 같다. `capacity.env` 값을 다시 명령행에 풀어 쓰지 않는다. +`activate-coverage`는 Redis의 전용 fixture manifest와 schema 확인 문자열을 모두 요구한 뒤, infra의 +advisory-lock/CAS transaction을 그대로 호출해 coverage 1과 초기 shared revision head를 활성화한다. +공유 schema나 manifest가 없는 runtime에는 실행되지 않는다. activation 전후의 coverage/head/outbox는 +`verify-fixture`의 비밀값 없는 aggregate로 확인할 수 있다. + ```sh pnpm --filter @sammo-ts/common build pnpm --filter @sammo-ts/logic build diff --git a/tools/load-tests/package.json b/tools/load-tests/package.json index 85203646..4c56e577 100644 --- a/tools/load-tests/package.json +++ b/tools/load-tests/package.json @@ -10,6 +10,7 @@ "prepare:capacity": "pnpm -w exec tsx tools/load-tests/src/cli.ts prepare", "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", + "activate-coverage": "pnpm -w exec tsx tools/load-tests/src/cli.ts activate-coverage", "materialize-calibration": "pnpm -w exec tsx tools/load-tests/src/cli.ts materialize-calibration", "cleanup": "pnpm -w exec tsx tools/load-tests/src/cli.ts cleanup", "test": "pnpm -w exec tsx --test tools/load-tests/test/*.test.ts", diff --git a/tools/load-tests/src/cli.ts b/tools/load-tests/src/cli.ts index b40f057b..b22df765 100644 --- a/tools/load-tests/src/cli.ts +++ b/tools/load-tests/src/cli.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { assertRuntimeMetadataFinalized, loadConfig, loadTokens } from './config.js'; import { + activateCapacityCoverage, cleanupCapacityFixture, materializeCalibrationConfig, prepareCapacitySecrets, @@ -11,16 +12,42 @@ import { } from './fixture.js'; import { describeDryRun, runLoadTest } from './runner.js'; -type Command = 'run' | 'dry-run' | 'validate' | 'prepare' | 'seed' | 'verify-fixture' | 'materialize-calibration' | 'cleanup'; +type Command = + | 'run' + | 'dry-run' + | 'validate' + | 'prepare' + | 'seed' + | 'verify-fixture' + | 'activate-coverage' + | 'materialize-calibration' + | 'cleanup'; const usage = (): never => { - process.stderr.write('usage: cli.ts --config [--tokens <0600-gitignored-file>] [--output ] [--confirm ]\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; confirm?: 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', 'prepare', 'seed', 'verify-fixture', 'materialize-calibration', 'cleanup'].includes(command ?? '')) usage(); + if ( + ![ + 'run', + 'dry-run', + 'validate', + 'prepare', + 'seed', + 'verify-fixture', + 'activate-coverage', + 'materialize-calibration', + 'cleanup', + ].includes(command ?? '') + ) + usage(); const values = new Map(); for (let index = 1; index < argv.length; index += 2) { const flag = argv[index]; @@ -33,10 +60,22 @@ const parseArguments = (argv: readonly string[]): { command: Command; config: st if (command === 'run' && (!values.get('--tokens') || !values.get('--output'))) usage(); if (command === 'seed' && (!values.get('--tokens') || values.has('--output') || values.has('--confirm'))) usage(); if (command === 'prepare' && (values.has('--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 === 'materialize-calibration' && (!values.get('--output') || values.has('--tokens') || values.has('--confirm'))) usage(); - if (command === 'validate' && (values.has('--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 === 'activate-coverage' && + (!values.get('--confirm') || values.has('--tokens') || values.has('--output')) + ) + usage(); + if ( + command === 'materialize-calibration' && + (!values.get('--output') || values.has('--tokens') || 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 { @@ -73,6 +112,10 @@ const main = async (): Promise => { process.stdout.write(`${JSON.stringify(await verifyCapacityFixture(config))}\n`); return; } + if (args.command === 'activate-coverage') { + process.stdout.write(`${JSON.stringify(await activateCapacityCoverage(config, args.confirm!))}\n`); + return; + } if (args.command === 'materialize-calibration') { process.stdout.write( `${JSON.stringify( @@ -89,9 +132,13 @@ const main = async (): Promise => { 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; + 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`); + process.stdout.write( + `${JSON.stringify({ valid: true, tokenFileValidated: tokens !== null, configSha256: sha256, plan: describeDryRun(config) }, null, 2)}\n` + ); return; } assertRuntimeMetadataFinalized(config); @@ -99,8 +146,13 @@ const main = async (): Promise => { await mkdir(path.dirname(output), { recursive: true }); const result = await runLoadTest({ config, configSha256: sha256, tokens: tokens!, workspaceRoot }); await writeFile(output, `${JSON.stringify(result, null, 2)}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 }); - const failedRequests = result.phases.reduce((total, phase) => total + Object.values(phase.metrics.http.errors).reduce((sum, count) => sum + count, 0), 0); - process.stdout.write(`${JSON.stringify({ completed: true, phases: result.phases.length, failedRequests, outputWritten: true })}\n`); + const failedRequests = result.phases.reduce( + (total, phase) => total + Object.values(phase.metrics.http.errors).reduce((sum, count) => sum + count, 0), + 0 + ); + process.stdout.write( + `${JSON.stringify({ completed: true, phases: result.phases.length, failedRequests, outputWritten: true })}\n` + ); }; main().catch((error: unknown) => { diff --git a/tools/load-tests/src/fixture.ts b/tools/load-tests/src/fixture.ts index c6a06571..17626b34 100644 --- a/tools/load-tests/src/fixture.ts +++ b/tools/load-tests/src/fixture.ts @@ -6,6 +6,7 @@ import { promisify } from 'node:util'; import { seedScenarioToDatabase } from '@sammo-ts/game-engine'; import { + activateReadModelRevisionCoverage, createGamePostgresConnector, createRedisConnector, GamePrisma, @@ -264,7 +265,10 @@ const resizeSeededGenerals = async (db: GamePrismaClient, config: LoadConfig): P where: { id: world.id }, data: { tickSeconds: Math.trunc(config.capacity.turnIntervalMs / 1_000), - meta: { ...(world.meta as Record), lastGeneralId: rows.length } as GamePrisma.InputJsonValue, + meta: { + ...(world.meta as Record), + lastGeneralId: rows.length, + } as GamePrisma.InputJsonValue, config: { ...(world.config as Record), maxUserCnt: expectedHuman, @@ -335,8 +339,9 @@ export const seedCapacityFixture = async (options: { 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()}` + const tokens = Array.from( + { length: options.config.capacity.authenticatedViewers }, + () => `ga_${randomUUID()}` ); await Promise.all( tokens.map((token, index) => { @@ -401,9 +406,16 @@ export const verifyCapacityFixture = async (config: LoadConfig, env: NodeJS.Proc const redis = createRedisConnector({ url: environment.redisUrl }); await postgres.connect(); try { - const [state, postgresRows] = await Promise.all([ + const [state, postgresRows, revisionMeta, revisionHeads, pendingOutbox] = await Promise.all([ projectFixtureState(postgres.prisma), postgres.prisma.$queryRaw>(GamePrisma.sql`SELECT version()`), + postgres.prisma.readModelRevisionMeta.findUnique({ where: { id: 1 } }), + postgres.prisma.readModelRevision.findMany({ + where: { domain: { in: ['dashboard.global', 'map.world'] }, entityId: 0 }, + orderBy: { domain: 'asc' }, + select: { domain: true, revision: true }, + }), + postgres.prisma.readModelOutbox.count({ where: { deliveredAt: null } }), ]); const fixtureSha256 = `sha256:${sha256(canonicalJson(state))}`; await redis.connect(); @@ -413,8 +425,7 @@ export const verifyCapacityFixture = async (config: LoadConfig, env: NodeJS.Proc if (rawManifest) { try { const parsed = JSON.parse(rawManifest) as Record; - manifestFixtureSha256 = - typeof parsed.fixtureSha256 === 'string' ? parsed.fixtureSha256 : null; + manifestFixtureSha256 = typeof parsed.fixtureSha256 === 'string' ? parsed.fixtureSha256 : null; } catch { manifestFixtureSha256 = null; } @@ -423,9 +434,7 @@ export const verifyCapacityFixture = async (config: LoadConfig, env: NodeJS.Proc const redisInfo = await redis.client.info('server'); const redisVersion = /^redis_version:(.+)$/mu.exec(redisInfo)?.[1]?.trim() ?? 'unknown'; const npcGenerals = state.generals.filter((general) => general.npcState >= 2).length; - const humanGenerals = state.generals.filter( - (general) => general.npcState === 0 && general.userId - ).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 && @@ -443,6 +452,12 @@ export const verifyCapacityFixture = async (config: LoadConfig, env: NodeJS.Proc redisManifestMatches: manifestFixtureSha256 === fixtureSha256, postgresVersion: postgresRows[0]?.version ?? 'unknown', redisVersion, + coverageVersion: revisionMeta?.coverageVersion ?? null, + revisionHeads: revisionHeads.map((head) => ({ + domain: head.domain, + revision: head.revision.toString(), + })), + pendingOutbox, }; } finally { await redis.disconnect(); @@ -452,6 +467,32 @@ export const verifyCapacityFixture = async (config: LoadConfig, env: NodeJS.Proc } }; +export const activateCapacityCoverage = async ( + config: LoadConfig, + confirmation: string, + env: NodeJS.ProcessEnv = process.env +) => { + if (confirmation !== config.isolation.postgresSchema) { + throw new Error('coverage activation confirmation must exactly equal isolation.postgresSchema'); + } + const environment = requireEnvironment(env); + assertFixtureIsolation(config, environment); + const fixture = await verifyCapacityFixture(config, env); + if (!fixture.valid) { + throw new Error('fixture verification failed; refusing coverage activation'); + } + const postgres = createGamePostgresConnector({ url: environment.databaseUrl }); + await postgres.connect(); + try { + const result = await postgres.prisma.$transaction((transaction) => + activateReadModelRevisionCoverage(transaction, 0) + ); + return { activated: true, ...result }; + } finally { + await postgres.disconnect(); + } +}; + export const materializeCalibrationConfig = async (options: { config: LoadConfig; outputPath: string; @@ -473,9 +514,7 @@ export const materializeCalibrationConfig = async (options: { } const verified = await verifyCapacityFixture(options.config, env); if (!verified.valid) throw new Error('fixture verification failed; refusing to materialize calibration config'); - const gitCommit = ( - await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: options.workspaceRoot }) - ).stdout.trim(); + const gitCommit = (await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: options.workspaceRoot })).stdout.trim(); const runtimeConfig: LoadConfig = { ...options.config, name: `${options.config.name}-calibration`, diff --git a/tools/load-tests/src/metrics.ts b/tools/load-tests/src/metrics.ts index 61d8037a..e6db70da 100644 --- a/tools/load-tests/src/metrics.ts +++ b/tools/load-tests/src/metrics.ts @@ -50,6 +50,9 @@ export class PhaseMetrics { sseReconnects = 0; sseFailures = 0; ssePrivacyViolations = 0; + httpSourceRevisionObserved = 0; + httpSourceRevisionKnownSent = 0; + httpSourceRevisionMatchedUnchanged = 0; recordHttp(name: string, latencyMs: number, outcome: string | null): void { const values = this.httpLatencyMs.get(name) ?? []; @@ -81,6 +84,11 @@ export interface PhaseMetricSummary { errors: Record; results: Record; latencyMs: Record; + sourceRevision: { + observed: number; + knownSent: number; + matchedUnchanged: number; + }; }; sse: { attempts: number; @@ -143,7 +151,10 @@ export class ProcessSampler { } } -export const summarizePhaseMetrics = (metrics: PhaseMetrics, processSummary: PhaseMetricSummary['process']): PhaseMetricSummary => ({ +export const summarizePhaseMetrics = ( + metrics: PhaseMetrics, + processSummary: PhaseMetricSummary['process'] +): PhaseMetricSummary => ({ http: { success: mapToObject(metrics.httpSuccess), errors: mapToObject(metrics.httpErrors), @@ -153,6 +164,11 @@ export const summarizePhaseMetrics = (metrics: PhaseMetrics, processSummary: Pha .sort(([left], [right]) => left.localeCompare(right)) .map(([name, values]) => [name, summarizeDistribution(values)]) ), + sourceRevision: { + observed: metrics.httpSourceRevisionObserved, + knownSent: metrics.httpSourceRevisionKnownSent, + matchedUnchanged: metrics.httpSourceRevisionMatchedUnchanged, + }, }, sse: { attempts: metrics.sseAttempts, diff --git a/tools/load-tests/src/runner.ts b/tools/load-tests/src/runner.ts index b030a09a..28f66497 100644 --- a/tools/load-tests/src/runner.ts +++ b/tools/load-tests/src/runner.ts @@ -56,8 +56,13 @@ const runtimeAndHost = async () => { }; const gitMetadata = async (workspaceRoot: string) => { - const run = async (args: string[]): Promise => (await execFileAsync('git', args, { cwd: workspaceRoot })).stdout.trim(); - const [commit, tree, status] = await Promise.all([run(['rev-parse', 'HEAD']), run(['rev-parse', 'HEAD^{tree}']), run(['status', '--porcelain=v1'])]); + const run = async (args: string[]): Promise => + (await execFileAsync('git', args, { cwd: workspaceRoot })).stdout.trim(); + const [commit, tree, status] = await Promise.all([ + run(['rev-parse', 'HEAD']), + run(['rev-parse', 'HEAD^{tree}']), + run(['status', '--porcelain=v1']), + ]); return { commit, tree, dirty: status.length > 0 }; }; @@ -77,22 +82,28 @@ const runHttpViewers = async (options: { await wait(stagger, options.signal); let iteration = 0; let dashboardRevisions: DashboardRevisions = {}; + let dashboardSourceRevisions: DashboardRevisions = {}; while (!options.signal.aborted) { const configuredOperation = schedule[(viewerIndex + iteration) % schedule.length]!; const operation = - configuredOperation.procedure === 'dashboard.getContextBundleDelta' && Object.keys(dashboardRevisions).length > 0 + configuredOperation.procedure === 'dashboard.getContextBundleDelta' && + Object.keys(dashboardRevisions).length > 0 ? { ...configuredOperation, input: { - ...(typeof configuredOperation.input === 'object' && configuredOperation.input !== null + ...(typeof configuredOperation.input === 'object' && + configuredOperation.input !== null ? configuredOperation.input : {}), known: dashboardRevisions, + ...(Object.keys(dashboardSourceRevisions).length > 0 + ? { knownSource: dashboardSourceRevisions } + : {}), forceSnapshot: false, }, } : configuredOperation; - const observedRevisions = await executeTrpcQuery({ + const observation = await executeTrpcQuery({ baseUrl: options.config.target.baseUrl, trpcPath: options.config.target.trpcPath, operation, @@ -100,7 +111,13 @@ const runHttpViewers = async (options: { signal: options.signal, metrics: options.metrics, }); - if (observedRevisions) dashboardRevisions = { ...dashboardRevisions, ...observedRevisions }; + if (observation) { + dashboardRevisions = { ...dashboardRevisions, ...observation.revisions }; + dashboardSourceRevisions = { + ...dashboardSourceRevisions, + ...observation.sourceRevisions, + }; + } iteration += 1; await wait(interval, options.signal); } @@ -194,7 +211,11 @@ export const runLoadTest = async (options: { git, runtime: environment.runtime, host: environment.host, - hashes: { configSha256: options.configSha256, runtimeSha256: environment.runtimeSha256, hostSha256: environment.hostSha256 }, + hashes: { + configSha256: options.configSha256, + runtimeSha256: environment.runtimeSha256, + hostSha256: environment.hostSha256, + }, targetRuntime: options.config.runtimeMetadata, phases, }; diff --git a/tools/load-tests/src/trpc.ts b/tools/load-tests/src/trpc.ts index 99c38121..eb52cb9f 100644 --- a/tools/load-tests/src/trpc.ts +++ b/tools/load-tests/src/trpc.ts @@ -6,7 +6,12 @@ export interface TrpcRequest { init: RequestInit; } -export const buildTrpcQuery = (baseUrl: string, trpcPath: string, operation: LoadOperation, token: string): TrpcRequest => { +export const buildTrpcQuery = ( + baseUrl: string, + trpcPath: string, + operation: LoadOperation, + token: string +): TrpcRequest => { const normalizedPath = trpcPath.endsWith('/') ? trpcPath.slice(0, -1) : trpcPath; const url = new URL(`${normalizedPath}/${operation.procedure}`, baseUrl); if (operation.input !== undefined) url.searchParams.set('input', JSON.stringify(operation.input)); @@ -25,7 +30,12 @@ const classifyTrpcPayload = (payload: unknown): string | null => { const error = (payload as { error?: unknown }).error; if (typeof error === 'object' && error !== null && 'data' in error) { const data = (error as { data?: unknown }).data; - if (typeof data === 'object' && data !== null && 'code' in data && typeof (data as { code?: unknown }).code === 'string') { + if ( + typeof data === 'object' && + data !== null && + 'code' in data && + typeof (data as { code?: unknown }).code === 'string' + ) { const code = (data as { code: string }).code; return `trpc-${/^[A-Z_]+$/u.test(code) ? code.toLowerCase() : 'error'}`; } @@ -50,11 +60,20 @@ export interface DashboardRevisions { boardAccess?: string; } -export const extractDashboardRevisions = (payload: unknown): { revisions: DashboardRevisions; resultKinds: string[] } | null => { +export interface DashboardObservation { + revisions: DashboardRevisions; + sourceRevisions: DashboardRevisions; + resultKinds: string[]; + resultKindsBySlice: Partial>; +} + +export const extractDashboardRevisions = (payload: unknown): DashboardObservation | null => { const data = asRecord(unwrapTrpcData(payload)); if (!data) return null; const revisions: DashboardRevisions = {}; + const sourceRevisions: DashboardRevisions = {}; const resultKinds: string[] = []; + const resultKindsBySlice: DashboardObservation['resultKindsBySlice'] = {}; for (const [wireName, outputName] of [ ['context', 'context'], ['commandTable', 'commandTable'], @@ -62,11 +81,19 @@ export const extractDashboardRevisions = (payload: unknown): { revisions: Dashbo ] as const) { const slice = asRecord(data[wireName]); if (!slice) continue; - if (typeof slice.revision === 'string' && /^[A-Za-z0-9_-]{22}$/u.test(slice.revision)) revisions[outputName] = slice.revision; - if (typeof slice.kind === 'string' && ['unchanged', 'snapshot', 'patch'].includes(slice.kind)) resultKinds.push(slice.kind); - else resultKinds.push('other'); + if (typeof slice.revision === 'string' && /^[A-Za-z0-9_-]{22}$/u.test(slice.revision)) + revisions[outputName] = slice.revision; + if (typeof slice.sourceRevision === 'string' && /^[A-Za-z0-9_-]{22}$/u.test(slice.sourceRevision)) { + sourceRevisions[outputName] = slice.sourceRevision; + } + const resultKind = + typeof slice.kind === 'string' && ['unchanged', 'snapshot', 'patch'].includes(slice.kind) + ? slice.kind + : 'other'; + resultKinds.push(resultKind); + resultKindsBySlice[outputName] = resultKind; } - return { revisions, resultKinds }; + return { revisions, sourceRevisions, resultKinds, resultKindsBySlice }; }; export const executeTrpcQuery = async (options: { @@ -76,7 +103,7 @@ export const executeTrpcQuery = async (options: { token: string; signal: AbortSignal; metrics: PhaseMetrics; -}): Promise => { +}): Promise => { const started = performance.now(); let outcome: string | null; try { @@ -90,9 +117,24 @@ export const executeTrpcQuery = async (options: { outcome = classifyTrpcPayload(payload); if (outcome === null && options.operation.procedure === 'dashboard.getContextBundleDelta') { const dashboard = extractDashboardRevisions(payload); - for (const kind of dashboard?.resultKinds ?? []) options.metrics.recordHttpResult(options.operation.name, kind); + for (const kind of dashboard?.resultKinds ?? []) + options.metrics.recordHttpResult(options.operation.name, kind); + const input = asRecord(options.operation.input); + const knownSource = asRecord(input?.knownSource); + for (const slice of ['context', 'commandTable', 'boardAccess'] as const) { + const sourceRevision = dashboard?.sourceRevisions[slice]; + if (sourceRevision !== undefined) options.metrics.httpSourceRevisionObserved += 1; + if (typeof knownSource?.[slice] === 'string') options.metrics.httpSourceRevisionKnownSent += 1; + if ( + dashboard?.resultKindsBySlice[slice] === 'unchanged' && + sourceRevision !== undefined && + knownSource?.[slice] === sourceRevision + ) { + options.metrics.httpSourceRevisionMatchedUnchanged += 1; + } + } options.metrics.recordHttp(options.operation.name, performance.now() - started, outcome); - return dashboard?.revisions; + return dashboard ?? undefined; } } } catch (error) { diff --git a/tools/load-tests/test/fixture.test.ts b/tools/load-tests/test/fixture.test.ts index 7d708c20..3a32b598 100644 --- a/tools/load-tests/test/fixture.test.ts +++ b/tools/load-tests/test/fixture.test.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import test from 'node:test'; import { validateLoadConfig } from '../src/config.js'; -import { assertFixtureIsolation, prepareCapacitySecrets } from '../src/fixture.js'; +import { activateCapacityCoverage, assertFixtureIsolation, prepareCapacitySecrets } from '../src/fixture.js'; const samplePath = new URL('../config/300-users-900-npcs-5m.json', import.meta.url); @@ -40,13 +40,19 @@ void test('fixture refuses a shared schema, shared Redis database, and public ho assert.throws( () => assertFixtureIsolation(config, { - databaseUrl: 'postgresql://fixture:secret@database.example.com:5432/sammo?schema=load_capacity_300_900_5m', + 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 ); }); +void test('coverage activation requires the exact dedicated schema confirmation before connecting', async () => { + const config = validateLoadConfig(JSON.parse(await readFile(samplePath, 'utf8'))); + await assert.rejects(activateCapacityCoverage(config, 'load_wrong_schema', {}), /confirmation must exactly equal/u); +}); + void test('prepare creates only three 0600 ignored-secret inputs without returning their values', async () => { const workspaceRoot = await mkdtemp(path.join(tmpdir(), 'sammo-capacity-prepare-')); try { @@ -63,10 +69,7 @@ void test('prepare creates only three 0600 ignored-secret inputs without returni const info = await stat(path.join(workspaceRoot, 'tools/load-tests/secrets', name)); assert.equal(info.mode & 0o777, 0o600); } - const envText = await readFile( - path.join(workspaceRoot, 'tools/load-tests/secrets/capacity.env'), - 'utf8' - ); + const envText = await readFile(path.join(workspaceRoot, 'tools/load-tests/secrets/capacity.env'), 'utf8'); assert.match(envText, /127\.0\.0\.1:25442/u); assert.match(envText, /127\.0\.0\.1:26379\/15/u); assert.equal(JSON.stringify(result).includes('postgresql://'), false); diff --git a/tools/load-tests/test/metrics.test.ts b/tools/load-tests/test/metrics.test.ts index 701cd105..60cbd2a1 100644 --- a/tools/load-tests/test/metrics.test.ts +++ b/tools/load-tests/test/metrics.test.ts @@ -26,6 +26,9 @@ void test('phase aggregation separates success, error, latency, and event counte metrics.recordSseEvent('ready'); metrics.recordSseEvent('ready'); metrics.recordHttpResult('own', 'unchanged'); + metrics.httpSourceRevisionObserved = 6; + metrics.httpSourceRevisionKnownSent = 3; + metrics.httpSourceRevisionMatchedUnchanged = 3; metrics.processRssBytes.push(100, 200); metrics.sseActiveConnections.push(0, 2); const summary = summarizePhaseMetrics(metrics, { @@ -36,6 +39,7 @@ void test('phase aggregation separates success, error, latency, and event counte assert.deepEqual(summary.http.success, { own: 1 }); assert.deepEqual(summary.http.errors, { 'own:http-500': 1 }); assert.deepEqual(summary.http.results, { 'own:unchanged': 1 }); + assert.deepEqual(summary.http.sourceRevision, { observed: 6, knownSent: 3, matchedUnchanged: 3 }); assert.equal(summary.http.latencyMs.own?.p50, 10); assert.deepEqual(summary.sse.events, { ready: 2 }); assert.equal(summary.sse.activeConnections.max, 2); diff --git a/tools/load-tests/test/trpc.test.ts b/tools/load-tests/test/trpc.test.ts index c9d209e6..2d08126c 100644 --- a/tools/load-tests/test/trpc.test.ts +++ b/tools/load-tests/test/trpc.test.ts @@ -8,7 +8,13 @@ void test('tRPC query uses bearer auth without putting the token in the URL', () const request = buildTrpcQuery( 'http://127.0.0.1:15001', '/api/trpc', - { name: 'own', procedure: 'dashboard.getContextBundleDelta', type: 'query', weight: 1, input: { include: { context: true } } }, + { + name: 'own', + procedure: 'dashboard.getContextBundleDelta', + type: 'query', + weight: 1, + input: { include: { context: true } }, + }, token ); assert.equal(new Headers(request.init.headers).get('authorization'), `Bearer ${token}`); @@ -25,11 +31,16 @@ void test('dashboard observations retain only opaque revisions and aggregate-saf data: { json: { context: { kind: 'unchanged', revision, data: { general: { id: 123 } } }, - commandTable: { kind: 'snapshot', revision }, + commandTable: { kind: 'snapshot', revision, sourceRevision: revision }, }, }, }, }), - { revisions: { context: revision, commandTable: revision }, resultKinds: ['unchanged', 'snapshot'] } + { + revisions: { context: revision, commandTable: revision }, + sourceRevisions: { commandTable: revision }, + resultKinds: ['unchanged', 'snapshot'], + resultKindsBySlice: { context: 'unchanged', commandTable: 'snapshot' }, + } ); });