crust-load-testing
Paced load runs with real percentiles and CI-ready exit codes — the load source, parallel sizing by Little's law, drop accounting, and the honesty guarantees that stop a zero-request run reporting success.
crust-load-testing teaches an agent to produce load numbers that are safe to quote — and to recognise the conditions under which they aren’t.
One line gives a paced run with percentiles and an exit code CI can gate on. This is single-process smoke-load and soak tooling, not a distributed rig.
The four shapes
Fixed volume — N requests, K at a time:
range(0, 999) | parallel 50 | GET :3000/health | expect 200 | stats
Paced — an arrival rate for a duration:
load 30s 100/s | parallel 50 | GET :3000/health | expect 200 | stats
Ramp — comma-separated phases, one stats stream across all of them:
load 10s 50/s, 30s 200/s | parallel 100 | GET :3000/health | stats --every 5
POST load — ticks are {n, phase, scheduledAt, lagMs}, so bodies build from them:
load 10s 20/s | (t => ({name: "user" + t.n})) | parallel 8 | POST :3000/users | expect 2xx | stats
Rate and concurrency are independent knobs. Little’s law sizes the pool: N ≥ rate × p99-in-seconds.
The honesty guarantees
This is the part of the skill that matters most, and it is why the numbers are quotable.
- A gate that measured nothing fails.
statsover an empty stream is taggedempty: trueandassertrefuses it. Previously{count: 0, p95: 0}satisfieds => s.p95 < 200, so a run that issued zero requests reported success. - Percentiles never flatter. They are bucketed and report the bucket’s upper bound, so they are never faster than reality.
countandmeanMsare exact, and memory is constant — a multi-hour soak is safe. stats.rpsis measured, never the target. If downstream saturates, stale slots are skipped rather than burst, and the drop is reported on stderr:
load: target 3000 ticks — emitted 2868, dropped 132 … achieved 95.6/s
A drop report means the target rate was not sustained. Raise parallel N or lower the rate before quoting percentiles.
- Percentiles come only from timed records and include body download. With
--every, a slow request lands in the window it finished in.
CI gates
Thresholds are just assert composition — one per threshold, so the failure names the predicate:
load 30s 100/s | parallel 50 | GET :3000/health | stats | assert (s => s.p95 < 200) | assert (s => s.rps > 80)
Baseline comparison — --out writes a versioned artifact and an async assert reads the previous one back:
load 10s 100/s | parallel 50 | GET :3000/health | stats --out load/last.json | assert (async s => { const b = await Bun.file("load/baseline.json").json(); return s.p95 < 2 * b.summary.p95 })
The artifact is written before the gate runs, so a failed gate still leaves the file for CI to upload.
Soak with fail-fast windows — --every N emits {window: k, …} deltas and then {final: true, …}:
load 60s 25/s | parallel 25 | GET :3000/health | stats --every 5 | assert (s => !s.window || s.p95 < 400)
!s.window || scopes a threshold to window objects, !s.final || to the final summary. Both are also correct without --every.
Warmup
Warmup is simply a separate line whose stats aren’t gated:
range(0, 99) | parallel 10 | GET :3000/health | stats
load 30s 100/s | parallel 50 | GET :3000/health | stats | assert (s => s.p95 < 200)