Skip to content

Cluster mode: several FHIRsmith processes on one port - #336

Closed
jmandel wants to merge 25 commits into
HealthIntersections:mainfrom
jmandel:cluster-mode
Closed

jmandel wants to merge 25 commits into
HealthIntersections:mainfrom
jmandel:cluster-mode

Conversation

@jmandel

@jmandel jmandel commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Cluster mode: several FHIRsmith processes on one port

One FHIRsmith process runs all its JavaScript on one main thread: request handling, expansion and validation, SNOMED CT (held in memory), and building responses. Some work runs on other threads: queries made through the async sqlite3 package (LOINC, RxNorm, NDC and others) run on libuv's thread pool, and V8 does part of its garbage collection on helper threads. Replaying recorded IG Publisher traffic against main on a 12-core desktop, one process at saturation used about 1.4 cores. That was about 0.9 of a core on the main thread, about 0.4 across the four pool threads (each about 10% busy), and about 0.1 in V8 helpers. Throughput stopped rising at about 230 requests/s once the main thread was about 90% busy, and an 8-thread pool made no difference. So a process can use more than one core, but its throughput is capped by the main thread; to use more cores, run more processes. This PR adds node cluster.js (npm run start:cluster): several complete server processes behind one port, with Node handing each connection to any of them. Everything that has to agree across the processes is kept in SQLite files in the data directory. Run as today (node server.js), a single process behaves exactly as before.

What changes when there are several processes

  • $cache-control caches are kept in resource-cache.db, so a cache-id issued by one process works on every other, and caches survive a restart. A request reads only the resources it uses.
  • Logins, generated session secrets and rate-limit counts (publisher, IPS, testing, token, registry, and all 13 rate limiters) are kept in cluster-state.db. A login made through one process holds on the others, and a limit of 20 attempts means 20 for the service.
  • Background jobs (VSAC sync, package crawler, XIG download, registry crawl, the daily report) run in one process at a time, chosen by a lock file. The other processes pick up what a job wrote when a request next uses it. VSAC uses a generation counter in its database; the others use the file the job replaces.
  • $closure locks its table across processes.
  • The memory guard measures the whole service against one limit, refuses together, aborts only in the largest process, and restarts a process that stays too big. Its in-flight requests finish first.
  • SHL and IPS FHIR validators: the first process keeps the configured port, and the others take free ports.

What stays per process: statistics and the dashboard, the expansion cache, and the npm projector's manual /_reload. docs/cluster.md covers the setup, the memory model, and Linux against other platforms.

Measured

  • Same answers. Replaying 35 recorded IG Publisher builds (11,920 calls, with their $cache-control sessions) against 4 processes: 0 cache-id-unknown, and all 11,810 compared calls give the same answer as one process. The 110 metadata calls weren't compared. The calls were spread evenly, 2,920–3,024 per process.
  • Faster. On one development machine, an earlier run of the same replay took 67.0 s with one process, 47.7 s with 2 and 44.4 s with 4.
  • Memory under pressure. 3 processes under a 2 GB systemd limit, with one process driven to 96% of the service's memory:
    • all three refused new requests;
    • a heavy operation was aborted only in that process;
    • the primary drained and restarted it 15 s later;
    • a 10-second request already running on it was answered before it exited;
    • the service then fell back to 503 MB, with no OOM kills.
  • HL7 tx-ecosystem suite (validator 7.0.3): the same 13 failures as main (cs-batch-validate*, translate-reverse-r5+*).

Decisions for you

Each of these is a choice this branch makes that you could accept or change. For each: what we chose, the main alternative, and why.

1. Shared state lives in SQLite files in the data directory

Chosen: everything the worker processes must agree on is in SQLite files next to the other data:

  • resource-cache.db holds the $cache-control caches.
  • cluster-state.db holds logins, generated session secrets, rate-limit counts, and the locks that cover work which waits on something.
  • background-jobs.lock says which worker runs the timed jobs.

Each file is opened the same way: WAL mode, a 5-second busy timeout, and every change in one BEGIN IMMEDIATE transaction.

