fix(persistence): surface backend failures instead of faking empty results, and assert the contract for every backend#275
Open
mauripunzueta wants to merge 5 commits into
Open
Conversation
…s unreachable Extends the MongoDB error-handling pattern from #211 to every storage backend, and fixes the two production bugs the new tests exposed. Adds `tests/backend_error_handling.rs`: one target stating the contract once — an operation against an unreachable or misconfigured store MUST return `StorageError::Backend(_)`, and MUST NOT return `Ok(None)`, `Ok(0)`, or hang. Covers sqlite, postgres, mongodb, elasticsearch and s3, needs no server and no Docker, and bounds every test with a deadline so a hang fails loudly instead of stalling CI. `ResourceStorage` is object-safe, so a single `&dyn` driver exercises all five despite their very different constructors. The MongoDB test moves here from `mongodb_tests.rs`: it needs no server, so it did not belong in a suite whose tests all skip without one, and its `mongodb_integration_*` name implied a Docker dependency it never had. Elasticsearch was silently reporting an unreachable cluster as an empty store. `read` mapped every transport error to `Ok(None)`, `search` mapped a connection failure to an empty result set, `count`/`search_count` returned `Ok(0)`, and `create_or_update` treated a failed existence check as "new resource, version 1", clobbering history. The composite layer prefers Elasticsearch for search, so a `*-elasticsearch` deployment with the cluster down answered searches with an empty Bundle and HTTP 200 — while recording the dead cluster as healthy. Transport failures now flow through the existing retry machinery and surface as `BackendError::Unavailable`. The genuinely-empty cases (index-not-found, doc-not-found) still return 404 -> empty, so healthy behaviour is unchanged. PostgreSQL `connect_timeout_secs` was dead config: declared, defaulted, serialized and never read. Wiring it alone is not enough — tokio-postgres applies `connect_timeout` only to `TcpStream::connect`, leaving DNS, TLS and the auth exchange unbounded — so deadpool's `create_timeout` now bounds connection establishment as a whole. `wait`/`recycle` are left unbounded: they govern load-shedding, not reachability. The `blackholed_server` test (bind a listener, never accept) is the regression test; it hangs without this fix.
CI caught the contract test's MongoDB case sitting for the full deadline. The cause was not a hang: `SERVER_SELECTION_TIMEOUT` was a hardcoded 15s, and it — not `connect_timeout_ms` — is what bounds an operation against an unreachable server, because the driver keeps re-attempting server selection while its monitor reports no healthy server. The connect timeout only bounds an individual TCP handshake, so lowering it (as the test did) changed nothing. So an unreachable MongoDB always took 15s to surface an error, and operators had no way to tune that. Promote it to `MongoBackendConfig::server_selection_timeout_ms` (default 15000, so existing deployments behave identically) and expose `HFS_MONGODB_SERVER_SELECTION_TIMEOUT_MS`. The contract test now sets 500ms and fails in well under a second. Also raise the contract deadline from 15s to 60s. At 15s it exactly matched MongoDB's default server-selection timeout, so the backstop was racing the very timeout it was meant to backstop. It must sit well clear of the backends' own timeouts to mean anything.
Closes the last two gaps against #212's stated motivation. S3 collapsed `NoSuchBucket` into the same `S3ClientError::NotFound` that `get_object`/`head_object` swallow into `Ok(None)`. So a bucket that was typo'd, deleted, or invisible to the credentials read as an *empty store*: every `read` answered "this resource does not exist", and `count` answered zero. This is the same misleading-success failure just fixed in Elasticsearch, and it is precisely what the backend error contract forbids — a missing *object* is a legitimate `Ok(None)`, but a missing *bucket* means we learned nothing at all. Give it its own `S3ClientError::BucketNotFound` variant so the object callers cannot swallow it, and map it to `BackendError::Unavailable`. The mock S3 client is made faithful to real S3 in the same way (head/get/list/put against a bucket that does not exist now fail with `NoSuchBucket` rather than reporting an empty result), which is what lets the new tests run with no server at all. Also adds `postgres_integration_unmigrated_store_surfaces_backend_error`. Postgres was the one backend whose per-operation error arms stayed uncovered: `new()` connects eagerly, so an unreachable server fails at construction and the caller never gets a backend to drive, leaving all 56 `internal_error(..)` arms in postgres/storage.rs unexercised — the exact class of uncovered code that motivated this issue. Reaching them needs a server that answers but cannot serve the query, so this test creates an empty database, skips `init_schema()`, and drives read/count/create against it, mirroring the SQLite unmigrated-store test. It needs Docker, so it lives with the other container tests.
34d3763 to
ff7012e
Compare
`connect_timeout_secs` was dead config until the previous commit wired it into deadpool's `create_timeout`. That fix is right, but it turned a field nothing read into a hard 5s ceiling on connection establishment — and left no way to raise it. `from_env()` reads six `HFS_PG_*` variables and not this one, and `parse_connection_string` starts from `Default`, so *every* deployment path was pinned to the default. A Postgres that needs longer than 5s to answer a fresh connection (a cold or loaded cluster, a cross-region failover, TLS through a distant proxy) would start failing with nothing an operator could do about it. This is the same defect the MongoDB commit on this branch fixes by promoting a hardcoded timeout to `HFS_MONGODB_SERVER_SELECTION_TIMEOUT_MS`; Postgres gets the symmetric treatment via `HFS_PG_CONNECT_TIMEOUT_SECS`. The read is factored into a helper so both construction paths honour it: `from_connection_string` is the dominant one (it backs `HFS_DATABASE_URL` and HTS's `database_url`), so wiring only `from_env` would have left the ceiling unreachable for most deployments. The URL never carries a timeout — `parse_connection_string` parses no query parameters — so applying the env value on top of it clobbers nothing. Also asserts `server_selection_timeout_ms` in the MongoDB env-overlay test, which covers the variable the previous commit added but left unasserted.
The mock's HEAD operations reported `BucketNotFound`, which the real client provably never can: a HEAD response has no body, so S3 has nowhere to put `<Code>NoSuchBucket</Code>`. The SDK sees a bare 404, synthesizes `NotFound`, and `S3Client::head_object` swallows that into `Ok(None)`. The `"NoSuchBucket"` arm added for the bucket/object split is unreachable from a HEAD request. So the new S3 tests were passing for a reason that does not hold in production. `s3_missing_bucket_create_and_count_are_errors` looked like it proved `create`'s existence check notices a missing bucket. It does not — the check concludes "new resource", and the *following* `PutObject` is what fails. Green either way, but the green meant something different from what it appeared to mean, which is the one thing a test must not do. Revert both HEAD mocks to the real client's semantics (they are now behaviourally identical to what they were before this branch), and say plainly in the test that `create` is covered via its write. The assertions still hold, now for the reason production actually errors. The underlying gap is filed as #284: the HEAD-based existence checks in `create` and bulk-submit are safe only because a write always follows them, and `validate_buckets` reports a missing bucket as "resource not found in S3". Not fixed here — it needs a way to disambiguate the two 404s, and S3's role is Archive — but recorded rather than papered over.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
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 #212.
Summary
Extends the MongoDB error-handling pattern from #211 to every storage backend — and fixes the five production bugs found along the way: four that the new tests exposed, and one that the fix for Bug 2 introduced and review caught.
The issue scoped this to PostgreSQL / SQLite / S3 and declared Elasticsearch out of scope ("search-only, never a standalone primary"). That turned out to be backwards: Elasticsearch is the one backend that actually violated the contract, and it violated it on the search path, in a shipped topology.
The contract
One new test target,
crates/persistence/tests/backend_error_handling.rs, states it once:The
Ok(None)clause is the point. Areadthat reports "not found" when it never reached the database is indistinguishable from one where the resource is genuinely absent — and "this patient has no recorded allergies" vs "I could not reach the database" are different clinical statements.countreturningOk(0)is the same lie wearing different clothes.9 tests, all five backends, no server and no Docker — they always run. Each is wrapped in a deadline, so a backend that hangs fails loudly rather than stalling CI.
ResourceStorageis object-safe, so a single&dyn ResourceStoragedriver exercises every backend even though their constructors differ wildly:new()verifies connectivity)searchtestThe MongoDB test moves here from
mongodb_tests.rs. It needs no server, so it did not belong in a suite whose tests all skip without one, and itsmongodb_integration_*name implied a Docker dependency it never had.Bug 1 — Elasticsearch silently reported an unreachable cluster as an empty store
Four catch-all arms:
storage.rsread:Err(_) => return Ok(None)— any transport error became "resource not found"search_impl.rssearch: a connection failure returnedSearchAttempt::EmptyIndex→ an empty result setcount/search_count:_ => Ok(0)create_or_update:_ => ("1", true)— a failed existence check meant "new resource, version 1", silently clobbering historyThis was live.
composite/storage.rsprefers the Search backend for search, and four of the eight supportedHFS_STORAGE_BACKENDmodes are*-elasticsearch. So in apostgres-elasticsearchdeployment with ES down,GET /Patient?identifier=Xreturned HTTP 200 with an empty Bundle — andupdate_health(id, result.is_ok())recorded the dead cluster as healthy.Transport failures now flow through the existing retry machinery (so a rolling restart or a not-yet-ready cluster is still absorbed) and surface as
BackendError::Unavailableonce retries are exhausted. The genuinely-empty cases —index_not_found_exception, and doc-not-found → 404 — still return empty, so healthy behaviour is unchanged and no existing ES test changes.Bug 2 — PostgreSQL
connect_timeout_secswas dead configDeclared, defaulted, serde-serialized… and never read.
create_poolnever touched it.Wiring it alone does not fix the hang: tokio-postgres applies
connect_timeoutonly toTcpStream::connect, leaving DNS, the TLS handshake and the Postgres startup/auth exchange awaited unbounded. A peer that completes the TCP handshake and then stalls — a hung server, or an LB/security group accepting into a blackhole — hung the caller forever.So this wires
cfg.connect_timeoutand deadpool'screate_timeout, which bounds the whole of connection establishment.wait/recycleare deliberately left unbounded: they govern behaviour under load (queueing for a free slot), not reachability, and capping them would change how a saturated pool sheds traffic.postgres_backend::blackholed_server_is_bounded_and_surfaces_backend_erroris the regression test — it binds a listener and never accepts, so the kernel completes the handshake and the client waits. It hangs onmain.Bug 3 — MongoDB's server-selection timeout was hardcoded (found by CI)
CI flagged the contract test's MongoDB case sitting for the full deadline. Not a hang:
SERVER_SELECTION_TIMEOUTwas a hardcoded 15s, and it — notconnect_timeout_ms— is what bounds an operation against an unreachable server, because the driver keeps re-attempting server selection while its monitor reports no healthy server. The connect timeout only bounds an individual TCP handshake, so lowering it (as the original #211 test did) changed nothing.An unreachable MongoDB therefore always took 15s to surface an error, with no way for an operator to tune it. Promoted to
MongoBackendConfig::server_selection_timeout_ms(default15000, so existing deployments behave identically) and exposed asHFS_MONGODB_SERVER_SELECTION_TIMEOUT_MS.Bug 4 — S3: a missing bucket read as an empty store
client.rscollapsedNoSuchBucketinto the sameS3ClientError::NotFoundthatget_object/head_objectswallow intoOk(None). So a bucket that was typo'd, deleted, or invisible to the credentials made everyreadanswer "this resource does not exist" andcountanswer zero — the identical failure mode to the Elasticsearch bug, still live in S3.A missing object is a legitimate
Ok(None). A missing bucket means we learned nothing at all. It now gets its ownS3ClientError::BucketNotFoundvariant that the object callers cannot swallow, mapped toBackendError::Unavailable.Scope, precisely: this covers
GetObject/PutObject/ListObjectsV2, whose error responses carry a body and can therefore sayNoSuchBucket. The twoHEADpaths cannot, and are not fixed here — see #284, and the Not in this PR section below. The mock is written to match: it reportsBucketNotFoundon get/put/list, and on the HEAD operations it mirrors the real client exactly (a bare 404 →NotFound→Ok(None)).That last part is deliberate and worth a reviewer's eye. An earlier revision had the mock answer
BucketNotFoundon HEAD too — a distinction the real client provably cannot make — which mades3_missing_bucket_create_and_count_are_errorslook like it provedcreate's existence check notices a missing bucket. It does not; the check concludes "new resource" and the followingPutObjectis what fails. Green either way, but the green meant something other than what it appeared to. The mock is now honest, the test says so in a comment, and the real gap is filed rather than papered over.Bug 5 — the Postgres timeout fix needed a knob (found in review)
Bug 2 above is right, but on its own it introduced a regression.
connect_timeout_secswent from a field nothing read to a hard 5s ceiling on connection establishment — with no way to raise it.from_env()reads sixHFS_PG_*variables and not that one, andparse_connection_stringstarts fromDefault, so every deployment path was pinned to the default. A Postgres that legitimately needs longer than 5s to answer a fresh connection (a cold or loaded cluster, a cross-region failover, TLS through a distant proxy) would have started failing with nothing an operator could do about it.That is the same defect Bug 3 fixes for MongoDB, so Postgres gets the symmetric treatment:
HFS_PG_CONNECT_TIMEOUT_SECS, read via a helper that both construction paths use.from_connection_stringis the dominant one — it backsHFS_DATABASE_URLand HTS'sdatabase_url— so wiring onlyfrom_envwould have left the ceiling unreachable for most deployments. The URL never carries a timeout (parse_connection_stringparses no query parameters), so applying the env value on top of it clobbers nothing. Documented in the README next to the otherHFS_PG_*variables.Closing the last coverage gap: PostgreSQL per-operation arms
Postgres was the one backend whose per-operation error arms stayed uncovered.
new()connects eagerly, so an unreachable server fails at construction and the caller never obtains a backend to drive — leaving all 56internal_error(..)arms inpostgres/storage.rsunexercised. That is exactly the "unexercised.map_errarms" this issue names as its motivation.Reaching them needs a server that answers but cannot serve the query, so
postgres_integration_unmigrated_store_surfaces_backend_errorcreates an empty database, skipsinit_schema(), and drives read/count/create against it — mirroring the SQLite unmigrated-store test. It needs Docker, so it lives with the other container tests.Not in this PR (real findings, each filed as its own issue)
NoSuchBucketthere; the SDK reports a bare 404 andhead_objectswallows it intoOk(None). Latent rather than live: thecreateand bulk-submit existence checks are each followed by a write that does fail correctly, andvalidate_bucketsstill returns the rightUnavailablevariant (only its message misleads). Safe by coincidence, so it is worth fixing — but not here.SET statement_timeoutis applied to exactly one pooled connection, then returned to the pool — every other connection gets the server default, sostatement_timeout_msis effectively unenforced. Fixing it via a deadpoolpost_createhook would newly enforce a 30s cap on queries that have never been capped, which could break long-running bulk-export work. Deserves its own PR and its own load validation.PostgresConfig::schema_nameis a third declared-but-unhonoured field in the same struct; folded into Postgres: statement_timeout is applied to one pooled connection, so ~9 of 10 connections run uncapped #285.crates/rest/src/error.rsmaps everyBackendErrorexceptUnsupportedCapabilityto a 500, andRestErrorhas noServiceUnavailablevariant — so "the database is down" is indistinguishable from "the server has a bug". No 503, noRetry-After, and/_readinesshardcodes"storage": "ok"(itsState(_state)is literally unused), so an orchestrator keeps routing traffic to a pod that cannot serve it. This PR raises the stakes on it: the Elasticsearch fix converts a silent empty-200 into a real error, and that error now lands as a 500. A 500 is the honest answer where an empty 200 was a wrong one, but 503 +Retry-Afteris the right answer, and it should follow soon.Testing
cargo test -p helios-persistence --all-features --test backend_error_handling— needs no Docker.CI is green, and the new tests are confirmed to have actually run rather than silently filtering to zero:
backend_error_handlingreports9 passed; 0 failed, and the Docker-backedpostgres_integration_unmigrated_store_surfaces_backend_errorpasses alongside it. (Authored without a local C linker, so CI was the first compile; every API used was verified against the vendored crate sources.)Issue checklist
mongodb_tests.rs; its hardcoded server-selection timeout is now tunable (Bug 3)