Skip to content

fix(sqlite): delete resource_fts on every purge path (#386) - #458

Open
mauripunzueta wants to merge 4 commits into
mainfrom
fix/386-sqlite-purge-fts
Open

fix(sqlite): delete resource_fts on every purge path (#386)#458
mauripunzueta wants to merge 4 commits into
mainfrom
fix/386-sqlite-purge-fts

Conversation

@mauripunzueta

@mauripunzueta mauripunzueta commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Closes #386.

What was wrong

resource_fts is an FTS5 virtual table, so it can carry no foreign key and the ON DELETE CASCADE from resources never reached it — and no SQLite purge path deleted from it explicitly. A purged resource left its narrative and its entire serialized body in the database after the API reported it removed. PostgreSQL was unaffected: its resource_fts is an ordinary table with ON DELETE CASCADE (postgres/schema.rs:224-225).

The issue's harm model needs one correction, which changed the whole test design. An orphaned row is not directly returned by a search: _text/_content compile to resource_id IN (SELECT … FROM resource_fts …) AND-ed onto an outer query over search_index, and purge does clear search_index. The two real harms are:

  1. PHI at rest. $purge exists to remove the bytes; they were still there.
  2. A match oracle on id reuse. Purge Patient/p1, create a new Patient/p1, and the old resource's text is matchable again and attributed to the new resource. Demonstrated failing on unfixed code (left: ["p1"], right: []). The SQLite FTS sub-select also lacks a resource_type predicate (sqlite/search/query_builder.rs:566) where Postgres joins on it, so residue leaks across resource types on id collision.

What this changes

The three purge paths (sqlite/storage.rs) get a probe-guarded, strict DELETE FROM resource_fts.

  • Strict, not let _ =: a swallowed failure on a purge path means the API attests to an erasure it did not perform — the same defect class as the issue, reached another way.
  • Probe-guarded: FTS5 is optional at compile time and a database built without it legitimately has no table. "Table absent" is a determinate fact (nothing to erase); "statement failed" is an unknown.
  • Not gated on is_search_offloaded(): that flag is a write-path optimisation; this is an erasure guarantee. A deployment that indexed locally before moving search to Elasticsearch still has rows to remove.

purge/purge_all are now transactional. They were four independent autocommit statements, so a failure partway through produced exactly the orphan state this prevents — and was indistinguishable on retry from a purge that never ran, because the retry hits the not-found guard. All three paths use TransactionBehavior::Immediate: they read before they write, and under WAL a deferred transaction fails that upgrade with SQLITE_BUSY_SNAPSHOT, for which the busy handler — and therefore busy_timeout — is never consulted.

A latent data-loss bug on both backends, which blocked the fourth path. run_reindex calls delete_search_entries for every resource unconditionally (search/reindex.rs:832), which drops the resource_fts row, while write_search_entries never rebuilt it. So $reindex silently disabled _text/_content until each resource was next written — on SQLite and PostgreSQL, with or without clear_existing. write_search_entries now repopulates FTS on both backends. That is what makes adding the FTS delete to SQLite's clear_search_index safe rather than destructive; adding it alone would have imported the data loss.

Migration v14 → v15 sweeps orphans already on disk. The code fix alone stops new orphans but leaves every database that has ever served a purge still holding the content. Soft-deleted resources keep their resources row and are correctly preserved.

/ui/tenants discarded purge_tenant_data's error after already deregistering the tenant — rendering success while the data remained. It now surfaces the failure.

Cross-backend scope

Verified each backend rather than assuming:

Backend Separate FTS store Action
SQLite resource_fts (FTS5 virtual, no FK possible) fixed — 4 paths
PostgreSQL resource_fts (ordinary table, ON DELETE CASCADE) purge paths already correct; write_search_entries fixed; _text/_content never applied at all — fixed, see below
MongoDB none — _text/_content return TextSearchNotAvailable no change
Elasticsearch none — FTS fields live on the resource document no change
S3 no search capability at all no change
Composite none — pure fan-out no change

search_index_fts (the other FTS5 table, external-content over search_index) is deliberately untouched: it is trigger-maintained, and I verified empirically that ON DELETE CASCADE fires its AFTER DELETE trigger with recursive_triggers both on and off. A direct DELETE against an external-content table would be a corruption hazard.

Tests

New backend-agnostic suite (tests/search/fts_purge_suite.rs) shared by SQLite and PostgreSQL via #[path], following the if_match_suite.rs precedent. It asserts on raw resource_fts rows, not search results — the obvious "search finds nothing after purge" test passes on the unfixed code and proves nothing. Every scenario opens with a positive control so it cannot pass vacuously.

Verified non-vacuous: against unfixed source, 10 of 13 fail. The 3 that pass are exactly the ones that should (FTS5 build pin, table-exists, FTS5-absent tolerance).

Coverage: residency for all three purge paths; over-broad-delete guard (bystander tenant survives); id-reuse and tenant-reuse oracles; repeated purge/recreate cycles; $reindex preserves full-text in both modes; repeated reindex doesn't duplicate rows; migration sweeps orphans but preserves soft-deleted; purge tolerates an FTS5-less database.

Two existing assertions that accepted zero results ("FTS unavailable") are tightened — they would have stayed green through a total full-text outage, which is how the $reindex bug survived.

Local: full helios-persistence (sqlite) suite 1,196 passing; helios-rest + helios-ui green; cargo fmt; clippy clean with CI's exact flags. PostgreSQL scenarios are unverified locally — no Docker here — so they need CI. Note this must not be stacked (CI only triggers for base: main) and must not come from a fork (DOCKER_HOST is a secret).

Follow-on fix: PostgreSQL never applied _text / _content

CI reported the two id-reuse oracles failing on PostgreSQL. The cause was not purge — PostgreSQL's purge does delete the row — it was that PostgreSQL had no _text/_content handling at all. Both are SearchParamType::Special, which fell through to the None arm of build_parameter_condition, so the parameter contributed no condition; a search whose only parameter was _text built an empty filter and search/search_count returned every non-deleted resource of that type. Not a false negative — an unfiltered result set presented as a full-text match. resource_fts was populated on every write and its tsvector columns and GIN indexes went unused by the ordinary search path. TextSearchProvider::search_text implements it correctly but has no callers; the REST layer goes through SearchProvider::search.

That is exactly what the suite is built to catch, and why it asserts through the public API as well as on raw rows: a backend that cannot filter cannot pass the id-reuse oracle. Its positive controls passed on the broken code for the reason they exist to expose — with one resource in the tenant, "returned everything" and "matched correctly" are the same answer.

Both parameters now resolve through the same resource_fts columns the write path fills, with plainto_tsquery('english', …) matching search_text/search_content, and the term bound rather than spliced so tsquery operators in user input stay data. The sub-select is scoped by tenant_id = $1 and resource_type = $2 — it yields a bare resource_id set the outer query intersects with this tenant's resources, so without the tenant predicate tenant B's Patient/123 selects tenant A's Patient/123, and without the type predicate an Observation's narrative selects a Patient of the same id.

Two smaller defects in the same code, one per backend:

  • Both builders numbered placeholders off the loop index while skipping unmatchable terms, leaving a gap. On SQLite _text=*,fracture emitted ?4 while binding one parameter, which SQLite rejects at prepare time.
  • A term that survives neither escaping (SQLite *) nor is present at all (blank) dropped the parameter, returning the whole resource type — the same fail-open. It now matches nothing, as a stopword-only term already does through plainto_tsquery.

Covered by DB-free unit tests in both builders (so they run on the Windows job too): column selection, tenant/type scoping, gap-free numbering, and fail-closed behaviour.

Deliberately out of scope

Found while doing this; each is filed here rather than as a separate issue, per the follow-ups-in-the-PR-body convention:

  • PostgresBackend::search_multi ignores every search parameter (search_impl.rs:369) — it builds SELECT … WHERE tenant_id = $1 AND resource_type IN (…) and never calls the query builder, so cross-type search returns the tenant's resources unfiltered. Same fail-open shape as the _text defect above, wider blast radius, and it needs its own change.

  • _filter is unimplemented on PostgreSQL — SQLite handles it, PostgreSQL drops it the way it used to drop _text. Not fixed here because _filter needs an expression compiler, not a one-arm dispatch; it should fail closed (or 400) rather than return everything in the meantime.

  • Neither backend advertises _text/_content in SearchCapabilityProvider, though SQLite has implemented both all along and the CapabilityStatement lists _text. The declaration and the implementation are maintained independently, which is how this gap stayed invisible.

  • PG's let _ = FTS deletes in the three purge paths (postgres/storage.rs:2178, 2230) are inert — the cascade plus the strict DELETE FROM resources two lines later is the real guarantee. Left alone rather than widening failure modes on the production backend.

  • delete_search_index early-returns when search is offloaded, skipping both the search_index and resource_fts deletes. On a migrated database that leaves stale rows for live resources forever. Same shape on SQLite, PG and Mongo — a staleness bug, not a purge bug.

  • DELETE is not erasure. Freed pages retain the bytes until overwritten; secure_delete is off and nothing runs VACUUM. Measured: sweeping 25k of 52k rows took the file from 138 MB to 140 MB. This PR removes the content from the database's logical content, not from the file. If HFS wants to claim regulatory erasure that needs a deliberate decision.

  • resource_fts key columns are UNINDEXED, so every DELETE … WHERE tenant_id = ? is a full scan (measured 30.7 ms at 52k rows, 60.9 ms at 102k). Not a new cost class — delete_search_index already pays it on every update. A resource_fts_map rowid table would make both O(1) (measured 32–290× faster).

  • composite/storage.rs:1047 purges the primary before secondaries, unlike purge/purge_all; a primary failure leaves the Elasticsearch copy undeleted.

  • transaction.rs has its own indexing path that never touches FTS, so resources created via a Bundle transaction are absent from _text/_content on SQLite.

  • Tables surviving purge_tenant_data: bulk_export_jobs/progress/files, bulk_submissions, bulk_manifests, bulk_entry_results, bulk_submission_changes, bulk_submit_files, user_settings. Most notable is bulk_submission_changes.previous_content, a complete verbatim prior copy of a resource that survives a full tenant purge — worth its own issue. bulk_*_files additionally reference NDJSON outside the database, which no DB-level purge can reach.

`resource_fts` is an FTS5 *virtual* table, so it can carry no foreign key
and the `ON DELETE CASCADE` from `resources` never reached it — and no
SQLite purge path deleted from it explicitly. Purged resources therefore
left their narrative and their entire serialized body in the database
after the API reported them removed, and a resource reusing the purged
logical id resurrected that text as a match oracle through `_text` /
`_content`. PostgreSQL was unaffected: its `resource_fts` is an ordinary
table with `ON DELETE CASCADE`.

Adds a probe-guarded, strict `DELETE FROM resource_fts` to `purge`,
`purge_all` and `purge_tenant_data`. Strict because a swallowed failure
on a purge path means the API attests to an erasure it did not perform;
probe-guarded because FTS5 is an optional compile-time feature and a
database built without it legitimately has no table. Ungated by
`is_search_offloaded()`: that flag is a write-path optimisation, while
this is an erasure guarantee, and a deployment that indexed locally
before moving search to Elasticsearch still has rows to remove.

`purge` and `purge_all` were four independent autocommit statements, so
a failure partway through produced the very orphan state this prevents,
indistinguishable on retry from a purge that never ran. Both are now
transactional. All three use `TransactionBehavior::Immediate`: they read
before they write, and under WAL a deferred transaction fails that
upgrade with `SQLITE_BUSY_SNAPSHOT`, for which the busy handler — and so
the configured `busy_timeout` — is never consulted.

Also fixes a latent data-loss bug on BOTH backends that blocked the
fourth purge path. `run_reindex` deletes every resource's search entries
via `delete_search_entries`, which drops the `resource_fts` row, while
`write_search_entries` never rebuilt it — so `$reindex` silently
disabled `_text`/`_content` until each resource was next written, with
or without `clear_existing`. `write_search_entries` now repopulates FTS
on SQLite and PostgreSQL, which is what makes adding the FTS delete to
SQLite's `clear_search_index` safe rather than destructive.

Migration v14 -> v15 sweeps orphans already on disk: the code fix alone
stops new ones but leaves every database that has ever served a purge
still holding the content. Soft-deleted resources keep their `resources`
row and are correctly preserved.

`/ui/tenants` discarded `purge_tenant_data`'s error after already
deregistering the tenant, rendering success while the data remained;
it now surfaces the failure.

Tests: a backend-agnostic suite shared by SQLite and PostgreSQL. It
asserts on raw `resource_fts` rows rather than search results, because
purge clears `search_index` and the orphaned row is therefore
unreachable through search — the obvious "search finds nothing after
purge" test passes on the unfixed code. Each scenario opens with a
positive control so it cannot pass vacuously. Verified against unfixed
source: 10 of 13 fail, including the id-reuse oracle. Two existing
assertions that accepted zero results ("FTS unavailable") are tightened,
since they would stay green through a total full-text outage.
The PostgreSQL query builder had no arm for `_text`/`_content`: both are
`SearchParamType::Special`, which fell through to the `None` case, so the
parameter contributed no condition at all. A search whose only parameter
was `_text` therefore built an empty filter and `search`/`search_count`
answered a full-text query with **every** non-deleted resource of that
type. Not a false negative — an unfiltered result set presented as a
match. SQLite, Elasticsearch and MongoDB have all handled both parameters
for as long as they have existed; PostgreSQL alone silently ignored them,
while `resource_fts` was populated on every write and its tsvector
columns and GIN indexes sat unused by the ordinary search path.
`TextSearchProvider::search_text` does implement it correctly, but
nothing calls it: the REST layer goes through `SearchProvider::search`.

Both parameters now resolve through the same `resource_fts` columns the
write path fills, with `plainto_tsquery('english', …)` to match
`search_text`/`search_content`, and the term bound rather than spliced so
tsquery operators in user input stay data. The sub-select is scoped by
`tenant_id = $1` **and** `resource_type = $2`: it yields a bare
`resource_id` set that the outer query intersects with this tenant's
resources, so without the tenant predicate tenant B's Patient/123 would
select tenant A's Patient/123 — a cross-tenant match oracle — and without
the type predicate an Observation's narrative would select a Patient of
the same id. Both binds are already tenant and resource type in every
caller of `build_search_query`, the invariant `build_missing_condition`
and `build_compartment_condition` already rely on.

This is what the two failing PostgreSQL cases in the #386 suite were
reporting. The suite asserts through the public search API precisely so
that a backend which cannot filter cannot pass, and PostgreSQL could not:
the id-reuse oracle re-created a resource under the purged id and `_text`
returned it, not because the purged narrative survived (PostgreSQL's
purge does delete the row) but because the search matched everything.
Its positive controls passed for the same reason they were meant to
catch — one resource in the tenant is indistinguishable from a filter
that matched it.

Both backends' FTS conditions also numbered placeholders off the loop
index while skipping unmatchable terms, so a skipped value left a gap:
on SQLite `_text=*,fracture` emitted `?4` while binding one parameter,
which SQLite rejects when preparing the statement. Numbering now follows
the accepted terms.

And a term that survives neither escaping (SQLite) nor is present at all
(blank) no longer drops the parameter, which would return the whole
resource type — the same fail-open this commit exists to close. It now
matches nothing, which is what a stopword-only term already does through
`plainto_tsquery`.
Conflict in `SqliteBackend::purge_tenant_data`: main (#313) added the
per-user settings sweep inside the transaction at the same seam where
this branch added the `resource_fts` delete (#386). Both belong there —
they clear different stores that the `resources` cascade cannot reach —
so both are kept, FTS first, still inside the one IMMEDIATE transaction
whose write lock a second connection would deadlock against.

No migration renumber needed: main is still at SQLite schema v14, so this
branch's v14 -> v15 is unchanged.
…erms

Two CI failures on this branch, with two unrelated causes.

1. `_content` never saw the narrative on PostgreSQL. `collect_strings` skips
   the `div` key to keep raw XHTML markup out of the index, which also dropped
   the narrative text from `full_content` entirely -- so a term appearing only
   in `text.div` was findable through `_text` and invisible to `_content`.
   FHIR defines `_content` as a search over the entire content of the
   resource, which makes it a superset of `_text`; SQLite has always had the
   narrative in `_content`. Append the already-stripped narrative to
   `full_content`, so the markup still stays out (as does base64 `data`), and
   pin the superset relationship with unit tests. This is what
   `reindex_preserves_full_text_search` was failing on, not the reindex fix.

2. The residency probe is deliberately not tenant-scoped, so it counted rows
   planted by *other* scenarios: PostgreSQL runs every scenario concurrently
   against one shared container database, and several end with a live indexed
   row on purpose. A distinct tenant per scenario was not enough because the
   planted token was a shared constant. Derive the token from the tenant id
   instead -- the probe stays global, and still catches a surviving row under
   any tenant, type or id, but only counts rows this scenario planted.
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.72727% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/persistence/src/backends/sqlite/storage.rs 84.61% 4 Missing ⚠️
crates/persistence/src/backends/sqlite/schema.rs 96.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@mauripunzueta
mauripunzueta marked this pull request as ready for review July 31, 2026 14:27
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.

SQLite: purge paths never delete from resource_fts, so purged content stays searchable

1 participant