fix(rest): run batch bundle entries with backend-bounded concurrency - #505
Open
aacruzgon wants to merge 3 commits into
Open
fix(rest): run batch bundle entries with backend-bounded concurrency#505aacruzgon wants to merge 3 commits into
aacruzgon wants to merge 3 commits into
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
aacruzgon
force-pushed
the
fix/501-batch-entry-concurrency
branch
from
August 5, 2026 16:48
49ee5e8 to
2072fdc
Compare
aacruzgon
force-pushed
the
fix/501-batch-entry-concurrency
branch
2 times, most recently
from
August 7, 2026 14:49
439b1a3 to
1a38eb7
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
#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
force-pushed
the
fix/501-batch-entry-concurrency
branch
from
August 9, 2026 20:32
1a38eb7 to
e36b555
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A
batchBundle 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:TimeoutLayerdrops 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_batchwas implemented five times and called by none of them, and #311'sifMatchfix had landed in that unreachable half — so batchifMatchhas 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, andprocess_batch'swhereclause already reached it — so this needs no new trait method and no widened bound: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
bufferedpolls 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-elasticsearchis the leg that changes.Changes
helios-rest— the entry loop.buffered(n), neverbuffer_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. Adebug_assert!pins the invariant.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 inrouting::fhir_routes, not against this function.POST [base], and is a lock-free, I/O-free function over&Principal, so concurrency cannot change a verdict.StructureDefinitiondrops to sequential: later entries validate against profiles earlier entries register viaupsert_stored_profile, the one cross-entry dependency on this path. The scan keys offrequest.urlthrough the sameparse_request_urlthe side effect keys off, so the two cannot disagree.BatchProgresslogs 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-rest—ifMatchon the batch pathprocess_batch_entrynow evaluates the precondition through the samecore::bundle_if_match_gatethe 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 evaluatesifMatchfirst — 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 onVersionedStorage— a traitcreate_routesdoes not boundSwith, so this path cannot call it. The window is the onehandlers::updatealready carries, with one addition worth naming: entries now run concurrently, so two entries carryingifMatchfor the same id can both pass the gate.helios-persistence— remove the unreachable halfDeleted
BundleProvider::process_batchand all five implementations. Keptsupports_atomic_transactions,process_transaction, the composite'ssync_bundle_results, andbundle_if_match_gate.Two things this surfaces. MongoDB's implementation was an unconditional
UnsupportedCapability— had anything actually delegated to it, everymongodb-*deployment would have 5xx'd on batch Bundles that succeed today. And S3's carried an unboundedjoin_allover 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
PUT/DELETEwith a stale, absent-target, malformed, or*-against-nothingifMatchnow 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.entry-indexdetail and the shared bundle-id.Testing
cargo check --workspace --all-targetsclean.cargo check -p helios-persistence --all-features --all-targetsclean across sqlite, postgres, mongodb, elasticsearch and s3.helios-persistencelib — 767 passed, 0 failedhelios-persistencetransactions_suite— 37 passed, 0 failedhelios-restfull suite — green, including the R4B legBoth new suites were verified to have teeth rather than trusted because they were green:
buffer_unorderedand fails (caught by thedebug_assert!).ifMatchsuite 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-targetsis unchanged at 930 against the pre-change tree, with no dead-code warnings left behind.I also ran
to_batch.jqover every Inferno fixture directly: all ten rewritten bundles have zero duplicate PUT targets, so concurrent execution cannot produce a write conflict, anduscore_bundle_asserted-date.jsonis the only fixture carrying aStructureDefinition— it correctly drops to sequential, at a cost of 2 entries.Not reproducible in
cargo test.batch_conformance.rsbuilds its router withcreate_routesrather than the full app, so noTimeoutLayeris 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 tobatch), 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 sixs3-elasticsearchjobs died at the load step:uscore_bundle_patient_355uscore_bundle_patient_85The 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: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:
postgresreports success while passing 25 of 545 tests (#491, with #490 as the root cause), and the baseline shows Inferno had never actually executed ons3-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
ifMatchscenarios inif_match_suitemove, one for one, tocrates/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 incore::preconditions.process_batch. It is nowbatch_entries_stamp_the_configured_defaultindefault_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_submitseeded viaprocess_batch; it now seeds throughResourceStorage::create, since its subject is the export/submit feed.Notes
batch, and that advice is only sound once batch can carry the load. This lands first.HFS_BATCH_MAX_CONCURRENCYmultiplies per concurrent request: twenty simultaneous batches at 16 put 320 storage operations in flight. This is a per-request bound, not a server-wide budget. A global semaphore would touch export, submit, subscriptions and seeding, and is deliberately out of scope.HFS_AUDIT_BACKEND=databaseagainst the same bucket should preferfile/cloudwatch/none. Bounding the audit spawns is a separate concern.PATCHreturns 405 while the transaction path accepts it, and methods are case-sensitive only in batch), batch: parse_request_url never strips the query string, so every conditional entry is dispatched against a resource type containing the query #503 (parse_request_urlnever strips the query string, so every conditional entry is dispatched and scope-checked against a resource type containing the query), batch: every per-entry error carries OperationOutcome codeprocessing— a 403, 404 and 405 are indistinguishable without parsing English #504 (every per-entry error carries OperationOutcome codeprocessing).ifNoneExistis not implemented on the batch path and is untouched here —parse_bundle_entryis its only reader and its only caller isprocess_transaction. There is no conditional-create race to fix in batch because batch has no conditional create. Whoever adds it will need to document the concurrency semantics.