Skip to content

fix(s3): decline transaction Bundles S3 cannot honour, and bound bundle concurrency - #500

Open
aacruzgon wants to merge 7 commits into
fix/501-batch-entry-concurrencyfrom
fix/489-s3-decline-unsupported-transactions
Open

fix(s3): decline transaction Bundles S3 cannot honour, and bound bundle concurrency#500
aacruzgon wants to merge 7 commits into
fix/501-batch-entry-concurrencyfrom
fix/489-s3-decline-unsupported-transactions

Conversation

@aacruzgon

@aacruzgon aacruzgon commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

An s3-elasticsearch deployment advertised FHIR transaction support 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:

  1. A capability lie — three places asserted transaction support without consulting the backend.
  2. An unbounded fan-outjoin_all issued 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 in process_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:

  • TimeoutLayer drops the handler future. 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.
  • The compensation list is only populated after join_all resolves, 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.md calls S3 "intentionally storage-focused … archive/history storage", and design discussion #28 places ACID on the relational tier. #28 even specifies the guard:

The trait separation makes this distinction clear: code that requires atomicity takes &dyn TransactionProvider, while code that can tolerate partial failures takes &dyn ResourceStorage.

process_transaction lives on BundleProvider, 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 (real TransactionProvider); MongoDB → true (server-side session, so it unwinds even on a dropped connection); S3 → false; composite → delegates to its primary.
  • S3 refuses before the first write with a new TransactionError::AtomicityUnsupported501 + 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::Transactions already encoded the truth per-backend (declared by SQLite/PostgreSQL/MongoDB, absent from S3) — nothing read it. CompositeStorage::capabilities inserted SystemInteraction::Transaction unconditionally, and the CapabilityStatement carried {"code": "transaction"} as a literal. Both now derive it. batch stays unconditional.

The fan-out goes with the path

Corrected. An earlier revision of this description claimed join_allbuffered(16). That is not what landed, and it is corrected here rather than quietly dropped.

origin/main's s3/bundle.rs had two unbounded join_all sites: one in process_transaction (:97) and one in process_batch (:160). Deleting the transaction machinery removes the first outright — there is nothing left to bound. The second was never reachable (BundleProvider::process_batch had 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.json alone has 473 entries and 2,058 inter-entry urn:uuid references 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.jq resolves them client-side, and install.sh probes /metadata — now truthful — to decide whether it's needed. The transform deliberately mirrors resolve_bundle_references, rewriting only an object's reference field and only for urn:uuid: values. Rewriting any matching string would corrupt an Identifier.value that merely looks like a uuid; pdex_bundle_patient_999.json contains 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_references are unreachable — bundle.rs drops 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-process Vec a 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 of resolve_bundle_references — still live, since they support transactions.

Testing

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,s3 — 20 test binaries, 0 failures.

New tests:

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 in helios-rest (batch_conformance.rs, batch_if_match.rs) and end-to-end by the s3-elasticsearch Inferno leg.

  • atomicity_unsupported_maps_to_501_not_supported — status, issue code, and that the message names the backend, points at batch, 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:

  1. /metadata lists batch, history-system, search-systemtransaction is gone.
  2. POST / with a transaction Bundle → 501 with the actionable OperationOutcome; Patient?_summary=count confirms nothing was written.
  3. install.sh self-detected ("Server does not advertise 'transaction'; rewriting bundles to batch") with no flag or configuration.
  4. All 15 fixtures loaded — 8 Patients, 246 Observations, 51 Conditions, 194 Encounters, identical to the transaction-path numbers on every other backend.
  5. References resolved — Observation?patient=… → 27, Condition → 2, Encounter → 1. Identical again.

Inferno: us_core_v800

