fix(search-index): oversized resources, reindex diagnostics, Mongo keyset receipts, rebuild banner - #1109
Merged
Conversation
…stay searchable (#1050) Elasticsearch rejects a whole document once it holds more nested objects than `index.mapping.nested_objects.limit`, summed across every nested search-parameter field. Its default of 10000 is below real data: 458 of the 11,704 Synthea Provenance resources carry more than 10000 `target` references (the largest, 28,192), so they were stored in the primary but never searchable, and `$reindex` failed on them the same way. Add `ElasticsearchConfig::nested_objects_limit` (default 50000) and `HFS_ELASTICSEARCH_NESTED_OBJECTS_LIMIT`. The limit is written into the index template for new indices, and `Backend::initialize` raises it on existing `{prefix}_*` indices that are below it. The setting is dynamic, so resources that already indexed need no reindex. The startup pass only ever raises, sends index names in chunks, and is non-fatal, so a missing `manage` privilege cannot stop the server. Tests: the mapping carries the setting; the startup parser picks exactly the indices below the target; config and serde defaults; and, against a real Elasticsearch, a 12,000-target Provenance indexes through the `$reindex` page writer, while an index created at 10000 rejects it until a backend started with the raised limit raises that existing index.
3 tasks
…jections (#1050) A reindex that finished with per-resource errors exposed only `errorCount` in `$reindex-status`, so the 458 Provenance records Elasticsearch rejected in #1050 could only be found by reading server logs. And the automatic deferred reindex (#1087) retried any such completion once, which for a document the index rejects outright just repeats the same failure over the same resources. - Each recorded error now carries `retryable`: true for the conditions REST answers with 503/504 (Unavailable, ConnectionFailed, PoolExhausted, Timeout), false otherwise. Unclassified errors deserialize as retryable, keeping the old behaviour. - The Elasticsearch `_bulk` path classifies failures: a request that never arrived, a 429 or a 5xx is transient and surfaces from `write_search_entries_page` as Unavailable; a 4xx item rejection (the nested-object limit) stays Internal. `create_many` keeps its existing error mapping. - `$reindex-status` lists the first 100 failing resources as `error` parameters (resourceType, resourceId, message, retryable), plus `errorsOmitted` and, for a failed job, `errorMessage`. `errorCount` is unchanged. - An automatic generation that completes with only permanent errors is not retried; it logs the count, the first failing Type/ids and the first error. Any transient error, a failed job or a panic still gets the single retry.
) The composite secondary sync read `bulk_entry_results` with `skip` and a `{line_number, file_url}` sort. Neither receipt index ends in that order, so every page examined every receipt key in the manifest and sorted in memory: on the #941 corpus (18,955,865 receipts) one page took 39 s, putting the sync of a manifest #1024 ingests in 55 minutes at roughly 95 hours. - `get_entry_results_page` now continues strictly after the last stored `(file_url, line_number)` with an `$or` range, sorted in that order, and returns `EntryResultContinuation::Keyset` with every receipt's stored identity, like SQLite and PostgreSQL (#986). An `Offset` continuation is rejected. - A receipt missing `file_url` or `line_number`, or with a negative line, is an error instead of a default, since a cursor built from an invented identity would skip or repeat receipts. - Schema v10 replaces `idx_bulk_entry_results_outcome` with `idx_bulk_entry_results_outcome_line`, which adds `(file_url, line_number)` so the sync's `outcome = success` pages walk it too. Its prefix still serves outcome counts. - The shared receipt paging contract now runs on MongoDB (`exact_sql_pages` becomes `exact_keyset_pages`), and a profiler guard asserts filtered pages have no blocking sort and no `skip`. Measured with explain on the #941 corpus: a page past receipt 9,000,000 examines 1,001 keys in 9 ms (was 18,955,865 keys, 39,096 ms). On 2M synthetic receipts with the v10 index, the filtered sync page examines 1,001 keys in 3 ms (was 1,900,000 keys, 3,692 ms).
While a `$reindex` runs, stored resources stay readable by id but searches can miss them, and nothing on the page said so. #1082 stopped the rail showing placeholder zeros in that state and named the other half as follow-up: a `reindex_jobs_active`-style snapshot field, fed from the reindex job registry, driving a "search index rebuilding" banner. This is that follow-up. - `ReindexProgress` records the tenant it rebuilds. - `DashboardSnapshot::reindex_active` carries a `ReindexActivity` (running jobs, processed, total) for the tenant, `None` when nothing is rebuilding or no `$reindex` operation is wired. - The REST dashboard provider fills it from `ReindexOperation`'s in-memory job registry, never storage, so page loads stay constant-time (#1078). - Home shows a warning line in the live region with the percentage ("Search index rebuilding — 42% (8,000 of 19,000 resources)"), or without one while the rebuild is still counting, never "0%". A running rebuild keeps the region on the fast refresh, and the line is announced once, not on every percentage tick. - Resources shows the same line under its page head. - Strings in en, es and de.
…he receipt index swap (#1046) `test_v7_to_v8_manifest_aware_submit_file_identity` migrates a database reset to schema v7 and pinned the resulting version at 9. #1046 bumped the MongoDB schema to v10, so the migration now correctly reaches 10. The test now also reconstructs the v7 receipt index (`idx_bulk_entry_results_outcome`) before migrating and asserts the migration drops it and builds `idx_bulk_entry_results_outcome_line`, which no test covered before.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
mauripunzueta
marked this pull request as ready for review
September 14, 2026 17:21
…e deprecation allow) into fix/1050-search-index-integrity
smunini
approved these changes
Sep 15, 2026
This was referenced Sep 15, 2026
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.
Summary
One branch for #1050, #1046 and #1065, because they share one gap: resources are stored in the primary but missing from the search index, and there is no reliable way to see which ones or why.
This is a draft. All four parts are implemented, one commit each. What remains is the full-corpus reconciliation re-run at the end of the test plan.
Closes #1050
Closes #1046
Closes #1065
Done
#1050: Elasticsearch nested-object limit
ElasticsearchConfig::nested_objects_limit(default50000), exposed asHFS_ELASTICSEARCH_NESTED_OBJECTS_LIMITand written into the index template.Backend::initializeraises the limit on existing{prefix}_*indices that are below it. It only ever raises, sends index names in chunks, and is non-fatal: a missing privilege logs a warning instead of stopping the server.PUT _settingson an open index was accepted, and a 28,192-nested document (the corpus maximum) then indexed. So existing deployments need no reindex of what already indexed.Provenancerecords exceed 10,000targetentries, matching the reindexerrorCount: 458in $reindex silently drops resources exceeding Elasticsearch's 10,000 nested-object limit — 458 Provenance unsearchable, errorCount is the only signal #1050. The maximum is 28,192; 176 more sit between 8,000 and 10,000.#1050: reindex diagnostics and retry classification
retryable:truefor the conditions REST answers with 503/504 (Unavailable,ConnectionFailed,PoolExhausted,Timeout),falseotherwise. An error recorded without the field deserializes as retryable, so older state keeps its old behaviour._bulkpath classifies failures. A request that never arrived, a429or a5xxis transient and reaches$reindexasUnavailable. A4xxrejection of the document (the nested-object limit) staysInternal.create_manykeeps its existing mapping.GET /$reindex-status/{job_id}lists the first 100 failing resources aserrorparameters (resourceType,resourceId,message,retryable), pluserrorsOmittedand, for a failed job,errorMessage.errorCountis unchanged, so existing clients are unaffected.Type/ids and the first error. Any transient error, a failed job or a panic still gets the single retry.#1046: MongoDB receipt paging by keyset
get_entry_results_pagecontinues strictly after the last stored(file_url, line_number)and sorts in that order, which both receipt indexes end in. It returnsEntryResultContinuation::Keysetwith every receipt's stored identity, as SQLite and PostgreSQL have since perf(bulk-submit): replace SQL receipt OFFSET pagination with keyset cursors #986. Theskipand the{line_number, file_url}sort, which no index could serve, are gone.file_urlorline_number, or with a negative line, is an error instead of a default, because a cursor built from an invented identity would skip or repeat receipts. Every MongoDB receipt has carried both fields since Bulk Submit ($bulk-submit) is not available on MongoDB or S3 primaries #521.idx_bulk_entry_results_outcomewithidx_bulk_entry_results_outcome_line(addsfile_url, line_number), so the composite sync'soutcome = successpages are an index walk too. The prefix still serves outcome counts. The migration builds this index once at startup.exact_sql_pagesis renamedexact_keyset_pages), and a profiler guard asserts that filtered receipt pages have no blocking sort and noskip.Measured on the #941 corpus (MongoDB 7.0,
helios.bulk_entry_results, 18,955,865 receipts in one manifest, schema v9 indexes)explain("executionStats"), page size 1000, same manifest scope, unfiltered (served byidx_bulk_entry_results_line):skip 15000, sort{line_number, file_url}FETCH <- SKIP <- SORT <- IXSCAN{file_url, line_number}LIMIT <- FETCH <- IXSCANLIMIT <- FETCH <- SORT_MERGE <- IXSCAN <- IXSCANThe old query examined every key in the manifest on every page and sorted in memory, independent of
skip. The keyset page examineslimit + 1keys at any depth.The composite sync (
CompositeSubmitJobs::sync_ingested) filtersoutcome = success, the shape the v10 index exists for. Its plan was measured on 2,000,000 synthetic receipts (20 files × 100,000 lines, 95% success) in a scratch database on the same MongoDB 7.0 server, dropped afterwards:outcome=success,skip 15000, sort{line_number, file_url}SKIP <- SORT <- FETCH <- IXSCAN(outcome)LIMIT <- FETCH <- IXSCAN(outcome_line)LIMIT <- FETCH <- SORT_MERGE <- IXSCAN <- IXSCAN(outcome_line)Building
idx_bulk_entry_results_outcome_linetook 3,979 ms on 2M receipts, so it adds roughly 40 s to the one-time v10 migration on the #941 corpus. Without it, the filtered keyset query falls back to the line index with a residual outcome filter: still no sort, but keys examined grow with how rare the filtered outcome is.#1065: "search index rebuilding" banner
PR #1082 (@angela-helios) already fixed the first half of #1065: the rail no longer shows placeholder zeros during a rebuild. It named the other half as follow-up, a
reindex_jobs_active-style snapshot field fed from the reindex job registry and driving a banner. This implements that design:ReindexProgressrecords the tenant it rebuilds.DashboardSnapshot::reindex_active: Option<ReindexActivity>(running jobs, processed, total) sits next toimport_jobs_active. It isNonewhen nothing is rebuilding or no$reindexoperation is wired.ReindexOperation's in-memory job registry, never storage, so page loads stay constant-time (ui: 1h "FHIR Resources over Time" chart stays on "Waiting for the live figures…" during a large import — dashboard blocks on storage scans instead of degrading to approximate data #1078).Context and coordination
search/reindex.rs, not the rebuild path, and leaves thebegin/end_bulk_index_rebuildexit handling untouched.Unavailableerrors and passes unchanged.search/reindex.rs, so a small conflict there is likely.create_manyerror mapping is deliberately unchanged, so fix(persistence): bulk-submitted resources reach Elasticsearch and the receipt reports the ones that do not (#1007) #1049's receipt semantics are unaffected.Test plan
cargo test -p helios-persistence --features elasticsearch --lib backends::elasticsearch::schema::tests(2 passed)cargo test -p helios-persistence --features elasticsearch --test elasticsearch_testsconfig unit tests (2 passed)es_integration_resource_over_elasticsearch_default_nested_limit_indexes,es_integration_startup_raises_nested_limit_on_existing_indexcargo check -p helios-restandcargo check -p helios-hfs --features elasticsearch,mongodb(clean, 0 warnings)cargo test -p helios-persistence --features elasticsearch --lib -- search::reindex backends::elasticsearch::storage::tests(31 passed, including bulk-submit: avoid redundant deferred reindex work across manifests #1087'sautomatic_reindex_retries_failure_error_result_and_panic_once, unchanged)automatic_reindex_does_not_retry_a_completion_with_only_permanent_errorsfail (2 runs instead of 1)es_integration_startup_raises_nested_limit_on_existing_index,es_integration_resource_over_elasticsearch_default_nested_limit_indexes,es_integration_reindex_page_*(4 passed)cargo clippy -p helios-persistence --features elasticsearch,mongodb --lib --tests: no findings in changed filescargo test -p helios-persistence --features mongodb --test mongodb_tests -- bulk_submit::test_receipt_paging_contract bulk_submit::test_receipt_pages_are_index_walks_without_a_blocking_sort bulk_submit::test_entry_results_from_two_files_do_not_collide(3 passed against a MongoDB testcontainer; each prints itsVerified …line, so none skipped){line_number, file_url}sort fails the contract on ordering and the plan guard onhasSortStage: truebackends::sqlite::bulk_submit::tests(45 passed);postgres_testscompiles with the renamed contract entry pointcargo clippy -p helios-persistence --features mongodb,elasticsearch,postgres --lib --tests: no new findings (the two in touched files,sqlite/bulk_submit.rs:2584andmongodb_tests.rs:3145, are pre-existing onmain)helios-ui: lib 258 passed (including en/es/de key-set parity);rebuild_banner_http(rebuilding, still counting, and idle, on Home and Resources, plus the fast refresh while rebuilding);dashboard_pending_http26,rail_counts_http1,dashboard_view_all_types_http4helios-restdashboard::tests38 passed (includingreindex_activity_sums_only_the_tenants_running_jobs);helios-observabilitydashboard52 passed; persistencesearch::reindex30 passedcargo check -p helios-hfs --features elasticsearch,mongodb;cargo clippy -p helios-observability -p helios-rest -p helios-ui --lib --tests: no new findings (3 pre-existing onmain)2cfde9b94(Test Rust, Linting, Code Coverage and codecov/patch, UI browser suite, search + Resources R4/R4B/R5/R6). The first run failed only becausetest_v7_to_v8_manifest_aware_submit_file_identitystill expected schema v9. It now expects v10 and also checks the v7 → v10 receipt index swap.mongodb_tests138,postgres_tests184,elasticsearch_tests110, all passedmongo-escorpus reconciliation: expect all 11,705Provenancesearchable