Alternative: a separate store such as Redis, or keeping the state in the primary process and having workers ask it over IPC.

Why: FHIRsmith already depends on SQLite and already keeps its data directory on local disk. A shared file adds no new service to install, survives restarts, and holds up if a worker crashes. Asking the primary would put a round trip on every request and lose everything when the primary restarts. The cost is that these files must be on a local disk, never NFS or SMB; cluster.md says so.

2. In cluster mode the memory resource cache becomes the SQLite one

Chosen: if a configuration sets resourceCache.store to "memory", or leaves it unset, and runs with more than one worker, the workers use the SQLite store. The log says so. One process keeps whatever is configured, so the default there is still memory.

Alternative: refuse to start, which is what the branch did before. Or require operators to set "sqlite" themselves.

Why: with the memory store, a cache-id only works on the worker that issued it, so cluster mode cannot work with it at all. Refusing a config that has only one sensible meaning just makes operators edit a file. Switching, and logging it, gets the same result with less friction.

3. The memory guard works on the whole service in cluster mode

Chosen: with more than one worker, the limit and the measure cover all the workers together.

  • The limit: server.memory.limit if set, now meaning the whole service. Otherwise the cgroup limit. Otherwise the machine's RAM.
  • The measure on Linux: used when the service has its own cgroup with a limit (MemoryMax on the unit or scope, or a container limit). It is that cgroup's private memory: memory.stat anon, or total_rss on cgroup v1. The page cache is left out because the kernel can take it back.
  • The measure elsewhere: used on macOS, on Windows, and on Linux without a limit of the service's own. Every worker sends its RSS to the primary once a second. The primary adds them up and sends each worker the total and the per-worker table.
  • Refusing: above 85% of the limit, every worker refuses new requests with 503.
  • Aborting: above 92%, operations are aborted with 422 only in the largest worker, or in a worker holding more than 1.5 times its fair share of the abort threshold. The others carry on.
  • Recycling: if the service stays above 85% for recycleAfterSeconds (default 60), the primary drains the largest worker and restarts it. Draining means it takes no new connections, finishes the requests it has for up to drainSeconds (default 30), then exits. Only one worker is recycled at a time, never while another is starting. Each further recycle in the same spell waits twice as long as the last, up to 15 minutes.
  • One process: behaves exactly as before.

Alternatives:

  • Keep a per-worker limit, as the earlier text in cluster.md told operators to set.
  • Abort in every worker when the service is over.
  • Never recycle, and leave restarts to the kernel's OOM killer or systemd.

Why:

  • The workers share one cgroup, so the kernel enforces one limit on all of them together. A per-worker share either wastes memory or does nothing when one worker is heavy and the rest are light.
  • Aborting everywhere means one heavy request cancels everyone's work. The largest worker most likely holds the operation that pushed the service over, and aborting there frees the most.
  • The 1.5-times-share clause lets two heavy workers both abort in the same round.
  • Recycling covers the case aborting cannot fix: a worker that has grown and stays big. The backoff stops a loop of restarts when the workers are simply too big for the limit.

The thresholds are the existing ones. The 1.5 factor, the 60-second wait and the 30-second drain are judgement calls.

4. Workers catch up with VSAC syncs by a generation counter

Chosen:

  • The VSAC database keeps a generation number. The worker that syncs adds one to it in the same transaction that records the end of the sync. A failed run and an operator resync of one value set also add one.
  • Every worker remembers the generation its value set list was loaded at.
  • When a request uses VSAC value sets, the worker reads the generation, at most once a second, with one read by primary key. It reloads only if the number changed.
  • The reload runs in the background. The request that noticed the change uses the old list.
  • XIG, the registry crawl and the package crawler status follow the same pattern. Each job replaces a whole file, so the file's identity (inode, size, modification time) serves as the version.

Alternative: reload on a timer. That is what the branch did before, at the VSAC refresh interval, 24 hours by default. Or watch files for changes.

Why: a timer either serves a stale list for up to a whole interval or reloads for nothing. A counter that changes only when the data changes costs one indexed read a second while nothing happens, and shows a sync on every worker about a second after it finishes.

5. New wording for an unknown cache-id with the SQLite store

Chosen: a new message, CACHE_ID_UNKNOWN_SHARED, used when the store is SQLite: "The cache '{0}' was never issued by this server for this endpoint … This server keeps its caches on disk, where they survive a restart". It drops the memory store's "or this server has restarted since it was issued". The memory store keeps CACHE_ID_UNKNOWN unchanged. Only the English messages file has the CACHE_ID_* keys, so the new key is English only, as the others are.

Alternative: keep one message for both stores.

Why: caches in the SQLite store survive restarts. The old wording would send someone looking for a restart that cannot be the cause.

6. Each worker runs its own FHIR validator (SHL, IPS)

Chosen:

  • In cluster mode each worker starts its own validator.
  • The first worker uses the configured port. The other workers take free ports from the operating system.
  • Each worker writes its own tx log, named <name>-<worker>.log.
  • The validator jar is downloaded by one process at a time, under a lock, because the download writes the jar in place.
  • A single process keeps the configured port.

Alternative: one validator for the whole service, either owned by one worker or started by the primary, with the other workers calling it.

Why: the validator wrapper assumes the process that started the validator owns it. Sharing one would mean a validator that disappears whenever its owning worker restarts, or teaching the primary about validators. The cost is N Java processes, which count against the memory limit; cluster.md says so.

7. One process can opt in to the shared store: server.sharedState

Chosen: off by default, so one process behaves as main does: sessions in memory, and a random session secret for a module that has none configured. With "server": {"sharedState": true}, one process keeps sessions, generated secrets and rate-limit counts in cluster-state.db, so logins survive a restart. Cluster mode, and the background-jobs lock, turn it on regardless of the setting.

Alternative: always use the shared store, even for one process. Or have no way to opt in.

Why: it leaves the default single-server behaviour exactly as it is today, while giving a one-process deployment that wants logins to survive restarts a single switch.

8. $closure tables are locked across workers with a lease

Chosen:

  • Within a worker, calls on a table still queue as they do today.
  • The call at the head of a worker's queue also takes a lease on that table in cluster-state.db, within the same lockTimeout. The holder renews the lease while it works. If the holder dies, the lease lapses after 30 seconds.
  • maxQueue counts the calls waiting in each worker.
  • maxTables is now checked inside the transaction that creates the table.

Alternative: optimistic concurrency, where a call computes its result, then writes only if the table's version is unchanged, and retries otherwise.

Why: the lease keeps the existing "one call per table at a time" behaviour and its error responses (429 when busy). An optimistic retry would repeat the expensive closure computation under contention and change what clients see.

🤖 Generated with Claude Code

jmandel and others added 24 commits November 3, 2026 06:59
…store = sqlite)

The $cache-control resource cache lives in each process's memory, so when several
processes serve one port, a request that reaches a process other than the one that
started its cache gets cache-id-unknown.

New setting in the tx module config: "resourceCache": {"store": "memory" | "sqlite",
"path": "<file>"}. "memory" is the default and behaves exactly as before. With
"sqlite", every process uses one SQLite file (default <data>/resource-cache.db),
through SqliteResourceCache, which has the same interface and semantics as
ResourceCache, so the callers (worker, $cache-control, $batch-validate-code) do not
change:

- sessions, resources (keyed type|url|version, order kept) and tombstones are
  tables; endpoints share the file but each has its own caches (scope = path).
- Nothing about a cache's contents is held in memory between requests: get()
  reads the rows and rebuilds the wrappers each time (in R5 form, then given back
  the FHIR version they came in as).
- WAL, busy_timeout 5 s, BEGIN IMMEDIATE for every write, short transactions.
- last_used is refreshed at most every 30 s per cache (touchIntervalSeconds), not
  on every read; an explicit $cache-control?mode=check touch is not throttled. A
  failed refresh is ignored.
- Re-sent unchanged resources are detected with reads, so they take no write lock.
- A failed write of uploaded resources is retried once, then answered 503 (the tx
  router now adds Retry-After to any 503 that has none), never dropped silently.
