Files
SnapOtter/tests/benchmark/perf-suite.mjs
T

880 lines
30 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
/**
* Mixed-pool load, isolation, sustained-throughput and soak harness.
*
* `bench.sh` measures one tool at a time against one pool. This drives all
* four processing pools at once, because that is the shape of a real install
* and the only shape in which pool fairness, queue backpressure and the
* shipped 6 GB memory ceiling mean anything.
*
* Every request goes through the same completion-aware resolver the rest of
* the benchmark suite uses, with a semantic oracle attached, so a tier can
* only be green when every response carried the bytes the settings asked for.
* Host load is recorded next to every tier: a number taken while the box is
* busy with someone else's work measures the host, not the product.
*
* node tests/benchmark/perf-suite.mjs fixtures --out fixtures.json
* node tests/benchmark/perf-suite.mjs concurrency --clients 1,3,5,10,20
* node tests/benchmark/perf-suite.mjs isolation
* node tests/benchmark/perf-suite.mjs sustained --minutes 20
* node tests/benchmark/perf-suite.mjs soak --minutes 20
* node tests/benchmark/perf-suite.mjs summary --in run.jsonl
*/
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { appendFileSync, readFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { loadavg } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { resolveBenchmarkResponse } from "./lib/job-aware.mjs";
const execFileAsync = promisify(execFile);
const HERE = dirname(fileURLToPath(import.meta.url));
const FIXTURE_ROOT = resolve(HERE, "../fixtures");
/**
* The frozen stress set. One representative fixture per modality, each chosen
* to be heavy enough that the work is real and small enough that a 20-client
* tier finishes inside a QA window. Hashes are recorded so a later run can
* prove it measured the same bytes.
*/
export const WORKLOADS = {
image: {
pool: "image",
tool: "image/resize",
files: ["image/valid/stress-large.jpg"],
settings: { width: 800, fit: "cover" },
oracle: { width: 800 },
},
imageHeavy: {
pool: "image",
tool: "image/convert",
files: ["image/valid/stress-large.jpg"],
settings: { format: "avif", quality: 50 },
expectedMime: "image/avif",
oracle: { minBytes: 4096 },
},
media: {
pool: "media",
tool: "audio/trim-audio",
files: ["audio/valid/media-30s.wav"],
settings: { startS: 0, endS: 5 },
oracle: { durationS: 5, toleranceS: 0.3 },
},
video: {
pool: "media",
tool: "video/trim-video",
files: ["video/valid/media-30s.mp4"],
settings: { startS: 0, endS: 3 },
oracle: { durationS: 3, toleranceS: 0.6 },
},
document: {
pool: "docs",
tool: "pdf/rotate-pdf",
files: ["document/valid/multipage-6.pdf"],
settings: { angle: 90 },
oracle: { pages: 6 },
},
file: {
pool: "docs",
tool: "files/csv-json",
files: ["data/valid/tiny.csv"],
settings: { pretty: true },
oracle: { textIncludes: "Grace" },
},
};
/** Round-robin order for the mixed tiers: every pool appears every cycle. */
const MIXED = ["image", "media", "document", "file", "imageHeavy", "video"];
const POOLS = ["image", "media", "ai", "docs", "system"];
function parseArgs(argv) {
const args = { _: [] };
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (!token.startsWith("--")) {
args._.push(token);
continue;
}
const key = token.slice(2);
const next = argv[index + 1];
if (next === undefined || next.startsWith("--")) {
args[key] = "true";
continue;
}
args[key] = next;
index += 1;
}
return args;
}
function config(args) {
return {
baseUrl: args["base-url"] ?? process.env.PERF_BASE_URL ?? "http://127.0.0.1:13496",
username: args.username ?? process.env.PERF_USERNAME ?? "admin",
password: args.password ?? process.env.PERF_PASSWORD ?? "",
appContainer: args["app-container"] ?? process.env.PERF_APP_CONTAINER ?? "",
pgContainer: args["pg-container"] ?? process.env.PERF_PG_CONTAINER ?? "",
redisContainer: args["redis-container"] ?? process.env.PERF_REDIS_CONTAINER ?? "",
redisPassword: args["redis-password"] ?? process.env.PERF_REDIS_PASSWORD ?? "snapotter",
out: args.out ?? process.env.PERF_OUT ?? "",
timeoutMs: Number(args["timeout-ms"] ?? 300_000),
sampleIntervalMs: Number(args["sample-interval-ms"] ?? 5_000),
};
}
function emit(cfg, record) {
const line = `${JSON.stringify({ ts: new Date().toISOString(), ...record })}\n`;
if (cfg.out) appendFileSync(cfg.out, line);
else process.stdout.write(line);
}
function log(message) {
process.stderr.write(`[${new Date().toISOString().slice(11, 19)}] ${message}\n`);
}
export function hostLoad() {
const [one, five, fifteen] = loadavg();
return {
load1: Number(one.toFixed(2)),
load5: Number(five.toFixed(2)),
load15: Number(fifteen.toFixed(2)),
};
}
export async function login(cfg) {
if (!cfg.password) throw new Error("--password is required");
const response = await fetch(new URL("/api/auth/login", cfg.baseUrl), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ username: cfg.username, password: cfg.password }),
});
if (!response.ok) throw new Error(`login returned HTTP ${response.status}`);
const payload = await response.json();
if (typeof payload.token !== "string") throw new Error("login response carried no token");
return payload.token;
}
/**
* Proves the container behind the base URL is the image under test. Recording
* a number against an unidentified stack is how a QA campaign ends up
* benchmarking last week's build.
*/
export async function proveImage(cfg, expectedImageId) {
if (!cfg.appContainer) throw new Error("--app-container is required to prove image identity");
const { stdout } = await execFileAsync("docker", [
"inspect",
"--type",
"container",
"--format",
"{{.Image}}\t{{.Config.Image}}\t{{.HostConfig.Memory}}\t{{.HostConfig.NanoCpus}}\t{{.State.Running}}",
cfg.appContainer,
]);
const [imageId, imageRef, memory, nanoCpus, running] = stdout.trim().split("\t");
if (running !== "true") throw new Error(`${cfg.appContainer} is not running`);
if (expectedImageId && imageId !== expectedImageId) {
throw new Error(`container runs ${imageId}, expected ${expectedImageId}`);
}
return { imageId, imageRef, memoryBytes: Number(memory), nanoCpus: Number(nanoCpus) };
}
export async function loadFixtures() {
const cache = new Map();
for (const workload of Object.values(WORKLOADS)) {
for (const relative of workload.files) {
if (cache.has(relative)) continue;
const bytes = await readFile(join(FIXTURE_ROOT, relative));
cache.set(relative, {
name: basename(relative),
bytes,
sha256: createHash("sha256").update(bytes).digest("hex"),
});
}
}
return cache;
}
export async function runWorkload(cfg, token, fixtures, key) {
const workload = WORKLOADS[key];
const form = new FormData();
for (const relative of workload.files) {
const fixture = fixtures.get(relative);
form.append("file", new Blob([fixture.bytes]), fixture.name);
}
if (workload.settings) form.append("settings", JSON.stringify(workload.settings));
const started = performance.now();
let response;
let body;
try {
response = await fetch(new URL(`/api/v1/tools/${workload.tool}`, cfg.baseUrl), {
method: "POST",
headers: { authorization: `Bearer ${token}` },
body: form,
signal: AbortSignal.timeout(cfg.timeoutMs),
});
body = Buffer.from(await response.arrayBuffer());
} catch (error) {
return {
workload: key,
pool: workload.pool,
tool: workload.tool,
ok: false,
admissionStatus: 0,
latencyS: (performance.now() - started) / 1000,
error: error instanceof Error ? error.message : String(error),
};
}
const admissionLatencyS = (performance.now() - started) / 1000;
try {
const resolved = await resolveBenchmarkResponse({
baseUrl: cfg.baseUrl,
token,
admissionStatus: response.status,
admissionMime: response.headers.get("content-type"),
admissionBody: body,
admissionLatencyS,
timeoutMs: cfg.timeoutMs,
expectedMime: workload.expectedMime,
oracle: workload.oracle,
});
return {
workload: key,
pool: workload.pool,
tool: workload.tool,
ok: true,
admissionStatus: response.status,
admissionLatencyS: Number(admissionLatencyS.toFixed(4)),
latencyS: Number(resolved.completionLatencyS.toFixed(4)),
outputSize: resolved.outputSize,
outputMime: resolved.outputMime,
};
} catch (error) {
return {
workload: key,
pool: workload.pool,
tool: workload.tool,
ok: false,
admissionStatus: response.status,
admissionLatencyS: Number(admissionLatencyS.toFixed(4)),
latencyS: (performance.now() - started) / 1000,
error: error instanceof Error ? error.message : String(error),
};
}
}
function percentile(sorted, fraction) {
if (sorted.length === 0) return 0;
const rank = Math.ceil(fraction * sorted.length) - 1;
return sorted[Math.min(Math.max(rank, 0), sorted.length - 1)];
}
export function latencyStats(values) {
const sorted = [...values].sort((a, b) => a - b);
return {
count: sorted.length,
p50: Number(percentile(sorted, 0.5).toFixed(3)),
p95: Number(percentile(sorted, 0.95).toFixed(3)),
max: Number((sorted.at(-1) ?? 0).toFixed(3)),
min: Number((sorted[0] ?? 0).toFixed(3)),
};
}
/** Periodic container, queue, database and disk readings taken during a tier. */
export class Sampler {
constructor(cfg) {
this.cfg = cfg;
this.samples = [];
this.running = false;
}
async probeDockerStats() {
const names = [this.cfg.appContainer, this.cfg.pgContainer, this.cfg.redisContainer].filter(
Boolean,
);
if (names.length === 0) return {};
const { stdout } = await execFileAsync(
"docker",
["stats", "--no-stream", "--format", "{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}", ...names],
{ timeout: 20_000 },
);
const readings = {};
for (const line of stdout.trim().split("\n")) {
const [name, cpu, mem] = line.split("\t");
if (!name) continue;
readings[name] = {
cpuPct: Number(String(cpu).replace("%", "")),
memMiB: parseMemUsage(mem),
};
}
return readings;
}
async probeQueues() {
if (!this.cfg.redisContainer) return {};
const script = `local r={} for _,q in ipairs(ARGV) do r[#r+1]=redis.call('llen','bull:snapotter-'..q..':wait') r[#r+1]=redis.call('llen','bull:snapotter-'..q..':active') r[#r+1]=redis.call('zcard','bull:snapotter-'..q..':failed') end return r`;
const { stdout } = await execFileAsync(
"docker",
[
"exec",
this.cfg.redisContainer,
"redis-cli",
"-a",
this.cfg.redisPassword,
"--no-auth-warning",
"eval",
script,
"0",
...POOLS,
],
{ timeout: 20_000 },
);
const numbers = stdout.trim().split("\n").map(Number);
const queues = {};
POOLS.forEach((pool, index) => {
queues[pool] = {
waiting: numbers[index * 3] ?? 0,
active: numbers[index * 3 + 1] ?? 0,
failed: numbers[index * 3 + 2] ?? 0,
};
});
return queues;
}
async probePostgres() {
if (!this.cfg.pgContainer) return {};
const sql =
"select pg_database_size(current_database()) || ' ' || coalesce((select sum(size) from pg_ls_waldir()),0) || ' ' || (select count(*) from pg_ls_waldir()) || ' ' || (select count(*) from jobs)";
const { stdout } = await execFileAsync(
"docker",
["exec", this.cfg.pgContainer, "psql", "-U", "snapotter", "-d", "snapotter", "-tAc", sql],
{ timeout: 20_000 },
);
const [dbBytes, walBytes, walFiles, jobRows] = stdout.trim().split(" ").map(Number);
return { dbBytes, walBytes, walFiles, jobRows };
}
/**
* Container lifecycle, read from Docker rather than inferred. A soak that
* only samples RSS cannot tell a flat memory curve from a container that
* quietly OOM-killed and restarted back to its baseline.
*/
async probeContainerState() {
if (!this.cfg.appContainer) return {};
const { stdout } = await execFileAsync(
"docker",
[
"inspect",
"--type",
"container",
"--format",
"{{.RestartCount}}\t{{.State.OOMKilled}}\t{{.State.Running}}\t{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
this.cfg.appContainer,
],
{ timeout: 20_000 },
);
const [restarts, oomKilled, running, health] = stdout.trim().split("\t");
return {
restarts: Number(restarts),
oomKilled: oomKilled === "true",
running: running === "true",
health,
};
}
async probeDisk() {
if (!this.cfg.appContainer) return {};
const { stdout } = await execFileAsync(
"docker",
[
"exec",
this.cfg.appContainer,
"sh",
"-c",
"du -sk /data /tmp/workspace 2>/dev/null | awk '{print $1}' | tr '\\n' ' '",
],
{ timeout: 60_000 },
);
const [dataKiB, workspaceKiB] = stdout.trim().split(/\s+/).map(Number);
return { dataKiB, workspaceKiB };
}
async takeSample(phase) {
const [stats, queues, postgres, disk, state] = await Promise.all([
this.probeDockerStats().catch((error) => ({ error: error.message })),
this.probeQueues().catch((error) => ({ error: error.message })),
this.probePostgres().catch((error) => ({ error: error.message })),
this.probeDisk().catch((error) => ({ error: error.message })),
this.probeContainerState().catch((error) => ({ error: error.message })),
]);
const sample = {
kind: "sample",
phase,
elapsedS: Number(((performance.now() - this.startedAt) / 1000).toFixed(1)),
host: hostLoad(),
containers: stats,
queues,
postgres,
disk,
state,
};
this.samples.push(sample);
emit(this.cfg, sample);
return sample;
}
start(phase) {
this.running = true;
this.startedAt = performance.now();
this.loop = (async () => {
while (this.running) {
await this.takeSample(phase).catch(() => {});
const deadline = Date.now() + this.cfg.sampleIntervalMs;
while (this.running && Date.now() < deadline) {
await new Promise((done) => setTimeout(done, 250));
}
}
})();
}
async stop() {
this.running = false;
await this.loop;
return this.samples;
}
}
function parseMemUsage(value) {
const match = /([0-9.]+)\s*([KMGT]?i?B)/.exec(String(value ?? ""));
if (!match) return 0;
const amount = Number(match[1]);
const unit = match[2];
const factor = unit.startsWith("G")
? 1024
: unit.startsWith("T")
? 1024 * 1024
: unit.startsWith("K")
? 1 / 1024
: unit === "B"
? 1 / (1024 * 1024)
: 1;
return Number((amount * factor).toFixed(2));
}
/**
* Growth shape of one sampled series over a phase.
*
* A two-point delta cannot separate a leak from churn: a run that allocates,
* frees and happens to end high looks identical to one that never gives memory
* back. So each series carries a least-squares slope (normalised per hour) and
* a split-half comparison. A leak has a positive slope AND a second half that
* sits above the first; churn has a slope near zero and halves that match.
*/
function seriesGrowth(points) {
const usable = points.filter(
([elapsed, value]) => Number.isFinite(elapsed) && Number.isFinite(value),
);
if (usable.length < 2) return { samples: usable.length };
const values = usable.map(([, value]) => value);
const half = Math.floor(usable.length / 2);
const mean = (list) => list.reduce((sum, value) => sum + value, 0) / list.length;
const meanX = mean(usable.map(([elapsed]) => elapsed));
const meanY = mean(values);
let covariance = 0;
let variance = 0;
for (const [elapsed, value] of usable) {
covariance += (elapsed - meanX) * (value - meanY);
variance += (elapsed - meanX) ** 2;
}
const slopePerSecond = variance === 0 ? 0 : covariance / variance;
return {
samples: usable.length,
first: values[0],
last: values.at(-1),
min: Math.min(...values),
max: Math.max(...values),
slopePerHour: Number((slopePerSecond * 3600).toFixed(3)),
firstHalfMean: Number(mean(values.slice(0, half)).toFixed(2)),
secondHalfMean: Number(mean(values.slice(half)).toFixed(2)),
};
}
/** Per-metric growth curves plus the lifecycle facts a leak verdict needs. */
export function growthReport(samples) {
const appName = Object.keys(samples[0]?.containers ?? {})[0];
const series = (pick) => seriesGrowth(samples.map((s) => [s.elapsedS, pick(s)]));
const restarts = samples.map((s) => s.state?.restarts).filter(Number.isFinite);
const backlog = (sample) =>
POOLS.reduce((total, pool) => total + (sample.queues?.[pool]?.waiting ?? 0), 0);
return {
appMemMiB: series((s) => s.containers?.[appName]?.memMiB),
workspaceKiB: series((s) => s.disk?.workspaceKiB),
dataKiB: series((s) => s.disk?.dataKiB),
dbBytes: series((s) => s.postgres?.dbBytes),
walBytes: series((s) => s.postgres?.walBytes),
jobRows: series((s) => s.postgres?.jobRows),
restartsFirst: restarts[0] ?? null,
restartsLast: restarts.at(-1) ?? null,
oomKillSamples: samples.filter((s) => s.state?.oomKilled).length,
notRunningSamples: samples.filter((s) => s.state?.running === false).length,
unhealthySamples: samples.filter(
(s) => s.state?.health && s.state.health !== "healthy" && s.state.health !== "none",
).length,
queueBacklogLast: samples.length ? backlog(samples.at(-1)) : 0,
queueFailedLast: POOLS.reduce(
(total, pool) => total + (samples.at(-1)?.queues?.[pool]?.failed ?? 0),
0,
),
};
}
export function summariseSamples(samples) {
const appName = Object.keys(samples[0]?.containers ?? {})[0];
const memory = samples.map((s) => s.containers?.[appName]?.memMiB ?? 0).filter(Boolean);
const cpu = samples.map((s) => s.containers?.[appName]?.cpuPct ?? 0);
const queueMax = {};
for (const pool of POOLS) {
queueMax[pool] = Math.max(0, ...samples.map((s) => s.queues?.[pool]?.waiting ?? 0));
}
const first = samples[0];
const last = samples.at(-1);
return {
samples: samples.length,
appMemMaxMiB: memory.length ? Math.max(...memory) : 0,
appMemLastMiB: memory.at(-1) ?? 0,
appCpuMaxPct: cpu.length ? Math.max(...cpu) : 0,
queueWaitingMax: queueMax,
dbBytesDelta: (last?.postgres?.dbBytes ?? 0) - (first?.postgres?.dbBytes ?? 0),
walBytesDelta: (last?.postgres?.walBytes ?? 0) - (first?.postgres?.walBytes ?? 0),
dataKiBDelta: (last?.disk?.dataKiB ?? 0) - (first?.disk?.dataKiB ?? 0),
workspaceKiBDelta: (last?.disk?.workspaceKiB ?? 0) - (first?.disk?.workspaceKiB ?? 0),
workspaceKiBLast: last?.disk?.workspaceKiB ?? 0,
growth: growthReport(samples),
};
}
function tierSummary(label, clients, results, samples, loadBefore, loadAfter, wallS) {
const ok = results.filter((r) => r.ok);
const byPool = {};
for (const pool of new Set(results.map((r) => r.pool))) {
const poolResults = results.filter((r) => r.pool === pool);
byPool[pool] = {
...latencyStats(poolResults.filter((r) => r.ok).map((r) => r.latencyS)),
errors: poolResults.filter((r) => !r.ok).length,
};
}
return {
kind: "tier",
tier: label,
clients,
requests: results.length,
errors: results.length - ok.length,
errorRatePct: Number((((results.length - ok.length) / results.length) * 100).toFixed(2)),
wallS: Number(wallS.toFixed(2)),
throughputPerS: Number((results.length / wallS).toFixed(3)),
latency: latencyStats(ok.map((r) => r.latencyS)),
admission: latencyStats(ok.map((r) => r.admissionLatencyS ?? 0)),
async202: results.filter((r) => r.admissionStatus === 202).length,
byPool,
resources: summariseSamples(samples),
hostLoadBefore: loadBefore,
hostLoadAfter: loadAfter,
failures: results
.filter((r) => !r.ok)
.slice(0, 10)
.map((r) => ({ workload: r.workload, status: r.admissionStatus, error: r.error })),
};
}
async function client(cfg, token, fixtures, keys, iterations, sink) {
for (let round = 0; round < iterations; round += 1) {
for (const key of keys) {
const result = await runWorkload(cfg, token, fixtures, key);
sink.push(result);
emit(cfg, { kind: "request", ...result });
}
}
}
function rotate(list, offset) {
const index = offset % list.length;
return [...list.slice(index), ...list.slice(0, index)];
}
function selectedWorkloads(args) {
const requested = args.workloads;
if (!requested) return MIXED;
const keys = requested.split(",");
for (const key of keys) {
if (!WORKLOADS[key]) throw new Error(`unknown workload ${key}`);
}
return keys;
}
async function commandConcurrency(cfg, args) {
const clients = String(args.clients ?? "1,3,5,10,20")
.split(",")
.map(Number);
const iterations = Number(args.iterations ?? 2);
const mix = selectedWorkloads(args);
const token = await login(cfg);
const fixtures = await loadFixtures();
const identity = await proveImage(cfg, args["expect-image"]);
emit(cfg, { kind: "identity", ...identity, baseUrl: cfg.baseUrl });
for (const count of clients) {
const loadBefore = hostLoad();
log(`tier c${count}: host load ${loadBefore.load1}`);
const sampler = new Sampler(cfg);
sampler.start(`concurrency-c${count}`);
const results = [];
const started = performance.now();
await Promise.all(
Array.from({ length: count }, (_, index) =>
client(cfg, token, fixtures, rotate(mix, index), iterations, results),
),
);
const wallS = (performance.now() - started) / 1000;
const samples = await sampler.stop();
const summary = tierSummary(
`${args.label ?? "concurrency"}-c${count}`,
count,
results,
samples,
loadBefore,
hostLoad(),
wallS,
);
emit(cfg, summary);
log(
`tier c${count}: n=${summary.requests} errors=${summary.errors} p50=${summary.latency.p50}s p95=${summary.latency.p95}s max=${summary.latency.max}s mem=${summary.resources.appMemMaxMiB}MiB`,
);
await new Promise((done) => setTimeout(done, 5_000));
}
}
/**
* Pool isolation: flood the image pool, then measure the docs pool from a
* separate client and compare against its own unloaded baseline. A shared
* worker pool would show docs latency tracking the image saturation.
*/
async function commandIsolation(cfg, args) {
const floodClients = Number(args["flood-clients"] ?? 12);
const probeCount = Number(args["probes"] ?? 12);
const token = await login(cfg);
const fixtures = await loadFixtures();
const identity = await proveImage(cfg, args["expect-image"]);
emit(cfg, { kind: "identity", ...identity, baseUrl: cfg.baseUrl });
const loadBefore = hostLoad();
const baseline = [];
for (let index = 0; index < probeCount; index += 1) {
baseline.push(await runWorkload(cfg, token, fixtures, "document"));
}
emit(cfg, {
kind: "isolation-baseline",
...latencyStats(baseline.filter((r) => r.ok).map((r) => r.latencyS)),
errors: baseline.filter((r) => !r.ok).length,
hostLoad: loadBefore,
});
const sampler = new Sampler(cfg);
sampler.start("isolation-flood");
const floodResults = [];
let flooding = true;
const flood = Promise.all(
Array.from({ length: floodClients }, async () => {
while (flooding) {
floodResults.push(await runWorkload(cfg, token, fixtures, "imageHeavy"));
}
}),
);
// Let the image pool build a real backlog before probing the docs pool.
await new Promise((done) => setTimeout(done, 15_000));
const underLoad = [];
for (let index = 0; index < probeCount; index += 1) {
underLoad.push(await runWorkload(cfg, token, fixtures, "document"));
}
flooding = false;
await flood;
const samples = await sampler.stop();
const baselineStats = latencyStats(baseline.filter((r) => r.ok).map((r) => r.latencyS));
const loadedStats = latencyStats(underLoad.filter((r) => r.ok).map((r) => r.latencyS));
emit(cfg, {
kind: "isolation",
floodClients,
floodRequests: floodResults.length,
floodErrors: floodResults.filter((r) => !r.ok).length,
docsBaseline: baselineStats,
docsUnderImageLoad: loadedStats,
docsErrors: underLoad.filter((r) => !r.ok).length,
p95RatioDocs: Number((loadedStats.p95 / Math.max(baselineStats.p95, 0.001)).toFixed(2)),
resources: summariseSamples(samples),
hostLoadBefore: loadBefore,
hostLoadAfter: hostLoad(),
failures: [...floodResults, ...underLoad]
.filter((r) => !r.ok)
.slice(0, 10)
.map((r) => ({ workload: r.workload, status: r.admissionStatus, error: r.error })),
});
}
async function commandSustained(cfg, args) {
const minutes = Number(args.minutes ?? 20);
const concurrency = Number(args.concurrency ?? 2);
const mix = selectedWorkloads(args);
const token = await login(cfg);
const fixtures = await loadFixtures();
const identity = await proveImage(cfg, args["expect-image"]);
emit(cfg, { kind: "identity", ...identity, baseUrl: cfg.baseUrl });
const loadBefore = hostLoad();
const sampler = new Sampler(cfg);
sampler.start("sustained");
const deadline = Date.now() + minutes * 60_000;
const results = [];
const started = performance.now();
await Promise.all(
Array.from({ length: concurrency }, async (_, index) => {
const keys = rotate(mix, index);
let cursor = 0;
while (Date.now() < deadline) {
const key = keys[cursor % keys.length];
cursor += 1;
const result = await runWorkload(cfg, token, fixtures, key);
results.push(result);
emit(cfg, { kind: "request", phase: "sustained", ...result });
if (index === 0 && cursor % 25 === 0) {
const last = sampler.samples.at(-1);
const appName = Object.keys(last?.containers ?? {})[0];
log(
`sustained: ${results.length} requests, ${results.filter((r) => !r.ok).length} errors, ` +
`${Math.round((deadline - Date.now()) / 60_000)} min left, ` +
`rss=${last?.containers?.[appName]?.memMiB ?? "?"}MiB ws=${last?.disk?.workspaceKiB ?? "?"}KiB`,
);
}
}
}),
);
const wallS = (performance.now() - started) / 1000;
const samples = await sampler.stop();
emit(cfg, {
...tierSummary("sustained", concurrency, results, samples, loadBefore, hostLoad(), wallS),
minutes,
latencyByDecile: decileTrend(results),
});
}
/** Latency per tenth of the run: a rising trend is the leak signature. */
function decileTrend(results) {
const ok = results.filter((r) => r.ok);
const bucket = Math.max(1, Math.ceil(ok.length / 10));
const trend = [];
for (let index = 0; index < ok.length; index += bucket) {
trend.push(latencyStats(ok.slice(index, index + bucket).map((r) => r.latencyS)).p50);
}
return trend;
}
async function commandSoak(cfg, args) {
const minutes = Number(args.minutes ?? 20);
const identity = await proveImage(cfg, args["expect-image"]);
emit(cfg, { kind: "identity", ...identity, baseUrl: cfg.baseUrl });
const token = await login(cfg);
const fixtures = await loadFixtures();
const sampler = new Sampler(cfg);
sampler.start("idle-soak");
const deadline = Date.now() + minutes * 60_000;
const health = [];
while (Date.now() < deadline) {
await new Promise((done) => setTimeout(done, 30_000));
const started = performance.now();
const response = await fetch(new URL("/api/v1/health", cfg.baseUrl)).catch(() => null);
health.push({
ok: Boolean(response?.ok),
latencyMs: Number((performance.now() - started).toFixed(1)),
});
if (health.length % 10 === 0) {
log(
`idle soak: ${health.length} health checks, ${Math.round((deadline - Date.now()) / 60_000)} min left`,
);
}
}
// An instance that idles without leaking is only half the answer: it also has
// to still do work afterwards. One real job, oracle-checked, closes that gap.
const afterIdle = await runWorkload(cfg, token, fixtures, "image");
const samples = await sampler.stop();
emit(cfg, {
kind: "soak",
minutes,
healthChecks: health.length,
healthFailures: health.filter((h) => !h.ok).length,
healthLatencyMsMax: Math.max(...health.map((h) => h.latencyMs)),
processingProbeAfterIdle: afterIdle,
resources: summariseSamples(samples),
hostLoadAfter: hostLoad(),
});
}
async function commandFixtures(cfg) {
const fixtures = await loadFixtures();
const manifest = [];
for (const [relative, fixture] of fixtures) {
manifest.push({
path: `tests/fixtures/${relative}`,
bytes: fixture.bytes.length,
sha256: fixture.sha256,
});
}
manifest.sort((a, b) => a.path.localeCompare(b.path));
emit(cfg, { kind: "fixtures", workloads: Object.keys(WORKLOADS), manifest });
}
function commandSummary(args) {
const rows = readFileSync(args.in, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line))
.filter((row) => row.kind === "tier");
const header =
"| tier | clients | n | errors | p50 s | p95 s | max s | req/s | 202s | app RSS MiB | host load |";
const lines = [header, "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |"];
for (const row of rows) {
lines.push(
`| ${row.tier} | ${row.clients} | ${row.requests} | ${row.errors} | ${row.latency.p50} | ${row.latency.p95} | ${row.latency.max} | ${row.throughputPerS} | ${row.async202} | ${row.resources.appMemMaxMiB} | ${row.hostLoadBefore.load1} -> ${row.hostLoadAfter.load1} |`,
);
}
process.stdout.write(`${lines.join("\n")}\n`);
}
const COMMANDS = {
fixtures: (cfg) => commandFixtures(cfg),
concurrency: commandConcurrency,
isolation: commandIsolation,
sustained: commandSustained,
soak: commandSoak,
};
async function main() {
const args = parseArgs(process.argv.slice(2));
const command = args._[0];
if (command === "summary") return commandSummary(args);
const handler = COMMANDS[command];
if (!handler) {
process.stderr.write(`unknown command ${command ?? "(none)"}\n`);
process.exitCode = 2;
return;
}
await handler(config(args), args);
}
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
await main();
}