Repository navigation
Conversation
…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>
|
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? |
|
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 |
…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>
|
Thanks, fair points all round. Closing this in favour of #342, which follows the model in nginx.md:
On your questions:
|
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
sqlite3package (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 addsnode 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-controlcaches are kept inresource-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.cluster-state.db. A login made through one process holds on the others, and a limit of 20 attempts means 20 for the service.$closurelocks its table across processes.What stays per process: statistics and the dashboard, the expansion cache, and the npm projector's manual
/_reload.docs/cluster.mdcovers the setup, the memory model, and Linux against other platforms.Measured
$cache-controlsessions) 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.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.dbholds the$cache-controlcaches.cluster-state.dbholds logins, generated session secrets, rate-limit counts, and the locks that cover work which waits on something.background-jobs.locksays 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 IMMEDIATEtransaction.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.storeto"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 stillmemory.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.
server.memory.limitif set, now meaning the whole service. Otherwise the cgroup limit. Otherwise the machine's RAM.MemoryMaxon the unit or scope, or a container limit). It is that cgroup's private memory:memory.statanon, ortotal_rsson cgroup v1. The page cache is left out because the kernel can take it back.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 todrainSeconds(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.Alternatives:
Why:
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:
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 keepsCACHE_ID_UNKNOWNunchanged. Only the English messages file has theCACHE_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:
<name>-<worker>.log.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.sharedStateChosen: 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 incluster-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.
$closuretables are locked across workers with a leaseChosen:
cluster-state.db, within the samelockTimeout. The holder renews the lease while it works. If the holder dies, the lease lapses after 30 seconds.maxQueuecounts the calls waiting in each worker.maxTablesis 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