- The idle sweep tombstones and deletes expired caches in one transaction; any
  process may run it and running it twice is harmless.
- add() does not bring back a cache another process has just closed or expired.
- Caches survive a restart. The file must be on a local disk.

The ResourceCache tests now run against both stores; new tests cover wrapper round
trips, scopes, throttled touch, write failure (retry, then 503), restart, schema
mismatch, and two real processes sharing one file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"resourceCache": {"maxBytes": N} caps the total size (JSON bytes) of the client
resources the cache holds. When a write takes the total over the cap, whole caches
are dropped, least recently used first, and tombstoned with the new reason
"evicted", so a later request carrying one of those ids is told it was dropped to
make room (CACHE_ID_EVICTED: when, the cap, how long it had been idle) rather than
"never issued". The cache being written is never dropped by its own write.

SQLite store: the cap covers the whole file (every endpoint), checked in the same
transaction as the write. Memory store: it covers that endpoint's cache; sizes are
only computed when a cap is set, so without one nothing changes.

No cap by default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
node cluster.js (npm run start:cluster) runs N copies of server.js under Node's
cluster module, all on the configured port; any worker may get any request. N comes
from config.json server.cluster.workers or FHIRSMITH_WORKERS (default 2).

- Workers start one after another, each waiting until the previous one is
  listening: the first start on a data directory builds the package index
  databases, and concurrent first starts corrupt them (F2).
- A worker that stops is started again, one at a time; one that keeps failing to
  start is retried with a growing delay (up to a minute).
- SIGTERM/SIGINT stop the workers (SIGTERM, then SIGKILL after 30 s).
- With more than one worker and the tx module enabled, it refuses to start unless
  modules.tx.resourceCache.store is "sqlite": with the memory store, cache-ids
  would only work on the worker that issued them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ests on the shared store

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With several processes on one data directory (cluster.js workers, or separately
started instances with server.backgroundJobs.lock = true), every timed job would run
in every process: N copies downloading the XIG database, crawling packages and the
registry, refreshing VSAC, working the publisher and IPS queues, sending the daily
report. library/background-jobs.js picks one: a small SQLite lock file in the data
directory (background-jobs.lock, or server.backgroundJobs.lockFile) holding one
row that names the holder, renewed every minute; another process takes it over when
it has not been renewed for 3 minutes, or at once when the holder shuts down. Taking
it is one BEGIN IMMEDIATE transaction, so two processes cannot both win; if the lock
can't be read, the job is skipped rather than risk running twice. Each job asks
isLeader() when its timer fires, so leadership can move. Without a lock (one
process, the default) isLeader() is always true and nothing changes.

Gated (leader only), and what the other processes do instead:
- VSAC refresh (start-up sync and timer): others reload the value set map from the
  database at the same interval.
- package crawler (start-up crawl and cron).
- XIG download (initial and nightly cron): others watch the file and reopen it
  when the leader has replaced it.
- registry crawl (start-up and periodic): others reload the saved registry data.
- publisher task queue; IPS document queue (re-queueing documents left in
  processing is done by the queue runner when it starts or takes over, not by every
  process that starts); SHL cleanup; TestReport retention purge; $closure table
  pruning; express-session cleanup; npm audit; the daily report.

Not gated, because the state is per process or the job is idempotent: the resource
cache idle sweep (any process may run it), expansion cache memory check, CodeSystem
list refresh, stats sampling and run heartbeat, logger flush, registry usage flush
(per-process counts added to the database).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rebuilding every resource of a cache on every request is what the shared store
costs over the memory store, and it grows with the cache: measured on sessions from
the IG Publisher corpus, 0.7 ms a request for a typical one (7 resources, 106 KB),
8 ms at 1 in 10 (86 resources, 0.9 MB) and 26 ms for the largest (284 resources,
2.3 MB) - paid again for each entry of a $batch-validate-code.

