fix(state): stop rebuilding the whole FTS index on every open when the trigram tokenizer is missing - #82867
fix(state): stop rebuilding the whole FTS index on every open when the trigram tokenizer is missing#82867briandevans wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes a startup performance/correctness bug in SessionDB where the FTS “triggers need repair” check could never converge on SQLite builds that lack the trigram tokenizer (SQLite < 3.34). The change makes the trigger-repair gate aware of which triggers are actually creatable on the current host, preventing repeated full rebuilds and preserving deferred rebuild resume markers.
Changes:
- Split the canonical
_FTS_TRIGGERSset into “base” vs “trigram-only” subsets and only require the trigram subset when trigram DDL actually succeeds. - Extend
_fts_trigger_countto optionally count only a provided subset of trigger names (with a safe empty-set fast path). - Add regression tests that trace executed SQL to ensure trigram-less hosts no longer rebuild FTS on every open while preserving real repair behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
hermes_state_schema.py |
Avoids a permanently-unsatisfiable trigger-count gate by checking base vs trigram triggers against actual trigram availability. |
tests/test_hermes_state.py |
Adds trace-based regressions proving the rebuild loop is eliminated on trigram-less SQLite while control cases still rebuild when appropriate. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…e trigram tokenizer is missing `_init_schema` decided whether the FTS triggers needed repair by comparing the live trigger count against `len(_FTS_TRIGGERS)`, the full six-name set. Three of those six are the `messages_fts_trigram_*` triggers, and they are declared only inside `FTS_TRIGRAM_SQL` / `LEGACY_FTS_TRIGRAM_SQL`, whose `CREATE VIRTUAL TABLE ... tokenize='trigram'` needs a tokenizer SQLite only gained in 3.34. On an older build `_ensure_fts_schema` soft-fails that DDL by design (via `_is_trigram_unavailable_error`) and returns False, so those three triggers can never be created. The count is therefore pinned at 3, `3 < 6` is permanently true, and the repair path ran on every single `SessionDB` open, forever, while holding the SQLite write lock. It never converged: every `hermes` command, gateway start, dashboard request and cron tick paid a full re-index of the message corpus. That is ordinary LTS territory — Ubuntu 20.04 ships 3.31, RHEL/CentOS 8 and Alibaba Cloud Linux ship 3.26, and Hermes has no minimum-SQLite gate precisely because it is supposed to degrade gracefully here. The v23 repair also ends by clearing `fts_rebuild_high_water` and `fts_rebuild_progress`, which is correct after a genuine full rebuild but means an interrupted `hermes sessions optimize-storage` silently lost its resume point on the next open, restarting the chunked backfill from zero every time. Fix: keep `_FTS_TRIGGERS` as the single source of truth and derive two subsets from it, then measure each half against the DDL that can actually create it. `_fts_trigger_count` takes an optional `names` sequence (defaulting to the full set, so no caller changes), and both branches gate on `base_triggers_missing or (trigram_enabled and trigram_triggers_missing)`. The counts are still taken before the DDL runs so they describe the pre-repair state, while `trigram_enabled` is only known afterwards — hence the combination at the `if` rather than at the assignment. Behaviour is unchanged wherever the tokenizer exists: a genuinely missing trigram trigger on a capable host still triggers the rebuild. Only the permanently unsatisfiable comparison changes.
87d9f88 to
60286b8
Compare
fix(state): stop rebuilding the whole FTS index on every open when the trigram tokenizer is missing
|
|
Merged via PR #93441 — your commit was cherry-picked onto current main with your authorship preserved in git history (merge commit 608a56e). The only adjustment was composing your split repair predicate with the new cross-process rebuild authority that landed in #93428 after you opened this: Nice diagnosis — the permanently-unsatisfiable six-trigger gate on pre-3.34 SQLite was subtle, and the resume-marker loss angle made the impact concrete. Thanks! |
What does this PR do?
SessionDB._init_schemadecided whether the FTS triggers needed repair with this test, in two identical places:_FTS_TRIGGERSis six names, and three of them are themessages_fts_trigram_*triggers. Those three are declared only insideFTS_TRIGRAM_SQLandLEGACY_FTS_TRIGRAM_SQL, whoseCREATE VIRTUAL TABLE ... tokenize='trigram'needs a tokenizer SQLite only gained in 3.34. On an older build_ensure_fts_schemasoft-fails that DDL through_is_trigram_unavailable_errorand returnsFalse— deliberately, so search degrades instead of breaking. The consequence is that those three triggers can never come into existence on such a host.So the count is pinned at
3,3 < 6is permanentlyTrue, and the repair path runs on everySessionDBopen, forever, holding the SQLite write lock. It never converges. Everyhermescommand, gateway start, dashboard request and cron tick re-indexes the whole message corpus, and the cost is linear in it.This is ordinary LTS territory rather than an exotic build — Ubuntu 20.04 ships SQLite 3.31, RHEL/CentOS 8 and Alibaba Cloud Linux ship 3.26, Amazon Linux 2 is older still. Hermes has no minimum-SQLite gate precisely because it is meant to degrade gracefully there, so those hosts run fine and pay the tax silently.
There is a second effect.
_rebuild_fts_indexesends with:which is correct after a genuine full rebuild, but running that rebuild unconditionally means an interrupted
hermes sessions optimize-storagesilently loses its resume point on the very next open. A chunked, throttled, progress-reported backfill is replaced by an unbounded foreground rebuild inside startup, every time.The fix
Keep
_FTS_TRIGGERSas the single source of truth and derive two subsets from it, then measure each half only against the DDL that can actually create it:_fts_trigger_countgains an optionalnamessequence defaulting to the full set, so no existing caller changes, and both branches now gate on:Ordering is preserved and load-bearing: both counts are still taken before the DDL runs, so they describe the pre-repair state, while
trigram_enabledis only known after_ensure_fts_schema. That is why the two halves are combined at theifrather than at the assignment.Behaviour is unchanged wherever the tokenizer exists — a genuinely missing trigram trigger on a capable host still triggers the rebuild, and a genuinely missing base trigger still triggers it everywhere. Only the permanently unsatisfiable comparison changes.
Sibling sweep
grep -rn "_fts_trigger_count\|triggers_need_repair"over production code returns four sites, and all four are in this PR:hermes_state_schema.pylegacy inline-FTS gate in_init_schemahermes_state_schema.pyv23 external-content gate in_init_schema_fts_trigger_countbody_FTS_TRIGGERSdefinition inhermes_state_common.pyDeliberately excluded, because they do not share the root cause:
_drop_fts_triggersiterates_FTS_TRIGGERSemittingDROP TRIGGER IF EXISTS. Dropping a trigger that was never created is a no-op; there is no count-vs-lencomparison to get wrong._FTS_CJK_TRIGGERS(hermes_state_search.py,hermes_state.py) has the same shape but a different mechanism: no count-vs-lencomparison exists, and_ensure_fts_cjk_schemagates on_fts_cjk_loadeddirectly, so it cannot get stuck permanently true.Related Issue
No filed issue — self-found defect, so there is nothing to auto-close here.
The population is independently documented by open #35931, which names the SQLite 3.26 hosts, and the tree has already accepted the general principle twice: merged #48688 ("survive SQLite builds without trigram tokenizer") and merged #77629 ("skip trigram sweep in
_fts_rebuild_finishwhen unavailable"). This is the same idea applied to startup — when trigram is unavailable, stop doing useless work.Type of Change
Changes Made
hermes_state_schema.py— derive_FTS_BASE_TRIGGERS/_FTS_TRIGRAM_TRIGGERSfrom_FTS_TRIGGERSat module scope, with a comment explaining why the halves differ in availability.hermes_state_schema.py—_fts_trigger_count(cursor, names=_FTS_TRIGGERS)takes the set to count. The default preserves every existing caller. An emptynamesshort-circuits to0rather than emittingname IN (), which is a SQLite syntax error.hermes_state_schema.py— both_init_schemaFTS branches (legacy inline and v23 external-content) computebase_triggers_missing/trigram_triggers_missingand gate the rebuild onbase_triggers_missing or (trigram_enabled and trigram_triggers_missing).tests/test_hermes_state.py— newTestFtsRebuildLoopWithoutTrigram, six tests, reusing the file's existing_NoTrigramConnection/_NoTrigramCursorrather than adding a fixture.How to Test
The tests observe the actual SQL SessionDB issues during an open, via
set_trace_callbackon the connection, so they cannot pass if the production change is reverted.Check out this branch and run the new class:
Revert only the production hunk (
git stash push hermes_state_schema.py) and re-run. Measured on this branch:test_missing_trigram_tokenizer_does_not_rebuild_fts_on_every_openINSERT INTO messages_fts(messages_fts) VALUES('rebuild')on every opentest_legacy_inline_fts_without_trigram_does_not_rebuild_on_every_openDELETE FROM messages_fts+ full reinsert on every opentest_pending_fts_rebuild_markers_survive_a_trigramless_open'30'/'10'→None/Nonetest_missing_base_trigger_still_repairs_oncetest_fts_trigger_subsets_match_the_ddltest_missing_trigram_trigger_still_repairs_where_the_tokenizer_existsFive of the six go red without the production change; the sixth is the control that must stay green in both directions.
Or reproduce by hand on a host whose SQLite predates 3.34 (
python3 -c "import sqlite3; print(sqlite3.sqlite_version)"): open a populatedstate.dbtwice withhermes sessions listand watch the second open re-index the corpus.The two control tests are the point of the design:
test_missing_base_trigger_still_repairs_onceproves a real degradation is still repaired on a trigram-less host, andtest_missing_trigram_trigger_still_repairs_where_the_tokenizer_existsproves a capable host is completely unaffected.test_fts_trigger_subsets_match_the_ddlpins the derived subsets against the DDL each trigger actually comes from, so renaming a trigger without moving it between DDL blocks fails loudly instead of quietly reintroducing an unsatisfiable check.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests pass — I ran the state/FTS surface rather than the whole tree, and it is green:tests/test_hermes_state.py(226),tests/state/+tests/hermes_state/(129), andtests/test_fts_cjk_bigram.py,tests/test_fts_update_of_narrowing.py,tests/test_search_slow_query_log.py,tests/test_schema_read_probe.py,tests/test_hermes_state_readonly_preflight.py,tests/test_hermes_state_wal_fallback.py,tests/test_state_db_malformed_repair.py,tests/test_state_db_stats.py,tests/test_zeroed_state_db.py,tests/test_hermes_state_compression_busy_retry.py,tests/test_hermes_state_compression_locks.py(98) — 453 passed, 0 failed. Leaving this unticked rather than claim a full-tree run I did not complete; CI covers the rest._NoTrigramConnection, which raises the exactno such tokenizer: trigramthat an older SQLite does — I have not run this on a real SQLite < 3.34 host.Documentation & Housekeeping
docs/, docstrings) — or N/A — docstrings and inline comments on the touched code; no user-facing docs affectedcli-config.yaml.exampleif I added/changed config keys — or N/A — no config keysCONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/ARelated / Positioning
#69085 (@eiritsu, "drop orphan FTS triggers on startup") edits the same two expressions for a different concern — it ORs a
had_orphansflag in so that non-canonical duplicate triggers force a rebuild. It does not address this bug: with orphans absent,had_orphansisFalseand the comparison stays3 < 6, so the permanent loop remains.The two compose mechanically rather than conflicting — the merged form is:
I have deliberately not absorbed
_drop_orphan_fts_triggershere, because it is a separate concern with its own scanning helper and its own tests, and folding it in would make this diff two ideas instead of one. Worth noting for whoever reviews them together: #69085's production hunks targethermes_state.py:3108-3181, but_init_schemano longer lives there since the21c7ae85630mixin split moved it tohermes_state_schema.py, so it will need a rebase before it can apply either way.#82568 (@thanosapollo) also touches
hermes_state_schema.py, but inside_reconcile_columns(hunks at@@ -394and@@ -415) with its tests in a new file. No overlap with this change.