Skip to content

batch/transaction: conditional interactions are refused rather than resolved — wire bundle entries to ConditionalStorage #511

Description

@aacruzgon

Summary

Bundle entries cannot perform FHIR conditional interactions. #503 fixed the parser that made
them corrupt data and made the batch path refuse them explicitly with a 400; this issue is the
work of actually resolving them.

The machinery already exists and is unused from the bundle path:

  • ConditionalStorage (crates/persistence/src/core/storage.rs:955) provides conditional_create,
    conditional_update, conditional_delete and conditional_patch, returning
    Created / Updated / Exists / NoMatch / MultipleMatches.
  • Five types implement it, and create_fhir_router already bounds S with it
    (crates/rest/src/routing/fhir_routes.rs:209) — batch_handler (batch.rs:63) just declares a
    narrower bound. But S3's impl is a stub: conditional_create (s3/storage.rs:1534),
    conditional_update (:1548) and conditional_delete (:1563) each return
    BackendError::UnsupportedCapability, and it does not override conditional_patch. So four
    backends resolve conditionals; one satisfies the bound and refuses at runtime — the same
    bound-satisfied/runtime-refuses shape as transaction: s3-elasticsearch advertises and accepts transaction Bundles it cannot honour — a timed-out bundle commits 466 of 473 entries behind a 408 #489.
  • The single-resource handlers are line-for-line reference implementations:
    conditional_update_handler (update.rs:262), conditional_delete_handler (delete.rs:239),
    conditional create at create.rs:114-125, conditional_patch_handler (patch.rs:177).
  • conditional_update takes search_params: &str as a raw k=v&k=v string and runs the search
    itself (SqliteBackend::find_matching_resources, sqlite/storage.rs:2761-2792), so the bundle
    path needs no SearchProvider bound and never holds a registry read guard across an await —
    which matters because batch entries now run concurrently under buffered() (batch: entries are processed sequentially and BundleProvider::process_batch is dead on every backend — a batch over ~90 entries times out on S3 #501).

So the query string in Patient?identifier=x is already in exactly the shape the trait wants.

Why this is honesty-relevant

crates/rest/src/handlers/capabilities.rs:370-373 hard-codes, for every resource type:

"conditionalCreate": true,
"conditionalRead": "full-support",
"conditionalUpdate": true,
"conditionalDelete": "single",

crates/rest/README.md used to advertise ifNoneExist as a supported bundle-entry header; that was
corrected by PR #512's third commit and now states the position accurately and points here. The
CapabilityStatement half is still true. The spec's own transaction example — vendored in this repo at
crates/fhir/tests/data/json/R4/bundle-transaction.json — uses all three conditional forms.

What is required

  1. Widen batch_handler / process_batch / process_batch_entry to + ConditionalStorage. Note
    the test module's run_batch<S> (batch.rs:1883-1889) must be widened in lockstep, and both
    test mocks (MockStorage ~:1373, DelayStorage ~:1745) need impl ConditionalStorage
    otherwise the whole #[cfg(test)] mod tests fails to compile.
  2. Three executors on the batch path — conditional update (upsert = true, matching update.rs:321),
    conditional delete (Deleted | NoMatch -> 204 per R4 §3.1.0.7.1 and delete.rs:275-279),
    conditional create (via request.ifNoneExist, which process_batch_entry does not read today).
    MultipleMatches -> 412, which is binding because capabilities.rs:373 elects
    conditionalDelete: "single".
  3. Decide the percent-decoding question. request.url arrives encoded; the backends'
    parse_simple_search_params (sqlite/storage.rs:2585-2597) splits on & then splitn(2, '=')
    with no decoding, while the HTTP handlers get a decode for free from axum's Query. The HTTP
    handlers also round-trip through HashMap<String, String> (update.rs:282-286), which silently
    drops repeated keys — so "mirror the HTTP path exactly" and "be correct" are not the same choice.
  4. ifMatch on a conditional entry has no defined meaning (the URL names no version). Refuse it.
  5. Concurrency: conditional_update is read-then-write inside the backend, not a compare-and-swap.
    Two conditional writes in one bundle can resolve against the same pre-bundle state and both
    write. Extend the batch_concurrency carve-out (batch.rs:157-192) the way the
    StructureDefinition case already does.
  6. Audit attribution: ConditionalDeleteResult::Deleted is a unit variant carrying no id
    (core/storage.rs:902-909), and emit_entry_audit (batch.rs:885-902) derives the entity from
    the URL id — empty on a conditional entry — or from result.resource, absent on a delete. A
    delete-by-criteria would emit an AuditEvent naming nothing, under a server whose audit story is
    IHE BALP. The same gap exists on the HTTP path (delete.rs:266-283 inserts no
    AuditResponseContext, unlike the instance delete at :215-223).

The transaction path is a separate, harder problem

#503 refuses conditional URLs on the transaction path outright. Resolving them there is blocked:

  • SqliteBackend::conditional_update (sqlite/storage.rs:2634-2686) calls self.update(...), not
    the open Transaction, whose trait (core/transaction.rs:147-189) exposes only
    create/read/update/delete. Calling it from inside process_transaction would commit outside the
    bundle's atomic scope — a rolled-back bundle would leave the conditional write behind.
  • R4 §3.1.0.11.2 additionally requires that overlapping identities resolved from conditional
    update/delete SHALL fail the transaction, which needs a pre-resolution pass over the ordered
    entries — the shape fix(transaction): resolve conditional references before execution #467 chose for conditional references.

MongoDB is the exception and must not be regressed. It already resolves ifNoneExist inside
the session: process_bundle_entry_transaction (mongodb/storage.rs:2675) calls
find_matching_resources_in_bundle_transaction, which is session-scoped
(.session(&mut *session), :3323-3345), returning 201 / 200 / 412. Covered by
mongodb_integration_transaction_bundle_conditional_headers
(crates/persistence/tests/mongodb_tests.rs:1059-1105). #503's transaction refusal is therefore
scoped to URL-borne criteria only and deliberately leaves ifNoneExist alone. SQLite and Postgres
silently ignore ifNoneExist in a transaction and create a duplicate at 201 — that gap wants a
backend capability predicate, not a REST-layer constant.

Related, deliberately not folded in

  • Gating the CapabilityStatement per backend. This turned out to be bigger than a conditional-flag
    fix — the literal at capabilities.rs:353-374 claims ~16 capabilities unconditionally, and
    CapabilityProvider (the trait designed to replace it) is implemented only by CompositeStorage,
    whose resource_caps is an empty HashMap that is never populated. Filed separately as capabilities: /metadata is a hardcoded literal claiming ~16 capabilities unconditionally, and CapabilityProvider is a stub that cannot yet replace it #514;
    neither issue blocks the other. Landing this one makes the conditional flags true on the batch
    path for sqlite/postgres/mongodb, leaving S3 the backend the literal still misrepresents.
  • BundleEntry.if_none_match (core/transaction.rs:266-268) is populated by parse_bundle_entry
    and read by nothing. PR fix(rest): parse bundle entry URLs without their query string #512 corrected the README to say so; the field itself is still
    unimplemented rather than pretend-supported.
  • The three backend parse_url copies (sqlite/storage.rs:3277, postgres/storage.rs:2943,
    mongodb/storage.rs:3475) are byte-equivalent and query-blind. #503 makes them unreachable from
    bundles for non-GET query-bearing URLs; converging them into one shared helper in
    helios_persistence::core (alongside the existing bundle_if_match_gate precedent) is a clean
    follow-up.
  • [type]/$op entry URLs (e.g. POST ValueSet/$lookup, present in the spec's own vendored fixture)
    parse to ("ValueSet", "$lookup") and the POST arm ignores the id, creating a bogus ValueSet.
    Same family, different symptom; needs an operation-dispatch design.

Where it came from

Split out of #503 during implementation, to keep that fix to the parser defect and the data-corruption
guards it required.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions