Replies: 1 comment
Status update — 2026-08-24The body above is preserved as the original design record. Since it was written, Corrections — claims that main has invalidatedC1 / §2b — the jti replay cache is gone, and the finding is reversed. C2 — F5 — narrower than written. Narrowed — landed on main, cluster gap remainsB3 — subscriptions are no longer reactive-only. C3 — local eviction fixed; cross-instance staleness still open. F3 — now export-only. Cosmetics. The SoF operations were renamed to Unchanged — re-verified against main @
|
Uh oh!
There was an error while loading. Please reload this page.
Cluster-capable state — discussion & design
Status: discussion draft (no code yet)
Source: PR #180 review comment,
which asks for a document describing the areas of
hfsthat must be modified —behind an environment-variable config switch — to use a unified,
cluster-capable job store, plus operator documentation for running
hfsin acluster. Relates to #169,
#170,
#150, and the ROADMAP
"Clustered / multi-instance deployment" item.
This draft is the output of an exhaustive sweep of the workspace for state that
lives only in one process's memory and would break, diverge, or silently produce
wrong results if
hfsran as multiple instances behind a load balancer. Itcovers the three areas already flagged (SoF export jobs, WebSocket Subscription
delivery, the JWT
jticache) plus everything else the sweep surfaced.1. What "cluster-capable" means here
A single
hfsprocess today holds a lot of correctness-bearing state on its ownheap: async job registries, WebSocket connections, replay caches, terminology
caches, and background reaper tasks. Behind a load balancer with ≥ 2 instances,
each request may land on a different instance than the one that created the
associated state. Four failure modes recur:
created on instance A returns 404 on instance B (SoF export jobs, reindex
jobs, WebSocket binding tokens).
with no durable record (all in-memory job registries, delivery retries).
was invalidated on one node only (HTS terminology caches), or a counter is
maintained per node (Subscription
eventNumber, failure counters).instance is accepted because the first instance's replay cache is not shared
(JWT
jti).The boundary we are drawing: process-local ephemera (connection pools, tokio
runtimes, immutable config loaded from disk, per-request scratch) may stay in
memory. Anything that outlives a single request and must be observed by
another actor — a poller, a worker, a second instance — must be externalized
to shared infrastructure when clustered.
2. The good news — HFS already has two working precedents
We are not starting from zero. Two subsystems are already cluster-correct, and
they establish the two shapes every fix below should take.
2a. DB-backed job store with lease + fencing tokens (Bulk Data)
Bulk Data
$exportand$bulk-submitare fully cluster-safe. Their job state(status, tenant ownership, per-type progress cursors, output-file rows) lives in
the primary database, and workers compete for jobs through an atomic
lease-and-fence protocol:
trait BulkExportJobStore(crates/persistence/src/core/bulk_export_worker.rs:215)and
trait BulkSubmitJobStore(crates/persistence/src/core/bulk_submit_worker.rs:300)compose job-state storage + a claim strategy + fenced worker storage.
ExportClaimStrategy::claim_next(bulk_export_worker.rs:124) atomicallyclaims one job (
SELECT … FOR UPDATE SKIP LOCKEDon Postgres; a process-localmutex on SQLite). Every worker mutation carries a
worker_id+ monotonicfencing_token; a zero-row guarded write returnsLeaseError::LeaseLostandthe worker aborts, so a zombie can never mutate a job another worker now owns.
spawn_export_workers,crates/hfs/src/main.rs:1035); every replica runs a pool that competes forDB-leased jobs.
HFS_BULK_EXPORT_DISABLE_LOCAL_WORKERturns a node API-only.This lease/fencing store is the reference architecture for the unified job
store in §4.
2b. Pluggable shared backend selected by env var (auth
jti)The
jtireplay cache is already a trait with swappable backends chosen atstartup:
trait JtiCache(crates/auth/src/jti/mod.rs:15) withInMemoryJtiCache(
memory.rs:14),RedisJtiCache(redis.rs:10, atomicSET NX EX), andDisabledJtiCache.HFS_AUTH_JTI_BACKEND(crates/auth/src/config.rs:73, default"memory"), Redis URL fromHFS_AUTH_REDIS_URL.The trait boundary and env-var selection are exactly the pattern §4 generalizes.
The gap (see §5, class C) is only that the safe backend is opt-in.
A third, already-written-but-never-wired piece exists too:
JwksCoordinator(
crates/auth/src/jwks/coordinator.rs:11) — a Redis leader-lock(
hfs:jwks:refresh_lock) plus shared key store for cluster JWKS refresh. It hasno call sites.
3. Shared substrate — what "unified" backs onto
Three externalization primitives cover every finding. The proposal does not
introduce three separate dependencies; it selects among what a deployment
already runs:
redisfeature) — §2b; or a DB tableLISTEN/NOTIFY, or a message busThe governing principle: when clustered, the primary backend must itself be a
shared database (Postgres / Elasticsearch / MongoDB). SQLite — the zero-config
default (
HFS_STORAGE_BACKEND=sqlite,crates/rest/src/config.rs:739) — is asingle-writer local file and cannot be clustered at all. Cluster guidance
starts there.
4. The unified cluster-capable job store
Generalize §2a into one
ClusterJobStoreseam that the in-memory asyncsubsystems plug into, selected by a single env var.
Trait shape (mirrors
BulkExportJobStore, kind-agnostic):jobstable in the shared DB, discriminated byJobKind(
bulk-export,bulk-submit,sof-export,reindex), so the foursubsystems share migrations, the claim query, lease/fence logic, tenant
scoping, and the cleanup reaper.
jtiprecedent:HFS_JOB_STORE_BACKEND = memory(default; in-processDashMap, singleinstance)
| database(shared, cluster-capable). A convenience master switchHFS_CLUSTER=trueflips this and the other cluster-safe defaults(§6) at once and fails fast if the primary backend is SQLite.
(
ExportJobController,crates/rest/src/export/controller.rs:196) and areserved-but-unread selector (
HFS_EXPORT_CONTROLLER,crates/rest/src/config.rs:805). The database backend implementsExportJobControlleroverClusterJobStoreand is wired whereInMemoryControlleris unconditionally constructed today(
crates/rest/src/lib.rs:517). Bulk export/submit already are this patternand can migrate onto the shared table opportunistically or stay as-is.
5. Inventory of affected areas
Grouped by the fix class they need. Each item: location · what breaks · severity.
Class A — in-memory async job registries → move to the unified job store (§4)
InMemoryController(crates/rest/src/export/in_memory.rs:55) holdsjobs: DashMap<JobId, JobStatus>(:56) andjob_tenants: DashMap(:59);submit()runs each job in a detachedtokio::spawn(:168) under aper-instance
Semaphore(so N instances run N× the intended concurrency);a per-instance TTL reaper (
spawn_cleanup:117/reap_expired:323) sees onlyits own jobs. The
202/status URL, cancellation, download, and the completionmanifest (
JobStatus::Completed { files, .. }) all live only in one heap. Apoll/cancel/download on another instance → 404; restart loses everything.
The single clear code defect; the whole
$…-exportasync surface issingle-instance today. Severity: functional breakage / result loss.
ReindexManager(crates/persistence/src/search/reindex.rs:363) keepsjobs: RwLock<HashMap<String, ReindexProgress>>+cancel_channels;start()mints a UUID and
tokio::spawns the work (:406). Status poll / cancel onanother instance sees no such job; restart orphans the reindex. Admin-triggered,
no corruption. Severity: degradation.
Class B — node-local connection registries & fan-out → shared pub/sub + shared state (Subscriptions, [#170])
Systemic: subscription reaction is per-instance, fire-and-forget, in-memory.
emit_subscription_event(crates/rest/src/handlers/subscription_event.rs:47)tokio::spawnson_resource_eventon whichever instance served the write,against that process's registries. Subscriptions/topics are loaded only
reactively (nothing reads them from the DB at startup). So a
Subscriptioncreated on A produces zero notifications for a matching write served by B,
and all in-memory state vanishes on restart.
WebSocketManager.clients(crates/subscriptions/src/channels/ws_manager.rs:19),a
DashMap<(tenant, subscription_id), Vec<WsClientSender>>holding thempsc-sender half of a live socket. Only the instance terminating the socket can
deliver;
WebSocketChannel::dispatch(channels/websocket.rs:45) hits thelocal manager and returns
Successeven with 0 local clients, so loss isinvisible. Sockets are inherently node-local → needs a pub/sub fan-out where
the event is broadcast to all instances and each delivers to its own sockets.
WsBindingTokenManager.tokens(channels/ws_token.rs:24), single-use ~30 stokens from
$get-ws-binding-token. The token is minted on one connection andredeemed on a separate WS-upgrade connection the LB may route elsewhere →
every cluster WS bind fails without sticky routing. Fix: shared KV (Redis TTL)
or a signed/stateless (HMAC/JWT) token.
SubscriptionManager.subscriptions(crates/subscriptions/src/manager/mod.rs:137),InMemoryTopicRegistry.topics(crates/subscriptions/src/topics/mod.rs:94),and
SubscriptionEngine.topic_resource_index(engine/mod.rs:37) are allin-memory with no startup load. An instance that never saw the topic write
rejects
registerwithTopicNotFound. Fix: DB-backed load (the resourcesalready persist) + startup reconciliation.
events_since_start→Subscription.status.notificationEvent[].eventNumber(
manager/mod.rs:124, built innotification/builder.rs:172) andconsecutive_failuresdrivingerror/offtransitions (manager/mod.rs:127,engine/mod.rs:854) are per-instance. Result: non-monotonic/duplicatedeventNumberacross the cluster (breaks subscriber gap-detection); a deadendpoint whose failures scatter across nodes never reaches the
offthreshold.Fix: shared atomic counters (DB row / Redis
INCR).dispatch_with_retry(engine/mod.rs:722) keeps the retry "queue" on afire-and-forget task's stack (backoff up to 60 s × 10). A redeploy drops all
pending retries with no record. Fix: a durable delivery outbox table
processed by workers — i.e. the §2a lease pattern again.
(
channels/{rest_hook,messaging,email}.rs) are stateless push-to-external —any instance delivers identically. No heartbeat/reaper loop exists yet
(
heartbeat_check_interval,config.rs:33, is unused) — but if one is addedit must be lease-guarded, not a plain per-instance
interval.Class C — shared caches / replay with local-only invalidation → shared store or cross-instance invalidation
C1 · JWT
jtireplay cache — SECURITY HOLE (default-on).InMemoryJtiCache(crates/auth/src/jti/memory.rs:14) is the default(
config.rs:73; built incrates/hfs/src/main.rs:617). A one-time clientassertion accepted on A and replayed to B is honored again — B's cache never
saw the
jti. Blast radius scales with instance count. Secondary bug evensingle-instance: it ignores the token's
expires_atand uses a flat 1 h TTL(
memory.rs:26/43), so a longer-lived token is replayable after eviction. Fixalready exists:
RedisJtiCache(redis.rs:10). Recommend: when clustered,make a shared backend mandatory / fail-closed. Severity: security.
C2 · JWKS refresh — MEDIUM (dead code ready to wire).
JwksCache(crates/auth/src/jwks/cache.rs:16) is per-instance — functionallyfine (every node fetches the same public keys) but each node independently
hammers the IdP on a
kidmiss.JwksCoordinator(jwks/coordinator.rs:11)was built to fix this (Redis leader-lock + shared key store) and is never
wired. Fix: wire it under the cluster switch.
C3 · HTS terminology response caches — HIGH (silent wrong clinical answers).
Per-process, no TTL, invalidated only on the instance that received the
write/import:
AppStatecaches —expand_cache,not_found_urls,*_validate_code_*,expand_handler_cache,lookup_handler_cache, etc.(
crates/hts/src/state.rs:190); cleared only by localclear_expand_cache(
state.rs:280, called fromimport_bundle.rs/crud.rs).inline_compose_cache,lookup_response_cache,cs_resolved_meta_cache,subsumes_response_cache,translate_response_cache(
crates/hts/src/backends/postgres/mod.rs:97; cleared:172), andprocess-global
OnceLockstaticsCLOSURE_COUNT_CACHE/CLOSURE_PREFIX_CACHE(postgres/value_set.rs:37,97).A CodeSystem/ValueSet/ConceptMap update on instance A leaves B serving stale
$expand/$validate-code/$lookup/$translate/$subsumesresultsindefinitely — a correctness hazard in a clinical system. Fix: cross-instance
invalidation (PG
LISTEN/NOTIFYon terminology writes, or a sharedterminology-version epoch keyed into the caches, or short TTLs). The SQLite-side
equivalents (
sqlite/value_set.rs:73,code_system.rs:119) are Low — acluster can't share a SQLite file anyway.
Class D — once-per-instance background tasks → leader-election / leasing
bootstrap_syncruns on every boot (crates/hts/src/main.rs:78,134,187),deduped by the shared
bootstrap_importsledger — but the check-then-import-then-record sequence has no lock. N instances cold-booting together (rolling
deploy, autoscale) each see "not imported" and import the same heavy
SNOMED/LOINC/RxNorm/ICD-10 file concurrently. Idempotent upserts keep the end
state correct, but you pay N× cost + write contention. Fix:
pg_advisory_lockaround bootstrap, or leader-election.
Unleased per-instance reapers (
crates/hfs/src/main.rs:1063,1244) scan theshared DB and race on deletes, but deletes are idempotent → duplicated work
only. Optional: gate behind a single-owner lease for tidiness.
Class E — durability queues
In async mode,
SyncManagerbuffers secondary-backend (e.g. Elasticsearch)propagation in an in-process
mpsc::channel(1000)with status inRwLock<HashMap>(crates/persistence/src/composite/sync.rs:157,160). Acrash/redeploy with events still queued permanently loses those secondary
writes → the search index silently diverges from the primary, recoverable only
by a full reindex. Both a crash-durability and a cluster issue. Fix: a durable
outbox (DB table) drained by workers.
Class F — configuration-level cluster caveats (no new code, must be documented)
HFS_STORAGE_BACKEND=sqlite(
crates/rest/src/config.rs:739) is single-writer local file. Clustering ⇒Postgres / Elasticsearch / MongoDB. HTS SQLite backend: same.
LocalFsOutputStoreis the default for both bulk subsystems (
crates/hfs/src/main.rs:955,1124); adownload routed to a different node than the writer 404s. Set
HFS_BULK_EXPORT_OUTPUT_BACKEND=s3/HFS_BULK_SUBMIT_OUTPUT_BACKEND=s3forshared, presigned output.
bulk_export.dbunder Mongo/S3 primary.When the primary backend can't host transactional job state (MongoDB, S3),
bulk-export job state falls back to a per-process local SQLite file
(
build_embedded_job_store,crates/hfs/src/main.rs:886, worst case aper-PID temp path). Under those backends in a cluster, jobs are invisible
across instances — the SoF failure mode. Fine for Postgres primary (shares the
primary DB).
FileSink(
crates/audit/src/sinks/file.rs:19) writes NDJSON to local (often ephemeral)disk → a fragmented, restart-lossy audit trail. Use
DatabaseSinkorCloudWatchLogsSink(both write immediately to shared infra) when clustered.version_id=current.parse::<u64>() + 1is a read-then-write with no lock on theunconditional update/delete path (
crates/persistence/src/backends/postgres/storage.rs:291,396;mongodb/storage.rs:110). Two concurrent writers (more likely acrossinstances) can both assign N+1, colliding ETags / losing a history version. The
conditional path is safe (guards on
expected_version,postgres/storage.rs:946). Fix: make the increment atomic (… SET version = version+1 … RETURNING) or requireIf-Match. Severity: LOW-MEDIUM.Confirmed benign (checked and cleared — for coverage confidence)
Server-assigned resource IDs are
Uuid::new_v4()(collision-free, no sharedcounter). The
AtomicU64/AtomicUsizecounters found (sqlite/mod.rs:484,persistence sqlite/backend.rs:23,sof/remote_fetch.rs:62) name ephemeralin-memory test DBs or count within a single operation. Per-backend
search_registryis immutable config loaded identically per instance. CompositeHealthMonitor, per-tenant pool LRU bookkeeping, fhirpath tracer/runtimestatics, outbound OAuth token cache (
crates/rest/src/bulk_submit_oauth.rs:30),and CDS Hooks types are per-instance-local or read-only and safe. Audit recording
is fire-and-forget with UUID IDs (no per-instance sequence), only a minor
crash-durability window.
6. Environment-variable configuration surface
One master switch plus per-subsystem overrides, all following the existing
HFS_*conventions.HFS_CLUSTERtrue/falsefalseHFS_JOB_STORE_BACKENDmemory/databasememory(databasewhenHFS_CLUSTER)HFS_AUTH_JTI_BACKENDmemory/redis/disabledmemoryredis) when clustered (C1). Consider fail-closed underHFS_CLUSTER. (exists)HFS_AUTH_REDIS_URLjti+ JWKS coordinator. (exists)HFS_SUBSCRIPTIONS_FANOUTmemory/redis/nats/pg-notifymemoryHFS_TERMINOLOGY_CACHE_INVALIDATIONlocal/pg-notify/redislocalHFS_BULK_EXPORT_OUTPUT_BACKENDlocal-fs/s3local-fss3when clustered (F2). (exists)HFS_BULK_SUBMIT_OUTPUT_BACKENDlocal-fs/s3local-fss3when clustered (F2). (exists)HFS_AUDIT_SINKdatabase/file/cloudwatch/ …filewhen clustered (F4). (exists)Design intent: an operator sets
HFS_CLUSTER=true, points at a shared primaryDB, and provides Redis (or accepts DB-notify equivalents); the switch turns the
individual backends to their cluster-safe variants and refuses to boot on an
unsafe combination rather than silently degrading.
7. Operator documentation deliverable
Ship a "Running HFS in a cluster" page (book chapter + skill note) covering:
SQLite), shared object storage (S3) for bulk output, and Redis (or the
pg-notifyalternatives) for replay/fan-out/invalidation.HFS_CLUSTER=true+ the shared-infra URLs, and thefail-fast checks it enforces.
$operation/ channel does with1 vs N instances, and which env var makes it cluster-safe.
sessions).
Subscription delivery (the triggering event originates on an arbitrary node);
pub/sub fan-out is required.
8. Suggested phasing
Each phase is independently shippable and leaves the tree green.
HFS_CLUSTERwith fail-fast validation(reject SQLite primary,
memoryjti,local-fsbulk output,fileaudit)and publish the §7 operator doc describing current single-instance limits. No
behavior change to the happy path. (Highest value / lowest risk — documents
and enforces the boundary immediately.)
ClusterJobStore(generalized from
BulkExportJobStore), implementExportJobControlleroverit, wire
HFS_JOB_STORE_BACKEND/HFS_EXPORT_CONTROLLER. Fold reindex jobs(A2) onto the same table.
jtishared-mandatory underHFS_CLUSTER(C1); wire
JwksCoordinator(C2). Small, security-first, mostly existing code.subscription/topic load + startup reconciliation (B3), shared pub/sub fan-out
(B1), shared/stateless WS binding tokens (B2), shared counters (B4), durable
delivery outbox (B5).
composite durable sync outbox (E1).
(F5 atomic version increment) folded in where cheap.
9. Out of scope / open questions
primary DB (using
LISTEN/NOTIFY+ a DB table for replay/fan-out) so acluster needs no Redis? Trade-off: DB load &
NOTIFYfan-out limits vs onefewer dependency. The env-var surface above keeps both open.
could share one leader-election primitive rather than each rolling its own
lease.
/metricswith tenant asa span attribute (never a metric label) is already correct; the open item is
documenting cross-instance aggregation, not code.
state onto the unified
databasejob store (needs a transactional home) or todocument it as Postgres-primary-only for clustering.
All reactions