Skip to content

fix(persistence): a transaction Bundle PUT to a deleted id restores it - #2227

Merged
smunini merged 4 commits into
mainfrom
fix/2145-mongo-bundle-put-restore
Oct 27, 2026
Merged

smunini merged 4 commits into
mainfrom
fix/2145-mongo-bundle-put-restore

Conversation

@mauripunzueta

Copy link
Copy Markdown
Contributor

Closes #2145.

Problem

A transaction Bundle PUT Patient/<id> where <id> is soft-deleted failed the whole Bundle with 400 … resource already exists, while the same PUT outside a Bundle restores the resource as a new version (FHIR http.html#delete).

This was not MongoDB-only, as the issue suggested checking: SQLite and PostgreSQL had the same gap. Their transaction read returns None for a tombstone, so the entry fell through to create, which collides with the tombstone's row (SQLite's existence probe / PG's ON CONFLICT DO NOTHING). Before the fix, the new SQLite test failed with exactly the issue's message:

BundleError { index: 0, message: "Entry processing failed: resource already exists: Patient/restored" }

Fix

On every backend, the Bundle PUT's "no current representation" arm now looks for a tombstone first and restores it inside the transaction. The restore continues the version chain (create 1, delete 2, restore 3), keeps the FHIR version the resource was stored under, rebuilds its search rows, and answers 201. Only an id that was never stored creates.

  • MongoDB: read_deleted_in_bundle_transaction + restore_resource_in_bundle_transaction. The Bundle update and the restore share write_next_version_in_bundle_transaction (CAS on the stored document, then the history row and search rows).
  • SQLite: SqliteTransaction::restore.
  • PostgreSQL: PostgresTransaction::restore. The update's single compare-and-swap CTE moved into write_next_version, which update and restore both call. The large diff in transaction.rs is mostly that move.

ifMatch behavior is unchanged: a supplied ifMatch still fails against a deleted resource, because there is no current representation.

Out of scope, unchanged: POST with a reused id (live or deleted) still fails with AlreadyExists (a_body_id_of_a_deleted_resource_fails_at_its_entry still passes). Conditional PUT (PUT Patient?…) that matches nothing still creates.

Tests

  • New bundle_identity_suite::put_over_a_deleted_id_restores_it, wired into SQLite, PostgreSQL and MongoDB.
  • MongoDB a_put_that_creates_over_a_deleted_id_fails_as_before (from perf(mongodb): stage transaction Bundle creates and send them in chunks #2144), which pinned the old answer, is replaced by a_put_over_a_deleted_id_restores_it. It runs in both the inline and deferred search-row modes and checks document and history counts plus searchability of the restored body.

Run locally:

  • SQLite transactions_suite: 89 passed. The new scenario fails on the unfixed code with the message above.
  • MongoDB bundle_create_chunks (live MongoDB via testcontainers): 14 passed.
  • PostgreSQL: in progress, results to follow in a comment.

A PUT entry whose id was soft-deleted read no current representation,
fell through to a create, and collided with the tombstone's row, so the
whole Bundle failed 400 "resource already exists". The same PUT outside
a Bundle restores the resource (FHIR http.html#delete).

The issue reported this for MongoDB; SQLite and PostgreSQL had the same
gap (their transaction `read` hides the tombstone and `create` refuses
its row). Each backend's Bundle PUT now restores a tombstone inside the
transaction, continuing its version chain and keeping its FHIR version,
before it falls back to a create:

- MongoDB: `read_deleted_in_bundle_transaction` +
  `restore_resource_in_bundle_transaction`, sharing one versioned write
  with the Bundle update.
- SQLite: `SqliteTransaction::restore`.
- PostgreSQL: `PostgresTransaction::restore`, sharing the update's
  compare-and-swap statement via `write_next_version`.

POST with a reused (live or deleted) id still fails with AlreadyExists.

Closes #2145
@mauripunzueta

Copy link
Copy Markdown
Contributor Author

PostgreSQL results, run locally (testcontainers, postgres:16-alpine):

  • postgres_integration_put_over_a_deleted_id_restores_it: passed.
  • Wider filtered run over the bundle identity, restore, if_match, conditional_url, update and transaction tests: 49 passed, 0 failed.

@codecov

codecov Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.90909% with 11 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/persistence/src/backends/mongodb/storage.rs 89.71% 11 Missing ⚠️

📢 Thoughts on this report? Let us know!

smunini and others added 3 commits October 24, 2026 12:14
FHIR/fhir-test-cases merged oldrobot's PRs #316-#320 on 2026-10-23 and
the "Test FHIRPath" gate (which runs the upstream corpus) went red on
every PR with 10 undeclared failures. Fix the engine rather than declare
them:

- Delimited identifiers: `none_of("`")` consumed the backslash before
  the escape parser ran, so Patient.`name` never resolved.
- unescape('json'): decode \uXXXX (pairing UTF-16 surrogates) and \/.
- Indexer: a negative index is out of range, so the result is empty
  instead of an error.
- skip({}) / take({}): an empty count returns empty.
- DateTime + / - calendar duration: accept hour- and minute-precision
  values with a timezone (@2015-02-04T10Z, @2015-02-04T10:00Z) and keep
  the input precision in the result, so `= @2015-02-04T11:00Z` compares.

Vendor the new cases into the local r4/r5 corpora. testLessThan28 then
exposed that extension(url) on a FHIR primitive only worked through a
hard-coded fabricated birthTime extension; read the primitive's own
extensions instead and drop the fabrication. The parse-all test now
requires invalid="syntax" expressions to be rejected.

(cherry picked from commit 7e2e6de)
main gained its own transaction-level restore (read_including_deleted +
restore(&deleted, body) over a shared write_revision) on SQLite and
Postgres, which overlapped this branch's restore(type, id, &body) and the
Postgres write_next_version refactor. Take main's transaction.rs on both
backends and route the bundle PUT-to-deleted-id path through main's API.
@mauripunzueta
mauripunzueta marked this pull request as ready for review October 27, 2026 13:01
@claude

claude Bot commented Oct 27, 2026

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

@smunini
smunini merged commit 6f62064 into main Oct 27, 2026
20 checks passed
@smunini
smunini deleted the fix/2145-mongo-bundle-put-restore branch October 27, 2026 13:03
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.

mongodb: PUT of a soft-deleted id inside a transaction Bundle fails 400 "already exists" while a standalone PUT restores it

2 participants