fix(s3): decline transaction Bundles S3 cannot honour, and bound bundle concurrency - #500
Conversation
e58461f to
0e1aa49
Compare
|
Code review Found 2 issues during review. 1. This transform relocates resources that already have a stable Several Inferno fixtures hit this. For example, This breaks fixtures that reference the original stable id via literal (non- Suggested fix: when an entry already has a hfs/crates/hfs/tests/inferno/to_batch.jq Lines 31 to 42 in 0e1aa49 2. This hfs/crates/rest/src/handlers/batch.rs Lines 160 to 175 in 0e1aa49 for loop over process_batch_entry, never calling into BundleProvider::process_batch.
This means the PR's stated fix for the #489 timeout (bounding rather than unbounding concurrency) doesn't take effect for live batch traffic. It's also the exact path this PR now directs clients to via the 501 refusal + hfs/crates/persistence/src/backends/s3/bundle.rs Lines 91 to 101 in 0e1aa49 |
The rewrite branched on `fullUrl` alone, so any entry with a `urn:uuid` fullUrl was moved to a uuid-derived id — including entries that already carried a concrete `PUT Type/<id>`. That relocated `Patient/85`, `/355`, `/907`, `/908` and `/999`: precisely the ids Inferno is configured to exercise (`patient_ids: 85,355,907,908,us-core-client-tests-patient`), the ids the Group fixtures point at, and the target of 21 literal `"Patient/999"` references, which are not `urn:uuid` and so were never rewritten to follow. Caught in review on #500. It also diverged from the server, which was the whole premise: phase 1 of `process_transaction` opens with `if entry.method != BundleMethod::Post { continue; }`, so a PUT entry keeps the id it names. Only POST entries are relocated now, and the id is taken from `resource.id` when present, matching the server's `Some(existing) => existing.to_string()`. Two further alignments, both about not being *better* than the server: - References to PUT-entry fullUrls stay unresolved, because the server's map holds no PUT fullUrls. That leaves 483 `urn:uuid` subject references in uscore_bundle_patient_355.json — issue #459, and why `Observation?patient=355` returns 0 on every backend. - Forward references stay unresolved. SQLite, PostgreSQL and MongoDB build the map incrementally, resolving each entry against the map so far, so a reference to a later entry never resolves. The rewrite now does the same single pass. Resolving them (as the old up-front map did) gave s3-elasticsearch three references the other backends lack, which would let it pass searches they skip. Adds a post-load verification step: every `PUT Type/id` the fixtures address is fetched and must return 200. A 2xx on the bundle POST proved nothing here — under the old rewrite every POST succeeded, resource counts were unchanged, and Inferno still reported ~60% because tests for a missing patient *skip* and the gate ignores skips (#491). The ids are derived from the fixtures, so the check follows them as they change. Tests: the equivalence check was strengthened from aggregate counts to full sorted id lists per type plus hashed reference values — the previous form could not see relocation at all. Transaction load vs rewritten batch load on SQLite now match exactly across 23 resource types, all five Inferno patient ids resolve on both, and stored reference values are identical for Observation, Encounter, Condition and DocumentReference. The guard was verified to fail, not just pass: restoring the old rewrite makes it report `MISSING: Patient/pat015 (HTTP 404)` and eight more, and exit non-zero. With the fix, 136 fixture-addressed resources are retrievable on both the transaction and the rewritten-batch path. Refs #489
29705ad to
76de6ad
Compare
The rewrite branched on `fullUrl` alone, so any entry with a `urn:uuid` fullUrl was moved to a uuid-derived id — including entries that already carried a concrete `PUT Type/<id>`. That relocated `Patient/85`, `/355`, `/907`, `/908` and `/999`: precisely the ids Inferno is configured to exercise (`patient_ids: 85,355,907,908,us-core-client-tests-patient`), the ids the Group fixtures point at, and the target of 21 literal `"Patient/999"` references, which are not `urn:uuid` and so were never rewritten to follow. Caught in review on #500. It also diverged from the server, which was the whole premise: phase 1 of `process_transaction` opens with `if entry.method != BundleMethod::Post { continue; }`, so a PUT entry keeps the id it names. Only POST entries are relocated now, and the id is taken from `resource.id` when present, matching the server's `Some(existing) => existing.to_string()`. Two further alignments, both about not being *better* than the server: - References to PUT-entry fullUrls stay unresolved, because the server's map holds no PUT fullUrls. That leaves 483 `urn:uuid` subject references in uscore_bundle_patient_355.json — issue #459, and why `Observation?patient=355` returns 0 on every backend. - Forward references stay unresolved. SQLite, PostgreSQL and MongoDB build the map incrementally, resolving each entry against the map so far, so a reference to a later entry never resolves. The rewrite now does the same single pass. Resolving them (as the old up-front map did) gave s3-elasticsearch three references the other backends lack, which would let it pass searches they skip. Adds a post-load verification step: every `PUT Type/id` the fixtures address is fetched and must return 200. A 2xx on the bundle POST proved nothing here — under the old rewrite every POST succeeded, resource counts were unchanged, and Inferno still reported ~60% because tests for a missing patient *skip* and the gate ignores skips (#491). The ids are derived from the fixtures, so the check follows them as they change. Tests: the equivalence check was strengthened from aggregate counts to full sorted id lists per type plus hashed reference values — the previous form could not see relocation at all. Transaction load vs rewritten batch load on SQLite now match exactly across 23 resource types, all five Inferno patient ids resolve on both, and stored reference values are identical for Observation, Encounter, Condition and DocumentReference. The guard was verified to fail, not just pass: restoring the old rewrite makes it report `MISSING: Patient/pat015 (HTTP 404)` and eight more, and exit non-zero. With the fix, 136 fixture-addressed resources are retrievable on both the transaction and the rewritten-batch path. Refs #489
The rewrite branched on `fullUrl` alone, so any entry with a `urn:uuid` fullUrl was moved to a uuid-derived id — including entries that already carried a concrete `PUT Type/<id>`. That relocated `Patient/85`, `/355`, `/907`, `/908` and `/999`: precisely the ids Inferno is configured to exercise (`patient_ids: 85,355,907,908,us-core-client-tests-patient`), the ids the Group fixtures point at, and the target of 21 literal `"Patient/999"` references, which are not `urn:uuid` and so were never rewritten to follow. Caught in review on #500. It also diverged from the server, which was the whole premise: phase 1 of `process_transaction` opens with `if entry.method != BundleMethod::Post { continue; }`, so a PUT entry keeps the id it names. Only POST entries are relocated now, and the id is taken from `resource.id` when present, matching the server's `Some(existing) => existing.to_string()`. Two further alignments, both about not being *better* than the server: - References to PUT-entry fullUrls stay unresolved, because the server's map holds no PUT fullUrls. That leaves 483 `urn:uuid` subject references in uscore_bundle_patient_355.json — issue #459, and why `Observation?patient=355` returns 0 on every backend. - Forward references stay unresolved. SQLite, PostgreSQL and MongoDB build the map incrementally, resolving each entry against the map so far, so a reference to a later entry never resolves. The rewrite now does the same single pass. Resolving them (as the old up-front map did) gave s3-elasticsearch three references the other backends lack, which would let it pass searches they skip. Adds a post-load verification step: every `PUT Type/id` the fixtures address is fetched and must return 200. A 2xx on the bundle POST proved nothing here — under the old rewrite every POST succeeded, resource counts were unchanged, and Inferno still reported ~60% because tests for a missing patient *skip* and the gate ignores skips (#491). The ids are derived from the fixtures, so the check follows them as they change. Tests: the equivalence check was strengthened from aggregate counts to full sorted id lists per type plus hashed reference values — the previous form could not see relocation at all. Transaction load vs rewritten batch load on SQLite now match exactly across 23 resource types, all five Inferno patient ids resolve on both, and stored reference values are identical for Observation, Encounter, Condition and DocumentReference. The guard was verified to fail, not just pass: restoring the old rewrite makes it report `MISSING: Patient/pat015 (HTTP 404)` and eight more, and exit non-zero. With the fix, 136 fixture-addressed resources are retrievable on both the transaction and the rewritten-batch path. Refs #489
76de6ad to
c914754
Compare
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Draft while blocked on #501 — and two corrections to this PRCode review on this PR flagged two issues; an overnight full-matrix Inferno run against this branch (30973069238) confirmed both. Both were valid, and the numbers in the original description are no longer accurate. 1. The batch rewrite relocated stable resource ids — fixed (
|
| bundle | entries | wall clock | result |
|---|---|---|---|
uscore_bundle_patient_355.json |
473 | 30.1s | 408 |
uscore_bundle_patient_85.json |
267 | 30.1s | 408 |
| all others | ≤54 | 0.2–15.8s | 200/201 |
~300ms per entry, consistently, so ~90 entries is the ceiling inside the 30s budget. patient_85 loaded in 5.5s through the concurrent transaction path — sequential batch is a ~15× regression for the same data.
That is a pre-existing defect, not one this PR introduces; it was invisible because nothing large went through batch until this PR started routing S3 there. Filed as #501.
Status
Draft until #501 lands. This PR declines transaction Bundles on S3 and points callers at batch; that advice is only sound once batch can carry the load. Merging now would turn the six s3-elasticsearch legs red at the load step.
Unaffected and still verified: the capability derivation, the refusal and its 501, the CapabilityStatement fix, the removal of the unreachable transaction machinery, and the tests. Every other leg of the overnight run — sqlite, sqlite-elasticsearch, postgres, mongodb, all six suites — passed.
Once #501 merges, this rebases and the s3-elasticsearch legs should produce a trustworthy pass rate for that backend for the first time.
c914754 to
e7f4cd8
Compare
The rewrite branched on `fullUrl` alone, so any entry with a `urn:uuid` fullUrl was moved to a uuid-derived id — including entries that already carried a concrete `PUT Type/<id>`. That relocated `Patient/85`, `/355`, `/907`, `/908` and `/999`: precisely the ids Inferno is configured to exercise (`patient_ids: 85,355,907,908,us-core-client-tests-patient`), the ids the Group fixtures point at, and the target of 21 literal `"Patient/999"` references, which are not `urn:uuid` and so were never rewritten to follow. Caught in review on #500. It also diverged from the server, which was the whole premise: phase 1 of `process_transaction` opens with `if entry.method != BundleMethod::Post { continue; }`, so a PUT entry keeps the id it names. Only POST entries are relocated now, and the id is taken from `resource.id` when present, matching the server's `Some(existing) => existing.to_string()`. Two further alignments, both about not being *better* than the server: - References to PUT-entry fullUrls stay unresolved, because the server's map holds no PUT fullUrls. That leaves 483 `urn:uuid` subject references in uscore_bundle_patient_355.json — issue #459, and why `Observation?patient=355` returns 0 on every backend. - Forward references stay unresolved. SQLite, PostgreSQL and MongoDB build the map incrementally, resolving each entry against the map so far, so a reference to a later entry never resolves. The rewrite now does the same single pass. Resolving them (as the old up-front map did) gave s3-elasticsearch three references the other backends lack, which would let it pass searches they skip. Adds a post-load verification step: every `PUT Type/id` the fixtures address is fetched and must return 200. A 2xx on the bundle POST proved nothing here — under the old rewrite every POST succeeded, resource counts were unchanged, and Inferno still reported ~60% because tests for a missing patient *skip* and the gate ignores skips (#491). The ids are derived from the fixtures, so the check follows them as they change. Tests: the equivalence check was strengthened from aggregate counts to full sorted id lists per type plus hashed reference values — the previous form could not see relocation at all. Transaction load vs rewritten batch load on SQLite now match exactly across 23 resource types, all five Inferno patient ids resolve on both, and stored reference values are identical for Observation, Encounter, Condition and DocumentReference. The guard was verified to fail, not just pass: restoring the old rewrite makes it report `MISSING: Patient/pat015 (HTTP 404)` and eight more, and exit non-zero. With the fix, 136 fixture-addressed resources are retrievable on both the transaction and the rewritten-batch path. Refs #489
The rewrite branched on `fullUrl` alone, so any entry with a `urn:uuid` fullUrl was moved to a uuid-derived id — including entries that already carried a concrete `PUT Type/<id>`. That relocated `Patient/85`, `/355`, `/907`, `/908` and `/999`: precisely the ids Inferno is configured to exercise (`patient_ids: 85,355,907,908,us-core-client-tests-patient`), the ids the Group fixtures point at, and the target of 21 literal `"Patient/999"` references, which are not `urn:uuid` and so were never rewritten to follow. Caught in review on #500. It also diverged from the server, which was the whole premise: phase 1 of `process_transaction` opens with `if entry.method != BundleMethod::Post { continue; }`, so a PUT entry keeps the id it names. Only POST entries are relocated now, and the id is taken from `resource.id` when present, matching the server's `Some(existing) => existing.to_string()`. Two further alignments, both about not being *better* than the server: - References to PUT-entry fullUrls stay unresolved, because the server's map holds no PUT fullUrls. That leaves 483 `urn:uuid` subject references in uscore_bundle_patient_355.json — issue #459, and why `Observation?patient=355` returns 0 on every backend. - Forward references stay unresolved. SQLite, PostgreSQL and MongoDB build the map incrementally, resolving each entry against the map so far, so a reference to a later entry never resolves. The rewrite now does the same single pass. Resolving them (as the old up-front map did) gave s3-elasticsearch three references the other backends lack, which would let it pass searches they skip. Adds a post-load verification step: every `PUT Type/id` the fixtures address is fetched and must return 200. A 2xx on the bundle POST proved nothing here — under the old rewrite every POST succeeded, resource counts were unchanged, and Inferno still reported ~60% because tests for a missing patient *skip* and the gate ignores skips (#491). The ids are derived from the fixtures, so the check follows them as they change. Tests: the equivalence check was strengthened from aggregate counts to full sorted id lists per type plus hashed reference values — the previous form could not see relocation at all. Transaction load vs rewritten batch load on SQLite now match exactly across 23 resource types, all five Inferno patient ids resolve on both, and stored reference values are identical for Observation, Encounter, Condition and DocumentReference. The guard was verified to fail, not just pass: restoring the old rewrite makes it report `MISSING: Patient/pat015 (HTTP 404)` and eight more, and exit non-zero. With the fix, 136 fixture-addressed resources are retrievable on both the transaction and the rewritten-batch path. Refs #489
e7f4cd8 to
2f9b722
Compare
A `batch` Bundle was processed one entry at a time, so wall clock was the sum of every entry's storage round trips. Against real S3 that is ~300ms per entry, which puts a bundle over ~90 entries past the 30s request timeout: `TimeoutLayer` drops the handler and the client gets an empty-bodied 408, while the entries that already committed stay committed. A 473-entry US Core fixture took 30.1s and failed; the same data went through the concurrent transaction path in 5.5s. #500 routes S3 clients to `batch`, so this blocked it. Entries now run through `buffered(n)`. The bound is the backend's own `ResourceStorage::bulk_write_concurrency()`, capped by a new `HFS_BATCH_MAX_CONCURRENCY` (default 16). Backend-declared rather than a flat constant, because the values already exist and are already right — SQLite keeps the default of 1, PostgreSQL/MongoDB/Elasticsearch declare 8, S3 declares 32, and a composite delegates to its primary — and because `process_batch` is already bounded on `ResourceStorage`, this needs no new trait method and no widened `where` clause. The config value is a ceiling: it can lower what a backend said it tolerates, never raise it, since only the backend knows what its pool absorbs. SQLite staying at 1 is honest rather than conservative. Its storage calls contain no await points, and `buffered` polls in place and never spawns, so a higher bound could not interleave them anyway. `buffered`, not `buffer_unordered`: batch response entries are positional, and nothing in a response entry carries its index, so a scramble would be invisible to clients and to every existing test. A `debug_assert!` pins the invariant, and the new ordering test was verified to fail under `buffer_unordered`. The stream is driven over indices rather than `entries.iter()` deliberately: a closure whose returned future borrows its *argument* needs a higher-ranked lifetime that inference cannot supply, and the resulting error is reported against the route registration in `routing::fhir_routes` rather than against this function. Two things deliberately stay where they were. The per-entry SMART scope check is the only authorization gate on `POST [base]` — it is a lock-free, I/O-free function over `&Principal`, so concurrency cannot change a verdict. Per-entry audit emission moves one nesting level inward, into the entry future, so an entry whose write committed before a timeout still produces its event; emitting after collection would drop the whole bundle's events on exactly the failure mode this fixes. A bundle that writes a StructureDefinition drops to sequential. Later entries validate against profiles earlier entries register through `upsert_stored_profile`, and that read-your-writes is the one cross-entry dependency on this path. The scan keys off `request.url` through the same `parse_request_url` the side effect keys off, so the two cannot disagree. `BatchProgress` logs how far a batch got if the handler future is dropped, carrying the bundle-id that per-entry audit events share. The timeout discards the response bundle, so this is otherwise the only trace of what landed. Behaviour change worth stating: at a bound above 1, entries are no longer ordered relative to one another. FHIR permits this ("the server may process the entries in any order"), but two entries writing the same id, or one reading what another writes, now resolve by timing. Documented in the REST README along with the load-amplification and audit-amplification consequences of raising the ceiling. Tests: new unit coverage for positional ordering under reversed completion, the bound being both honoured and real (peak <= declared, peak > 1, measurable speedup), a single-writer backend never exceeding 1, the cap table including a zero config, the StructureDefinition carve-out keyed off the URL, and per-entry scope denial under concurrency — which had no test at all before. `helios-rest` suite green (800+ tests), `helios-persistence` lib green, clippy clean on every touched file. Refs #501
…_batch `process_batch` was implemented five times — SQLite, PostgreSQL, MongoDB, S3 and the composite — and called by none of them. The REST layer runs its own entry loop, so the only references outside the trait were each backend's own unit tests and the composite's delegation to itself. Five unreachable copies is not a tidiness problem, it is a correctness one: #311's `ifMatch` fix landed in the batch arm here, where it could never execute, and `ifMatch` stayed broken over HTTP for two releases while these tests passed. #501 fixed it on the live path; this removes the half that invited the mistake. Deleted: the trait method, all five implementations, and — with them — the last unbounded `join_all` over caller-controlled input in this crate (`s3/bundle.rs`), which #500's description believed it had bounded. With `process_batch` gone, S3's entire private bundle-entry machinery (`execute_bundle_entry`, `bundle_error_result`, `storage_error_status`, `operation_outcome`, `parse_url`) became unreachable too, so `bundle.rs` drops to the transaction refusal alone. MongoDB's copy was an unconditional `UnsupportedCapability`, which is worth noting: had anything actually delegated to this method, every `mongodb-*` deployment would have 5xx'd on batch Bundles that succeed today. Kept: `supports_atomic_transactions`, `process_transaction`, the composite's `sync_bundle_results` (still called by the transaction path), and `core::preconditions::bundle_if_match_gate`, which the REST batch path now calls and which keeps its own unit tests. The trait doc now records why batch is not here — per-entry SMART scope enforcement and per-entry audit are invisible to the persistence tier, and `POST [base]` has no other authorization gate — so the next reader does not "fix" the asymmetry by wiring the two back together. Tests re-homed rather than dropped: - The nine batch `ifMatch` scenarios in `if_match_suite` move, one for one, to `helios-rest/tests/batch_if_match.rs` (added in the previous commit), where they run against the loop the server executes. The five transaction scenarios stay, along with their SQLite and PostgreSQL macro expansions. Coverage narrows from two backends to one, which is right now that the comparison is backend-independent and unit-tested in `core::preconditions`. - #350's version-stamping guarantee had a batch half asserted through `process_batch`. It is now `batch_entries_stamp_the_configured_default` in `helios-rest/tests/default_version_fallback.rs`; the transaction half stays in the SQLite unit test, with a pointer between them. - `s3_tests::test_aws_bundle_bulk_export_and_submit` seeded via `process_batch`; it now seeds through `ResourceStorage::create`, since its subject is the export/submit feed, not bundle processing. - Deleted outright: three SQLite batch unit tests, the composite's no-capability test, MongoDB's not-supported test, and the three S3 batch tests — including the comment claiming entries "now run concurrently (`buffered`)", which described behaviour that never existed. The persistence README's "Transaction & Batch Support" section claimed this crate processes batch Bundles. Corrected, with a note on where batch actually runs and why. Tests: `helios-persistence` lib 767 passed, `transactions_suite` 37 passed, full `helios-rest` suite green including the R4B leg. Workspace `cargo check --all-targets` clean; clippy warning count on `helios-persistence --all-features --all-targets` unchanged at 930 against the pre-change tree, and no dead-code warnings left behind. Closes #501
2f9b722 to
88ce432
Compare
The rewrite branched on `fullUrl` alone, so any entry with a `urn:uuid` fullUrl was moved to a uuid-derived id — including entries that already carried a concrete `PUT Type/<id>`. That relocated `Patient/85`, `/355`, `/907`, `/908` and `/999`: precisely the ids Inferno is configured to exercise (`patient_ids: 85,355,907,908,us-core-client-tests-patient`), the ids the Group fixtures point at, and the target of 21 literal `"Patient/999"` references, which are not `urn:uuid` and so were never rewritten to follow. Caught in review on #500. It also diverged from the server, which was the whole premise: phase 1 of `process_transaction` opens with `if entry.method != BundleMethod::Post { continue; }`, so a PUT entry keeps the id it names. Only POST entries are relocated now, and the id is taken from `resource.id` when present, matching the server's `Some(existing) => existing.to_string()`. Two further alignments, both about not being *better* than the server: - References to PUT-entry fullUrls stay unresolved, because the server's map holds no PUT fullUrls. That leaves 483 `urn:uuid` subject references in uscore_bundle_patient_355.json — issue #459, and why `Observation?patient=355` returns 0 on every backend. - Forward references stay unresolved. SQLite, PostgreSQL and MongoDB build the map incrementally, resolving each entry against the map so far, so a reference to a later entry never resolves. The rewrite now does the same single pass. Resolving them (as the old up-front map did) gave s3-elasticsearch three references the other backends lack, which would let it pass searches they skip. Adds a post-load verification step: every `PUT Type/id` the fixtures address is fetched and must return 200. A 2xx on the bundle POST proved nothing here — under the old rewrite every POST succeeded, resource counts were unchanged, and Inferno still reported ~60% because tests for a missing patient *skip* and the gate ignores skips (#491). The ids are derived from the fixtures, so the check follows them as they change. Tests: the equivalence check was strengthened from aggregate counts to full sorted id lists per type plus hashed reference values — the previous form could not see relocation at all. Transaction load vs rewritten batch load on SQLite now match exactly across 23 resource types, all five Inferno patient ids resolve on both, and stored reference values are identical for Observation, Encounter, Condition and DocumentReference. The guard was verified to fail, not just pass: restoring the old rewrite makes it report `MISSING: Patient/pat015 (HTTP 404)` and eight more, and exit non-zero. With the fix, 136 fixture-addressed resources are retrievable on both the transaction and the rewritten-batch path. Refs #489
9be3f6b to
173b580
Compare
…ke atomic An `s3-elasticsearch` deployment accepted FHIR transaction bundles it had no way to unwind. When the 30s request timeout fired mid-bundle, 466 of 473 entries were left durably committed with no tombstone or compensating delete, while the client was told the transaction failed (#489). The S3 backend does have a compensation log, but it cannot run on this failure mode. `TimeoutLayer` *drops* the handler future, and async cancellation stops the task at its await point without propagating an error, so every `Err`/`status >= 400` arm holding rollback logic is skipped by construction. Compounding it, the compensation list is only populated after all entry futures resolve, so at the moment of cancellation it is still empty — there is nothing to undo with, even given a cancellation-safe unwind. Design discussion #28 already specified the guard: "code that requires atomicity takes `&dyn TransactionProvider`, while code that can tolerate partial failures takes `&dyn ResourceStorage`". But `process_transaction` lives on `BundleProvider`, which every backend implements, so S3 served transactions without ever satisfying the trait meant to gate them. The persistence README likewise describes S3 as "intentionally storage-focused ... archive/history storage" and #28 places ACID on the relational tier, so the fix is to stop claiming an atomicity this tier was never meant to provide — not to build a transaction log over an object store. `BundleProvider::supports_atomic_transactions` is added as a *required* method, no default: a new backend must state its position rather than inherit one, because the wrong default is silent corruption in one direction and a needless refusal in the other. SQLite and PostgreSQL answer true (real `TransactionProvider`), MongoDB true (server-side session transaction, so it unwinds even on a dropped connection), S3 false, and the composite delegates to its primary instead of assuming. S3 now refuses before the first write with a new `TransactionError::AtomicityUnsupported`, surfaced as 501 + `not-supported` naming `batch` as the alternative — matching the two sibling capability gaps already mapped that way. A total refusal means a retry finds the server exactly as it left it; today's partial commit would double-write every POST entry, which carries server-assigned ids and so has no idempotency. Three places claimed transaction support without consulting the backend, even though `BackendCapability::Transactions` already encodes it correctly (declared by SQLite/PostgreSQL/MongoDB, absent from S3): `CompositeStorage::capabilities` inserted `SystemInteraction::Transaction` unconditionally, and the CapabilityStatement carried `{"code": "transaction"}` as a literal. Both now derive it from the primary. `batch` stays unconditional — precisely what #28 prescribes for a backend without transaction support. Tests: `cargo check --workspace --all-targets` clean across sqlite, postgres, mongodb, elasticsearch and s3; `cargo test -p helios-persistence -p helios-rest --features R4,sqlite,postgres` green (30 test binaries, 0 failures). Clippy deferred to CI at the author's request — the CI-style invocation cannot pass on this toolchain due to pre-existing lints in generated and untouched files. Refs #489
The refusal added in bdacd94 had no test, and once the Inferno loader stops sending transaction bundles to S3 (next commit) nothing exercises that path at all — it could regress silently and only resurface the next time a bundle timed out mid-write. Three tests on the S3 backend: - `transaction_bundle_is_refused_without_writing_anything` asserts both halves of the contract. The refusal is the easy part; the `count == 0` assertion is the point, because a refusal that still wrote something would reproduce #489 in miniature. - `batch_bundle_still_works_without_transaction_support` pins the fallback design discussion #28 prescribes — losing transactions must not cost us batches. - `batch_entries_are_independent_when_one_fails` covers the semantics the Inferno loader now depends on: one bad entry must not discard the rest, and there is no rollback. The failing entry is malformed rather than fault-injected at the S3 layer, because entries now run concurrently (`buffered`) so "fail the Nth PUT" no longer maps to a given entry. And one on the REST mapping, `atomicity_unsupported_maps_to_501_not_supported`, which checks the status, the issue code, and that the message names both the backend and `batch` — the alternative a caller can act on — plus states that nothing was applied, so a retry is known-safe. Removes the three `bundle_transaction_*` tests. They covered `process_transaction`'s happy path, its compensation-log rollback and its rollback-failure reporting; all three are now unreachable, since the method refuses before doing any work. A note in their place records what they covered and where the replacement lives, rather than leaving a silent gap in the file. Also drops `MockS3Client::set_fail_put_after`, which existed solely for those rollback tests and is now unused. Tests: `cargo test -p helios-persistence -p helios-rest --features R4,sqlite,postgres,s3` — 20 test binaries, 0 failures. Refs #489
Every Inferno fixture is a transaction Bundle, so once S3 declines transactions the s3-elasticsearch legs fail at the "Load Inferno test data" step before a single conformance test runs. Posting them as batches instead is not a straight swap. A transaction resolves urn:uuid fullUrls into literal Type/id references before writing; a batch does not, and per spec must not contain interdependent entries. `uscore_bundle_patient_355.json` alone carries 473 entries, all 473 with urn:uuid fullUrls, and 2,058 references pointing at them — a naive batch load would leave every one of them dangling, manufacturing #459 for that backend and producing conformance failures that look like server bugs. `to_batch.jq` does that resolution client-side: each urn:uuid becomes a literal id (a UUID is already a valid FHIR id, so no allocation scheme is needed), every reference to it is rewritten, and POST becomes PUT against the derived id — which also makes the load idempotent, so re-running install.sh no longer duplicates resources. It deliberately mirrors `resolve_bundle_references` in crates/persistence/src/backends/s3/bundle.rs, rewriting only an object's `reference` field and only when it starts with `urn:uuid:`. Rewriting any matching string would corrupt an Identifier.value that merely looks like a uuid — pdex_bundle_patient_999.json contains exactly that case. The loader asks rather than assumes: it probes /metadata for the `transaction` interaction, which is now derived from the configured backend, so this stays correct as backends are added without the loader needing to know their names. Tests: the rewrite was validated by loading both ways into sqlite and diffing. Across 22 resource types and 7 reference searches the two loads are identical — including Observation/Condition/Encounter by patient, which is precisely what a failed reference resolution would break. Both branches of install.sh were then run end to end against sqlite servers, 15/15 bundles loaded each way, with identical resulting data. Refs #489
The matrix defined a primary as providing 'CRUD, versioning, transactions' and listed S3 as primary for 'S3 + Elasticsearch', which contradicted the storage-focused paragraph fifteen lines above it and made transaction support on S3 read as intentional. States the actual rule: SQLite and PostgreSQL provide transactions through TransactionProvider, MongoDB through a server-side session, and S3 cannot, so an S3-primary deployment declines transaction Bundles with 501 not-supported and advertises only batch. Both S3 rows now say 'batch only'. Refs #489
With `process_transaction` refusing before it does any work, everything behind that refusal is dead: the `CompensationAction` log, the `rollback_compensations` and `apply_compensation` helpers, the three-phase transaction body, S3's private copy of `resolve_bundle_references`, and the compensation half of `execute_bundle_entry`'s return type. Their tests went with them in 02ebc09, so it was also untested dead code. Deleted rather than kept for a future durable-intent design. The premise of this change is that S3 is not a transactional tier — the persistence README calls it "intentionally storage-focused … archive/history storage" and design discussion #28 places ACID on the relational tier — so it is not going to grow transaction support later. If that decision were ever reversed, a durable design would need a persisted intent journal that survives process death, not an in-process `Vec` that a dropped future discards; none of this would be reusable. Meanwhile it reads as a live atomicity guarantee to anyone opening the file, which is precisely the misreading that produced #489. Git history keeps it if it is ever wanted. `resolve_bundle_references` is removed only from the S3 backend. SQLite, PostgreSQL and MongoDB each keep their own copy — they still support transactions, where inter-entry reference resolution is required and live. For S3 the responsibility moved to the Inferno loader in 6055c8c, which resolves references client-side before posting a batch. Net effect on the file: 337 lines to 53, and no `#[allow(dead_code)]` left behind. Tests: `cargo test -p helios-persistence -p helios-rest --features R4,sqlite,postgres,s3` green; `cargo check` emits no unused-import or dead-code warnings for this crate. Refs #489
The rewrite branched on `fullUrl` alone, so any entry with a `urn:uuid` fullUrl was moved to a uuid-derived id — including entries that already carried a concrete `PUT Type/<id>`. That relocated `Patient/85`, `/355`, `/907`, `/908` and `/999`: precisely the ids Inferno is configured to exercise (`patient_ids: 85,355,907,908,us-core-client-tests-patient`), the ids the Group fixtures point at, and the target of 21 literal `"Patient/999"` references, which are not `urn:uuid` and so were never rewritten to follow. Caught in review on #500. It also diverged from the server, which was the whole premise: phase 1 of `process_transaction` opens with `if entry.method != BundleMethod::Post { continue; }`, so a PUT entry keeps the id it names. Only POST entries are relocated now, and the id is taken from `resource.id` when present, matching the server's `Some(existing) => existing.to_string()`. Two further alignments, both about not being *better* than the server: - References to PUT-entry fullUrls stay unresolved, because the server's map holds no PUT fullUrls. That leaves 483 `urn:uuid` subject references in uscore_bundle_patient_355.json — issue #459, and why `Observation?patient=355` returns 0 on every backend. - Forward references stay unresolved. SQLite, PostgreSQL and MongoDB build the map incrementally, resolving each entry against the map so far, so a reference to a later entry never resolves. The rewrite now does the same single pass. Resolving them (as the old up-front map did) gave s3-elasticsearch three references the other backends lack, which would let it pass searches they skip. Adds a post-load verification step: every `PUT Type/id` the fixtures address is fetched and must return 200. A 2xx on the bundle POST proved nothing here — under the old rewrite every POST succeeded, resource counts were unchanged, and Inferno still reported ~60% because tests for a missing patient *skip* and the gate ignores skips (#491). The ids are derived from the fixtures, so the check follows them as they change. Tests: the equivalence check was strengthened from aggregate counts to full sorted id lists per type plus hashed reference values — the previous form could not see relocation at all. Transaction load vs rewritten batch load on SQLite now match exactly across 23 resource types, all five Inferno patient ids resolve on both, and stored reference values are identical for Observation, Encounter, Condition and DocumentReference. The guard was verified to fail, not just pass: restoring the old rewrite makes it report `MISSING: Patient/pat015 (HTTP 404)` and eight more, and exit non-zero. With the fix, 136 fixture-addressed resources are retrievable on both the transaction and the rewritten-batch path. Refs #489
… path `AtomicityUnsupported` reaches clients by two routes. `handlers/batch.rs` maps it with the bundle-shaped message, and that arm is tested; the `From<TransactionError> for RestError` impl catches it anywhere else it surfaces, and had no test. The comment on that arm says the two must be kept in step, which nothing enforced — the impl's neighbouring arms are untested too, so a drift to 500 would have gone unnoticed (#489). Also collapses the `create_minimal_routes` bound back to one line. `BundleProvider` is already imported at the top of the module, so the fully-qualified path was redundant; adding it inline made rustfmt split a single-line where clause across six. Tests: `cargo test -p helios-rest --lib error::` (36 passed). Clippy with CI's flag set on `-p helios-rest --all-targets --all-features` is clean.
A `batch` Bundle was processed one entry at a time, so wall clock was the sum of every entry's storage round trips. Against real S3 that is ~300ms per entry, which puts a bundle over ~90 entries past the 30s request timeout: `TimeoutLayer` drops the handler and the client gets an empty-bodied 408, while the entries that already committed stay committed. A 473-entry US Core fixture took 30.1s and failed; the same data went through the concurrent transaction path in 5.5s. #500 routes S3 clients to `batch`, so this blocked it. Entries now run through `buffered(n)`. The bound is the backend's own `ResourceStorage::bulk_write_concurrency()`, capped by a new `HFS_BATCH_MAX_CONCURRENCY` (default 16). Backend-declared rather than a flat constant, because the values already exist and are already right — SQLite keeps the default of 1, PostgreSQL/MongoDB/Elasticsearch declare 8, S3 declares 32, and a composite delegates to its primary — and because `process_batch` is already bounded on `ResourceStorage`, this needs no new trait method and no widened `where` clause. The config value is a ceiling: it can lower what a backend said it tolerates, never raise it, since only the backend knows what its pool absorbs. SQLite staying at 1 is honest rather than conservative. Its storage calls contain no await points, and `buffered` polls in place and never spawns, so a higher bound could not interleave them anyway. `buffered`, not `buffer_unordered`: batch response entries are positional, and nothing in a response entry carries its index, so a scramble would be invisible to clients and to every existing test. A `debug_assert!` pins the invariant, and the new ordering test was verified to fail under `buffer_unordered`. The stream is driven over indices rather than `entries.iter()` deliberately: a closure whose returned future borrows its *argument* needs a higher-ranked lifetime that inference cannot supply, and the resulting error is reported against the route registration in `routing::fhir_routes` rather than against this function. Two things deliberately stay where they were. The per-entry SMART scope check is the only authorization gate on `POST [base]` — it is a lock-free, I/O-free function over `&Principal`, so concurrency cannot change a verdict. Per-entry audit emission moves one nesting level inward, into the entry future, so an entry whose write committed before a timeout still produces its event; emitting after collection would drop the whole bundle's events on exactly the failure mode this fixes. A bundle that writes a StructureDefinition drops to sequential. Later entries validate against profiles earlier entries register through `upsert_stored_profile`, and that read-your-writes is the one cross-entry dependency on this path. The scan keys off `request.url` through the same `parse_request_url` the side effect keys off, so the two cannot disagree. `BatchProgress` logs how far a batch got if the handler future is dropped, carrying the bundle-id that per-entry audit events share. The timeout discards the response bundle, so this is otherwise the only trace of what landed. Behaviour change worth stating: at a bound above 1, entries are no longer ordered relative to one another. FHIR permits this ("the server may process the entries in any order"), but two entries writing the same id, or one reading what another writes, now resolve by timing. Documented in the REST README along with the load-amplification and audit-amplification consequences of raising the ceiling. Tests: new unit coverage for positional ordering under reversed completion, the bound being both honoured and real (peak <= declared, peak > 1, measurable speedup), a single-writer backend never exceeding 1, the cap table including a zero config, the StructureDefinition carve-out keyed off the URL, and per-entry scope denial under concurrency — which had no test at all before. `helios-rest` suite green (800+ tests), `helios-persistence` lib green, clippy clean on every touched file. Refs #501
…_batch `process_batch` was implemented five times — SQLite, PostgreSQL, MongoDB, S3 and the composite — and called by none of them. The REST layer runs its own entry loop, so the only references outside the trait were each backend's own unit tests and the composite's delegation to itself. Five unreachable copies is not a tidiness problem, it is a correctness one: #311's `ifMatch` fix landed in the batch arm here, where it could never execute, and `ifMatch` stayed broken over HTTP for two releases while these tests passed. #501 fixed it on the live path; this removes the half that invited the mistake. Deleted: the trait method, all five implementations, and — with them — the last unbounded `join_all` over caller-controlled input in this crate (`s3/bundle.rs`), which #500's description believed it had bounded. With `process_batch` gone, S3's entire private bundle-entry machinery (`execute_bundle_entry`, `bundle_error_result`, `storage_error_status`, `operation_outcome`, `parse_url`) became unreachable too, so `bundle.rs` drops to the transaction refusal alone. MongoDB's copy was an unconditional `UnsupportedCapability`, which is worth noting: had anything actually delegated to this method, every `mongodb-*` deployment would have 5xx'd on batch Bundles that succeed today. Kept: `supports_atomic_transactions`, `process_transaction`, the composite's `sync_bundle_results` (still called by the transaction path), and `core::preconditions::bundle_if_match_gate`, which the REST batch path now calls and which keeps its own unit tests. The trait doc now records why batch is not here — per-entry SMART scope enforcement and per-entry audit are invisible to the persistence tier, and `POST [base]` has no other authorization gate — so the next reader does not "fix" the asymmetry by wiring the two back together. Tests re-homed rather than dropped: - The nine batch `ifMatch` scenarios in `if_match_suite` move, one for one, to `helios-rest/tests/batch_if_match.rs` (added in the previous commit), where they run against the loop the server executes. The five transaction scenarios stay, along with their SQLite and PostgreSQL macro expansions. Coverage narrows from two backends to one, which is right now that the comparison is backend-independent and unit-tested in `core::preconditions`. - #350's version-stamping guarantee had a batch half asserted through `process_batch`. It is now `batch_entries_stamp_the_configured_default` in `helios-rest/tests/default_version_fallback.rs`; the transaction half stays in the SQLite unit test, with a pointer between them. - `s3_tests::test_aws_bundle_bulk_export_and_submit` seeded via `process_batch`; it now seeds through `ResourceStorage::create`, since its subject is the export/submit feed, not bundle processing. - Deleted outright: three SQLite batch unit tests, the composite's no-capability test, MongoDB's not-supported test, and the three S3 batch tests — including the comment claiming entries "now run concurrently (`buffered`)", which described behaviour that never existed. The persistence README's "Transaction & Batch Support" section claimed this crate processes batch Bundles. Corrected, with a note on where batch actually runs and why. Tests: `helios-persistence` lib 767 passed, `transactions_suite` 37 passed, full `helios-rest` suite green including the R4B leg. Workspace `cargo check --all-targets` clean; clippy warning count on `helios-persistence --all-features --all-targets` unchanged at 930 against the pre-change tree, and no dead-code warnings left behind. Closes #501
173b580 to
bea4cd7
Compare
Summary
An
s3-elasticsearchdeployment advertised FHIRtransactionsupport in its CapabilityStatement, accepted transaction Bundles, and could honour neither. When the 30s request timeout fired mid-bundle, 466 of 473 entries were left durably committed with no tombstone or compensating delete, while the client was told the transaction had failed (#489).This stops claiming the guarantee rather than trying to build it. Two defects are fixed:
join_allissued one S3 PUT per entry all at once, which is what blew the timeout in the first place. Refusing transactions removes it outright: that fan-out lived inprocess_transaction, which this PR deletes.Fixes #489.
Why not implement atomicity instead
The S3 backend has a compensation log. It cannot run on the failure mode that matters, for two structural reasons:
TimeoutLayerdrops the handler future. Async cancellation stops the task at its await point without propagating an error, so everyErr/status >= 400arm holding rollback logic is skipped by construction.join_allresolves, so at the moment of cancellation it is empty — there is nothing to undo with, even given a cancellation-safe unwind.More decisively, the architecture already ruled on this.
crates/persistence/README.mdcalls S3 "intentionally storage-focused … archive/history storage", and design discussion #28 places ACID on the relational tier. #28 even specifies the guard:process_transactionlives onBundleProvider, which every backend implements — so S3 served transactions without ever satisfying the trait meant to gate them. Building a transaction log over an object store would engineer a guarantee this tier was deliberately not given.Changes
Express and enforce the capability
BundleProvider::supports_atomic_transactions()— a required method, no default. A new backend must state its position rather than inherit one, because the wrong default is silent corruption in one direction and a needless refusal in the other. SQLite/PostgreSQL →true(realTransactionProvider); MongoDB →true(server-side session, so it unwinds even on a dropped connection); S3 →false; composite → delegates to its primary.TransactionError::AtomicityUnsupported→ 501 +not-supported, matching the two sibling capability gaps already mapped that way. A total refusal means a retry finds the server as it left it; the old partial commit would double-write every POST entry, which carries server-assigned ids and so has no idempotency.Stop advertising what we can't do
BackendCapability::Transactionsalready encoded the truth per-backend (declared by SQLite/PostgreSQL/MongoDB, absent from S3) — nothing read it.CompositeStorage::capabilitiesinsertedSystemInteraction::Transactionunconditionally, and the CapabilityStatement carried{"code": "transaction"}as a literal. Both now derive it.batchstays unconditional.The fan-out goes with the path
Corrected. An earlier revision of this description claimed
join_all→buffered(16). That is not what landed, and it is corrected here rather than quietly dropped.origin/main'ss3/bundle.rshad two unboundedjoin_allsites: one inprocess_transaction(:97) and one inprocess_batch(:160). Deleting the transaction machinery removes the first outright — there is nothing left to bound. The second was never reachable (BundleProvider::process_batchhad no production caller on any backend) and is deleted by #501, which this PR is now stacked on. Neither was ever bounded, and.buffered(appears nowhere in this branch.Batch entry concurrency — the thing that actually governs an S3 client's wall clock, now that this PR routes those clients to
batch— is #501's subject, not this one's.Keep the Inferno coverage
Every fixture is a transaction Bundle, so the s3-elasticsearch legs would now fail at the load step. Posting them as batches is not a straight swap —
uscore_bundle_patient_355.jsonalone has 473 entries and 2,058 inter-entryurn:uuidreferences that only a transaction resolves. A naive batch load would leave all of them dangling, manufacturing #459 for that backend.crates/hfs/tests/inferno/to_batch.jqresolves them client-side, andinstall.shprobes/metadata— now truthful — to decide whether it's needed. The transform deliberately mirrorsresolve_bundle_references, rewriting only an object'sreferencefield and only forurn:uuid:values. Rewriting any matching string would corrupt anIdentifier.valuethat merely looks like a uuid;pdex_bundle_patient_999.jsoncontains exactly that case. POST becomes PUT against the derived id, which also makes loads idempotent.Remove the now-dead machinery
With the refusal in place, the compensation log, both rollback helpers, the three-phase transaction body and S3's private
resolve_bundle_referencesare unreachable —bundle.rsdrops from 337 lines to 53, with no#[allow(dead_code)]left. Deleted rather than kept: if the decision were ever reversed, a durable design would need a persisted intent journal that survives process death, not an in-processVeca dropped future discards. Meanwhile it reads as a live atomicity guarantee, which is the misreading that produced this bug. SQLite/PostgreSQL/MongoDB keep their own copies ofresolve_bundle_references— still live, since they support transactions.Testing
cargo check --workspace --all-targetsclean across sqlite, postgres, mongodb, elasticsearch and s3.cargo test -p helios-persistence -p helios-rest --features R4,sqlite,postgres,s3— 20 test binaries, 0 failures.New tests:
transaction_bundle_is_refused_without_writing_anything— thecount == 0assertion is the point; a refusal that still wrote something would reproduce transaction: s3-elasticsearch advertises and accepts transaction Bundles it cannot honour — a timed-out bundle commits 466 of 473 entries behind a 408 #489 in miniature.Batch is deliberately not re-asserted at the S3 level. Two such tests were on this branch before the restack; both drove
BundleProvider::process_batch, which #501 removed as unreachable, so batch has no S3-level entry point left to test. It is covered over HTTP inhelios-rest(batch_conformance.rs,batch_if_match.rs) and end-to-end by thes3-elasticsearchInferno leg.atomicity_unsupported_maps_to_501_not_supported— status, issue code, and that the message names the backend, points atbatch, and states nothing was applied.Removed the three
bundle_transaction_*tests, which covered the now-unreachable path, leaving a note in their place.Verified live against a real s3-elasticsearch deployment
MinIO + Elasticsearch,
HFS_STORAGE_BACKEND=s3-elasticsearch:/metadatalistsbatch,history-system,search-system—transactionis gone.POST /with a transaction Bundle → 501 with the actionable OperationOutcome;Patient?_summary=countconfirms nothing was written.install.shself-detected ("Server does not advertise 'transaction'; rewriting bundles to batch") with no flag or configuration.Observation?patient=…→ 27, Condition → 2, Encounter → 1. Identical again.Inferno:
us_core_v800Zero s3-elasticsearch-only failures — its 9 are a strict subset of postgres's 10. This backend has never produced a pass rate before; every nightly died at the load step, so 60.4% is the first number for it.
Now confirmed in CI by run 31016157176 on this branch, once #501 landed beneath it: 30/30 jobs green, s3-elasticsearch 329 pass / 204 skip / 9 fail / 2 error at v8.0.0 — reproducing the 329 above exactly, and identical at the test-id level to sqlite-elasticsearch (0 tests passing only on one side). The two large fixtures that previously 408'd now load in 9.42s (473 entries) and 5.48s (267 entries).
Worth being precise about the earlier evidence: the table above came from a local MinIO deployment, where latency is negligible and the request timeout never fires. The prior CI run on this branch (30973069238) never reached Inferno at all — all six s3-elasticsearch jobs failed at "Load Inferno test data into HFS" and skipped every subsequent step. Sequential batch was the reason, which is why #501 had to land first.
The rewrite was also validated independently by loading both ways into sqlite and diffing: identical across 22 resource types and 7 reference searches.
Notes
batch, and that advice only holds once batch can carry the load — sequential batch 408s past ~90 entries on S3. The base branch isfix/501-batch-entry-concurrency; this PR's diff shows only its own changes.s3/tests.rscomment claiming entries "now run concurrently (buffered)" — behaviour that never existed — is gone: it went with the tests it annotated during the restack onto batch: entries are processed sequentially and BundleProvider::process_batch is dead on every backend — a batch over ~90 entries times out on S3 #501.data_absent_reason×2,practitioner_address_test) also pass on s3-elasticsearch, so the two are safe together in any order.sync_bundle_resultsstill swallows secondary-sync errors withwarn!only, so a clean write can leave S3 committed and Elasticsearch unindexed. Separate pre-existing gap, noted in Test suites can pass vacuously: env-gated tests silently skip in CI (sibling of #389) #390's discussion, not addressed here.