Files
SnapOtter/tests/benchmark/limits-profile.mjs
T

441 lines
15 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
/**
* Resource-limit profiles: what the app does when it is given less than the
* shipped envelope, and whether it comes back.
*
* The shipped `docker/docker-compose.yml` pins the app at `mem_limit: 6g` and
* `cpus: 4`. Two questions follow, and only one of them is answered by running
* the shipped numbers:
*
* 1. Is 6 GB actually safe under *concurrent* load rather than one tool at a
* time? Answered by the `baseline` profile's peak RSS and the headroom it
* leaves over the heaviest single-tool figure the AI lane measured.
* 2. When an operator gives it less, does it fail cleanly? A container that
* OOM-kills and comes back healthy is an acceptable answer. One that
* wedges, corrupts an artifact or strands a job is not.
*
* Each profile recreates the app container at the profile's limits, reads the
* limits back from Docker rather than trusting the file, drives real
* oracle-checked load through every pool, then asks three questions the verdict
* is built from:
*
* bounded the container stayed inside its own limit, or the kernel
* enforced it; the host was never the thing that gave way
* clean no request returned wrong bytes, and no job was left
* non-terminal in the database once the dust settled
* recoverable /api/v1/health answered again, and a real job submitted after
* the event completed and passed its oracle
*
* node tests/benchmark/limits-profile.mjs --profiles baseline,mem-2g,cpu-1
*/
import { execFile, spawn } from "node:child_process";
import { appendFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import {
hostLoad,
latencyStats,
loadFixtures,
login,
runWorkload,
Sampler,
summariseSamples,
} from "./perf-suite.mjs";
const execFileAsync = promisify(execFile);
const HERE = dirname(fileURLToPath(import.meta.url));
const COMPOSE_FILE = resolve(HERE, "perf-stack.compose.yml");
const MIXED = ["image", "media", "document", "file", "imageHeavy", "video"];
/**
* `baseline` is the shipped envelope and exists to produce the headroom number.
* `mem-2g` is the interesting failure: comfortably above the idle footprint,
* comfortably below what concurrent load wants, so the kernel has to enforce it
* mid-job. `mem-1g` is below the idle footprint, which tests the other failure
* shape: dying before it can do anything. `cpu-1` starves scheduling rather
* than memory, which is the profile a small VPS actually runs.
*/
const PROFILES = {
baseline: { mem: "6g", cpus: "4", clients: 20, iterations: 2 },
"mem-2g": { mem: "2g", cpus: "4", clients: 10, iterations: 2 },
"mem-1g": { mem: "1g", cpus: "4", clients: 4, iterations: 1 },
"cpu-1": { mem: "6g", cpus: "1", clients: 5, iterations: 2 },
};
function parseArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
if (!argv[index].startsWith("--")) continue;
const key = argv[index].slice(2);
const next = argv[index + 1];
args[key] = next === undefined || next.startsWith("--") ? "true" : next;
if (args[key] !== "true") index += 1;
}
return args;
}
const args = parseArgs(process.argv.slice(2));
const cfg = {
project: args.project ?? "snapotter-qa-soak-limits",
port: args.port ?? "13498",
image: args.image ?? "",
expectImage: args["expect-image"] ?? "",
password: args.password ?? "",
username: "admin",
out: args.out ?? "",
timeoutMs: Number(args["timeout-ms"] ?? 900_000),
sampleIntervalMs: Number(args["sample-interval-ms"] ?? 5_000),
redisPassword: "snapotter",
settleMs: Number(args["settle-ms"] ?? 60_000),
};
cfg.baseUrl = `http://127.0.0.1:${cfg.port}`;
cfg.appContainer = `${cfg.project}-app`;
cfg.pgContainer = `${cfg.project}-postgres`;
cfg.redisContainer = `${cfg.project}-redis`;
function emit(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`);
}
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
async function docker(...argv) {
const { stdout } = await execFileAsync("docker", argv, { timeout: 300_000 });
return stdout.trim();
}
async function compose(env, ...argv) {
const { stdout, stderr } = await execFileAsync(
"docker",
["compose", "-p", cfg.project, "-f", COMPOSE_FILE, ...argv],
{
timeout: 600_000,
env: {
...process.env,
PERF_PROJECT: cfg.project,
PERF_APP_PORT: cfg.port,
PERF_IMAGE: cfg.image,
PERF_ADMIN_PASSWORD: cfg.password,
...env,
},
},
);
return `${stdout}${stderr}`.trim();
}
/** Limits as Docker actually applied them, never as the compose file asked. */
async function readBackLimits() {
const raw = await docker(
"inspect",
"--type",
"container",
"--format",
"{{.Image}}\t{{.HostConfig.Memory}}\t{{.HostConfig.NanoCpus}}\t{{.RestartCount}}\t{{.State.OOMKilled}}",
cfg.appContainer,
);
const [imageId, memory, nanoCpus, restarts, oomKilled] = raw.split("\t");
return {
imageId,
memLimitMiB: Number(memory) / (1024 * 1024),
cpus: Number(nanoCpus) / 1e9,
restarts: Number(restarts),
oomKilled: oomKilled === "true",
};
}
async function waitForHealth(budgetMs) {
const started = performance.now();
while (performance.now() - started < budgetMs) {
const response = await fetch(new URL("/api/v1/health", cfg.baseUrl), {
signal: AbortSignal.timeout(5_000),
}).catch(() => null);
if (response?.ok) return Number(((performance.now() - started) / 1000).toFixed(2));
await sleep(2_000);
}
return null;
}
/** Rows that never reached a terminal state are the stranded-state signature. */
async function nonTerminalJobs() {
const raw = await docker(
"exec",
cfg.pgContainer,
"psql",
"-U",
"snapotter",
"-d",
"snapotter",
"-tAc",
"select count(*) from jobs where status not in ('completed','failed','canceled')",
);
return Number(raw.trim());
}
async function jobRowCount() {
const raw = await docker(
"exec",
cfg.pgContainer,
"psql",
"-U",
"snapotter",
"-d",
"snapotter",
"-tAc",
"select count(*) from jobs",
);
return Number(raw.trim());
}
/**
* Watch for the container dying, live, for as long as the profile runs.
*
* Getting this after the fact does not work. `.State.OOMKilled` belongs to the
* run that ended, and `restart: unless-stopped` has already replaced it with a
* fresh run whose flag is false before anything can ask. Polling `docker inspect`
* is no better, because Docker goes restarting -> running inside a second.
* Replaying `docker events` with `--since/--until` in the past is no better
* either: Docker Desktop keeps only about thirty seconds of history, so the query
* returns an empty result that reads exactly like "nothing died".
*
* A live subscription started before the load catches every death and carries the
* exit code with it, which is what separates an OOM kill (137) from anything
* else. The dedicated `oom` event is subscribed alongside it and agrees, but the
* exit code is the one to reason from: it is present whatever the cause.
*/
function watchDeaths() {
const child = spawn("docker", [
"events",
"--filter",
`container=${cfg.appContainer}`,
"--filter",
"event=die",
"--filter",
"event=oom",
"--format",
'{{.Action}} {{index .Actor.Attributes "exitCode"}}',
]);
const lines = [];
child.stdout.on("data", (chunk) => {
for (const line of String(chunk).split("\n")) {
if (line.trim()) lines.push(line.trim());
}
});
child.on("error", () => {});
return {
stop() {
child.kill();
const exitCodes = lines
.filter((line) => line.startsWith("die"))
.map((line) => Number(line.split(" ")[1]));
return {
dieExitCodes: exitCodes,
oomKills: exitCodes.filter((code) => code === 137).length,
oomEvents: lines.filter((line) => line.startsWith("oom")).length,
};
},
};
}
function rotate(list, offset) {
const index = offset % list.length;
return [...list.slice(index), ...list.slice(0, index)];
}
async function driveLoad(token, fixtures, clients, iterations) {
const results = [];
await Promise.all(
Array.from({ length: clients }, async (_, index) => {
const keys = rotate(MIXED, index);
for (let round = 0; round < iterations; round += 1) {
for (const key of keys) {
const result = await runWorkload(cfg, token, fixtures, key);
results.push(result);
emit({ kind: "request", ...result });
}
}
}),
);
return results;
}
/**
* Classify every failure. "Errors happened" is not a verdict: a refused
* connection while the container is being OOM-killed is the limit working,
* whereas a request that hangs to the client timeout is the wedge this profile
* exists to catch.
*/
function classifyFailures(results) {
const buckets = { refused: 0, timedOut: 0, httpError: 0, oracle: 0, other: 0 };
for (const result of results.filter((r) => !r.ok)) {
const message = String(result.error ?? "");
if (/fetch failed|ECONNREFUSED|ECONNRESET|socket hang up|other side closed/i.test(message)) {
buckets.refused += 1;
} else if (/timed out|timeout|aborted/i.test(message)) {
buckets.timedOut += 1;
} else if (/HTTP \d{3}|returned HTTP/i.test(message)) {
buckets.httpError += 1;
} else if (
/expected|magic|truncated|oracle|page|duration|width|is not valid|reported failure/i.test(
message,
)
) {
buckets.oracle += 1;
} else {
buckets.other += 1;
}
}
return buckets;
}
async function runProfile(name) {
const profile = PROFILES[name];
if (!profile) throw new Error(`unknown profile ${name}`);
const deaths = watchDeaths();
log(`profile ${name}: recreating app at mem=${profile.mem} cpus=${profile.cpus}`);
await compose(
{ PERF_APP_MEM: profile.mem, PERF_APP_CPUS: profile.cpus },
"up",
"-d",
"--force-recreate",
"app",
).catch((error) => log(`compose up reported: ${error.message.slice(0, 200)}`));
const bootHealthS = await waitForHealth(180_000);
const limits = await readBackLimits();
const jobsBefore = bootHealthS === null ? null : await jobRowCount().catch(() => null);
// Profiles share one database, so a raw count would inherit whatever the
// previous profile left behind. Only the delta belongs to this profile.
const strandedBefore = bootHealthS === null ? null : await nonTerminalJobs().catch(() => -1);
emit({ kind: "profile-start", profile: name, ...profile, limits, bootHealthS, strandedBefore });
if (bootHealthS === null) {
// The app never became healthy at this ceiling. That is a legitimate
// outcome to record, not a harness failure: it is the answer to "what
// happens below the idle footprint".
const logs = await docker("logs", "--tail", "30", cfg.appContainer).catch(() => "");
const verdict = {
kind: "profile",
profile: name,
...profile,
limits,
bootHealthS: null,
booted: false,
restartsDuringBoot: (await readBackLimits()).restarts - limits.restarts,
...deaths.stop(),
tailLog: logs.split("\n").slice(-6).join(" | ").slice(0, 600),
hostLoad: hostLoad(),
};
emit(verdict);
return verdict;
}
const token = await login(cfg);
const fixtures = await loadFixtures();
const sampler = new Sampler(cfg);
sampler.start(`limits-${name}`);
const loadBefore = hostLoad();
const started = performance.now();
const results = await driveLoad(token, fixtures, profile.clients, profile.iterations);
const wallS = (performance.now() - started) / 1000;
// Let restarts, retries and the queues settle before asking whether anything
// was left behind. Reading the jobs table the instant load stops would count
// still-running work as stranded.
await sleep(cfg.settleMs);
const samples = await sampler.stop();
const healthRecoveryS = await waitForHealth(180_000);
const afterProbe =
healthRecoveryS === null ? null : await runWorkload(cfg, token, fixtures, "image");
const after = await readBackLimits();
const stranded = await nonTerminalJobs().catch(() => -1);
const jobsAfter = await jobRowCount().catch(() => null);
const failures = classifyFailures(results);
const resources = summariseSamples(samples);
const peakRssMiB = resources.appMemMaxMiB;
const verdict = {
kind: "profile",
profile: name,
...profile,
limits,
booted: true,
bootHealthS,
requests: results.length,
ok: results.filter((r) => r.ok).length,
errors: results.filter((r) => !r.ok).length,
failures,
wallS: Number(wallS.toFixed(2)),
throughputPerS: Number((results.length / wallS).toFixed(3)),
latency: latencyStats(results.filter((r) => r.ok).map((r) => r.latencyS)),
peakRssMiB,
limitMiB: limits.memLimitMiB,
peakOfLimitPct: Number(((peakRssMiB / limits.memLimitMiB) * 100).toFixed(1)),
restartsDelta: after.restarts - limits.restarts,
oomFlagAtEnd: after.oomKilled,
...deaths.stop(),
jobsBefore,
jobsAfter,
strandedBefore,
strandedJobs: stranded,
strandedDelta: stranded - (strandedBefore ?? 0),
healthRecoveryS,
afterProbeOk: Boolean(afterProbe?.ok),
afterProbeError: afterProbe?.ok ? undefined : afterProbe?.error,
// bounded: RSS never exceeded the ceiling the container was given.
bounded: peakRssMiB <= limits.memLimitMiB,
// clean: nothing came back wrong, and nothing was left half-done.
clean: failures.oracle === 0 && failures.other === 0 && stranded - (strandedBefore ?? 0) === 0,
recoverable: healthRecoveryS !== null && Boolean(afterProbe?.ok),
resources,
hostLoadBefore: loadBefore,
hostLoadAfter: hostLoad(),
sampleFailures: results
.filter((r) => !r.ok)
.slice(0, 8)
.map((r) => ({ workload: r.workload, status: r.admissionStatus, error: r.error })),
};
emit(verdict);
log(
`profile ${name}: ${verdict.ok}/${verdict.requests} ok, peak ${peakRssMiB} MiB of ${limits.memLimitMiB} MiB, ` +
`restarts +${verdict.restartsDelta}, stranded ${stranded}, recoverable ${verdict.recoverable}`,
);
return verdict;
}
async function main() {
if (!cfg.image) throw new Error("--image is required");
if (!cfg.password) throw new Error("--password is required");
const names = (args.profiles ?? "baseline,mem-2g,mem-1g,cpu-1").split(",");
emit({ kind: "run", project: cfg.project, baseUrl: cfg.baseUrl, profiles: names });
const verdicts = [];
for (const name of names) {
verdicts.push(await runProfile(name));
await sleep(5_000);
}
emit({
kind: "summary",
profiles: verdicts.map((v) => ({
profile: v.profile,
booted: v.booted,
peakRssMiB: v.peakRssMiB ?? null,
limitMiB: v.limitMiB ?? v.limits?.memLimitMiB ?? null,
errors: v.errors ?? null,
restartsDelta: v.restartsDelta ?? null,
strandedJobs: v.strandedJobs ?? null,
bounded: v.bounded ?? null,
clean: v.clean ?? null,
recoverable: v.recoverable ?? null,
})),
});
}
await main();