test: source revision capacity 계측을 추가

This commit is contained in:
2026-08-16 19:03:54 +00:00
parent cfcf57c877
commit a4274c64b6
10 changed files with 252 additions and 53 deletions
+64 -12
View File
@@ -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 <validate|dry-run|run|prepare|seed|verify-fixture|materialize-calibration|cleanup> --config <file> [--tokens <0600-gitignored-file>] [--output <new-json-file>] [--confirm <load_schema>]\n');
process.stderr.write(
'usage: cli.ts <validate|dry-run|run|prepare|seed|verify-fixture|activate-coverage|materialize-calibration|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; 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<string, string>();
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<void> => {
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<void> => {
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<void> => {
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) => {
+51 -12
View File
@@ -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<string, unknown>), lastGeneralId: rows.length } as GamePrisma.InputJsonValue,
meta: {
...(world.meta as Record<string, unknown>),
lastGeneralId: rows.length,
} as GamePrisma.InputJsonValue,
config: {
...(world.config as Record<string, unknown>),
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<Array<{ version: string }>>(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<string, unknown>;
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`,
+17 -1
View File
@@ -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<string, number>;
results: Record<string, number>;
latencyMs: Record<string, DistributionSummary>;
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,
+28 -7
View File
@@ -56,8 +56,13 @@ const runtimeAndHost = async () => {
};
const gitMetadata = async (workspaceRoot: string) => {
const run = async (args: string[]): Promise<string> => (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<string> =>
(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,
};
+52 -10
View File
@@ -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<Record<keyof DashboardRevisions, string>>;
}
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<DashboardRevisions | undefined> => {
}): Promise<DashboardObservation | undefined> => {
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) {