backend pass rate failures
s3-elasticsearch (this branch) 329/544 60.4% 9
postgres (with #495 + #496) 331/544 60.8% 10
sqlite 326/544 59.9% 10

Zero 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

@claude

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown

Code review

Found 2 issues during review.

1. to_batch.jq relocates resources that already have a stable PUT id, breaking fixture references

This transform relocates resources that already have a stable PUT Type/<id> request onto a uuid-derived id instead, because the then branch only checks whether fullUrl starts with urn:uuid: — it ignores the entry's existing request.method/request.url and resource.id.

Several Inferno fixtures hit this. For example, uscore_bundle_patient_907.json already has resource.id: "907" and request: {method: "PUT", url: "Patient/907"} alongside fullUrl: "urn:uuid:a24eb240-...". After this transform it gets relocated to Patient/a24eb240-9ea3-4fa5-b12d-b798587879d7.

This breaks fixtures that reference the original stable id via literal (non-urn:uuid:) references, which resolve() never rewrites — pdex_bundle_patient_999.json has 21 literal "Patient/999" references left dangling after the transform, and Group fixtures (uscore_group_1a.json, pdex_proxy_group_patient_999.json) that reference Patient/85/Patient/355/Patient/907/Patient/999 by their original stable id become dangling once those Patients relocate.

Suggested fix: when an entry already has a PUT request to a concrete Type/id, preserve that id/url instead of synthesizing a new one from the uuid, and map fullUrl to that preserved Type/id in $map.

| .entry |= map(
if ((.fullUrl // "") | startswith("urn:uuid:")) then
(.fullUrl | uuid_of) as $id
| (.resource.resourceType // "Unknown") as $rt
| .resource.id = $id
| .resource |= resolve($map)
| .fullUrl = ($rt + "/" + $id)
| .request = { method: "PUT", url: ($rt + "/" + $id) }
else
.resource |= (if . == null then . else resolve($map) end)
end
)

2. buffered(16) concurrency bound is unreachable from the actual HTTP batch path

This buffered(BUNDLE_CONCURRENCY) change bounds concurrency inside S3Backend::process_batch, but that method has no caller reachable from an actual HTTP request — a repo-wide search shows its only non-test caller is CompositeStorage::process_batch, which itself is only invoked by its own unit test. The REST batch handler (see batch.rs lines 160-175:

for (index, entry) in entries.iter().enumerate() {
let result =
process_batch_entry(state, &tenant, fhir_version, entry, index, principal).await;
let correlation_details = EntryAuditCorrelation::from_bundle(&correlation, index);
emit_batch_entry_audit(
state,
entry,
&result,
principal,
None,
Some(&correlation_details),
);
response_entries.push(bundle_entry_result_to_json(&result, base_url, prefer));
}
) processes "batch" bundles with its own fully-sequential 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 + to_batch.jq conversion: a large bundle like the 473-entry one from #489, converted to batch, would still hit the sequential per-entry loop under the same 30s timeout — just serialized instead of unbounded, with no guarantee it completes in time.

// `BUNDLE_CONCURRENCY` of them at a time.
use futures::StreamExt;
let futs: Vec<_> = entries
.iter()
.map(|entry| self.process_batch_entry(tenant, entry, fhir_version))
.collect();
let results: Vec<_> = futures::stream::iter(futs)
.buffered(BUNDLE_CONCURRENCY)
.collect()
.await;

aacruzgon added a commit that referenced this pull request Aug 5, 2026
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
@aacruzgon
aacruzgon force-pushed the fix/489-s3-decline-unsupported-transactions branch from 29705ad to 76de6ad Compare August 5, 2026 03:39
aacruzgon added a commit that referenced this pull request Aug 5, 2026
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
aacruzgon added a commit that referenced this pull request Aug 5, 2026
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
@aacruzgon
aacruzgon force-pushed the fix/489-s3-decline-unsupported-transactions branch from 76de6ad to c914754 Compare August 5, 2026 03:41
@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

@aacruzgon

Copy link
Copy Markdown
Contributor Author

Draft while blocked on #501 — and two corrections to this PR

Code 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 (c914754ce)

to_batch.jq branched on fullUrl alone, so entries carrying both a urn:uuid fullUrl and a concrete PUT Type/<id> were moved onto uuid ids. That relocated Patient/85, /355, /907, /908 and /999 — the ids Inferno is configured to exercise, the ids the Group fixtures reference, and the target of 21 literal "Patient/999" references, which are not urn:uuid and so never followed.

It also diverged from the server, which was the premise of the whole transform: phase 1 of process_transaction skips non-POST entries, so a PUT keeps the id it names.

Chasing that turned up a second divergence the review did not mention: SQLite, PostgreSQL and MongoDB build the reference map incrementally, resolving each entry against the map so far, so they never resolve a forward reference. The original rewrite built the map up-front and did, giving s3-elasticsearch three references the other backends lack. Now a single incremental pass.

The consistent rule, applied in all three places: the loader mirrors the server, including where the server does nothing. Same reason references to PUT entries stay unresolved (#459) — resolving them would hand one backend better data and break comparability.

Why the original validation missed it, which matters more than the bug. The equivalence check compared aggregate counts, which cannot see relocation — 8 Patients either way. And the local Inferno run reported 60.4% with "zero backend-specific failures" while four of five test patients did not exist, because tests for a missing patient skip and the gate ignores skips (#491). The pass rate was never evidence the data was correct.

Now: the check compares full sorted id lists per type plus hashed reference values (identical across 23 types), and install.sh fetches all 136 fixture-addressed ids after loading. That guard was verified to fail on the old rewrite — MISSING: Patient/pat015 (HTTP 404) and eight more — not merely to pass on the new one.

The 60.4% figure in the description above is void. It was measured with the buggy transform.

2. buffered(16) was unreachable — commit dropped, real defect filed as #501

BundleProvider::process_batch has no caller reachable from HTTP; the REST handler implements batch itself with a sequential loop. So that commit could not execute, and claiming it fixed the #489 timeout was exactly the kind of unearned capability claim this PR exists to remove. It has been dropped from the branch.

The underlying defect is real and now measured. From the run above, loading fixtures as batch against real S3:

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.

@aacruzgon
aacruzgon force-pushed the fix/489-s3-decline-unsupported-transactions branch from c914754 to e7f4cd8 Compare August 5, 2026 14:31
aacruzgon added a commit that referenced this pull request Aug 5, 2026
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
@aacruzgon
aacruzgon changed the base branch from main to fix/501-batch-entry-concurrency August 5, 2026 14:32
@aacruzgon
aacruzgon marked this pull request as ready for review August 5, 2026 16:47
aacruzgon added a commit that referenced this pull request Aug 5, 2026
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
@aacruzgon
aacruzgon force-pushed the fix/489-s3-decline-unsupported-transactions branch from e7f4cd8 to 2f9b722 Compare August 5, 2026 16:48
aacruzgon added a commit that referenced this pull request Aug 6, 2026
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
aacruzgon added a commit that referenced this pull request Aug 6, 2026
…_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
@aacruzgon
aacruzgon force-pushed the fix/489-s3-decline-unsupported-transactions branch from 2f9b722 to 88ce432 Compare August 6, 2026 17:25
aacruzgon added a commit that referenced this pull request Aug 6, 2026
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
@aacruzgon
aacruzgon force-pushed the fix/489-s3-decline-unsupported-transactions branch from 9be3f6b to 173b580 Compare August 7, 2026 14:49
…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.
aacruzgon added a commit that referenced this pull request Aug 9, 2026
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
aacruzgon added a commit that referenced this pull request Aug 9, 2026
…_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
@aacruzgon
aacruzgon force-pushed the fix/489-s3-decline-unsupported-transactions branch from 173b580 to bea4cd7 Compare August 9, 2026 20:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

transaction: s3-elasticsearch advertises and accepts transaction Bundles it cannot honour — a timed-out bundle commits 466 of 473 entries behind a 408

1 participant