get() now reads the cache's index (type, url, version, supplements, content hash;
not the content) and returns, for each CodeSystem / ValueSet / ConceptMap, a
stand-in that is an instance of the wrapper class and answers those from the
index. The first time a request touches anything else on one, its JSON is read and
the real wrapper built; after that everything goes to the wrapper and its methods
run on it directly. Looking through the cache by url and version therefore loads
nothing, and a request pays only for what it uses: the largest session is now
0.9 ms to get, plus 4 ms to load its largest resource if the request needs it.

Two callers read the whole content of every resource just to check something the
index has; they now ask for it in a way the stand-in can answer:
- TerminologyWorker.loadSupplements reads cs.supplementsUrl (new CodeSystem getter)
  instead of cs.jsonObj.supplements.
- ExpansionCache.computeKey uses r.cacheContentHash when a resource has one: the
  same hash of the same JSON, computed when it was stored (tested to give the same
  key as the eagerly built resources).

If a resource is gone by the time a request uses it (the client ended the cache,
or it expired, mid-request) the request fails with cache-id-unknown, 404. Schema
version 2 (adds resources.supplements).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Besides the memory resource cache, three things keep state in each process's
memory that a request reaching another worker would not find: $closure's per-table
lock around each read-compute-write (two workers could interleave updates to one
table), and the logins of the publisher, IPS and testing modules (memory session
store). With more than one worker, cluster.js now lists every such part of the
configuration and stops, rather than starting a server that fails in ways that
look random.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…kground-jobs lock

cluster.md: how to run several processes on one port; the settings
(server.cluster.workers, modules.tx.resourceCache, server.backgroundJobs,
server.memory.limit per worker); that both SQLite files must be on a local disk,
never a network filesystem; what is shared and what stays per worker (statistics
and the dashboard are per worker); how the shared resource cache differs from the
memory one (survives restarts, last-used written at most every 30 s, loads only
what a request uses, 503 + Retry-After when a write fails twice); the size cap;
every background job and what cluster mode does with it; what refuses to run in
cluster mode; sequential start; a systemd unit.

config-template.json documents the new settings; nginx.md and README point to
cluster mode as the single-machine alternative to routing on instance codes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…before loading it

loadSupplements asked isGlobalSupplement() of every supplement CodeSystem among
the request's resources before checking whether it supplements the code system
being looked up at all. isGlobalSupplement reads the whole resource, so with the
shared resource cache every supplement in the cache was loaded on every code
system lookup (a replayed IG build with 63 supplements, 1.9 MB of them, spent most
of its time there). The checks are the same, in the other order: does it supplement
this code system and version (needs only the canonical), then is it global or
stated, then is it already in the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every code system lookup asks each supplement of that code system in the request's
resources whether it is global (isGlobalSupplement reads its type and extensions).
A replayed IG build has 48 non-global SNOMED CT supplements (1.1 MB) in its cache, so
each SNOMED lookup loaded all 48 just to skip them. The answer depends only on the
resource's content, so it is worked out when the resource is stored and the
stand-in answers it from the index (resources.global_supplement).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With cluster mode any worker may get any request, so the publisher, IPS and
testing modules' logins (kept in each process's memory) logged a user out on the
next request that reached another worker, and every module without a configured
sessionSecret signed its cookies with a random secret of its own, so a cookie from
one worker didn't verify on the next. Rate limits counted per process, so a limit
of 20 login attempts was 20 per worker.

library/cluster-state.js keeps what the processes on one data directory share in
<data>/cluster-state.db (WAL, busy_timeout, BEGIN IMMEDIATE, as the resource cache
and the background-jobs lock do):
 - sessions: an express-session store per module (SqliteSessionStore, its own table)
   for publisher, ips and testing; token and registry already kept theirs in SQLite;
 - session secrets: when none is configured, one random secret per module, made by
   whichever process asks first and read back by the others;
 - rate limits: an express-rate-limit store that counts in one transaction per hit,
   for every limiter (publisher, ips, testing, token, registry, packages);
 - leases: named locks across processes for work that awaits, renewed while held
   and lapsing if the holder dies.

All of it applies only when several processes share the data directory (cluster
mode, or the background-jobs lock); one process keeps its sessions, secret and
counts in memory as before.

