perf(state): merge FTS5 segments on VACUUM + add 'hermes sessions optimize' - #34074
Closed
kshitijk4poor wants to merge 1 commit into
Closed
perf(state): merge FTS5 segments on VACUUM + add 'hermes sessions optimize'#34074kshitijk4poor wants to merge 1 commit into
kshitijk4poor wants to merge 1 commit into
Conversation
…imize' The FTS5 indexes (messages_fts, messages_fts_trigram) grow as a series of incremental b-tree segments — one per trigger-driven insert batch. SQLite's automerge caps at ~16 segments, so a long-lived store keeps scanning many segments per MATCH and never collapses them unless the special 'optimize' command runs. Nothing in the codebase ever ran it: vacuum() only fired after a prune that deleted rows, and even then never merged FTS segments. Changes: - SessionDB.optimize_fts(): merges each FTS5 index to a single segment, probing for the (optional/lazy) trigram table first so it is safe to call unconditionally. Layout-only — search results and snippet() are unchanged. - vacuum() now calls optimize_fts() before VACUUM so freed index pages are returned to the OS in the same pass. - 'hermes sessions optimize' CLI subcommand for on-demand reclamation + segment compaction (previously there was no way to compact the store without a prune deleting rows), with before/after size reporting. Benchmark (8000 msgs, fragmented to 8 segments/index): - segments 8 -> 1 on both indexes - porter MATCH 5.5x faster (0.449 -> 0.081 ms/q) - trigram MATCH 3.0x faster (0.632 -> 0.207 ms/q) - 8000 matches before == 8000 after, identical row ids (no functional change) Orthogonal to the structural FTS-size PRs (#20239 external-content, #27770 optional trigram) — segment merge helps regardless of those. Tests: TestOptimizeFts covers index count, search+snippet preservation, missing-trigram path, and idempotency. Full test_hermes_state.py green (227).
Contributor
🔎 Lint report:
|
Contributor
|
Merged via PR #34596. Your commit was cherry-picked onto current main with your authorship preserved in git log (3869525). One small follow-up on top: vacuum() now returns the FTS index count so the CLI summary uses the real merged-index count instead of probing private members. Thanks for the clean, well-benchmarked PR. |
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
The FTS5 indexes backing session search (
messages_fts,messages_fts_trigram) grow as a series of incremental b-tree segments — one per trigger-driven insert batch. SQLite's background automerge caps at ~16 segments, so a long-lived store ends up scanning many segments perMATCHand never collapses them unless the special'optimize'command is issued.Nothing in the codebase ever ran it.
SessionDB.vacuum()only fired after a prune that deleted rows, and even then it never merged FTS segments — so the indexes stay fragmented for the life of the database.This PR wires FTS5 segment-merge into the existing reclamation path and adds an on-demand command. It's a layout-only optimization: search results and
snippet()output are unchanged.Changes
hermes_state.pySessionDB.optimize_fts()— merges each FTS5 index to a single segment via theINSERT INTO <fts>(<fts>) VALUES('optimize')command. Probes for the trigram table first (it's lazily created and can be disabled viaHERMES_DISABLE_FTS_TRIGRAM), so it's safe to call unconditionally. Returns the number of indexes optimized.vacuum()now callsoptimize_fts()beforeVACUUM, so the pages freed by the segment merge are returned to the OS in the same pass.hermes_cli/main.pyhermes sessions optimize— on-demand FTS merge + VACUUM with before/after size reporting. Previously there was no way to compact the session store without a prune that deleted rows.tests/test_hermes_state.pyTestOptimizeFts— index count, search + snippet preservation, missing-trigram path, idempotency.Why this is orthogonal to the existing FTS-size PRs
The big structural size cuts are already in flight: #20239 (external-content FTS, kills the duplicate
_contentshadow tables) and #27770 (make the trigram index optional). Segment merge is complementary to both — it helps regardless of inline vs external-content storage and regardless of whether the trigram index exists. This PR does not touch the schema or migration path, so it doesn't conflict with either.Benchmark
8000 messages across 200 sessions, committed in small batches to fragment the indexes (8 segments each):
messages_ftssegmentsmessages_fts_trigramsegmentsMATCHlatencyMATCHlatency'needle'matchesThe query-latency win is the consistent, provable benefit. On-disk size reclaim is variable: it's large on heavily fragmented indexes and negligible on an already-merged one (e.g. a real 1.8 GB store whose segments were already merged reclaimed only ~5 MB). The PR does not overclaim size savings — the durable value is sustained query speed plus the on-demand command.
Compatibility / safety
optimize_fts()skips any absent FTS table, so it's a no-op-safe call on databases with the trigram index disabled.VACUUMstill acquires an exclusive lock; the command is intended to run when the gateway/CLI is idle (same constraint already documented onvacuum()).Test plan
python -m pytest tests/test_hermes_state.py— 227 passed.hermes sessions optimizesmoke-tested on a fresh DB and on a 1.8 GB real-data copy (integrity checkok, message/FTS counts intact, search + snippets working).