Repository navigation
Conversation
library/role.js reads the role from --role (or FHIRSMITH_ROLE), the instance code from --instance-code (or FHIRSMITH_INSTANCE_CODE, or modules.tx.instanceCode for the master) and the port from --port. applyRole changes the parsed config.json in place: - master: every module and background job as configured; tx gets the instance code, clusterRole 'master', and the VSAC provider is told it is the leader - tx-worker: every module but tx disabled; the statistics database, npm audit and daily report off (the master has them); its own instance code (required: the config's is the master's); VSAC follower Both turn on the group memory measure (server.memory.group). server.js calls applyRole only when parseRole returns a role, and parseRole returns null when neither --role nor FHIRSMITH_ROLE is set. --port or --instance-code without a role is refused, so a mistyped command doesn't silently start an ordinary server. The other two server.js changes are `if (role)` and `(role && role.port) ||`, so `node server.js` runs exactly as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ocal source caches A tx-worker (modules.tx.clusterRole, set by --role) doesn't open the closure database or the syndication store. Both are the master's: the launcher and the nginx example route $closure and /syndication to it. The worker's CapabilityStatement still declares $closure when closure is enabled, since the service offers it; one that reaches a worker anyway gets the existing 501 not-supported. A folder: or file: source wipes and rebuilds its cache folder (terminology-cache/folder-source-<hash>) on every load, and a load prunes other such folders untouched for a day. Several processes on one data directory would wipe each other's. With a role, each process's folders carry its instance code (folder-source-<code>-<hash>) and it doesn't prune. Without a role, clusterRole and localSourceTag are unset and both paths are as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nter Ported from HealthIntersections#336 (cf38dde), for the master/worker split only. With vsacCfg.cluster (set by --role): - leader (the master) syncs as before, and bumps vsac_settings 'generation' once the sync has written its value sets (and after a failed run, and after an operator resync of one value set) - follower (a tx-worker) never syncs and runs no refresh timer. It loads the list at start if the database exists, recording the generation (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 reads the generation at most once a second (one read by key) and reloads in the background when it has moved. It doesn't open the database before the master has created it, since opening it would create an empty file. Its resync form is off: the info pages go to the master. Both open the database in WAL mode with a 5 s busy timeout, so a worker's reload doesn't make the master's writes fail. Without a role none of this runs: every new line is behind this.cluster or this.shared. Test: two real processes on one VSAC database, syncing against a fake VSAC. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…largest process The master and its workers run in one cgroup, and the kernel's limit is on all of them together; each measuring its own RSS against that limit lets the group go well over it. With server.memory.group (set by --role) and a cgroup that has the limit on it (MemoryMax on the unit or scope, a container), each process reads the cgroup's private memory (memory.stat anon; v1 total_rss) itself: - every process refuses new requests above rejectFraction - above abortFraction, only the process with the largest RSS in the cgroup (from cgroup.procs and /proc/<pid>/status) aborts its operations; the others carry on - the in-operation check reuses a measure at most 100 ms old, since it reads a file - the forced GC while refusing needs only this process's share of the excess From HealthIntersections#336's guard (9466ddb and follow-ups) this keeps the cgroup measure and the largest-aborts rule, and drops the IPC (RSS reports to a primary), the "processes" measure for platforms without cgroups, and draining and recycling the largest worker. The launcher restarts a process the kernel kills. Without a role state.group is null and the guard is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An optional launcher, for those who don't put nginx in front. It starts the master (server.js --role=master), waits until it answers /health (on a new data directory the master builds the package caches and creates the VSAC database, which processes starting together would corrupt), listens, and starts the workers (--role=tx-worker, codes tx1..txN, ports after the master's). It proxies with node http and keep-alive agents, by the rules in nginx.md: - X-Cache-Id: to the process its prefix names; if that one is down or unknown, to any tx process, which answers cache-id-unknown - $closure, /syndication, /info/ (the VSAC resync form) in any tx endpoint, and every path outside the tx endpoints: to the master - any other tx request: to the ready tx process with the fewest outstanding requests (the master included unless server.cluster.masterServesTx is false) It appends the client to X-Forwarded-For, so rate limits and logs see the client. A process that exits is started again after 1 s, doubling while it keeps failing quickly, up to a minute. SIGTERM/SIGINT: stop accepting, close idle connections, wait for the requests in progress (drainSeconds), then SIGTERM each process (SIGKILL after 30 s) and exit 0. Tests: the rules and the least-loaded choice, restarts and the drain, against fake upstream processes; and real processes (a master and two workers on a temporary data directory with a fake VSAC): 6 cache sessions through the launcher, 48 later uses, no cache-id-unknown (with cache-id routing disabled the same test fails); $closure and non-tx paths reach the master; a VSAC sync on the master reaches both workers; SIGTERM lets in-flight requests finish, stops every process and exits 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ry); nginx.md example cluster.md replaces HealthIntersections#336's: the master/tx-worker roles and their flags, a table of what runs in which process, the four routing rules, cluster.js and its settings, the group memory measure, and what this arrangement doesn't do (caches and statistics are per process; the master is a single point for $closure and the non-tx modules). nginx.md gains "A master and tx workers on one machine": the existing pool, per- instance and closure upstreams, with the master as the closure instance and the default location, plus the syndication and info paths. README links to cluster.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Node's server has already answered a client's "Expect: 100-continue" by the time the request is proxied, so the header is dropped like the other hop-by-hop headers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ExpansionCache.computeKey puts a content hash of every additional resource into the expansion cache key. With a cache-id those resources are the client's whole $cache-control cache, so every $expand serialised and sha256-hashed everything the IG had uploaded. Under replayed IG Publisher traffic at C=16 that was about a quarter of the main thread. CodeSystem, ValueSet and ConceptMap (CanonicalResource) now work the hash out on first use and remember it, in a non-enumerable field tied to the jsonObj it was computed from (a replaced jsonObj, or a replacement resource, is hashed again). setExpansionKeyHash lets a store that already knows the hash record it. The hash is exactly the one computeKey made, and the key is built the same way, so keys are byte-identical and expansions already cached stay valid. Remembering the hash needs a cached resource's json not to change once it is in use. One thing did change it: $compare sorted, merged and removed the compose includes of the value sets it compared, in place - cache-id resources and the server's own. It now works on copies. Tests: computeKey against the previous implementation for a mix of resources and parameters; one serialisation and hash per resource across many keys; a new hash for a replacement resource; and expand, validate-code, lookup and compare through a sealed cache-id with the cached json frozen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
loadSupplements read cs.jsonObj.supplements of every CodeSystem among the request's resources. A new getter, CodeSystem.supplementsUrl, returns exactly that, and loadSupplements uses it. Nothing changes for the resources the server builds today; the point is the shared resource cache in the next commit, whose stand-ins answer supplementsUrl from their index row without loading the resource (as in HealthIntersections#336, a37c84d). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ile every process shares Off by default. With it on (and a role), the master and the tx-workers keep their $cache-control caches in databases/resource-cache.db instead of their own memory, so any process can answer any cache-id and a restarted process still has them. tx/resource-cache-sqlite.js is the store from HealthIntersections#336 (4656acae, dea114c4, 80d2e834, 76f37f51, 1b1c632), cut to what tx needs: - WAL, busy_timeout 5 s, every write one BEGIN IMMEDIATE transaction, retried once and then answered 503 CACHE_WRITE_FAILED (nothing is ever dropped silently) - last_used refreshed at most every 30 s per cache, so steady use is reads only; an unchanged re-send of resources takes no write lock - the idle sweep and the size cap (server.cluster.sharedCacheMaxMB, 0 = none, over the whole file) move what they drop to tombstones in the same transaction, so any process can say what happened to a dead id (CACHE_ID_EVICTED for the cap) - a request only loads the resources it uses: get() returns stand-ins answering url, version, type, supplements and the global-supplement flag from the index - each wrapper gets the stored row's hash through setExpansionKeyHash (P6, the previous commit but one), and a stand-in answers expansionKeyHash() from the row, so expansion keys are byte-identical to the memory store's and computing one loads and hashes nothing - an id with no record is CACHE_ID_UNKNOWN_SHARED whatever its prefix: every process sees every cache and caches survive a restart, so neither "issued somewhere else" nor "restarted since" applies Not brought: the memory store's size cap, the FHIRSMITH_CLUSTER_SIZE switch, the resourceCache.store config, the Retry-After middleware and HealthIntersections#336's computeKey change (P6 replaces it). ResourceCache and operation-context.js are untouched. Wiring, all behind values only applyRole sets: - library/role.js: server.cluster.sharedCache / cacheAffinity must be booleans; sharedCache sets modules.tx.sharedCache (and sharedCacheMaxBytes) - tx/tx.js: `this.config.sharedCache ? createSharedResourceCache(path) : new ResourceCache(this.stats)`; shutdown closes the file only when it is on - cluster.js: with sharedCache, a cache-id request goes to the least-loaded tx process; cacheAffinity: true keeps the prefix routing. Without sharedCache the routing is as before. nginx.md says the map can go. Tests: the resource-cache suite runs against both stores (the misrouted-id and size-cap cases per store), plus the store's own cases and the P6 pre-fill (same key as the memory store's resources, two hashes only: the key and a plain resource); two forked processes on one file (use, end, sweep, restart, concurrent writes, the cap across processes); and cluster.js with a master and two workers: a session started on each process is used on all three (18 of 18, no cache-id-unknown), 60 concurrent requests through the launcher, an unknown id in the shared wording, and a worker SIGKILLed and restarted still has every session. With sharedCache false the same cluster test fails (12 cache-id-unknown). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k across processes ClosureStore gets an option, shared, for a database that other processes answer $closure on too. Nothing sets it yet (the next commit does); without it every path is as before, and the only change to them is that ClosureWorker.add is split into workOutAdd (reads and computes) and addResult (logs and answers) around the same recordAdd call. With shared: - an add is worked out against the table as read (its id and version) and written by recordAddChecked: one BEGIN IMMEDIATE transaction that first checks the table still has that id and version. If not, nothing is written; the worker reads the table again and works the add out again from what is there now. The code lookups and subsumption tests already done are remembered across these tries (for one table id), so a retry costs reads. It waits a little, with jitter, between tries (up to 50 ms); after writeRetries (default 10) it answers the existing 429 CLOSURE_TABLE_BUSY. A busy timeout that runs out counts as a conflict. A table dropped meanwhile is 404 CLOSURE_TABLE_UNKNOWN. An add with nothing new writes nothing and needs no check. - creating checks for an existing table (409) and for maxTables (422) in the same write transaction as the insert (createTableChecked); the worker's checks before it stay, so the errors come in the same order as before - a resync reads only up to the version it reports (entriesPage's upTo), so a write by another process between its pages can't slip later entries in - the stale-table sweep deletes with one conditional statement - the database is opened with a 5 s busy timeout before switching to WAL, and the tables are created (and an old database brought up to date) in an IMMEDIATE transaction - the per-process table lock is kept: within a process calls on one table still queue, so they don't conflict with each other Read and write sets, against main's closure-store.js and workers/closure.js: an add reads the table row (id, version), its parameters (fixed for the table id), its concepts and its entry count, and writes concepts and entries at version+1 and the row's version and counts. The check covers all of it: the parameters change only with the id, and the concepts and counts only with the version. A resync reads the row and entries with version in (v, version]; create and reset read the row and the table count. Tests (tests/tx/closure-shared.test.js): a second ClosureStore on the same file writes between the module's read and its write. Three conflicting writes: the add is retried and ends as one writer doing the same adds in that order (version, entries, whole table), each pair tested for subsumption once across the tries; a concept added by the other process meanwhile is not added twice and its entries stay its own; every try conflicting gives 429 after writeRetries and writes nothing; a table dropped meanwhile is 404; a resync bounded by version; create racing another creator is 409, and the table limit holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Off by default. With it on (and a role): - library/role.js sets modules.tx.sharedClosure (a boolean, like the other options) - tx/tx.js opens the closure database in every process, the workers included, in shared mode (previous commit). Only the master runs the stale-table sweep. Without the option a worker still opens nothing and answers 501 - cluster.js sends $closure to the least-loaded tx process (whatever its cache-id); /syndication and /info stay the master's. nginx.md shows the location change Test (tests/server/cluster-shared-closure.test.js), a master and two workers on one closure.db: 3 processes x 4 clients add concepts at once, one or two per request, to one shared table and to four tables shared in threes, over a 40-concept tree. Each table then has exactly the concepts asked for, once, and exactly the entries a single writer gets for them; versions run 1..n with no gaps; every write was answered with its own version and exactly the entries stored at it; and "changes since v", asked of each process in turn, is the union of the answers after v, for every v. 12 concurrent creates of one table make one (11 x 409). Creates racing for the room left under maxTables (room + 15 of them) make exactly room, and 15 get 422. With the version check removed from recordAddChecked, the same test fails (500s from duplicate concepts). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s a copy) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What each option does, what it costs, and how the routing changes, with a table of the four combinations. "What runs where", "Routing" and "Limits" point to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…igrate one On a new package cache the master built each .valuesets-2.db with no content_hash on its rows (a package's value sets are written without one), so the next process to open the file ran the content_hash backfill: with the workers started together after the master, both backfilled hl7.terminology at once and crashed (SQLITE_BUSY / READONLY / IOERR), and a worker whose completeness check failed under the lock deleted and rebuilt the master's file. - ValueSetDatabase.upsertValueSet stores the content's hash when it isn't given one (the hash the backfill computes), so a cache is final when it is built - with --role=tx-worker the package value set and concept map caches are opened read-only. A worker never creates, rebuilds, discards or migrates one: one that is missing, incomplete or still needs a migration is waited for (up to 5 minutes, FHIRSMITH_CACHE_WAIT_MS), then the worker stops with an error saying the master builds it - cluster.js starts the workers one after another, each once the one before is ready - cluster.md: the package caches are the master's; starting the master first only spares the wait, it is not what keeps the workers from writing Tests: a cluster on a new data directory with the package's caches removed starts every process once, with no backfill and neither cache file written after the master is ready; read-only ValueSetDatabase never writes or creates the file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cess's own Every process on a data directory (a master and its tx-workers, cluster.md) refreshes media-types.xml at startup. With one temporary name for all, one process could rename another's half-written download into place; each now writes its own and renames it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…does The launcher's keep-alive agents kept idle connections indefinitely. A process closes an idle connection after its keepAliveTimeout - 5 s on a server without library/keep-alive.js (65 s with it) - and a request the launcher sent down one as it closed was reset: a 502 to the client, and the owner of a cache-id seen as down. The agents now drop a connection idle for 4 s, below either. A request in progress is not affected. No retry on a reset instead: tx requests are mostly POSTs whose bodies are streamed through, so a retry would mean buffering every body, and with the idle limit below the process's no reused connection is one the process is closing. nginx.md (a master and tx workers): keep the server's keepAliveTimeout above nginx's upstream keepalive_timeout, or set keepalive_timeout 4s on servers without the fix. Test: against a process with Node's 5 s, the launcher's idle connection is gone before 4.9 s (it fails without the change: kept until the process closes it), a request at 5 s uses a new connection, and a 6 s request is not cut. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…never download it Every process on a data directory downloaded media-types.xml at each load (15 s timeout) and renamed it over the cached copy. With a role, only the master refreshes it now: tx.js sets library.mimeTypesReadOnly for a tx-worker, and MimeTypeServicesFactory then only reads the cached copy. The workers start once the master is ready, so the copy is fresh. A worker that finds no copy logs a warning and goes on as a failed download with no cache does (no `registered` filter); it does not download. With no role nothing changes. Since only the master writes the file, the per-process temporary name (cade59e) is no longer needed: the temporary file is media-types.xml.tmp again, as on main. cluster.md: the registry row in "What runs where". Tests: a read-only (worker) load, directly and through Library.loadInternal, makes no axios call and leaves the cached file's mtime and the folder unchanged; with no cached copy it warns and has no registered types; a no-role/master load downloads and replaces it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… one With a role, each process kept a folder:/file: source's caches in its own folder-source-<instance code>-<hash>. Since workers stopped building caches (120c529), a worker waited for its own folder's .valuesets-2.db, which nothing built, and after 300 s stopped: a cluster with a file: source on a new data directory crash-looped. - every process in a cluster uses the master's folder, folder-source-master-<hash> - a worker never removes, creates or writes it: its providers open the caches read-only, waiting for the master like the package caches - the master builds the caches in a temporary sibling folder and renames it into place, so a worker running across a master restart never finds the folder half built - only the master (and a process with no role) prunes folder-source-* folders; no role is unchanged Test: a cluster on a new data directory with two file: sources (a CodeSystem and a ValueSet) starts every process once; the caches are in the master's folders, untouched after the master is ready, and $lookup and ValueSet search answer from each worker and through the launcher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Measured on GCP e2-highmem-4 (4 vCPU = 2 cores, 32 GB, production's machine type), with a separate load generator. The workload replays 35 recorded IG Publisher builds (11,920 calls with their What the numbers say:
Main-based setups (before our fixes, no mmap). Left: throughput against concurrency. Right: memory. This PR, with the four cache/closure combinations. Throughput against the number of workers: Memory against workers, mmap on: IG traffic under closure stress (N = 2): Found along the way:
|
Allocate shared table IDs from a durable counter, initialized above existing IDs, so resets and dispose/recreate cannot accept old writes or reuse retry state. Check duplicate-only adds too, and refuse a resync interrupted by reset. Existing databases keep their rows and foreign keys. Seven new regression cases fail before this change and pass after it, including reversed-hierarchy retries and reuse after reopening an empty database. Co-Authored-By: GPT-6 <noreply@openai.com>
Check a lazy resource against the hash and FHIR version read with its index. Return a retryable HTTP 409 if it changed, before new content can be used with an old expansion-cache key. The real expansion cache path fails two new before-change tests (add and set replacement). Both pass after the fix; retrying from a fresh index succeeds, and the old key remains empty. Co-Authored-By: GPT-6 <noreply@openai.com>
Check actual WAL mode and busy timeout on fresh databases constructed by leader and follower providers. This guards the constructor-order conflict resolution when combining cluster-lite with VSAC on demand. Both cases pass on cluster-lite; a pre-existing WAL file cannot hide a missing shared option. Co-Authored-By: GPT-6 <noreply@openai.com>
Describe the rare client-visible 409s when a lazy resource is replaced or a closure table resets during resync, and distinguish them from internal retries of closure adds. Co-Authored-By: GPT-6 <noreply@openai.com>
|
Thanks - a big piece of work, and I'm happy with the defaults (master serves tx, per-process
|




What it does
This follows the model in
nginx.md. One master runs everything. N tx-workers run onlythe terminology module and spread the tx load. Each process puts its instance code on the
cache-ids it issues, and a front door sends each request to the right process.
The master runs every configured module, every background job (VSAC sync, package
crawler, XIG download, registry crawl, daily report, npm audit, timers),
$closureand thesyndication feed. It loads the terminologies, since closure needs subsumption. By default it
also serves tx requests (
server.cluster.masterServesTx).A tx-worker runs the tx module only. It has no background jobs, no statistics database,
and no closure or syndication store. Its
$cache-controlcaches stay in its own memory, inthe existing memory store. It picks up the master's VSAC syncs through a generation counter,
checked when a request uses the value sets (at most once a second). It reads nothing else the
master writes: XIG, the registry, packages and the rest aren't loaded in a worker. The only
tx-to-xig import is a pure string-escaping function.
Routing, the same in
cluster.jsand in the new nginx example:X-Cache-Idgoes to the process named by the prefix. If that process is down, or theprefix is unknown, the request goes to any tx process, which answers
cache-id-unknown.$closurein any endpoint goes to the master, and so do/syndication…and/info/….The VSAC resync form on
/info/vsacwrites the VSAC database.cluster.jsis an optional launcher. It starts the master first and waits for/health,so the workers never build the package caches or the VSAC database at the same time as it
(Cluster mode: several FHIRsmith processes on one port #336's register F2). It then starts the workers and proxies on one port with node
httpandkeep-alive agents. It appends the client address to
X-Forwarded-For. It restarts a processthat dies, after 1 s, 2 s, 4 s and so on, up to a minute. On SIGTERM it drains: it stops
accepting connections, closes idle keep-alive connections, waits for the requests in
progress, then sends SIGTERM to each process.
Memory. With a role, the memory guard measures the cgroup's private memory
(
memory.statanon), since the kernel's limit applies to all the processes together. Everyprocess refuses new requests above 85%. Above 92% only the process with the largest RSS in
the cgroup aborts its operations.
Local
file:/folder:sources. Each library load wipes and rebuilds its cache folder.With a role, every process uses the master's folder (
folder-source-master-<hash>): themaster builds it in a temporary sibling and renames it into place; a worker reads it
read-only (waiting for it like a package cache) and never prunes.
Two options, both off by default (
server.cluster, read only with a role; seecluster.md "Options"):
sharedCache: true: the$cache-controlcaches go in one SQLite file(
databases/resource-cache.db) that every process shares, so any process answers anycache-id and caches survive restarts. The launcher then spreads cached requests by load;
cacheAffinity: truekeeps the prefix routing.sharedCacheMaxMBcaps the file. Thestore is Cluster mode: several FHIRsmith processes on one port #336's, cut to what tx needs, and pre-fills each resource's expansion-key hash
from its stored row (P6, cherry-picked from Small fixes: 422s instead of 500s, a request that never got an answer, LOINC data, and two robustness fixes #337).
sharedClosure: true: every tx process answers$closureondatabases/closure.db, andthe launcher sends
$closureto the least-loaded tx process. Correctness across processescomes from optimistic, version-checked writes (no lease): an add is worked out against
table version n and written in one
BEGIN IMMEDIATEtransaction that first checks thetable is still at n; if not, it is worked out again from the fresh table (bounded
retries, jitter, then the existing 429
CLOSURE_TABLE_BUSY). Creating a table and thetable limit are checked in the insert's transaction.
Docs:
cluster.mdreplaces #336's.nginx.mdgains "A master and tx workers on one machine".What changes for a single process: nothing
node server.jswith no--roleand noFHIRSMITH_ROLEbehaves exactly as before.How that's guaranteed.
parseRolereturnsnullwhen no role is given. The only changesto
server.jsareif (role) applyRole(config, role),if (role) log…andconst PORT = (role && role.port) || process.env.PORT || …. So with no role the configurationobject is the one read from disk, untouched. Every new behaviour in a shared file is behind a
value that only
applyRolesets:server.jsrolenulltx/tx.js(closure store, syndication store, local cache tag)config.clusterRoletx/library.js(cache folder name, build-and-rename, pruning)this.localSourceTag,this.packageCachesReadOnlynull,falsetx/vs/vs-vsac.js(follower start-up, catch-up, generation bumps, resync form)this.cluster(fromvsacCfg.cluster)nulltx/vs/vs-database.js(WAL, busy timeout)this.sharedfalselibrary/memory-guard.js(group measure, largest-aborts, GC share)state.group(fromserver.memory.group)nulltx/tx.js(shared resource cache)config.sharedCache(set byapplyRoleonly whenserver.cluster.sharedCacheistrue)tx/tx.js(closure store in workers, shared mode)config.sharedClosure(likewise)tx/closure/closure-store.js(busy timeout, IMMEDIATE schema, checked create and add, bounded resync, one-statement sweep)this.shared(from thesharedoption)falsetx/workers/closure.js(checked create, retries, bounded resync)this.store.sharedfalsecluster.js(cache-id and$closurerouting)settings.routing{cacheAffinity: true, sharedClosure: false}: the old rulesThree changes run without a role, and are equivalent to what they replace:
ClosureWorker.addis split intoworkOutAddandaddResultaround the samerecordAddcall (same reads, writes and errors, in the same order);
loadSupplementsreadscs.supplementsUrl, a new getter returningcs.jsonObj.supplements; and P6 (cherry-pickedfrom #337 as its own commit) remembers each wrapper's expansion-key hash, giving
byte-identical keys.
The other additions are new functions that nothing calls without a role:
getGeneration,bumpGeneration,catchUp,largestInGroup,ownCgroupDir,readCgroupAnonandreadCgroupProcessRss.Checked three ways:
listed (
git diff -U0 origin/main -- server.js tx/ library/memory-guard.js).node server.jswas run with no flags on main (9059fb9) and on thisbranch. After the options were added, the same was done at 9ee5293 (before them) and
6c66ebc (with them; later commits are tests and docs), with a config of tx with closure
(
maxTables3) and vcl. 29 requests:/health, metadata, a$cache-controlsessionused by
$expand(twice),$validate-codeand$lookup, then checked, ended and usedagain, unknown cache-ids with another instance's prefix and with its own, and a
$closuretable through init, re-init (409), adds (one unknown code), resyncs, reset with an add,
dispose, use after dispose (404) and the table limit (422). The responses were identical
(UUIDs and timestamps masked), the data directories held the same files, and the logs
differed only in paths, PIDs, UUIDs and stack-trace line numbers (one frame is now
workOutAdd). The first run, before the options, was with the same config (tx with closure, and vcl) on a fresh data directory./health,/r4/metadata,$cache-control?mode=start,$closureand$lookupgave identicalresponses (timestamps and UUIDs masked). The server logs were identical apart from the
checkout path. The data directory held the same files.
m331/run_eco.sh). At8b31d29 (port 20040) and again at 6c66ebc, with both options in (port 20090,
runs/cluster-lite-options), it failed 13 of 5061 tests, and they are the same 13 teststhat fail on main (
runs/fixes1103-main, 9059fb9):cs-batch-validate*andtranslate-reverse-r5+*.tests/library/role.test.jsalso checks that a config passed through server.js'sparse-and-maybe-apply step, with no role, serialises byte for byte as it was.
Decisions for you
server.cluster.masterServesTx: false(or--master-serves-tx=false) turns it off. In nginx, leave it out of the pool.$cache-controlcaches liveserver.cluster.sharedCache: trueputs them in a shared SQLite file instead, with least-load routing (cacheAffinity: truekeeps the prefix routing)cluster.jscluster.js(about 300 lines of code) gives a one-command setup for those without nginx, plus the start ordering, restart and drain that a set of nginx upstreams doesn't do. Dropping it would leave operators to order the start-up themselves (systemd units, a/healthwait).anonfor the whole group; every process refuses; only the largest aborts$closure(unlesssharedClosure),/syndication…,/info/…$closureonly--role,--instance-code,--port,FHIRSMITH_ROLE,FHIRSMITH_INSTANCE_CODE,PORT). The master's code can also come frommodules.tx.instanceCode.$closurecorrectness across processes (sharedClosure)BEGIN IMMEDIATEtransaction that checks the table is still n (same id), else recompute from the fresh table; at mostclosure.writeRetries(10) retries, 0-50 ms jitter, then 429CLOSURE_TABLE_BUSY. Code lookups and subsumption tests are remembered across retries. The per-process queue stayssharedscenario (8 writers on one table, 3 processes) gave 172 retries in 277 adds, almost all a single retry, and no 429.cacheContentHashchange tocomputeKeysetExpansionKeyHash(row.hash)and answersexpansionKeyHash()from the rowCACHE_ID_UNKNOWN_SHARED(#336's 1b1c632 wording) whatever the id's prefixCACHE_ID_OTHER_INSTANCEfor another member's prefixserver.clusterin config.json onlyWhat was kept from #336, and what was dropped
Kept, cut down:
the counter; workers check it on use, at most once a second. Both use WAL and a 5 s busy
timeout.
anonmeasure and the largest-process-aborts rule (9466ddb). The "largest"comes from
cgroup.procsand/proc/<pid>/status, not from IPC.This plays the part of ce7d0ae0 and ecee02a. The single-process check is unchanged.
sharedCacheoption: the SQLite resource cache (4656acae, dea114c4, 80d2e834,76f37f51, 1b1c632), with WAL, busy timeout,
BEGIN IMMEDIATE, the idle sweep, themaxBytescap, the throttledlast_usedand lazy loading. Not its memory-store cap,resourceCache.storeconfig,FHIRSMITH_CLUSTER_SIZEswitch or Retry-After middleware.Dropped: shared logins and sessions (
cluster-state.db), the rate-limit store, validatorport juggling, leases (and the closure lease), the background-jobs lock, crawler, XIG and
registry catch-up, the "processes" memory measure and its IPC, and
memory-driven drain and recycle. None of them is needed when the non-tx modules run only on the
master.
Size
cluster.jsis 299 of them)Code files:
cluster.js(new),library/role.js(new),library/cgroup-memory.js,library/memory-guard.js,server.js,tx/tx.js,tx/library.js,tx/vs/vs-vsac.jsandtx/vs/vs-database.js. #336 changed 56 files; this branch changes 19.Tests
tests/library/role.test.js: flags, environment and errors; no role means the config isuntouched; what each role turns off.
tests/server/cluster-launcher.test.js: the routing rules (cache-id prefix,$closureineach FHIR version and encoded, syndication and info, non-tx paths), the least-loaded choice,
settings, restart after a crash, and the drain on stop. The drain test checks that no new
connections are taken, that the request in progress gets 200 with
Connection: close, andthat the processes stop only afterwards. All of these run against fake upstreams.
tests/server/cluster-integration.test.jsruns real processes:node cluster.jswith amaster and 2 workers on a temporary data directory, with a fake VSAC.
$cache-controlsessions are started through the launcher and used by 48 laterrequests, with no
cache-id-unknown. With cache-id routing switched off, the same testfails.
$closureand the non-tx paths reach the master.tests/tx/vsac-generation.test.jsruns two real processes on one VSAC database. The workersees a sync within about a second and reloads once. With nothing changing, many uses cost at
most two generation reads. A resync reaches the worker. A worker started before the master
doesn't create the database.
tests/library/memory-guard-group.test.js: the group measure refuses requests while thisprocess is small; only the largest process aborts; the fallback without a cgroup; and the
describe()text is unchanged without a role.The options:
tests/tx/resource-cache.test.jsruns against both stores; plus the shared store's owncases (lazy loading, touch throttle, write retry then 503, the cap across endpoints,
restart, schema check) and P6's pre-fill: the same expansion key as the memory store's
resources, hashing only the key and a plain resource, whether the resources are loaded
or not.
tests/tx/resource-cache-multiprocess.test.js: two forked processes on one file (use,end, idle sweep from either, restart, 100 concurrent writes, the size cap across
processes).
tests/server/cluster-shared-cache.test.js:cluster.jswithsharedCache. A sessionstarted on each of the three processes is used on all three (18 of 18, no
cache-id-unknown); 60 concurrent cached requests through the launcher; an unknown idin the shared wording; a worker SIGKILLed and restarted still has every session. With
sharedCache: falsethe same test fails (12cache-id-unknown).tests/tx/closure-shared.test.js: a second store on the same file writes between themodule's read and its write. Forced conflicts retry and end as a single writer would;
exhaustion gives 429 with nothing written; a table dropped meanwhile is 404; bounded
resync; create and the limit checked in the write.
tests/server/cluster-shared-closure.test.js: 3 processes x 4 clients adding at once toone table and to four shared-in-threes tables, checked against a single-writer reference
(concepts, entries, no loss or duplicates), versions 1..n with no gaps, each write's
answer equal to what is stored at its version, and "changes since v" from every process
for every v. 12 concurrent creates of one name make one; creates racing for the limit
stop at it. With the version check removed, it fails.
closure-model/closuregen.py) against a launcher port (20070, master and 2workers, both options on, SNOMED CT and LOINC):
sharedfor 60 s (277 adds, 99resyncs) and
mixedfor 60 s (1041 adds, 23 tables); every request 200, and its oraclecheck passed on every table.
All the touched suites were run under
systemd-run --user --scope -p MemoryMax=6G: role, memory-guard, memory-guard-group, cgroup-memory, vs-vsac, vs-vsac-next-link, vsac-generation, library, library-local-source-cache, closure, closure-enabled, syndication-routes, syndication-store, cache-control, cache-id, stats, cluster-launcher and cluster-integration. All 18 suites passed: 232 tests. With the options: those 18 plus closure-shared,cache-id-params, cluster-shared-cache, cluster-shared-closure, resource-cache,
resource-cache-multiprocess, expansion-key-hash, expansion-cache, supplement-filters, worker
and provider-tho-precedence, at f81442a: all 29 suites passed: 469 tests (4 skipped:
the size-cap cases, for the memory store, which has no cap).
🤖 Generated with Claude Code