Skip to content

Serve the site on every core, not on one of twenty-four - #186

Merged
ralyodio merged 1 commit into
mainfrom
web-cluster
Sep 7, 2026
Merged

Serve the site on every core, not on one of twenty-four#186
ralyodio merged 1 commit into
mainfrom
web-cluster

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

What is wrong

rssamplifier.com is down as I write this, and the container is 92% idle.

A residential-proxy fleet is walking /topics/*, /api/topics/* and the reader at ~140 req/s. In a 3.5-second sample of Railway's HTTP log: 500 requests, 500 distinct source IPs, no path requested twice (329 distinct topic slugs). The throttle meters a caller and this caller carries nothing twice; a cache needs a repeat and there are none. Neither refuses a single request.

The container holds one node server.mjs. Measured on the live container:

cgroup cpu.max 2400000 10000024 CPUs
node processes 1 (/proc/1)
node CPU 154% of one core
memory.current / memory.max 6.9 GB / 24 GB
in flight 128/128, ~495k refused
edge connection dial timeout

A topic page costs ~150 ms of JavaScript, and JavaScript renders on one thread. That caps the site near 7 req/s no matter what it is standing on. Everything above that queued behind the in-flight ceiling until the event loop stopped getting back to accept(), and then readers were locked out exactly as hard as the fleet was.

This is not the 2026-09-03 OOM — memory is at 29% of the limit. Same symptom, different cause.

What this does

The primary forks one server per CPU the container is allowed; the workers share the port via node:cluster.

Three things that had to be right:

  • The budgets come from the cgroup, not from os. This container reports 48 host CPUs and 393 GB of host RAM while being allowed 24 CPUs and 24 GB. Forking per host CPU would put twice the runnable threads on the quota; sizing heap from host memory would promise each worker forty times what the container has.
  • The in-flight ceiling stays container-wide and is divided between workers. It was sized against a container's heap. Leaving 128 on each of sixteen workers would raise the real ceiling to 2,048 and hand back the outage loadShed.js was written to prevent. WEB_MAX_INFLIGHT keeps meaning what it meant before there were workers.
  • Each worker's heap ceiling is computed by the primary and passed on the worker's command line. The Dockerfile's NODE_OPTIONS=--max-old-space-size=12288 is sized for one process holding the whole container; inheriting it would tell every worker it may take half of one.

WEB_WORKERS overrides the count. WEB_WORKERS=1 is the way back to the single process — worth having, because a bug that only appears with more than one of something is diagnosed by turning the something off.

What it does not do

Capacity is not a defence, and workers.js says so where someone will read it. This absorbs this fleet; one twice the size puts the site back where it started. What actually stops a distributed scrape is refusing it before it costs a render. The header checks in the x402 gateway do not catch this fleet — it already sends Sec-Fetch-Mode. This buys the room to go build the next rung.

One deliberate loosening, documented in server.mjs: module state (the throttle's counters, the traffic tally, the verified-key cache) is now per worker. For the counters that bound memory that is the correct place for them. For the counters that meter a caller it is a loosening — a client on a keep-alive connection stays on one worker so its own limit is intact, but a caller opening fresh connections is metered by each worker separately. Left as-is on purpose: the traffic this was written for arrives one request per address and defeats a per-caller limit outright, and tightening those limits sixteenfold during an outage would refuse readers to no purpose.

Verification

  • node --test test/*.test.js403 pass, 0 fail (15 of them new/changed).
  • pnpm build — green.
  • Booted locally with WEB_WORKERS=4: primary forked 4, all bound the port, each got --max-old-space-size=2398 and an in-flight cap of 32 (32×4 = 128, unchanged).
  • Same build, same four topic pages, 120 requests at concurrency 24:
workers throughput p50 p95
1 28.3 req/s 475 ms 2292 ms
4 50.3 req/s 234 ms 1412 ms

The local box is not the container — its ratio is held down by fewer cores and a shared remote database. On a container whose only constraint is single-threaded SSR the gain should be considerably larger.

Deploying

Merging redeploys the web service. If it misbehaves, set WEB_WORKERS=1 on the rssamplifier.com service and redeploy — that is the pre-existing arrangement exactly.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KPvk8mVEpxWwTRFby9m8VT

The site was dark on 2026-09-07 while twenty-two of its twenty-four CPUs
sat idle. A residential-proxy fleet walked /topics/*, /api/topics/* and
the reader at about 140 requests a second, five hundred requests from
five hundred distinct addresses with no path asked for twice, so neither
the throttle nor any cache touched one of them. The container held a
single `node server.mjs`. A topic page costs roughly 150ms of JavaScript
and JavaScript renders on one thread, so the whole site could serve about
seven requests a second whatever hardware it stood on. The rest queued
behind the in-flight ceiling until the event loop stopped getting back to
accept() and the edge proxy began reporting connection dial timeouts, at
which point readers were as locked out as the fleet.

The ceiling from 2026-09-03 was the right answer to a memory problem and
is untouched here. This is a different failure wearing the same symptom:
a process cannot work on more than one thing at a time, and no ceiling
fixes that.

So the primary now forks one server per CPU the container is allowed and
the workers share the port. Three things that had to be got right:

- The budgets come from the cgroup, not from `os`. This container reports
  48 host CPUs and 393 GB while being allowed 24 CPUs and 24 GB; forking
  per host CPU would put twice the runnable threads on the quota, and
  sizing heap from host memory would promise each worker forty times what
  the container has.
- The in-flight ceiling stays a container-wide number and is divided
  between the workers. Leaving 128 on each of sixteen would raise the
  real ceiling to 2,048 and hand back the outage it was written for.
- Each worker's heap ceiling is computed by the primary and passed on the
  worker's command line. The Dockerfile's NODE_OPTIONS is sized for one
  process holding the whole container, and inheriting it would tell every
  worker it may take half of one.

WEB_WORKERS overrides the count and WEB_WORKERS=1 is the way back to the
single process, because a bug that only shows up with more than one of
something is diagnosed by turning the something off.

Measured locally on the same build and the same four topic pages: one
worker 28.3 req/s at p50 475ms, four workers 50.3 req/s at p50 234ms.

Capacity is not a defence, and workers.js says so where someone will read
it. A fleet twice this size puts the site back where it started; what
actually stops a distributed scrape is refusing it before it costs a
render. This buys the room to go build that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPvk8mVEpxWwTRFby9m8VT
@ralyodio
ralyodio merged commit c6009c6 into main Sep 7, 2026
3 checks passed
@ralyodio
ralyodio deleted the web-cluster branch September 7, 2026 01:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant