mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
A release-readiness QA pass over the whole product. The commits split into defects a user would hit and gates that were reporting green while measuring nothing. ## Fixes that change behaviour Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so request.ip came from a client-set header and a forged X-Forwarded-For got past the login limiter. The default is now a private-network trust list. A transient Postgres outage stranded in-flight jobs, leaving finished output on disk with no row pointing at it. A reconciler now resolves those rows and adopts the bytes rather than dropping the work. A Redis connection that moved to a new address wedged every read-blocked consumer, so completions stopped signalling while health still answered 200. Socket timeouts plus subscriber pings recover it. Installing more than one AI bundle left the shared venv multi-versioned and silently broke three tools. The installer now reconciles distributions to one version each. Converting an image to JXL at quality 1 through 4 returned a 500, because libjxl 0.7 rejects the distance those values compute. The quality is floored at what the encoder honours. A missing ffmpeg was also reported to the user as a corrupt upload; it now says the engine is unavailable. RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at 0.22.2, and the release scan was split so it can fail on an unfixed critical instead of hiding it behind ignore-unfixed. ## Gates that could not fail Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs build; coverage discarded its whole report on any failing test; the lint gate skipped root tests, scripts, and two workspaces; and several generated matrices counted a host missing ffmpeg as a passing tool. Each now measures what it claims. Full evidence and the outstanding release items are tracked locally and are not part of this branch.
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();
|