The SHL and IPS modules each start a FHIR validator on a fixed port in every
process. With several, the first worker keeps the configured port and the others
take free ones, each with its own tx log, and the jar download (which writes the
jar in place) runs under a lease. The publisher, SHL and token databases (sqlite3)
now wait for another process's write (busyTimeout) rather than failing at once.

Tests: two real processes on one data directory serving the testing module - the
CSRF token comes from one, the login is posted to the other, both see the login,
and a logout through one ends it on the other; the login limit counts the attempts
both saw; a lease is held by one process at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oss them

$closure serialises the read-compute-write of each table with a queue of promises
in memory, so two cluster-mode workers could each read version n of a table and
both write n + 1, losing one call's concepts. When several processes share the
data directory, the call at the head of a process's queue also takes a lease on
the table in cluster-state.db before it runs, within the same lock timeout (a
timeout is still 429 CLOSURE_TABLE_BUSY). The queue bound stays per process.

maxTables was checked before creating a table and the create was a separate
step: atomic within one process (nothing awaits in between), not across several.
createTable now checks the count in the same BEGIN IMMEDIATE transaction as the
insert.

Tests: two processes each make ten read-yield-write calls on one table; with the
lease all twenty versions are there, without it (the control) some are lost.
Two processes creating ten tables against a limit of five make exactly five.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s the shared one

cluster.js no longer refuses configurations (singleProcessOnly is gone): logins,
rate limits and $closure tables are now shared between workers (cluster-state.db).
A configuration whose tx resource cache is the memory store - set, or by default -
gets the SQLite store in cluster mode instead, and says so in the log: with the
memory store a cache-id only works on the worker that issued it. One process
(server.js, or cluster.js with one worker) keeps the store it is configured with.

Tests: the store each setting gives with one process, one worker and three; and
cluster.js for real with two workers, the testing and publisher modules enabled -
both workers start, and a login made through one is used through both (every
request on a new connection, served-by header to show which worker answered).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt as the reason

CACHE_ID_UNKNOWN says a cache-id is only valid on the server that issued it,
"either it was issued somewhere else, or this server has restarted since". The
shared SQLite store keeps its caches on disk across restarts, so for it the
restart is never the explanation. It reports CACHE_ID_UNKNOWN_SHARED instead: the
id was never issued by this server for this endpoint, and caches here survive a
restart. The memory store's message is unchanged. Only the English messages file
carries the CACHE_ID_* keys; no other language has them, so the new one is English
only as well (the others fall back to it).

Tests: the store tests expect each store's message id; the $cache-control test
checks the text offers a restart with the memory store and not with sqlite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…imer

In cluster mode one worker syncs VSAC and the others reloaded the value set list
from the database on the refresh timer (24 hours by default), so they could serve
a day-old list after a sync. Now the database counts generations: vsac_settings
'generation' is bumped in the same BEGIN IMMEDIATE transaction that records the end
of a sync run (finishRun; failRun too, since what a failed run wrote is there), and
after an operator resync of one value set. A process records the generation it
loaded the list at (read before the list, so a sync finishing meanwhile makes it
load again, never the reverse). When a request uses the value sets (fetch by url or
id, search), it compares that with the database's, at most once a second (one read
by primary key), and reloads only when they differ. The reload runs in the
background and replaces the map in one step; the request that noticed uses the map
it has. The worker that syncs reloads after recording the run, at the new
generation, so it doesn't load twice. The timer now only runs the sync.

The database connection waits for another process's write (busyTimeout 5 s) and
uses WAL, so the workers' reads don't wait for the sync's writes.

Tests: two processes on one VSAC database, the sync run for real against a fake
VSAC: the other process finds a new value set within about a second of the sync,
reloading once; 20+ uses over 1.5 s with nothing changed cost at most two
generation reads and no reload; a resync of one value set reaches the other too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… on use

