perf(state): external-content FTS5 — stop storing message text three times - #57965
perf(state): external-content FTS5 — stop storing message text three times#57965Aoshi-Dev wants to merge 1 commit into
Conversation
…times Both messages_fts and messages_fts_trigram were inline-mode FTS5 tables, each keeping a full private copy of every message's indexed text in its *_content shadow table. On a real-world 752 MB state.db those two copies alone accounted for ~256 MB (the same bytes living in messages, base FTS, and trigram FTS). Switch both tables to external content backed by a new messages_fts_source view over messages: - The view is the single source of truth for the indexed expression (content || tool_name || tool_calls). FTS5 reads it for 'rebuild' and for snippet()/highlight(), so search results and snippets are byte-identical to inline mode. - Insert/delete/update triggers derive the text from the same expression over NEW/OLD values; the FTS5 'delete' command therefore always receives exactly what the insert indexed, structurally preventing the NousResearch#16751 value-mismatch class of corruption that motivated the v11 move to inline mode. - _rebuild_fts_indexes now uses the FTS5 'rebuild' command (external content tables reject plain DELETE FROM). - v18 migration drops the inline tables and recreates + rebuilds from the view; skips when tables are already external (fresh DBs, or ancient DBs the v11 block just rebuilt with the current DDL). Tests: hand-built v17 inline DB migrates with search/tool-token/snippet parity and no *_content shadow tables; delete/update paths keep the index consistent per FTS5 integrity-check.
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the measured FTS storage-reduction work. Current main still uses inline FTS tables (hermes_state.py:913-965), so the performance premise remains relevant.
Problems
- The migration cannot land as written: the PR uses schema v18, but current main is at
SCHEMA_VERSION = 21(hermes_state.py:143) and already usescurrent_version < 18for gateway metadata backfill (hermes_state.py:1649-1662). GitHub also reports this branch as conflicting. The FTS migration needs a new version and must preserve the current migration chain.
Suggested changes
- Rework the migration and fixture around a current-main pre-upgrade database, retaining the v18 gateway backfill and later migrations.
- Update
website/docs/developer-guide/session-storage.md:103-154and its zh-Hans counterpart for the changed FTS layout and migration history.
This is an automated hermes-sweeper review.
| if current_version < 18: | ||
| # v18: switch both FTS tables from inline to external-content | ||
| # mode backed by the messages_fts_source view. Inline mode | ||
| # stored a full private copy of every message's text in each |
There was a problem hiding this comment.
This v18 migration now collides with current main: SCHEMA_VERSION is 21 and v18 already performs the gateway metadata backfill. Please allocate a new migration version and retain the current v18/v20 blocks when salvaging.
|
Closing with credit — this exact idea (external-content FTS5 tables replacing the inline copies) shipped on main in PR #65798 (schema v23). Your PR was submitted first and correctly identified the 3x storage amplification; the merged implementation extends the same approach with a tool-row-free trigram index, a resumable/throttled rebuild engine, and an opt-in Thanks @Aoshi-Dev — you were the first to bring the external-content design to the repo, and the real-world 752 MB analysis in this PR body was solid evidence for the direction. |
What does this PR do?
Switches both session-store FTS5 tables (
messages_fts,messages_fts_trigram) from inline-content mode to external content backed by a newmessages_fts_sourceview overmessages.In inline mode, each FTS table keeps a full private copy of every message's indexed text in its
*_contentshadow table — so the same bytes live three times on disk: once inmessages, once per FTS table. On a real-world 752 MBstate.db(540 sessions / 49k messages), those two redundant copies alone accounted for 256 MB:messages(canonical)messages_fts_trigram_data(index)messages_fts_trigram_content(redundant copy)messages_fts_content(redundant copy)messages_fts_data(index)After migration +
hermes sessions optimize: 752.5 MB → 496.4 MB (−34%), with search results andsnippet()output byte-identical.Why external content is safe now (the #16751 history)
The v11 migration deliberately moved away from external content ("Fixes #16751"), but the underlying bug wasn't external content itself — it was the delete trigger passing
old.contentwhile the insert trigger indexedcontent || tool_name || tool_calls, so the FTS5'delete'command received text that never matched what was indexed.This PR eliminates that failure class structurally: the view is the single source of truth for the indexed expression.
'rebuild'andsnippet()/highlight()read the view, and the insert/delete/update triggers inline the same expression over NEW/OLD values (AFTER triggers can't consult the view because the row is already gone/changed). Insert, delete, and rebuild can no longer disagree about what text a rowid maps to.Related Issue
Related to #43690 (FTS5 trigram bloat on tool_calls JSON — this removes the duplicated content half of that cost; the trigram index expansion itself remains) and #53415 (state.db size contributing to resident memory).
Type of Change
Changes Made
hermes_state.pymessages_fts_sourceview;FTS_SQL/FTS_TRIGRAM_SQLnow create external-content tables (content='messages_fts_source',content_rowid='id')'delete'command built from OLD values with the same concat expression the insert trigger uses_rebuild_fts_indexes()uses the FTS5'rebuild'command (external-content tables reject plainDELETE FROM)SCHEMA_VERSION17 → 18 with a v18 migration: drops inline-mode FTS tables, recreates from the new DDL, and rebuilds from the view. Skips when tables are already external-content (fresh DBs, or ancient DBs the v11 block just rebuilt with current DDL). Mirrors the v11 block's FTS5-unavailable error handling.tests/test_hermes_state.pyTestFTS5ExternalContentMigration: hand-built v17 inline DB migrates with search/tool-token parity,*_contentshadow tables gone, version bumpedintegrity-checkpasses (regression guard for thesession_searchdoes not indextool_callsortool_name#16751 value-mismatch class)How to Test
pytest tests/test_hermes_state.py tests/test_state_db_malformed_repair.py -q— includes the new v17→v18 migration tests and the existing v10→v11 upgrade pathstate.db: open it once (migration runs automatically, ~35 s for 49k messages), thenhermes sessions optimizeto reclaim the freed pageshermes sessions stats(same session/message counts), anysearch_messages()query returns identical hits and snippetsChecklist
Code
tests/test_hermes_state.py,tests/test_state_db_malformed_repair.py,tests/hermes_state/, WAL fallback, compression locks): 384 passed. Fullpytest tests/ -qon this machine has pre-existing Windows-only failures (file-locking inTemporaryDirectoryteardown) that are identical on a cleanmaincheckoutDocumentation & Housekeeping
hermes_state.py— schema comments document the view contractcli-config.yaml.example— N/A (no config keys added)CONTRIBUTING.md/AGENTS.md— N/A'delete'/'rebuild'commands and external-content views are long-standing FTS5 featuresScreenshots / Logs