Skip to content

perf(persistence/sqlite): covering search indexes and bounded first-page probe (schema v41 -> v46) - #2214

Open
eterceros wants to merge 14 commits into
mainfrom
feat/2212-perf-sqlite-covering-search
Open

eterceros wants to merge 14 commits into
mainfrom
feat/2212-perf-sqlite-covering-search

Conversation

@eterceros

@eterceros eterceros commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

At a glance

SQLite search by token, quantity, composite and date parameters reaches about 30 times the throughput at 30 virtual users (about 11 times lower p50 at 1 user), with identical results and no large change in write cost.

What Result main this PR Evidence
Search, 30 VU, requests/s IMPROVES x31 (x33 on the current main) 13.9 436.1 4 pairs, every B beat every A; 1 pair on current main
Search, 30 VU, p50 / p95 IMPROVES -96.6 % / -96.8 % 1,596 / 5,463 ms 54 / 174 ms 4 pairs; current main: -95.1 % / -97.4 %
Search, HFS CPU per request IMPROVES -96.8 % 479 ms 15.1 ms 4 pairs
Search, 1 VU, p50 / p95 IMPROVES -91.7 % / -98.8 % 11.4 / 752 ms 1.0 / 8.8 ms 4 pairs
Peak resident memory (search) IMPROVES -56.6 % 4,655 MB 2,021 MB 4 pairs
CRUD, 300 VU, HFS CPU per request IMPROVES -2.4 % (small, mean of 7 pairs) better in 6 of 7 pairs; the exception is one pair on the current main: EMPEORA +5.5 %
CRUD, 300 VU, requests/s neutral 566 605 +7.5 %, pairs -12.4 to +29.1
CRUD, bytes written per request neutral 403,776 390,818 -3.1 % +- 4.3
Search results unchanged 22 URLs x 3 pages, 0 differences, twice

Fixes #2212
Part of #2180

What changed

14 commits, 7 files, all under crates/persistence (SQLite backend and README); SQLite schema 41 -> 46.

  • Covering search indexes (v42). idx_search_quantity, idx_search_token and the new idx_search_composite_cov carry resource_key, so a search reads the index alone. idx_search_token is keyed code-first (value_token_code, resource_key, value_token_system, ...), so a token search without a system can seek it. Stale shapes are rebuilt at startup.
  • Bounded first-page probe for one-parameter searches: it reads at most 2,001 index rows and, when the key list is short, drives the page from it and reads resources by rowid. HFS_SQLITE_SEARCH_PROBE=0 (false, off, no) turns it off; read once, default on; documented in the README.
  • Early-exit recency window for broad one-parameter first pages (newest 512 resources, retried on the newest 4,096 when the page cannot be filled), now including UNION ALL date shapes, which used to materialise 146k-292k keys.
  • Code-first composite pairing (EXISTS) for token+quantity composites.
  • Drop the two prefix indexes (v45): idx_resources_type and idx_history_resource, only when their stored SQL is exactly the historical one.
  • Write path: create and restore_deleted run in one BEGIN IMMEDIATE transaction (it used to auto-commit ~17 statements; a failure could leave a resource without its index) and a resource's main search rows go in with multi-row INSERTs (up to 512 rows per statement) instead of groups of eight plus single rows.
  • v43 and v44 are deliberate no-ops that keep the version numbers: an earlier build of this work created two more indexes there, which cost about a third more WAL per create and were replaced by reshaping idx_search_token in v46. v46 drops them if present. No migration step v2-v41 is edited; the fresh-database base schema (create_indexes) no longer creates the two prefix indexes.

Why it is safe

  • Results: 0 differences against main over 22 URLs x 3 pages (11 distinct totals, 16 distinct first ids, so the check is not vacuous), on the original base and again on the current main.
  • Migrations are DDL that can be repeated: the version is stamped after each step, a crash between a DROP INDEX and its CREATE INDEX leaves the database at the old version and the next start redoes it, and the startup check recreates a missing index.
  • Prefix indexes are dropped only when their stored definition matches exactly; an index of the same name with another shape is left alone (tests cover both).
  • No file other than the SQLite backend and the persistence README changed. The backend code is the code that was measured: after series 1 only comment, README and rustfmt commits were added; series 2 measured the tip with the comment and README commit, and the final formatting-only commit came after it.

