Measurements: one host, 2026-10-15¶
Four runs of tools/loadtest.py, kept as raw reports so that every
number quoted in docs/testing.md,
docs/caching.md and
docs/production-coverage.md can be checked against the document
that produced it instead of being taken on trust.
Read the host line before the numbers. These are measurements of Docket on this machine, not benchmarks of Docket, of a model provider, or of your machine:
| host | 2 vCPUs, ~2 GB RAM, Python 3.13.16, Linux 6.1.158 x86_64 |
| provider | python -m docket.fakeupstream on loopback, one scripted step, no network |
| boundary | docket run (supervisor + boundary in one process), one usage row per call, fsync per event |
| clock | this container's, with other jobs on the same 2 cores between runs |
| paths | the container's own: the reports are the JSON the harness wrote, unedited, so their command, ledger_path and receipt_path fields carry absolute paths under /home/user/.cache/tmp/… |
The runs answer four different questions, and the reports are deliberately separate artefacts:
| report | question | shape |
|---|---|---|
load-report.json |
how fast can the recorder go, and what does a call cost in round trips? | 2,000 calls, 8 threads, as fast as the boundary answers |
soak-report.json |
does anything drift or grow over ten minutes of steady traffic? | 12,002 calls, 2 threads, paced to 20 calls/s for 600 s |
control-report.json |
is that growth a leak, or the bounded cost of the events recorded? | 6,002 calls, the same rate for half the time |
cache-report.json |
what does the response cache answer under the same 8-thread load, and does the receipt still reconcile? | 2,000 identical calls, 8 threads, cache capped at 5,000 answers |
Every run ends the same way: the receipt is read back, the ledger is located by the digest the
receipt claims, the client's count of answered calls is compared with the ledger's rows, and
docket verify re-checks the receipt offline. A run whose two sides disagreed, whose rows lacked a
latency, or whose receipt failed to verify would have been reported as not usable as evidence and
would not be in this directory.
The load point¶
The command is
make load # python3 tools/loadtest.py --calls 2000 --concurrency 8 --report load-report.json
| measurement | value | source |
|---|---|---|
| calls answered | 2,000 | client, and 2,000 model.response rows in the ledger |
| calls refused | 0 | client, from the boundary's code |
| calls errored | 0 | client |
| concurrency | 8 threads, one process | the driver's own argv |
| wall clock | 11.368 s | the orchestrator's clock |
| throughput | 175.93 calls/s | calls answered ÷ wall clock |
| client latency p50 / p95 / p99 | 44 / 48 / 53 ms (max 58 ms) | nearest-rank over 2,000 samples |
| supervisor RSS start / end / peak | 27,220 / 31,408 / 31,408 KiB (11 samples) | /proc/<pid>/status once a second, plus VmHWM |
| receipt verifies afterwards | yes (7 pass / 0 fail / 4 skip) | docket verify --json |
Host: Python 3.13.16 on Linux-6.1.158+-x86_64-with-glibc2.41, 2 cpus. one machine, one supervisor process; not a benchmark of the provider or of any other host
The ten-minute soak¶
The command is
make soak # python3 tools/loadtest.py --calls 200000 --concurrency 2 --seconds 600 --rate 20
| measurement | value | source |
|---|---|---|
| calls answered | 12,002 | client, and 12,002 model.response rows in the ledger |
| calls refused | 0 | client, from the boundary's code |
| calls errored | 0 | client |
| concurrency | 2 threads, one process | the driver's own argv |
| wall clock | 600.166 s | the orchestrator's clock |
| throughput | 20.00 calls/s | calls answered ÷ wall clock |
| client latency p50 / p95 / p99 | 4 / 5 / 7 ms (max 35 ms) | nearest-rank over 12,002 samples |
| supervisor RSS start / end / peak | 27,148 / 54,328 / 54,328 KiB (600 samples) | /proc/<pid>/status once a second, plus VmHWM |
| receipt verifies afterwards | yes (7 pass / 0 fail / 4 skip) | docket verify --json |
Host: Python 3.13.16 on Linux-6.1.158+-x86_64-with-glibc2.41, 2 cpus. one machine, one supervisor process; not a benchmark of the provider or of any other host
The control run: is that growth a leak?¶
The ten-minute soak ends +27,180 KiB above where it started, which is exactly the kind of number that gets read as a leak and is worth testing rather than arguing about. So the same run was done again at the same rate for half the time:
| run | window | calls | ledger events | supervisor RSS start → end | growth | per event |
|---|---|---|---|---|---|---|
soak (soak-report.json) |
600.166 s | 12,002 | 24,014 | 27,148 → 54,328 KiB | +27,180 KiB | 1.132 KiB |
control (control-report.json) |
300.083 s | 6,002 | 12,014 | 27,132 → 40,852 KiB | +13,720 KiB | 1.142 KiB |
load point (load-report.json) |
11.368 s | 2,000 | 4,010 | 27,220 → 31,408 KiB | +4,188 KiB | 1.044 KiB |
Half the duration halves the growth, and the per-event cost lands between 1.04 and 1.14 KiB across
runs spanning a 6× range in length, with the two paced runs — which differ only in how long they
lasted — agreeing to within 1%. That is the opposite of a leak's signature: what is growing is the
in-memory list of events the boundary keeps so it can derive a Merkle root and write a receipt,
bounded by the run's own max_events and max_ledger_bytes ceilings, and gone when the process
exits. That is a real design consequence, not a reassurance: a run that recorded ten million events
would hold roughly 11 GB in memory before the ceilings stopped it, which is why the ceilings exist
and why docs/production-coverage.md lists reading a ledger at that
scale as unmeasured. The measured claim here is narrower and checkable: memory tracks events
recorded, not time elapsed, at ~1.1 KiB per event, on this host, up to 24,014 events.
The cached point: what the cache answers under the same load¶
The load point repeated with the response cache on (make cache-load, raw report in
cache-report.json). The driver sends one identical body from eight threads for the same call
budget, so the cache's ceiling is set to 5,000 answers — high enough that no call in this run hits
the brake:
| measurement | cached (cache-report.json) |
uncached (load-report.json) |
|---|---|---|
| calls answered | 2,000 | 2,000 |
| reached the provider | 8 | 2,000 |
| answered from cache | 1,992 | — |
| not spent, recomputed by the verifier | $11.952000 | — |
spend kept in totals.cost_usd |
$0.048000 | $12.000000 |
| wall clock | 11.018 s | 11.368 s |
| throughput | 181.53 calls/s | 175.93 calls/s |
| client latency p50 / p95 / p99 | 44 / 48 / 48 ms (max 52) | 44 / 48 / 53 ms (max 58) |
| supervisor RSS start → end | 27,284 → 29,624 KiB | 27,220 → 31,408 KiB |
docket verify afterwards |
11 pass / 0 fail / 3 skip | 11 pass / 0 fail / 3 skip |
The saving is the measurement: 1,992 of 2,000 calls never left the process, and the receipt states the amount the verifier recomputes. The spend line is the second half of the same claim — the totals carry the eight calls that really happened and no trace of the 1,992 that did not, which is the property that keeps a receipt describing a run rather than a story about one.
Two things this table does not support, both of them stated rather than implied:
- Not a throughput claim. 181.53 vs 175.93 calls/s is a 3% difference between two runs on a shared 2-vCPU host, the same order as the run-to-run spread already recorded above (99.69 → 166.56 → 175.93 calls/s as the host settled). What makes the cached calls cheap is that they do not do work, not that the code got faster.
- Not one call for 2,000. Eight calls reached the provider because eight threads missed the same
key at the same instant and there is no request coalescing: each thread that finds nothing
forwards, and the last store wins. Eight metered responses produced six cache groups (two origins
were replaced before anything replayed them) — visible in the receipt rather than in a footnote.
docs/caching.mdnames this as a limit; this run is the number behind it. - Not a saving on your workload. One identical body is the best case a cache can be given. The
honest version of this measurement for a real agent is
cache.hitsin its own receipt.
What these numbers cannot support¶
- Not a provider benchmark. The upstream is scripted and local; a real provider's latency would dominate everything here and would say nothing about Docket.
- Not a load curve. One machine, one process, one concurrency point (8 threads) and one paced rate (20 calls/s). There is no scaling curve, no saturation knee, no queueing analysis, and no p99-vs-concurrency plot — claiming any of those from these four runs would be inventing them.
- Not a leak-free claim. Ten minutes at 20 calls/s is a bounded test that would catch a gross leak; it cannot speak to hours. What it does support is the observation above — memory tracks the number of recorded events, bounded by the run's own ceilings — with a control run rather than an argument.
- Not portable. Another host, another filesystem (the evidence write is
fsynced per event), and another Python build will give different numbers. The command is in every report; re-running it is the only way to get a number about your machine.
Reproducing¶
make load # 2,000 calls, 8 threads
make soak # 12,000 calls paced over ten minutes
make cache-load # 2,000 identical calls, 8 threads, response cache on
Each writes its ledger and receipt next to the report (--work-dir), so the evidence for a number
you want to argue about is on disk. make check does not run either one: a shared CI runner's
throughput would be a fact about the runner. CI runs the harness's own tests instead
(tests/test_loadtest.py), including the ones that assert a dropped call, a missing latency, a
receipt that does not verify, or two sides that disagree all make a run unusable.
One version note. The two paced runs were taken with the harness as it stood when this directory was written; the load point was re-taken later. The change in between touched only how the memory sampler waits for the workers to exit — it could add up to a second to a run's wall clock, and it never touched a count, a latency or a sample. Re-running any of the three against the current tree is the way to see that for yourself.