Files
core2026/tools/load-tests/src/runner.ts
T

223 lines
8.5 KiB
TypeScript

import { execFile } from 'node:child_process';
import { setMaxListeners } from 'node:events';
import { promisify } from 'node:util';
import os from 'node:os';
import { readFile as readTextFile } from 'node:fs/promises';
import { canonicalJson, expandWeightedOperations, sha256, type LoadConfig, type LoadPhase } from './config.js';
import { PhaseMetrics, ProcessSampler, summarizePhaseMetrics, type PhaseMetricSummary } from './metrics.js';
import { runSseConnection } from './sse.js';
import { executeTrpcQuery } from './trpc.js';
import type { DashboardRevisions } from './trpc.js';
const execFileAsync = promisify(execFile);
const wait = (milliseconds: number, signal: AbortSignal): Promise<void> =>
new Promise((resolve) => {
if (signal.aborted) return resolve();
const done = () => {
clearTimeout(timer);
signal.removeEventListener('abort', done);
resolve();
};
const timer = setTimeout(done, milliseconds);
signal.addEventListener('abort', done, { once: true });
});
const loadText = async (file: string): Promise<string | null> => {
try {
return (await readTextFile(file, 'utf8')).trim();
} catch {
return null;
}
};
const runtimeAndHost = async () => {
const cpuQuota = await loadText('/sys/fs/cgroup/cpu.max');
const memoryLimit = await loadText('/sys/fs/cgroup/memory.max');
const runtime = { node: process.version, v8: process.versions.v8 };
const cpus = os.cpus();
const host = {
platform: os.platform(),
release: os.release(),
arch: os.arch(),
logicalCpuCount: cpus.length,
cpuModel: cpus[0]?.model ?? 'unknown',
totalMemoryBytes: os.totalmem(),
cgroupCpuMax: cpuQuota,
cgroupMemoryMax: memoryLimit,
};
return {
runtime,
host,
runtimeSha256: sha256(canonicalJson(runtime)),
hostSha256: sha256(canonicalJson(host)),
};
};
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']),
]);
return { commit, tree, dirty: status.length > 0 };
};
const runHttpViewers = async (options: {
config: LoadConfig;
phase: LoadPhase;
tokens: readonly string[];
signal: AbortSignal;
metrics: PhaseMetrics;
}): Promise<void> => {
if (options.phase.requestIntervalMs === null || options.phase.operations.length === 0) return;
const schedule = expandWeightedOperations(options.phase.operations);
const interval = options.phase.requestIntervalMs;
await Promise.all(
options.tokens.map(async (token, viewerIndex) => {
const stagger = Math.floor((viewerIndex / options.tokens.length) * interval);
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,
input: {
...(typeof configuredOperation.input === 'object' &&
configuredOperation.input !== null
? configuredOperation.input
: {}),
known: dashboardRevisions,
...(Object.keys(dashboardSourceRevisions).length > 0
? { knownSource: dashboardSourceRevisions }
: {}),
forceSnapshot: false,
},
}
: configuredOperation;
const observation = await executeTrpcQuery({
baseUrl: options.config.target.baseUrl,
trpcPath: options.config.target.trpcPath,
operation,
token,
signal: options.signal,
metrics: options.metrics,
});
if (observation) {
dashboardRevisions = { ...dashboardRevisions, ...observation.revisions };
dashboardSourceRevisions = {
...dashboardSourceRevisions,
...observation.sourceRevisions,
};
}
iteration += 1;
await wait(interval, options.signal);
}
})
);
};
export interface PhaseResult {
name: string;
kind: LoadPhase['kind'];
configuredDurationMs: number;
elapsedMs: number;
metrics: PhaseMetricSummary;
}
const runPhase = async (config: LoadConfig, phase: LoadPhase, tokens: readonly string[]): Promise<PhaseResult> => {
const metrics = new PhaseMetrics();
const sampler = new ProcessSampler(metrics);
const controller = new AbortController();
setMaxListeners(0, controller.signal);
let activeConnections = 0;
const started = performance.now();
sampler.start();
const timer = setTimeout(() => controller.abort(), phase.durationMs);
const sseUrl = new URL(config.target.ssePath, config.target.baseUrl).toString();
const sseTasks = tokens.slice(0, phase.sseConnections).map((token) =>
runSseConnection({
url: sseUrl,
token,
signal: controller.signal,
metrics,
onActiveChange: (delta) => {
activeConnections += delta;
metrics.sseActiveCurrent = activeConnections;
},
})
);
const httpTask = runHttpViewers({ config, phase, tokens, signal: controller.signal, metrics });
const settled = await Promise.allSettled([...sseTasks, httpTask]);
clearTimeout(timer);
const rejected = settled.find((result): result is PromiseRejectedResult => result.status === 'rejected');
if (rejected) {
sampler.stop(activeConnections);
throw rejected.reason;
}
const processSummary = sampler.stop(activeConnections);
return {
name: phase.name,
kind: phase.kind,
configuredDurationMs: phase.durationMs,
elapsedMs: Math.round(performance.now() - started),
metrics: summarizePhaseMetrics(metrics, processSummary),
};
};
export const describeDryRun = (config: LoadConfig) => ({
name: config.name,
targetHost: new URL(config.target.baseUrl).hostname,
isolation: config.isolation,
capacity: config.capacity,
phases: config.phases.map((phase) => ({
name: phase.name,
kind: phase.kind,
durationMs: phase.durationMs,
sseConnections: phase.sseConnections,
requestIntervalMs: phase.requestIntervalMs,
operations: phase.operations.map((operation) => ({ name: operation.name, weight: operation.weight })),
})),
});
export const runLoadTest = async (options: {
config: LoadConfig;
configSha256: string;
tokens: readonly string[];
workspaceRoot: string;
}) => {
const startedAt = new Date().toISOString();
const [git, environment] = await Promise.all([gitMetadata(options.workspaceRoot), runtimeAndHost()]);
const phases: PhaseResult[] = [];
for (const phase of options.config.phases) phases.push(await runPhase(options.config, phase, options.tokens));
return {
formatVersion: 1,
startedAt,
finishedAt: new Date().toISOString(),
config: {
name: options.config.name,
sha256: options.configSha256,
capacity: options.config.capacity,
isolation: options.config.isolation,
},
git,
runtime: environment.runtime,
host: environment.host,
hashes: {
configSha256: options.configSha256,
runtimeSha256: environment.runtimeSha256,
hostSha256: environment.hostSha256,
},
targetRuntime: options.config.runtimeMetadata,
phases,
};
};