Skip to content

What one host can serve

Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.

This page carries the daemon's measured capacity, the conditions that produced it, and — at the bottom, and read it — what it does not cover.

A capacity statement is a claim about a system nobody has run at capacity. The primary's ambient rate over 671 hours is 1.4 requests per minute, which is about 0.015% of the ceiling below. The number on this page will be quoted for years and has never been near a real load. That is not a reason to withhold it; it is a reason to publish the conditions in the same artefact.

The statement

For one fleet host, on this read mix:

Quote both numbers, never one

About 165 requests per second, and hold in-flight requests at or below 3.

Three for throughput, two for latency. There is no flat latency region at all — p50 and p90 rise from concurrency 1 — and 2 is the last point where p90 is still under 30 ms while throughput is already 96% of maximum.

Past 3 the CPU cap is reached and added concurrency buys nothing: roughly 5 ms of p50 and 8 ms of p90 per extra in-flight request, which is pure queueing.

The ramp

Closed-loop, 40 seconds per level, ten levels, 0 errors in 64 057 requests. Each worker holds one connection open the way a browser does and asks for the next thing the moment the last answer lands, so concurrency here is in-flight requests, not request rate.

in-flight 1 2 3 4 5 6 8 10 12 16
requests/s 111.8 150.9 163.3 163.8 169.6 166.3 168.1 169.8 163.4 163.8
p50 ms 5 9 14 19 24 32 45 55 71 96
p90 ms 20 27 35 42 52 60 74 88 112 150
p99 ms 34 53 58 73 77 91 102 120 151 192
container CPU 130% 141% 152% — the cap 152% 153% 152% 156% 153% 147% 151%

Read the p90 row across, not down: 27 ms at two in flight and 150 ms at sixteen, for a throughput that never rises above the level-5 figure. That is the whole argument for the second number.

A single expensive read dropped into a steady stream is a smaller event than the table above might suggest: one /hygiene/orphans at 950 ms moved session.one from p50 5.1 ms to 11.0 ms while it ran (n=152) and back to 5.8 ms after. The route it lands on matters more than the load — see The accept loop.

An earlier pass, on the previous image digest, measured ~173 rps with the same knee at 3. Both readings are here rather than averaged: they are of different artefacts, and the shape — a knee at 3, no flat latency region, the CPU cap reached and held — is what survives both.

The conditions, in the same artefact as the number

image digest sha256:31003384432b7e6d00de2b8fb2ed31975458adff4da5bf0381bf745b51c1d21e, the digest versions.lock pins
limits 1500000000 nanocpus / 3221225472 bytes — a fleet host's, asserted against a live one rather than trusted
corpus a throwaway clone of repozytorium-fabularne-dev at de1fc06e, 3 548 entities. That bed is retired. Project 113 replaced it with repozytorium-fabularne-prodkopia on 2026-08-29, which the fleet serves at 3 540 entities — so this row is provenance for a run that cannot be repeated against the same tree, not a description of what a host holds today. It is left naming de1fc06e deliberately: re-taking the baseline is a measurement, and quietly editing the corpus a published number was taken on is how a rig number becomes folklore
corpora wired neither logi nor mc — so roughly a dozen routes answer 200 with empty bodies and /maps/dimensions answers 404
read mix the harness's, 102 of 231 routes
the dev fleet up throughout, contributing about 0.5% CPU per fleet container
reader threads 2, from .nerthus/local/server.json, the way the container entrypoint writes it
search tiers lore, session, speech, external

It is a rig number. The rig reproduces a fleet host's HostConfig byte for byte and the daemon itself is not host-dependent — eder, an idle eight-core VPS, against the laptop's bench container on the same image and the same limits, measured a median ratio of 1.03× across eleven routes. The eight-core box is not faster because it is not allowed to be.

The read mix is the harness's, not a user's. A mix weighted toward the model-backed routes would give a lower number: those cost 100× to 2 500× more on a converging primary than on a rig, for a reason described under Why a rig and a host disagree.

It says nothing about writes. See The write path.

The eight cores nobody is allowed to use

Read from the rented host on 2026-08-25:

