mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
880 lines
30 KiB
JavaScript
880 lines
30 KiB
JavaScript
#!/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();
|
||
|
|
}
|