Skip to content

fix(rest): run batch bundle entries with backend-bounded concurrency - #505

Open
aacruzgon wants to merge 3 commits into
mainfrom
fix/501-batch-entry-concurrency
Open

fix(rest): run batch bundle entries with backend-bounded concurrency#505
aacruzgon wants to merge 3 commits into
mainfrom
fix/501-batch-entry-concurrency

Conversation

@aacruzgon

@aacruzgon aacruzgon commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

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 ~300 ms per entry, which puts a bundle over ~90 entries past the 30 s request timeout: TimeoutLayer drops the handler, the client gets an empty-bodied 408, and the entries that already committed stay committed. A 473-entry US Core fixture took 30.1 s and failed; the same data went through the concurrent transaction path in 5.5 s.

Entries now run through buffered(n), bounded by what the backend says it tolerates. Along the way this fixes a second, quieter defect and removes the structure that produced it: BundleProvider::process_batch was implemented five times and called by none of them, and #311's ifMatch fix had landed in that unreachable half — so batch ifMatch has been silently unconditional over HTTP for two releases.

Fixes #501. Also fixes the batch half of #311.

Why the bound comes from the backend

ResourceStorage::bulk_write_concurrency() already existed on every backend with the right values and the right composite delegation, and process_batch's where clause already reached it — so this needs no new trait method and no widened bound:

Backend Declares Effective (default cap 16)
SQLite 1 (default) 1
PostgreSQL / MongoDB / Elasticsearch 8 8
S3 32 16
Composite its primary's its primary's

HFS_BATCH_MAX_CONCURRENCY (default 16) is a ceiling: it can lower what a backend declared, never raise it, because 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 without spawning, so a higher bound could not interleave them anyway. This is therefore a no-op on SQLite and on the sqlite/sqlite-elasticsearch CI legs; s3-elasticsearch is the leg that changes.

Changes

helios-rest — the entry loop

  • .buffered(n), never buffer_unordered: 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.
  • 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 here, and the resulting error is reported against the route registration in routing::fhir_routes, not against this function.
  • Per-entry SMART scope enforcement stays exactly where it was — it is the only authorization gate on POST [base], and is a lock-free, I/O-free function over &Principal, so concurrency cannot change a verdict.
  • Per-entry audit 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 precisely the failure mode being fixed.
  • A bundle that writes a StructureDefinition drops to sequential: later entries validate against profiles earlier entries register via upsert_stored_profile, 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.

helios-restifMatch on the batch path

process_batch_entry now evaluates the precondition through the same core::bundle_if_match_gate the backends use: a list is satisfied when any member matches, * requires a current representation, a deleted resource has none, and a malformed value fails closed. The gate runs ahead of write-path validation, because every backend evaluates ifMatch first — a stale precondition carrying an invalid body is a 412, not a 422. Entries without a precondition pay nothing, not even the read.

This is a read-then-write check, not an atomic compare-and-swap. The backends reach an atomic re-check through update_with_match, which lives on VersionedStorage — a trait create_routes does not bound S with, so this path cannot call it. The window is the one handlers::update already carries, with one addition worth naming: entries now run concurrently, so two entries carrying ifMatch for the same id can both pass the gate.

helios-persistence — remove the unreachable half

Deleted BundleProvider::process_batch and all five implementations. Kept supports_atomic_transactions, process_transaction, the composite's sync_bundle_results, and bundle_if_match_gate.

Two things this surfaces. MongoDB's implementation was an unconditional UnsupportedCapability — had anything actually delegated to it, every mongodb-* deployment would have 5xx'd on batch Bundles that succeed today. And S3's carried an unbounded join_all over caller-controlled input, which could never have bounded anything from where it sat.

The trait doc now records why batch is not there — per-entry scope enforcement and per-entry audit are invisible to the persistence tier — so the next reader does not "fix" the asymmetry by wiring the two back together.

Behaviour changes visible to a client

  1. Large batches succeed. 473 entries on S3: 30.1 s → 408 becomes an expected ≈9 s → 200.
  2. Entries are no longer ordered relative to one another at a bound above 1. 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. Invisible on SQLite.
  3. A batch PUT/DELETE with a stale, absent-target, malformed, or *-against-nothing ifMatch now returns a per-entry 412 where it previously returned 200 and wrote. The bundle still returns HTTP 200 — a failed precondition is one entry's outcome, not the batch's.
  4. Audit emission order becomes completion order. Position is still carried explicitly by the entry-index detail and the shared bundle-id.

Testing

cargo check --workspace --all-targets clean. cargo check -p helios-persistence --all-features --all-targets clean across sqlite, postgres, mongodb, elasticsearch and s3.

  • helios-persistence lib — 767 passed, 0 failed
  • helios-persistence transactions_suite37 passed, 0 failed
  • helios-rest full suite — green, including the R4B leg

Both new suites were verified to have teeth rather than trusted because they were green:

  • The ordering test was run against buffer_unordered and fails (caught by the debug_assert!).
  • The ifMatch suite was run with both gate call sites removed: 8 of 13 fail. The 5 that still pass are the must-succeed cases, which guard the opposite direction.

Clippy warning count on helios-persistence --all-features --all-targets is unchanged at 930 against the pre-change tree, with no dead-code warnings left behind.

I also ran to_batch.jq over every Inferno fixture directly: all ten rewritten bundles have zero duplicate PUT targets, so concurrent execution cannot produce a write conflict, and uscore_bundle_asserted-date.json is the only fixture carrying a StructureDefinition — it correctly drops to sequential, at a cost of 2 entries.

