Repository navigation
perf(persistence/sqlite): covering search indexes and bounded first-page probe (schema v41 -> v46) - #2214
Open
eterceros wants to merge 14 commits into
Open
perf(persistence/sqlite): covering search indexes and bounded first-page probe (schema v41 -> v46)#2214eterceros wants to merge 14 commits into
eterceros wants to merge 14 commits into
Conversation
…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>
Code reviewNo 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 Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
This branch has not been deployed
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.
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.
main)mainmain: -95.1 % / -97.4 %main: EMPEORA +5.5 %Fixes #2212
Part of #2180
What changed
14 commits, 7 files, all under
crates/persistence(SQLite backend and README); SQLite schema 41 -> 46.idx_search_quantity,idx_search_tokenand the newidx_search_composite_covcarryresource_key, so a search reads the index alone.idx_search_tokenis 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.resourcesby rowid.HFS_SQLITE_SEARCH_PROBE=0(false,off,no) turns it off; read once, default on; documented in the README.UNION ALLdate shapes, which used to materialise 146k-292k keys.EXISTS) for token+quantity composites.idx_resources_typeandidx_history_resource, only when their stored SQL is exactly the historical one.createandrestore_deletedrun in oneBEGIN IMMEDIATEtransaction (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-rowINSERTs (up to 512 rows per statement) instead of groups of eight plus single rows.idx_search_tokenin 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
mainover 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 currentmain.DROP INDEXand itsCREATE INDEXleaves the database at the old version and the next start redoes it, and the startup check recreates a missing index.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/main678fc50 (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, 0database is locked, foreign CPU <= 0.2 cores except one run (0.64).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.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/main064d977, 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.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
main3281155 (git diffempty; the only change since 064d977 is #2210, which touches Postgres files and the README);mainchanged 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.
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):
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
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.system|searches, still maintainsidx_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)..ok(), so aSQLITE_BUSYor interrupt there falls back to the full page statement instead of failing; results stay correct.INSERTprepares one statement per row count; with 64 cache slots, many distinct counts could evict hot statements. Not observed in the CRUD runs.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/main3281155; 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 unusedmut):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 --checkonschema.rs,search_impl.rsandstorage.rs: clean after the last commit (a formatting fix, its own commit).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 warningswith the allow-list of the CILintingjob: clean after the last commit. The first push failed that job on two errors (choose_first_page_planunused outside tests, an unusedmutin astorage.rstest); the last commit fixes both. Not run with--all-featuresover the whole workspace as CI does.helios-persistencesuite on the rebased branch, and the PostgreSQL, MongoDB and Elasticsearch suites: not run (this change touches only the SQLite backend).🤖 Generated with Claude Code