fix(subscriptions): persist server-driven status transitions (#357) - #441
Open
mauripunzueta wants to merge 3 commits into
Open
fix(subscriptions): persist server-driven status transitions (#357)#441mauripunzueta wants to merge 3 commits into
mauripunzueta wants to merge 3 commits into
Conversation
Subscription runtime status lived only in the engine's in-memory DashMap. Nothing was ever written back, so every decision the server made about a subscription was lost on restart and the stored resource contradicted `$status` for the life of the resource. Server-driven transitions — `requested` -> `active` after a successful handshake, `-> error`, `-> off` once the delivery circuit breaker trips — are now written into the stored `Subscription` through the ordinary `ResourceStorage::update`. That needs no new table and no migration, so it applies identically to SQLite, PostgreSQL, MongoDB, S3, and the composite deployments. `SubscriptionEngine::transition_status` is the single choke point. The write is gated on the manager accepting the transition, which bounds it to at most three writes per subscription lifetime: `update_status` rejects same-state transitions, so a subscriber outage that parks hundreds of dispatch tasks all observing `consecutive_failures >= off_threshold` yields exactly one write. A no-op transition issues none at all. Write-back fails open (the in-memory transition stands, logged at `error`), drops rather than retries on a losing race with a client write, and is bounded by a semaphore and a timeout so it cannot drain the backend pool or pin a dispatch task. Persisting the breaker is only safe alongside three supporting fixes: - Rehydration no longer skips `off`. Skipping was defensible while status was volatile; once it persists, skipping means the subscription is absent from the engine forever and `$status` answers 404 for a resource that reads back 200. `off` and `entered-in-error` are now registered dormant — visible to `$status`, never matched, never handshaken. - `error` is re-activated on restart. Nothing performs `error` -> `active` while a subscription is dormant, so before this the *loss* of the status was what quietly recovered it. Persisting `error` without this would have turned a transient subscriber outage into permanent silent death. - `register` no longer zeroes the runtime counters on every write. It runs on every create/update/patch, so an unrelated edit silently reset the circuit breaker with no restart involved. Counters now survive re-registration unless the client is deliberately re-arming the subscription. Also: `entered-in-error` is parsed instead of falling back to `requested` (rehydration would otherwise handshake and activate a retracted subscription), and `$status`/`$events` fall back to the stored resource instead of 404 when the engine does not hold the subscription. `eventsSinceSubscriptionStart` is deliberately not persisted — it has no element on `Subscription` in any FHIR version and needs the side store #269 is building; see the PR body. Config: `HFS_SUBSCRIPTION_PERSIST_STATUS` (default true) is the kill switch, `HFS_SUBSCRIPTION_STATUS_WRITE_TIMEOUT_MS` (default 5000) bounds each write.
…e write-back suite The new status-persistence tests seeded the SubscriptionTopic into storage but never into the engine's in-memory topic registry, which is populated only by resource events or by `rehydrate`. `SubscriptionManager::register` therefore rejected every subscription with `TopicNotFound`, and six tests failed. Two others passed *vacuously* for the same reason: their negative assertion is "the stored status is still requested", which a setup failure satisfies trivially. Both now assert the in-memory transition happened first, so they prove the write was suppressed rather than never attempted. A production-code bug this was not: the harness drove `on_resource_event` the way the REST handlers do, but skipped the topic's own event.
…tus fallback Three review fixes on top of the write-back: - `resolve_snapshot` fabricated a `TenantPermissions::full_access()` context to read the stored Subscription. That is wrong in a request path: the tenant and its permissions must be the ones the request actually resolved to, so it now takes the `TenantExtractor` and uses `tenant.context()`. The engine's own write-back keeps a server-authored context — it runs on a background task with no request behind it, same as startup rehydration — and that is now stated at the call site along with why it cannot widen tenant reach. - The startup log now reports `engine.persists_status()` rather than the config flag alone, so it reads OFF when either the flag is false *or* no store was attached, instead of claiming ON for an engine that cannot write. - Split the stored-resource parsing out of `resolve_snapshot` into `snapshot_from_stored` so the only part with real branching is unit-testable without standing up an app and an engine, and covered it: native `topic` vs R4-backport `criteria`, `entered-in-error` surviving as itself rather than decaying to `requested`, and absent/unknown fields.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
mauripunzueta
marked this pull request as ready for review
July 30, 2026 13:04
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #357.
What was wrong
Subscription runtime status lived only in the engine's in-memory
DashMap.SubscriptionManager::update_statusmutated the entry and returned; nothing was ever written back. Every decision the server made about a subscription was therefore lost on restart, and the stored resource contradicted$statusfor the life of the resource.Approach, and why not a side store
The issue offered three directions. This PR takes write-back into the stored
Subscription, through the ordinaryResourceStorage::update.That choice is load-bearing: it needs no new table and no migration, so it applies identically to SQLite, PostgreSQL, MongoDB, S3, and the composite deployments. A separate per-backend projection store would have been ~1,500 lines across four backends for a feature that is opt-in twice (
subscriptionsis not a default cargo feature, andHFS_SUBSCRIPTIONS_ENABLEDdefaults tofalse).It is also the only option that makes search agree.
statusis a search parameter served from the index built off stored resources, so a read-time overlay would leaveGET /Subscription?status=activeanswering from stale documents whileGET /Subscription/{id}said otherwise. Write-back makes read, search,$status, and_historytell one story.The main objection to write-back is that the engine's own write would re-enter
on_resource_event→register()and erase the counters it just saved. That does not happen:emit_subscription_eventis called only from the REST write handlers (create.rs:231,update.rs:224,patch.rs:149,delete.rs:90). A write issued by the engine straight throughResourceStorageemits no event.Bounding the write volume
SubscriptionEngine::transition_statusis the single choke point; the engine no longer callsmanager.update_statusdirectly anywhere.The write is gated on the manager accepting the transition.
update_statusrejects same-state transitions and transitions out of a terminal state, so when a subscriber outage parks hundreds of concurrent dispatch tasks that all observeconsecutive_failures >= off_threshold, exactly one getsOkand exactly one writes. Over a subscription's whole lifetime that is at most three writes (→ active,→ error,→ off). A transition whose target already matches the stored status issues no write at all, which also removes the version-bump burst on the first boot after upgrading.Write-back fails open: the in-memory transition stands and the failure is logged at
error(grepFailed to persist subscription status transition). Refusing to mark a subscriptionoffbecause the database blipped would put the server straight back to hammering the endpoint the breaker just gave up on. It drops rather than retries on a losing race with a concurrent client write — the client's copy is the newer intent. It is bounded by a semaphore (4) and a timeout so it can neither drain the backend pool nor pin a dispatch task.Three supporting fixes, without which persisting the breaker is worse than not
off. Skipping was defensible while the status was volatile. Once it persists, skipping means the subscription is absent from the engine forever, so$statusanswers404for a resource thatGET /Subscription/{id}answers200for — a regression on the very symptom being fixed.offandentered-in-errorare now registered dormant: visible to$status, never matched (the evaluator selects onlyactive), never handshaken.erroris re-activated on restart. Nothing in the engine performserror→activewhile a subscription is dormant: it is never dispatched to, so it never succeeds, so nothing resets it. Before this PR the loss of the status on restart is what quietly recovered it. Persistingerrorwithout re-running activation would have converted a transient subscriber outage into permanent silent death.registerno longer zeroes the runtime counters. It runs on every create/update/patch, so an unrelated edit — adding a filter, rotating a header — was already a silent reset of the delivery circuit breaker with no restart involved. Counters now survive re-registration unless the client is deliberately re-arming (writingrequested, or moving the subscription out oferror/offitself), which is the documented way back from a tripped breaker.Also fixed
entered-in-erroris parsed. It previously failedfrom_fhir_str, soregisterfell back torequested— and rehydration would handshake and activate a subscription the client had explicitly retracted. It is now a status of its own, terminal in both directions.$status/$eventsfall back to storage instead of404when the engine does not hold the subscription (rehydration failed for a tenant,HFS_SUBSCRIPTION_REHYDRATE=false, an unparseable topic). Both handlers had the identical miss.Deliberately not in scope
eventsSinceSubscriptionStartis not persisted. It has no element onSubscriptionin any FHIR version — it lives onSubscriptionStatus— so persisting it genuinely needs the side store that #269 is building, and doing it naively costs a database write per delivered notification. Today it resets to0on restart, which is a rewind; the correct fix is a reservation watermark (persistcounter + N, restore from the watermark) so numbering only ever gaps forward, atO(subscriptions / interval)writes rather thanO(events). That belongs with the store, not here.Relationship to #269
PR #269 (cluster-capable HFS) introduces a
SubscriptionStateStorecarrying exactly this state — but it is PostgreSQL-only and attached only underHFS_CLUSTER=true. After #269 merges, #357 remains open for the default deployment: single node, SQLite, no cluster. This PR closes it there.The two compose rather than collide: #269 holds runtime status in its shared store and documents that "the FHIR resource's own
statusfield is never written back". If #269 lands after this, the write-back should be gated on!engine.is_cluster_backed()at the one choke point this PR created, so the shared store stays authoritative in cluster mode. That is a one-line boolean at a single site, which is auditable in a 99-file rebase.This PR touches no persistence schema and adds no migration, specifically so it cannot collide with #269's migration numbering (which must be renumbered on rebase regardless — its ladder claims PG v15–v19 while main has since merged v15 and v16).
Follow-ups noted here rather than filed
reset_failuresfires on every successful delivery and its PG statement is an unconditionalUPDATE … SET consecutive_failures = 0, so a healthy subscription writes a row per notification. MakingSubscriptionManager::reset_failuresreturn "was non-zero" and skipping the store call would halve cluster-mode per-notification write cost.conditional_update_handler,conditional_delete_handler) and batch/transaction write paths emit no subscription events at all, so a subscriber silently misses every conditionally- or transactionally-written resource.$purgeon a Subscription never deregisters it from the engine.error→activerecovery other than a restart, and no heartbeat loop (heartbeat_check_intervalandall_subscriptions()have no production callers).AuditEvent. The write goes straight throughResourceStorage, deliberately bypassing the REST layer (that is what keeps it from re-enteringon_resource_event), which also means it bypasses the audit middleware. Arguably a server-authored change to a clinical-workflow resource should be audited;helios_persistence::core::storage::auditalready has the shape for it.Testing
New
crates/subscriptions/tests/status_persistence.rsdrives the engine through its publicon_resource_eventagainst a real SQLite backend and asserts the stored resource, not the in-memory entry: activation persistsactive; a delivery failure persistserror; the breaker persistsoff; write-back preserves every other field including the version-shape markers rehydration infers the FHIR version from; a no-op transition mints no new version; no store andpersist_status = falseboth leave storage untouched; a deleted subscription is not resurrected.rehydrate_integration.rsgains the newoff/entered-in-errordormant-registration anderrorre-activation contracts (the oldoff_subscriptions_are_skippedtest asserted the behaviour this PR deliberately changes). Manager unit tests cover counter preservation, the three re-arm paths, tenant scoping, andentered-in-error.crates/rest/src/handlers/subscriptions.rsgains unit tests for the$status/$eventsstorage fallback: the parsing is split into a puresnapshot_from_storedso it is testable without standing up an app and an engine — nativetopicvs R4-backportcriteria,entered-in-errorsurviving as itself rather than decaying torequested, and absent/unknown fields.Verification
Not compiled locally (no C linker in the dev environment), so CI is the verification:
Lintingrunscargo clippy --all-targets --all-features -- -D warnings, so a green tick there means everything including the new tests compiles clean and lint-clean.Test Rustruns the suites below. It went green on the commit carrying the write-back and the full suite; see the checks on this PR for the current tip.Two findings from a self-audit after the first CI run, both fixed here:
TopicNotFound: the harness seeded theSubscriptionTopicinto storage but never into the engine's in-memory topic registry, which is populated only by resource events or byrehydrate. A harness bug, not a production one.requested", which a setup failure satisfies trivially — so they would have kept passing even with write-back entirely broken. Both now assert the in-memory transition happened first, proving the write was suppressed rather than never attempted.Two claims in this description were checked against the code rather than assumed:
rehydratedefers every activation toactivate_pendingafter the whole tenant scan completes.CompositeStorage::updatewrites the primary and thensync_to_secondaries(SyncEvent::Update), soGET /Subscription?status=activereally does become correct on composite deployments — the search-consistency argument for this design holds.