Evidence

Local shared 8-core box, 30 % corpus slice, ratios only, fresh copy of the same database template per run, runs one at a time under a FIFO lock. A = origin/main, B = this change.

Series 1: 4 ABAB pairs per suite, A = origin/main 678fc50 (v41), B = the same 11 SQLite commits before the rebase (v46). Search 30 VU x 120 s and 1 VU x 60 s, CRUD 300 VU x 90 s. 0 HTTP errors, 0 database is locked, foreign CPU <= 0.2 cores except one run (0.64).

Search metric A B Change Pairs
30 VU requests/s 13.9 436.1 IMPROVES +3,046 % (x31) 4/4, every B beat every A (A 13.0-14.7, B 423-444)
30 VU p50 1,596 ms 54 ms IMPROVES -96.6 % 4/4
30 VU p95 5,463 ms 174 ms IMPROVES -96.8 % 4/4
30 VU HFS CPU per request 479 ms 15.1 ms IMPROVES -96.8 % 4/4
30 VU peak resident memory 4,655 MB 2,021 MB IMPROVES -56.6 % 4/4
1 VU requests/s 7.0 494.4 IMPROVES x70 4/4
1 VU p50 / p95 11.4 / 752 ms 1.0 / 8.8 ms IMPROVES -91.7 % / -98.8 % 4/4

All 24 request shapes improve (p50 -94 % to -98 % at 30 VU), including Observation?component-code-value-quantity (p50 2,656 -> 155 ms, -94 %). A is slow because a token search without a system reads the parameter's whole index range and each candidate costs a table read; under 30 VU this saturates 6.5 cores (4.7 GB resident memory for 14 requests/s) and slows every shape.

CRUD metric (300 VU) A B Change Reading
Requests/s 566 605 +7.5 % +- 17 (pairs -12.4, +6.8, +6.4, +29.1) neutral: inside the A-to-A spread (502-620)
p50 158.8 ms 146.6 ms IMPROVES -7.8 % +- 4.5 4/4 pairs
p95 1,227 ms 1,265 ms +3.5 % +- 20.5 noise
HFS CPU per request 1.12 ms 1.08 ms IMPROVES -3.7 % +- 2.9 4/4 pairs, small
Bytes written per request 403,776 390,818 -3.1 % +- 4.3 not conclusive
Write syscalls per request 175.0 162.7 IMPROVES -7.0 % +- 2.3

Per-shape p95 in CRUD scatters widely (for example PUT Patient +110 %, POST Organization -26 %) with ranges across pairs of 100 to 400 %; it is not used for any claim.

Series 2: one A/B pair per suite on the current main, A = origin/main 064d977, B = this branch at 4ced50edc (after the rebase), same template and protocol, shorter windows (search 30 VU x 60 s and 1 VU x 30 s, CRUD 300 VU x 60 s). Both release binaries were built back to back in one target directory (462 s and 482 s). 0 HTTP errors, foreign CPU <= 0.13 cores.

Metric A B Change
Search 30 VU requests/s 13.7 453.0 IMPROVES +3,207 % (x33)
Search 30 VU p50 / p95 1,048 / 6,611 ms 51.6 / 168.7 ms IMPROVES -95.1 % / -97.4 %
Search 30 VU HFS CPU per request 491.6 ms 14.8 ms IMPROVES -97.0 %
Search 1 VU requests/s 8.3 533.5 IMPROVES x64
Peak resident memory 4,815 MB 1,791 MB IMPROVES -62.8 %
CRUD requests/s 509.7 646.6 +26.9 %, noise (A fell in the low part of its spread)
CRUD p50 132.6 ms 143.9 ms EMPEORA +8.5 %, outside the series 1 pair range (-14.3 to -4.8 %, all improvements)
CRUD HFS CPU per request 1.01 ms 1.06 ms EMPEORA +5.5 %, outside the series 1 pair range (-0.8 to -7.2 %, all improvements)
CRUD p95 1,341 ms 1,359 ms +1.4 %, neutral
CRUD bytes written per request 379,040 380,861 +0.5 %, neutral