Not reproducible in cargo test. batch_conformance.rs builds its router with create_routes rather than the full app, so no TimeoutLayer is installed, and SQLite's storage calls never yield.

Verified against real S3 — Inferno US Core run 31016157176

Run on fix/489-s3-decline-unsupported-transactions (the stacked branch, which contains these three commits plus the loader that routes S3 to batch), because that is the only configuration where Inferno exercises this code path. 30/30 jobs green, against a baseline on the same branch (run 30973069238, the run issue #501 quotes) where all six s3-elasticsearch jobs died at the load step:

Bundle Entries Baseline This run
uscore_bundle_patient_355 473 HTTP 408 @ 30.09s HTTP 200 @ 9.42s
uscore_bundle_patient_85 267 HTTP 408 @ 30.06s HTTP 200 @ 5.48s

The log confirms the path taken: Server does not advertise 'transaction'; rewriting bundles to batch. All 15 fixtures loaded.

Correctness, not just speed. US Core v8.0.0, latest result per test_id:

Backend Loaded via Pass Skip Fail Error
s3-elasticsearch batch, 16-way concurrent 329 204 9 2
sqlite-elasticsearch transaction 329 204 9 2
sqlite transaction 326 207 9 2
mongodb transaction 365 168 9 2
postgres transaction 25 511 8 0

S3's pass set is identical to sqlite-elasticsearch's at the test-id level, not merely equal in count — 0 tests pass only on S3, 0 only on sqlite-ES, and zero S3-only failures. The same fixtures loaded through the concurrent batch loop produce exactly the same conformance outcome as loading them through a transaction: no lost entries, no ordering corruption, no unresolved references.

All 10 named failures are inside the workflow's 17-entry omit list, so the gate's "0 failures" is genuine rather than an artifact of #491.

Two observations from the run that are not caused by this PR, recorded because the run makes them visible: postgres reports success while passing 25 of 545 tests (#491, with #490 as the root cause), and the baseline shows Inferno had never actually executed on s3-elasticsearch — every previous run stopped at the load step, so this is the first conformance number that backend has produced in CI.

Tests re-homed rather than dropped

  • The nine batch ifMatch scenarios in if_match_suite move, one for one, to crates/rest/tests/batch_if_match.rs, where they run against the loop the server executes. The five transaction scenarios stay, with their SQLite and PostgreSQL macro expansions. Coverage narrows from two backends to one, which is the right trade now that the comparison is backend-independent and separately unit-tested in core::preconditions.
  • Transaction/batch Bundles ignore fhirVersion content negotiation and persist entries as compile-time default (R4); HFS_DEFAULT_FHIR_VERSION ineffective #350's version-stamping guarantee had a batch half asserted through process_batch. It is now batch_entries_stamp_the_configured_default in 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.

Notes

@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.26752% with 18 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/rest/src/handlers/batch.rs 93.97% 18 Missing ⚠️

📢 Thoughts on this report? Let us know!

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
#311 fixed `If-Match` list handling across the bundle arms, but the batch
half of that fix landed in `BundleProvider::process_batch` — a method no
deployment calls, because the REST layer runs its own entry loop. Over
HTTP a batch `PUT` or `DELETE` carrying `ifMatch` was therefore still
unconditional: stale tag, 200 OK, lost update. `parse_bundle_entry` is the
only place that reads the field today and its sole caller is
`process_transaction`, so the batch arm never saw it at all.

`process_batch_entry` now evaluates the precondition through the same
`core::bundle_if_match_gate` the backends use, so a list is satisfied when
any member matches, `*` requires a current representation, a deleted
resource has none, and a malformed field value fails closed rather than
becoming an unconditional write.

Two placement details matter:

- The gate runs *ahead* of write-path validation, because every backend
  evaluates `ifMatch` first. A stale precondition carrying an invalid body
  is a 412, not a 422.
- Entries that send no precondition pay nothing, not even the read.

This is a read-then-write check, not an atomic compare-and-swap, and the
difference is worth naming rather than glossing. The backends reach an
atomic re-check through `update_with_match`, which lives on
`VersionedStorage` — a trait `create_routes` does not bound `S` with, so
this path cannot call it. The window is the one `handlers::update` already
carries for single-resource updates, with one addition: entries within a
bundle now run concurrently, so two entries carrying `ifMatch` for the
same id can both pass the gate and both write.

Behaviour change for clients: a batch `PUT`/`DELETE` whose `ifMatch` is
stale, absent-target, malformed, or `*` against a nonexistent resource now
returns a per-entry 412 where it previously returned 200 and wrote. The
bundle still returns HTTP 200 — a failed precondition is one entry's
outcome, not the batch's.

Tests: new `crates/rest/tests/batch_if_match.rs`, 13 HTTP-level tests
covering stale/matching/multi-valued/star/malformed preconditions on PUT
and DELETE, absent and deleted targets, that a rejected entry leaves
storage untouched, and that other entries are unaffected. Verified to have
teeth: with the two gate call sites removed, 8 of the 13 fail — the 5 that
still pass are the must-succeed cases, which guard the opposite direction.
Full `helios-rest` suite green; clippy clean on both touched files.

Refs #311, #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
@aacruzgon
aacruzgon force-pushed the fix/501-batch-entry-concurrency branch from 1a38eb7 to e36b555 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.

batch: entries are processed sequentially and BundleProvider::process_batch is dead on every backend — a batch over ~90 entries times out on S3

1 participant