The same pattern as VSAC's generation, for the other background jobs whose results
the workers read; here the version is the file itself, since each is a file the job
replaces whole:

 - XIG: the nightly download renames a new database over the old one. Workers
   compared the file's inode with the one they opened every minute (fs.watchFile);
   now a request makes the check, at most once a second (one stat), and the reopen
   runs in the background. An update asked of one worker (the update page goes to
   any worker) while another is downloading is skipped: the download runs under a
   lease in cluster-state.db, since both would write the same temporary file.
 - Registry: the worker that crawls saved registry-data.json in place, and the
   others reloaded it on the crawl timer. It is now written whole and renamed into
   place (so a reader never sees half a file), and the others load it when a
   request finds its inode, size or mtime changed (at most once a second).
 - Package crawler: its status (running, run count, last run, the log) was only in
   the memory of the worker that ran it, so the status, log and stats pages on the
   other workers said no crawl had run. The worker running it writes
   packages/crawler-status.json at the start and end of each run; the others read
   it when it changes. With one process nothing is written.

The package database itself, the IPS, testing and SHL stores, and the extension
tracker are read per request, so they need nothing.

Tests: a real second process follows the XIG file as it is replaced (and doesn't
look again within the second), and skips an update while the lease is held; two
registry and package module instances on one file stand in for two workers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…abort in the largest, recycle

Each worker measured its own RSS against the cgroup limit that all of them share,
so the guard did nothing until one worker alone neared the whole service's limit
(cluster.md said to set server.memory.limit per worker to work around it). In
cluster mode the guard now works on the group:

 - The measure. On Linux, when the service's own cgroup has a memory limit
   (MemoryMax on the unit or scope, a container limit): that cgroup's private
   memory, memory.stat anon (v1: total_rss) - what the kernel can't reclaim, unlike
   the page cache of the SQLite and terminology files. Each worker reads it itself.
   Otherwise (macOS, Windows, no cgroup limit of the service's own): every worker
   reports its RSS to the primary each second over IPC, and the primary sends back
   the table and the total (library/cluster-memory.js). The limit:
   server.memory.limit (now for the whole service), else the cgroup's, else the
   machine's RAM. server.memory.measure forces "cgroup" or "processes".
 - Over the reject fraction, every worker refuses new requests (503).
 - Over the abort fraction, operations abort (422) only in the largest worker by
   RSS, or in one holding more than 1.5 times its share of the abort threshold
   (abortAt / workers). The largest is the likeliest to hold the operation that
   pushed the group over, and aborting there frees the most; the others' operations
   carry on, rather than one heavy worker aborting everyone's. The 1.5x share lets
   two heavy workers abort in the same round. A worker spared says so in the log.
 - If the group stays over the reject fraction (recycleAfterSeconds, default 60),
   the primary drains the largest worker: it stops taking connections (the primary
   gives them to the others), closes idle keep-alive connections and each busy one
   after its response, finishes its requests up to drainSeconds (default 30; killed
   10 s after that), and exits; cluster.js starts it again. One at a time, not while
   a worker is starting, and each further recycle in one spell waits twice as long
   (up to 15 minutes), on top of the existing backoff for workers that fail to start.

One process (server.js, or cluster.js with one worker) works exactly as before.

Tests: the abort rule; the group measure from the primary's reports, with this
worker's own part kept current; the cgroup v2/v1 readings, and when the cgroup
isn't the service's own; the primary's recycling (timing, one at a time, not while
starting, backoff, kill after the deadline). And cluster.js for real with two
workers against a 900 MB limit, one made heavy: both refuse, only the heavy one
aborts, an operation in it is aborted part way, and kept over it is drained and
replaced while the other serves on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er platforms

