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