One pair cannot prove that CRUD is neutral; it shows that there is no large regression on the current main. The two CRUD numbers that moved against this change in series 2 (CPU +5.5 %, p50 +8.5 %) are small and are the only results with the opposite sign to series 1, so a real small write-side cost cannot be excluded with this data.

Measurement base: the SQLite backend directory is identical between 678fc50, 064d977 and the current main 3281155 (git diff empty; the only change since 064d977 is #2210, which touches Postgres files and the README); main changed outside it (SQL-on-FHIR handlers, memory trimming, Postgres and Elasticsearch work), and series 2 re-checked the search and CRUD path on that base.

Series 3: a repeat of the CRUD pair on a verified quiet machine, order reversed (B first), same binaries as series 2, CRUD 300 VU x 60 s. Gate: no compiler running and load1 <= 2.5 on three samples 30 s apart (0.60 at the last), idle baseline 0.04 busy cores and 18 GB free, load1 0.51 / 2.78 at the start of the two runs, foreign CPU 0.09-0.11 cores (about 1 % of the machine). The first pair (B1/A1) is the official one; B2/A2 is an extra pair produced when the harness discarded each run once by mistake.

CRUD metric A (v41) B (v46) Change B vs A
HFS CPU per request 1.10 ms 1.05 ms IMPROVES -4.7 %
p50 / p95 162.5 / 1,317 ms 150.0 / 952 ms IMPROVES -7.7 % / -27.7 %
Bytes written per request 423,364 397,725 IMPROVES -6.1 %
Write syscalls per request 179.3 164.4 IMPROVES -8.3 %
Requests/s 632 892 +41 % (noisy)

The extra pair: CPU per request -2.5 %, bytes -4.5 %, write syscalls -7.1 %, p50 EMPEORA +4.9 %, requests/s -9 %.

All seven CRUD pairs (B vs A, negative = B better; simple mean of the pair values):

Series HFS CPU per request p50
1 (4 pairs) -2.1, -0.8, -4.8, -7.2 % -14.3, -7.0, -4.9, -4.8 %
2 (1 pair, A first) +5.5 % +8.5 %
3 (2 pairs, B first) -4.7, -2.5 % -7.7, +4.9 %
Mean of 7 -2.4 % -3.6 %

CPU per request is better in 6 of 7 pairs, p50 in 5 of 7, and bytes and write syscalls per request are lower with B in every pair (-3 % to -8 %). The data does not say whether the two pairs that went the other way are noise or a small real cost, and seven pairs cannot rule one out; they do not show a regression of any size.

Known limitations

  • Startup cost after upgrade. v42 rebuilds three indexes under the write lock before the server listens: the benchmark database needed 16-40 s to become ready on B against 1.1 s on A. The code comments estimate about 16 s per 6 GB for the covering indexes plus (only for databases that already ran an earlier build of this work) about 10 s per GB of token index, and temporary space about the size of the index being built. Not measured on a large production database.
  • system| regression. A token search with a system and no code lost its seek (the index is now code-first) and reads the parameter's range.
  • Rolling back to an older binary keeps results correct but is slower for system| searches, still maintains idx_search_composite_cov, and a bulk rebuild by the old binary recreates the old index shapes, which the new binary rebuilds on the next upgrade. An older binary opens a newer database without complaint (no version check exists today).
  • Two empty migrations (v43, v44) stay in the ladder; collapsing everything into one v42 would be cleaner but changes code that was measured, so it is left for a reviewer's call.
  • Window attempts ignore errors. The recency-window statements run with .ok(), so a SQLITE_BUSY or interrupt there falls back to the full page statement instead of failing; results stay correct.
  • Statement cache. The multi-row INSERT prepares one statement per row count; with 64 cache slots, many distinct counts could evict hot statements. Not observed in the CRUD runs.
  • Open PR perf(bulk-submit): parallel ingest and a parallel, scoped rebuild on every backend (1.9x-5.4x at 900k) #1941 also edits the SQLite schema; the second to land renumbers.
  • 7 CRUD pairs and 4 search pairs plus 1 on the current main, one 30 % corpus slice, shared machine (series 3 on a verified quiet one), one empty-database CRUD scenario.

Test plan

What was run on this branch (14 commits on origin/main 3281155; the first 13 commits were carried over unchanged from a branch on 064d977, where series 2 and 3 were measured; the 14th only fixes two lint errors, in test-only code and an unused mut):

  • cargo test -p helios-persistence --features sqlite --lib sqlite: 572 passed, 0 failed.
  • cargo test -p helios-persistence --features sqlite --test sqlite_tests: 118 passed, 0 failed.
  • rustfmt --edition 2024 --check on schema.rs, search_impl.rs and storage.rs: clean after the last commit (a formatting fix, its own commit).
  • Equivalence, A against B: 22 URLs x 3 pages, 0 differences (series 1 and series 2).
  • Earlier, before the rebase (base 678fc50, tip 109da4fed): cargo test -p helios-persistence --features sqlite, all suites: 2,416 passed, 0 failed, 31 ignored. Not repeated on the rebased branch.
  • cargo clippy -p helios-persistence --all-targets --features sqlite -- -D warnings with the allow-list of the CI Linting job: clean after the last commit. The first push failed that job on two errors (choose_first_page_plan unused outside tests, an unused mut in a storage.rs test); the last commit fixes both. Not run with --all-features over the whole workspace as CI does.
  • The full helios-persistence suite on the rebased branch, and the PostgreSQL, MongoDB and Elasticsearch suites: not run (this change touches only the SQLite backend).

🤖 Generated with Claude Code

eterceros and others added 13 commits October 21, 2026 18:15
…ing (schema v42)

Append resource_key to idx_search_quantity and idx_search_token and add
idx_search_composite_cov so the rowid IN (SELECT resource_key ...) membership
subqueries are answered from the index alone, without a table lookup per hit.
Migration v41->v42 rebuilds them and logs progress; the canonical
SEARCH_VALUE_INDEXES list (fresh DBs, bulk rebuild, self-heal) carries the new
definitions. idx_search_composite is kept for delete-by-resource.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
ensure_search_value_indexes only checked index names, so a rollback to a v41
binary could leave idx_search_quantity/token/composite_cov in the old
non-covering shape at v42 forever. Compare the stored definition with the
canonical one (normalised) and drop+recreate on mismatch, with a warning.
Document the v42 CREATE INDEX temp-space and write-lock cost.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…t-page searches

A first page (no cursor, offset or sort) filtered by one plain parameter
membership runs a probe, count(*) over the parameter's index rows capped at
2001. Under the cap the page is driven from the key list with resources read
by rowid (an empty or rare filter no longer walks the whole type). At or over
the cap an early-exit attempt tests the newest 512 resources with a correlated
EXISTS on idx_search_composite and is kept only when it fills the page;
otherwise the previous statement runs. Results and ORDER BY last_updated DESC,
id are unchanged; composites, :not, several parameters, cursors, offsets and
sorts keep the previous path. HFS_SQLITE_SEARCH_PROBE=0|false|off|no disables
it (read once).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ng for token+quantity composites

Migration v43 adds idx_search_composite_code (tenant, type, param, token code,
resource_key, composite_group, token system) over composite rows that carry a
code. A two-component token(code)+quantity composite is now built as: token
rows found by code on that index, each paired by an EXISTS probe of
idx_search_composite_cov on (resource_key, composite_group), instead of a
GROUP BY ... HAVING over every row of the parameter (about 340k rows visited
for 256 groups on the 30% corpus). Both INDEXED BY hints are required: without
them the planner picks idx_search_quantity for the inner loop.

The same row set is selected; the EXISTS-driven recency window learns the new
shape. The pairing is used only when both indexes exist (checked per search,
with a retry on "no such index" for a rebuild racing the check), so a bulk
rebuild that drops the value indexes degrades to the previous GROUP BY form.
`system|` tokens (no code to seek) and other composite shapes keep the GROUP BY.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ithout a system

Migration v44 adds idx_search_token_code (tenant, type, param, code, system,
resource_key) WHERE code IS NOT NULL. idx_search_token starts with the system,
so 'code=8302-2', 'category=laboratory' and 'status=finished' (no system) could
not seek it and read every row of the parameter (203,346 for Observation?code).

idx_search_token is NOT reordered: searches that have a system and no code
(token 'system|' in token.rs, an identifier's 'system|' in reference.rs) need
the system as the seekable prefix, so a reorder would turn them into a
parameter-wide scan. The code-first twin is a second index instead (one more
entry per token row on every write; see the write-cost numbers in the PR
notes). system|code and |code searches seek either index.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…action

create auto-committed each of its ~17 statements: the resource row, the
history row and every search_index / FTS write were separate WAL commits (and
separate fsyncs under synchronous=FULL), and a failure part-way left a resource
with no, or half, of its search index. It is now BEGIN IMMEDIATE ... COMMIT,
as update and delete already are at this base (update's reindex is inside its
transaction, so the "re-index after COMMIT" in the EXPLAIN audit does not apply
to this tree); restore_deleted gets the same treatment.

The existence check runs under the write lock (no check/insert race), and
last_updated is stamped under it too, so a create queued behind another writer
cannot commit with an earlier timestamp than one that committed before it.
Search consistency: readers now see a create fully indexed or not at all,
instead of the row before its index.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ith a wider retry

The bounded early-exit attempt (newest 512 resources in page order, each tested
with a correlated EXISTS) skipped any filter that was not one plain parameter
select, so broad date searches (ne/le/ge: a UNION ALL of ranges) materialised
146k-292k keys for a 21-row page. A UNION ALL of parameter selects now becomes
EXISTS (...) OR EXISTS (...), the same membership test per range.

A window that cannot fill the page (few of the newest resources match; a probe
case returned 13 of 21 rows on 512) is retried on the newest 4,096 before the
planner's page statement runs. A short window is never used as the answer: it
is kept only with more than `count` rows, so the page is always the first
count+1 matches in page order.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…n v45)

idx_resources_type (tenant_id, resource_type) is a prefix of the resources
primary key (tenant_id, resource_type, id) and of idx_resources_reindex;
idx_history_resource (tenant_id, resource_type, id) is a prefix of
resource_history's primary key (tenant_id, resource_type, id, version_id).
Every lookup they served seeks the longer key on the same equality prefix, and
they cost a b-tree insertion per write (about -5% WAL per create, ~51 MB on the
30% corpus). PostgreSQL dropped the same pair earlier.

Dropped only when the stored definition is exactly the historical one; fresh
databases no longer create them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
write_prepared_index stepped an 8-row INSERT per full group of eight rows and
a single-row INSERT for each of the up to seven left over (8 statements for
15 rows). It now binds all rows of the resource (in chunks of at most 512, far
below the variable limit) to one INSERT whose text is built once per row count
and kept, so the prepared-statement cache holds one stable string per count.
Same rows in the same order, so identical rowids; the audit measured -20..25%
CPU per create in autocommit and about -5% inside a transaction.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
idx_search_token began its values with the system, so a token search with no
system (code=8302-2, category=laboratory, status=finished) could not seek it
and read every row of the parameter. Reshape it as (tenant_id, resource_type,
param_name, value_token_code, resource_key, value_token_system,
composite_group, is_contained) and use it for the code-first token+quantity
composite pairing, instead of adding idx_search_composite_code (v43) and
idx_search_token_code (v44), which cost about a quarter more WAL per create.
v43 and v44 become no-ops; v46 drops both when present and rebuilds the index
when it is still in the old shape. A bare system| token loses its seek and
reads the parameter's range instead.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The first-page probe counted the filter's index rows and, under 2,001 of
them, the page statement evaluated the same filter again. For the code-first
token+quantity pairing (component-code-value-quantity: about 9,000 token rows
paired by probe) that doubled the CPU of the search against the single
GROUP BY it replaced, and showed as a 2x p50 under 30 VU. The probe now
returns the distinct resource keys and a ListDriven page reads them by
rowid (rowid IN (?3, ...)), so the filter is evaluated once.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… index comments

Documents the bounded-probe switch in the persistence README, notes that
idx_history_resource only exists below schema v45, and words the v43 and v44
no-op migrations so they read without the history of the branch.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@claude

claude Bot commented Oct 21, 2026

Copy link
Copy Markdown

Code review

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

choose_first_page_plan is only called by tests, so it is cfg(test); an unused
`mut` in a storage test is dropped. Both were errors under -D warnings.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 21, 2026

Copy link
Copy Markdown

This branch has not been deployed

No deployments
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.

perf(sqlite): covering search indexes and bounded first-page probe (schema v41 -> v46)

1 participant