cluster.md: every configuration runs in cluster mode. What is shared and how
(cluster-state.db: logins, session secrets, rate limits, leases; $closure's lock
across workers and the table limit); how the other workers catch up with the
background jobs (VSAC's generation counter, the XIG / registry / package crawler
files by their version, checked on use at most once a second); the memory model
(the limit and measure are the whole service's; cgroup anon on Linux when the
service has a cgroup limit of its own, the workers' RSS added up by the primary
elsewhere; refuse in every worker, abort in the largest or one well over its
share, recycle the largest when the service stays over, with backoff); and what
stays per worker (statistics and dashboard, expansion cache, one FHIR validator
per worker on its own port, the npm projector's manual reload). The settings
table has the new memory settings, server.memory.limit for the whole service
rather than per worker, and the memory store becoming sqlite in cluster mode.

config-template.json says the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…xpress counted

A recycled worker waited for a count of requests kept by an express middleware,
so anything answered before that middleware (or a connection between requests)
was not waited for, and was cut off when the worker exited. The drain now waits
for http.Server.close's callback - every connection closed, idle keep-alive ones
closed at once and every 100 ms, busy ones after their response (Connection:
close) - or the deadline.

Checked with the memory scenario: a 10 s request started on the heavy worker 7 s
before it was drained was answered (200), and the worker exited after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r-state.db too

One process keeps main's behaviour by default: sessions in memory, and a random
session secret for a module with none configured, so logins end with a restart.
"server": {"sharedState": true} makes it use cluster-state.db as cluster mode
does: sessions, generated secrets (kept in the data directory) and rate-limit
counts there, so logins survive a restart. Cluster mode (and the background-jobs
lock) turns it on regardless. A FHIR validator of a single process keeps its
configured port either way; only cluster workers after the first take free ones.

Tests: off by default, nothing written; on, the secret is the same after the
database is reopened; and with real processes, a login made before a restart is
refused after it by default and still good after it with sharedState.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The group code read process.memoryUsage.rss() in four places; they now go
through ownRss(), so that what a process reports and compares is the same
reading the single-process guard uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… sets per request

Where the value sets are read from the database on each request (no list kept in
memory), what the generation drives is a recount; the test counts whichever the
provider has, and checks the generation with an explicit catch-up once the new
value set has been seen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sures

How much memory takes the service over a threshold depends on how a process is
measured (a branch that leaves file-backed pages out counts less for the same
workers), so the test reads the measure and thresholds from a worker and sizes
the fill and the final leak from them, rather than using fixed sizes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…old before the recycle

With 60 MB over, a GC in the refusing workers could bring the service back just
under the reject threshold as the recycle timer ran out (1 run in 6 on one branch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@grahamegrieve

Copy link
Copy Markdown
Contributor

This is the kind of thing you should talk to me about before doing all this work.

It shouldn't apply to some of the modules - it's dumb to try to apply it to all the existing ones but the terminology server module. I think.

And have you looked at the nginx documentation?

But what's the performance hit for moving shared state out of memory and into a database? it can't be small?

@grahamegrieve

Copy link
Copy Markdown
Contributor

I'm also concerned about the state and size of some of these databases. Particularly caching on behalf of the client - what's to stop it growing and growing?

How can this not make any changes in the singleton case? That's what is says, but it's evidently not true

@jmandel
jmandel marked this pull request as ready for review November 4, 2026 00:46
…s old

In one process, overAbortThreshold (from every deadCheck) read
process.memoryUsage.rss() fresh on every call: about 11 a request, about
7 us each, near 3% of the main thread at saturation. It now reuses the
last measure if it is at most ABORT_SAMPLE_MS (10 ms) old. A dropped
sample (configure, a report from the primary) is still measured again
at once. The 250 ms reject-gate sample and the cluster-mode group
sample are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jmandel

jmandel commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, fair points all round. Closing this in favour of #342, which follows the model in nginx.md:

  • One master runs every module, all background jobs (VSAC sync, crawlers, XIG) and $closure.
  • N tx-only workers each put their instance code on the cache-ids they issue, and requests are routed to the owner by that prefix.
  • Routing is either nginx (a new section in nginx.md) or an optional small launcher, cluster.js.
  • Scope: only the terminology module is spread across processes. The other modules run on the master alone, so there are no shared logins, rate-limit stores or validator ports, and no shared database by default.
  • Single process unchanged: with no --role, a single process runs exactly the same code paths as main, and the PR shows how that's guaranteed.
  • About a quarter of the size of this one.

On your questions:

@jmandel jmandel closed this Nov 4, 2026
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.

2 participants