Load, Soak and Failure Injection
REQ-033 (#144). Nobody had observed the platform under sustained load or partial failure, so its behaviour when a provider is slow, the queue backs up or the database goes away was unknown. The first customer to hit real load should not be the experiment.
Everything below is measured, not estimated. The harness is src/loadtest, the runner is
scripts/loadtest.mjs, and every number here came out of a JSON report the runner wrote.
npm run loadtest -- --pg <url> --mode staircase
npm run loadtest -- --pg <url> --mode soak --minutes 480
npm run loadtest -- --pg <url> --mode inject [--stop-container <name>]
What is real, and what is not
Real: a real PostgreSQL server, a real migrated schema, several real WorkerRuntime instances competing for one
queue, real atomic lease claims, real checkpoints, a real durable event log, real approval suspension and resume.
Synthetic: the agent engine. A load test cannot drive a paid model provider — the cost scales with the load, the provider's own rate limits become the thing under test, and a provider outage looks like a platform bug. Nobody load-tests through a third party. What is under test is the platform, and the engine's job is to be a believable source of latency, failure and external side effects.
Not reached: a deployed HTTP instance. #144 asks for one and there is none yet, so the GraphQL layer and the genuine process boundary between host and worker are not exercised. Everything below them is. This is stated in every report the runner writes, so a number cannot be quoted without it.
Also not reached: redis-unavailable. The harness deliberately substitutes a bounded in-process queue for
BullMQ, so the backpressure bound is ours rather than a Redis memory setting — which means Redis is not on the
harness's path at all. Running it would prove nothing, so it is declared with its runbook and left unrun.
The measured envelope — AC-1, AC-6
Measured on 14 cores / 24 GiB, Node 25, darwin-arm64, against PostgreSQL 16 in a local container. Traffic: 3 steps per run, 40 ms of simulated model latency per step, 30% of runs performing an external side effect, 10% suspending for a human approval. 3 worker runtimes × concurrency 4, queue bound 200.
| Offered | p50 | p99 | Completed | Refused | Peak queue | Peak RSS |
|---|---|---|---|---|---|---|
| 5/s | 153 ms | 178 ms | 4.9/s | 0 | 0 | 139 MiB |
| 10/s | 146 ms | 165 ms | 9.7/s | 0 | 0 | 135 MiB |
| 20/s | 137 ms | 186 ms | 19.3/s | 0 | 0 | 121 MiB |
| 40/s | 133 ms | 142 ms | 38.0/s | 0 | 0 | 132 MiB |
| 80/s | 129 ms | 207 ms | 76.1/s | 0 | 6 | 136 MiB |
| 160/s | 2180 ms | 2306 ms | 88.8/s | 301 | 200 (bound) | 130 MiB |
Sustainable: 80/s. Degrades at 160/s. Manner: honest refusal.
Read it this way: throughput tracks offered load linearly to 80/s with p99 flat around 200 ms, then at 160/s the queue fills to its bound, 301 admissions are refused with a typed error, and nothing fails and nothing is lost. Memory is flat across the whole range — 121 to 139 MiB, with no trend.
Scale the number by the model latency: this configuration spends 120 ms per run inside a synthetic provider, so
real capacity is bounded by workers × concurrency / provider latency. Twelve slots at 130 ms is ~92/s, and 76/s
measured is that minus overhead. The lever is worker count, not database tuning — which is worth knowing
before someone spends a week on indexes.
Latency excludes runs that waited for a human
Deliberately. Their end-to-end time is dominated by how fast the approver answered, which here is the harness's poll interval and in production is a person. Mixing them in put p99 at ~7 s at every step and made the envelope read "sustainable 0/s" while the platform's real latency was 130 ms. Approval wait is measured separately, for exactly the reason #143 keeps it a distinct metric: it must be visibly not the platform's latency.
Two measurement bugs worth recording
Both flattered the system, which is the direction that matters.
The first measured admission latency, not end-to-end. admit() is a create plus an enqueue — a few
milliseconds however deep the backlog is. The staircase reported "p99 5 ms, sustainable 20/s" for a step whose
queue reached 101 jobs and whose real throughput was 6.9/s. A load test that flatters the system is worse than no
load test, because it converts an unknown into a false belief. Latency now comes from the store's own
created_at → finished_at.
Latency alone is not enough either. A step can post an excellent p99 while completing a third of what was
offered, because the work that never got picked up contributes no sample at all — the fast runs are measured and
the queued ones are invisible. The envelope now requires completions to stay within 10% of offered load, and
reports backlog as a distinct degradation mode from graceful-queueing.
Soak — AC-2
--mode soak samples RSS every second and fits growth over the run, discarding the first quarter as warm-up.
Threshold: 32 MiB/hour. Above JIT warm-up, heap fragmentation and a pool filling to its configured size; below anything that survives a night. A process leaking at that rate grows 768 MiB a day.
The detector refuses to conclude below 12 samples or 5 minutes, and says which. That arm matters more than the others: a short run reporting "no leak" is the single most misleading output this harness could produce, because it looks exactly like a passing result. AC-2 asks for a multi-hour soak because a short burst hides slow growth.
What was actually measured
A six-minute soak, 359 samples:
| Runs admitted | 15,082 |
| Refused at admission | 0 |
| Completed | 15,082 |
| Failed | 0 |
| Still non-terminal | 0 |
| Server-side DB connections at the end | 14 |
| RSS range | 41 – 153 MiB |
| RSS first quartile → last | 109.7 → 87.2 MiB |
| Growth (post-warm-up fit) | −325 MiB/h |
| Verdict | stable |
| Admission latency p50 / p99 | 1 ms / 34 ms |
Every admitted run reached a terminal state — including the tenth that suspended for a human and was resumed. Memory fell over the run rather than rising, and the connection count is flat at 14 against a pool of 32, so neither memory nor connections were being leaked. Admission latency is the enqueue only, and it is quoted as such: the end-to-end figures are in the envelope table.
But this is minutes, not hours. The multi-hour soak AC-2 asks for has not been performed. The harness
supports it (--minutes 480) and six minutes shows no growth at all, but "no leak over six minutes" is not the
claim AC-2 wants and is not being presented as one — the criterion exists precisely because a short burst hides
slow growth.
Growth is judged by a least-squares fit and a quartile comparison, both on the post-warm-up window. Each catches what the other misses: a fit is robust to a spike but dragged by a ramp, a quartile comparison ignores a ramp but is fooled by one outlier. The warm-up exclusion was missing at first and the test for it failed — a 20-second ramp to 500 MiB followed by a flat hour fit at ~420 MiB/h and was reported as a leak. The comment claimed the quartile check handled it; it did not, because the first quartile was the ramp. Two checks sharing a blind spot are one check.
Failure injection — AC-3
Each mode declares what recovery means for it, so "recovered" is not a judgement call made per run. Verdicts require: no lost work, no duplicated external action, and recovery without a human.
| Mode | Result | Evidence |
|---|---|---|
worker-kill | ✅ | Killed 1 of 3 workers holding a lease, mid-run. 40/40 admitted runs terminal; 10 external effects for 10 distinct keys. |
overload | ✅ | 800 offered against a bound of 25: 771 refused, peak depth exactly 25, 29/29 admitted terminal, 8 effects for 8 keys, RSS 123 → 143 MiB. |
provider-rate-limit | ✅ | 30% of steps rejected. 60/60 terminal, 3 effects for 3 keys. |
provider-timeout | ✅ | 30% of steps timed out. 60/60 terminal, 1 effect for 1 key. |
database-unavailable | ⚠️ partial | Real container stopped ~3 s with runs mid-step. 40/40 terminal, 16 effects for 16 keys — but all 40 needed a manual re-drive. |
redis-unavailable | not run | Not on this harness's path; see above. |
database-failover | not run | Needs a replica to promote. A single container has none. |
slow-consumer | not run | Declared with its runbook; the harness has no subscriber yet. |
No duplicated external action, in every mode that ran. Effects equal distinct idempotency keys every time, including after a worker was killed mid-run holding a lease. That is the assertion a load test is uniquely able to make, and it is the one that matters most.
The finding: a run whose claim fails has no recovery path
database-unavailable passes "no data loss" and "no duplicate", and fails "recovers unattended" — all forty
runs had to be re-driven. I first assumed that was a harness artifact, because the harness's queue drops a failed
job. It is not: QUEUE_ATTEMPTS is 1, so the production BullMQ queue does not retry either.
So recovery rests entirely on the lease reaper, and the reaper finds runs in running with an expired lease. A
run whose claim itself failed — the job was consumed, the claim hit a dead database, the run stayed queued —
is on no queue and holds no lease. Nothing will ever pick it up.
Recorded rather than fixed here. The queue's retry policy is a #105/#107 decision with consequences beyond this harness (a retried job that is genuinely poisonous, an attempt budget interacting with the run's own retries), and choosing it inside a load-test issue would be the wrong place. The verdict is left failing so it cannot be forgotten.