eder 8 cores, 15 996 MB, load average 0.25 1.14 1.60
nerthus-eder-nerthusd-1 1.5 CPU / 3 072 MB
nerthus-lang-runner-runner-1 0.5 CPU / 256 MB
compose-runner-1 0.5 CPU / 256 MB
nerthus-eder-cloudflared-1 0.25 CPU / 128 MB
allocated 2.75 of 8 cores, 3 712 of 15 996 MB

The rented box is the most throttled host in the fleet, and the 1.03× ratio above is the proof that the cap and not the hardware is what binds.

The reason is structural rather than accidental. reader_threads and search_index are per-role in Nerthus.Core.Infra.Dev/fleet.settings.yml; cpus and memory are literals in Nerthus.Core.Infra.Common/compose/docker-compose.yml, shared by every host, sized in a comment against "a laptop's share" from a burn-in on four laptop hosts. The settings schema already knows how to be per-role; the compose file does not know how to be per-host.

The decision is deferred deliberately. Adding a schema key to a fleet contract that three hosts are not yet executing is how the previous contract problem recurred. The measurement is finished and recorded here so it is not derived a third time; the contract edit belongs after the fleet is running the contract it declares.

And the shape it should take, since the question was asked: not a per-host override but two declared host roles, small and large, the way primary and replica already work — a per-host key invites drift no one reviews, while a role is a decision made once and applied by membership. small should keep today's numbers exactly (cpus: "1.5", memory: 3072M): they were sized against a laptop that also runs a second daemon stack, a CI runner and a Docker VM, and this page's own ramp shows a single daemon saturating 150% of a core cap under load. Nothing here argues for lowering them, and raising them on the laptop over-commits a machine that is already the fleet's contention floor.

The accept loop is a single point of failure

The daemon answers 231 routes on one accept loop plus two reader threads. Of its 134 GET routes, 67 may be answered on a reader thread and 67 are answered by the loop itself — which also runs thirteen scheduled ticks before every connection it accepts.

The isolation works in exactly one direction. Under control, 45-second arms with probes on both surfaces throughout (measured 2026-08-22, before the cost repairs that made two of these loads cheap):

arm pooled probe p50 accept-loop probe p50
baseline 8.8 ms 8.9 ms
2 × a pooled 1 010 ms route 954.9 ms 7.1 ms
4 × the same route 2 495.4 ms 7.2 ms
2 × an unpooled 1 585 ms route 3 001.5 ms 2 979.6 ms

Oversubscribing the reader pool fourfold leaves the loop at 7.2 ms. Two ordinary unpooled GETs take both surfaces, 341× and 337×.

On a real host over 671 hours, from the primary's own request stream: 323 accept-loop requests took longer than ten seconds, holding 5 961.5 s of merged wall time, during which the host answered 19 other requests — 0.191 per minute against an ambient 1.409. The same window's thirteen long pooled holds let 41 others through, 8.9× above ambient. The pool blocks nothing; the loop blocks everything.

GET /routes now says which is which, and why: poolReason distinguishes a route on the loop because it is cheap from one on the loop because it mutates shared state.

The write path

Every write measured on this daemon runs from tens of milliseconds to several seconds, against reads whose p50 is 2–20 ms. A capacity figure derived from reads and quoted at a system that also writes is the kind of number that has to be re-derived later.

status ms
PATCH /config/schedules/{workload} 200 341.9
PUT /edits/{eid}/entries 200 383.9
POST /edits (warm) 200 546.1 – 707.3
POST /edits (first authenticated call) 200 680.9
POST /edits/{eid}/publishthis commits 200 2 382.1
POST /maps/layout — for a 137-byte answer 200 8 623.1 – 10 555.4
POST /api-keys 201 41.4

The reader that pays for the commit. Two readers driving GET /sessions/summary across a real publish, on the repaired daemon:

n p50 max
before 63 611.2 ms 1 114.0 ms
during 2 — not a measurement, and reported as one refusal rather than a percentile
after 848 73.4 ms 3 962.3 ms

The first read after the commit was the slowest in the window, at 3 962.3 ms against a p50 of 73.4 ms. That is the surface set being rebuilt, and the daemon now starts that rebuild from the write rather than waiting for a reader to ask — but a reader arriving inside the rebuild window still waits for it. What changed is the ordinary case on a host at 1.4 requests per minute, where the first reader after a write arrives long after the build has finished.

And that the read was slow is the proof it was not stale. A rebuild moved onto a background thread that served the old answer would have made that read fast. It was the slowest one.

POST /sessions/report is not in the table. The measurement sent no body and the route needs one, so what was recorded was a 400 in 346.9 ms — the cost of a refusal, not of a write.

Why a rig and a host disagree

State-free routes agree within 1.5×–8×. Model-backed ones do not:

route rig p50 live primary p50 live n live min live max
/hygiene/orphans 3.9 ms 9 507.5 ms 8 3 ms 17 431 ms
/normalization/suggestions 10.5 ms 1 782.5 ms 180 19 ms 29 645 ms
/entities/search 11.1 ms 512.0 ms 394 53 ms 4 160 ms
/health 2.7 ms 21.0 ms 24 882 0 ms 87 529 ms
/schema 16.0 ms 24.0 ms 215 7 ms 70 ms

The rig is not measuring wrongly. It only ever measures the warm case. A rig boots with sync off against an idle throwaway clone, pays a cold derivation once, and then measures the cached path forever. The discriminating evidence is the same bare route on the same host 111 seconds apart:

2026-08-05T15:46:10Z   GET /hygiene/orphans   17 431 ms
2026-08-05T15:48:01Z   GET /hygiene/orphans        7 ms
2026-08-05T15:48:11Z   GET /hygiene/orphans        3 ms

Nothing changed between them but whether something had already been derived — no converge fired, and the expensive sample had one other request in flight while the cheap ones had none, so it is neither the corpus nor queueing. The same split exists on the rig and had simply never been recorded: /hygiene/orphans costs 806.6 ms cold and 4.7 ms warm there.

At an ambient 1.4 requests per minute, every live read is a cold read. That is what the live figures above are a sample of.

A magnitude gap survives the split — the rig's cold arm is 806.6 ms and the primary's is 9 507–17 431 ms — and closing it needs a rig pointed at the primary's own commit with the same corpora wired. That has not been done, and this paragraph is the whole of what is known about it.

Three published numbers this page corrects

claim where it lived measured
cold boot 20.7 s / 17.6 s the benchmark runbook's baseline 12.5–14.4 s, consistent with a quieter machine rather than a code change: the surface set is 6.1–9.6 s of that boot and /health reports it directly
lore index: 6.4 s cold, 30–53 ms warm, ~420 MB resident fleet.settings.yml 785 ms cold, 25.8 ms warm p50, +26.1 MB retained / +59.8 MB working set — corrected in place
a "~19 s first-read surface build" estate folklore it does not exist. Two cold costs exist and neither is 19 s

The two cold costs, so they are not conflated again:

  • the surface set, built one-shot at boot and again whenever the model stamp moves — 6 087–9 560 ms on a rig and 27 613–33 556 ms on the three fleet hosts, read from /health.surfaces.ms on 2026-08-25;
  • the search index, built lazily on first read — 785 ms for the lore tier and 4 738 ms for all four.

The fleet's surface build is four to five times the rig's on the same image, which is the largest unexplained rig-versus-host gap on this page and is not the same gap as the model-backed one above.

What is NOT covered

A capacity page with no section like this is the shape that makes the next reader believe the number covers their case.

  • Writes. The ramp is reads. The write costs are in their own section and were not driven concurrently.
  • The authenticated route surface. The eder comparison covers 22 anonymous routes, and only the 12 that resolve without a path parameter, so nothing in it speaks to authorization cost.
  • The browser tier. ci/bench/04-sweep.mjs was not run.
  • 129 of the 231 routes. The mix reaches 94 of them. The run says nothing about the rest.
  • Every route whose measurement describes an EMPTY ARCHIVE rather than a fast daemonspeech, scenes, mentions, relations, log.archive, and /maps/dimensions, which answers 404 on a rig because the map corpus is not wired. Those belong to whoever wires the corpora.
  • Production. Every number here describes the dev fleet.
  • Sustained load against a fleet host. Permitted for this page and not taken: the daemon is not host-dependent (1.03×), so the reading would have cost a hole in the availability ledger to confirm a ratio already measured.