Skip to content

fix(state): add sessions.trigram_fts flag to drop the trigram FTS index - #111

Closed
exiao wants to merge 7 commits into
live-configfrom
fix/trigram-fts-config-flag
Closed

fix(state): add sessions.trigram_fts flag to drop the trigram FTS index#111
exiao wants to merge 7 commits into
live-configfrom
fix/trigram-fts-config-flag

Conversation

@exiao

@exiao exiao commented Jul 14, 2026

Copy link
Copy Markdown
Owner

Summary

Adds sessions.trigram_fts config flag (default true = current behavior) to
disable the secondary trigram FTS5 index over message content.

The trigram index (messages_fts_trigram) stores its own full copy of every
message plus a 3-character-gram index, making it 3-5x the raw message text and
by far the largest object in state.db. It fires on every message INSERT, which
on a heavy gateway+cron user lengthens WAL write-lock holds enough to exhaust the
15-retry app-level write-lock budget in _execute_write, surfacing as:

gateway.session: state.db routing save failed: database is locked

and stalling the gateway on restart (observed on a 9.4 GB state.db).

The trigram index only serves substring / CJK (3+ char) search. English word
search, recall, and hermes sessions search all run on the base messages_fts
word index, which this change leaves untouched.

Behavior

sessions.trigram_fts: false:

  • schema init drops messages_fts_trigram + its 3 triggers and does not
    recreate them (previously recreated unconditionally every startup);
  • _trigram_available is forced False, so search_messages transparently uses
    the base-FTS / LIKE fallback that already existed;
  • the expected-trigger-count check drops to 3 so trigger-repair doesn't falsely
    fire (and needlessly rebuild base FTS) every startup.

Setting it back to true recreates the index on next open. Default preserves
current behavior, so this is a no-op for existing installs until opted in.

Changes

  • hermes_cli/config.py — default sessions.trigram_fts: True + explanatory comment.
  • hermes_state.py_TRIGRAM_FTS_TRIGGERS, _drop_trigram_fts(),
    _trigram_fts_enabled() (lazy best-effort config read), and the gated runtime
    FTS block in _init_schema.
  • tests/test_hermes_state.pytest_trigram_fts_config_flag_false_drops_index.

Test Plan

  • New test + full FTS/search suite: 74 passed.
  • Standalone E2E vs a real temp DB: fresh-true (trigram + 6 triggers),
    fresh-false (no trigram, 3 base triggers), migrate-off drops the index,
    migrate-on recreates it.
  • Search with trigram off: word search hits via base FTS, no crash.

Note

Separate, rarer issue seen in the same incident (out of scope here): a /new
reset whose resource cleanup exceeded 30s left an orphaned worker thread holding
the write lock. A smaller DB makes it benign.

The secondary trigram FTS5 index (messages_fts_trigram) stores its own
full copy of every message plus a 3-gram index (3-5x the raw text) and
fires on every message INSERT. On heavy gateway+cron users it dominates
state.db size and lengthens WAL write-lock holds enough to exhaust the
15-retry write-lock budget, surfacing as 'state.db routing save failed:
database is locked' and stalling the gateway on restart.

Add sessions.trigram_fts (default True = current behavior). When false,
schema init drops the trigram index + its triggers and never recreates
them; _trigram_available is forced False so search_messages uses the
existing base-FTS/LIKE fallback. The base messages_fts word index (used
by recall and session search) is unaffected. Setting it back to true
recreates the index on next open.

Patch note: ~/.hermes/plans/hermes-patches/2026-07-14-trigram-fts-config-flag.md
@claude

claude Bot commented Jul 14, 2026

Copy link
Copy Markdown

Claude encountered an error after 2s —— View job


I'll analyze this and get back to you.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a configuration option trigram_fts to allow users to disable the secondary trigram FTS5 index on message content, which helps reduce database size and write latency on heavy usage. When disabled, the trigram index and its triggers are dropped, and search falls back to a LIKE scan. The feedback highlights two key improvements: first, a bug in the trigger repair logic where transitioning the configuration could leave missing base triggers unrepaired; second, an opportunity to check the return value of _drop_trigram_fts to suggest running a VACUUM command to reclaim disk space.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread hermes_state.py Outdated
Comment thread hermes_state.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2d6099754c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread hermes_state.py Outdated
Comment thread hermes_state.py Outdated
Addresses two P2s from independent review of the sessions.trigram_fts flag:

1. Base-trigger repair could be masked when trigram is disabled. Comparing
   the total FTS trigger count against a flag-adjusted expectation let a DB
   with 0 base + 3 stale trigram triggers tie the threshold and skip the base
   FTS rebuild, leaving rows written during the gap unsearchable. Count base
   triggers on their own (_base_fts_trigger_count / _BASE_FTS_TRIGGERS) so base
   health is judged independently of trigram presence.

2. The v11 migration built and backfilled messages_fts_trigram before the
   runtime block dropped it, so a legacy v10 user opting out still paid the
   full trigram rebuild + WAL churn once. Gate the v11 trigram build on
   _trigram_fts_enabled() and drop any pre-existing copy instead.

Regression test: test_trigram_disabled_still_repairs_missing_base_triggers.
Suite: 75 passed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 776d466640

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread hermes_state.py
exiao added 2 commits July 14, 2026 15:40
Codex review P2: the CJK LIKE fallback in search_messages omitted the
(m.active = 1 OR m.compacted = 1) predicate that the base and trigram
FTS paths apply, so it returned messages the user rewound (active=0,
compacted=0). Pre-existing, but this PR widens exposure because
sessions.trigram_fts=false now routes 3+ char CJK queries into that
fallback by config, not just short-CJK ones. Add the filter, gated on
include_inactive to match the other paths.

Regression test test_cjk_like_fallback_excludes_rewound_rows
(fail-before/pass-after verified). Suite: 77 passed.

Not fixed here: the same fallback drops AND/NOT boolean operators
(long-standing, predates this PR, separate change).
…K LIKE fallback

Builds on the active-row filter added in the prior commit. Codex P2: when
sessions.trigram_fts is false, all 3+ char CJK searches route through the
LIKE fallback, which flattened AND / OR / NOT to a single OR of every term.
So '大别山项目 NOT 桂林项目' returned rows containing the excluded term and
'A AND B' returned rows matching only A.

- Walk the tokens honoring AND / OR / NOT (adjacent terms default to AND per
  FTS5). NOT attaches as 'AND NOT (...)'.
- COALESCE the LIKE columns to '' so a NULL tool_name/tool_calls yields FALSE,
  not NULL — otherwise 'AND NOT (FALSE OR NULL)' collapses to NULL and drops
  every row.
- Log a VACUUM hint when the trigram index is actually dropped (it can be
  multiple GB; SQLite won't shrink the file automatically).

Tests: test_cjk_like_fallback_preserves_{not,and}_operator (fail-before/
pass-after). tests/test_hermes_state.py: 344 passed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8905d9acc5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread hermes_state.py Outdated
Codex re-review P2: _drop_trigram_fts swallowed a broad OperationalError
around DROP TABLE and still returned existed=True, so a 'database is locked'
during startup (gateway + cron) would set _trigram_available=False and log the
new 'dropped, run VACUUM' hint while the table + triggers actually survived.

Re-check sqlite_master after the DROP attempt and return True only when the
table that existed is genuinely gone; return False on any lock/undroppable
state so the caller's VACUUM hint never misleads.

Test: test_drop_trigram_fts_returns_false_when_drop_thwarted.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 83f841a60d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread hermes_state.py Outdated
Comment on lines +1115 to +1118
try:
cursor.execute(f"DROP TRIGGER IF EXISTS {trigger}")
except sqlite3.OperationalError:
pass

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not ignore failed trigram trigger drops

When sessions.trigram_fts is false and a transient database is locked hits one of these DROP TRIGGER statements but clears before the later DROP TABLE, this suppresses the failure and can still remove messages_fts_trigram. SQLite does not remove triggers that are defined on messages, so any surviving messages_fts_trigram_* trigger will fire on the next message insert/update and fail with no such table: main.messages_fts_trigram, breaking message persistence for that DB until the stale trigger is removed.

Useful? React with 👍 / 👎.

@exiao

exiao commented Jul 14, 2026

Copy link
Copy Markdown
Owner Author

Local code review (review bots note: claude-review check is red on a bot budget/setup failure, not a test failure — all Python tests slices are green)

Reviewed at PR HEAD 83f841a60 against base live-config (7ea68192, merge-base == base head, branch up to date). Ran the repo's own suite: scripts/run_tests.sh tests/test_hermes_state.py345 passed, 0 failed. The three earlier Codex P2s (active-row filtering, boolean semantics, locked-DROP-TABLE) are all fixed with fail-before/pass-after regression tests. The config wiring is correct (sessions.trigram_fts lives in the existing DEFAULT_CONFIG["sessions"] section, no _config_version bump needed, default True is a true no-op for existing installs), and it is config.yaml rather than a new env var, per the repo policy.

Verdict: REQUEST-CHANGES — one live P2, reproduced.

P2 — _drop_trigram_fts drops the table even when a trigger DROP was thwarted by a lock (hermes_state.py:1114-1128).

The DROP TRIGGER loop swallows OperationalError per-trigger and then proceeds to DROP TABLE independently. This is the exact scenario the latest commit (83f841a6) set out to harden, but it only guarded the table drop, not the trigger drops. If a transient database is locked hits a DROP TRIGGER but clears before the DROP TABLE (precisely the gateway+cron contention this PR targets), the table is removed while a messages_fts_trigram_* trigger survives. That orphaned trigger fires on the next message INSERT and raises no such table: main.messages_fts_trigram, breaking message persistence for that DB until the stale trigger is manually removed. On top of that, _drop_trigram_fts returns True in this state — the opposite of the "don't report a drop that didn't happen" contract the commit added.

Reproduced locally (fail-before) by making a cursor that raises database is locked only on the messages_fts_trigram_insert DROP TRIGGER and letting DROP TABLE through:

dropped returned: True
table still present: False
surviving trigram triggers: ['messages_fts_trigram_insert']
append_message FAILED: OperationalError no such table: main.messages_fts_trigram

Fix (small, proven pass-after): make the drop all-or-nothing — if any DROP TRIGGER raises OperationalError, bail before dropping the table and return False, leaving table + triggers consistent for the next uncontended startup:

for trigger in _TRIGRAM_FTS_TRIGGERS:
    try:
        cursor.execute(f"DROP TRIGGER IF EXISTS {trigger}")
    except sqlite3.OperationalError:
        # A thwarted trigger drop must abort the table drop: an orphaned
        # trigger over a missing table breaks the next message INSERT.
        return False

With that guard the same repro yields dropped=False, table + trigger both intact, and append_message succeeds. Suggested regression test (fail-before/pass-after against current HEAD):

def test_drop_trigram_fts_bails_when_trigger_drop_locked(self, tmp_path, monkeypatch):
    db_path = tmp_path / "state.db"
    monkeypatch.setattr(SessionDB, "_trigram_fts_enabled", staticmethod(lambda: True))
    db = SessionDB(db_path=db_path)
    try:
        db.create_session(session_id="s1", source="cli")
        db.append_message("s1", role="user", content="hello world")
        real_conn = db._conn

        class _BlockInsertTriggerDropCursor:
            def execute(self, sql, *args, **kwargs):
                if "DROP TRIGGER" in sql and "messages_fts_trigram_insert" in sql:
                    raise sqlite3.OperationalError("database is locked")
                return real_conn.execute(sql, *args, **kwargs)

        dropped = db._drop_trigram_fts(_BlockInsertTriggerDropCursor())
        db._conn.commit()
        assert dropped is False
        assert db._fts_table_exists("messages_fts_trigram") is True
        db.append_message("s1", role="assistant", content="after")  # must not raise
        assert len(db.search_messages("after")) == 1
    finally:
        db.close()

Non-blocking (P3)

  • hermes_state.py:1538-1549 — v10 migration creates the trigram table unconditionally (not gated behind _trigram_fts_enabled(), unlike the v11 block at 1598). Effectively dead in current code (SCHEMA_VERSION == 10 is never the target now) and the runtime block drops it afterward on the same init, so it is only wasted churn on a hypothetical v10-target path — worth a one-line comment or gate for consistency, not a blocker.
  • hermes_state.py:4877non_op_tokens = [...] or [raw_query] is now dead as an empty-guard: the real empty-case is handled by the later if not expr_parts: branch. non_op_tokens[0] is still used for the snippet instr(), so keep the assignment but drop the misleading or [raw_query] fallback (or add a comment).
  • hermes_state.py:4886-4899 — a leading NOT foo query treats the first term as a positive match (the if not expr_parts branch wins over pending_op == "NOT"), so NOT foo matches rows containing foo. Degenerate query with no preceding term; low impact, but a pending_op == "NOT" check on the first term would make it match nothing, closer to intent.

Everything else — the independent base/trigram trigger-repair detection, the active-row filter in the LIKE fallback, and the AND/OR/NOT boolean reconstruction — is correct and well-tested. Fix the P2 (message-persistence break under the very contention this PR targets) and this is good to land.

Local Codex/Claude-persona review: ran the repo's run_tests.sh and reproduced the P2 against live HEAD 83f841a60; not a formal approval.

@exiao

exiao commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9401caf9f3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread hermes_state.py
Comment on lines +1136 to +1137
if SessionDB._is_fts5_unavailable_error(exc):
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep trigram triggers dropped when tokenizer is missing

When sessions.trigram_fts is false for an existing DB that already has messages_fts_trigram, but the current SQLite build lacks the trigram tokenizer, the DROP TABLE can raise the same no such tokenizer: trigram error this branch suppresses. Because the handler rolls back before returning here, the trigger drops above are undone while init reports success; later message INSERTs can still fire messages_fts_trigram_insert against the unusable vtable and fail instead of degrading to the base FTS/LIKE path.

Useful? React with 👍 / 👎.

When sessions.trigram_fts is false on a DB that has messages_fts_trigram
but the SQLite build lacks the trigram tokenizer, DROP TABLE raises
"no such tokenizer: trigram" and the rollback undoes the tokenizer-free
trigger drops. The stale triggers then fire against the unusable vtable
on every message INSERT and crash. Drop the triggers in a standalone
committed step on the FTS-unavailable path so writes degrade to the base
FTS/LIKE path. Addresses reviewer P2.
@exiao

exiao commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: c7576ed7c6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@exiao

exiao commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

Superseded. #110 (merged) shipped the same trigram-disable feature under the config key sessions.fts_trigram instead of this PR's sessions.trigram_fts, so merging this would add a duplicate knob + second implementation. The two genuine improvements unique to this PR (CJK LIKE-fallback AND/NOT boolean semantics, and fail-closed lock/tokenizer handling on the trigram drop) are ported onto the merged key in #114 with red-before/green-after tests.

@exiao exiao closed this Jul 15, 2026
exiao added a commit that referenced this pull request Jul 15, 2026
…il-closed drops (#114)

* fix(state): honor boolean operators in trigram-off LIKE fallback + fail-closed drops

Salvages the two genuine improvements from #111 onto the merged
sessions.fts_trigram gate (#110), which shipped the same trigram-disable
feature under a different config key. #111 is superseded and closed.

1. CJK/substring LIKE fallback now preserves query boolean structure: it
   walks tokens left-to-right honoring AND (intersection) and NOT
   (exclusion) instead of OR-joining every term. Once trigram is disabled
   this fallback is the only CJK path, so "A NOT B" no longer returns rows
   with B and "A AND B" no longer returns rows matching only A. COALESCE
   guards the AND NOT branch against NULL tool columns dropping every row.

2. _drop_trigram_schema now fails closed: the trigger + table drops run in
   one BEGIN IMMEDIATE transaction so a "database is locked" propagates
   instead of leaving a stale trigger pointing at a missing table. On a
   build missing the trigram tokenizer (DROP TABLE raises), the triggers are
   re-dropped in a standalone committed step so message INSERTs degrade to
   the base FTS/LIKE path instead of crashing on the unusable vtable.

Regression tests: red-before/green-after for the boolean fallback and the
locked-drop propagation; tokenizer-missing trigger cleanup.

* fix(state): fail closed on locked tokenizer-fallback trigger drop + support OR NOT

Addresses codex review on #114: (1) the tokenizer-missing trigger-drop
recovery now propagates a lock instead of swallowing it (a swallowed lock
left stale triggers crashing INSERTs); (2) the LIKE-fallback boolean parser
now keeps the OR connector on 'A OR NOT B' instead of collapsing it to
'A AND NOT B'. Adds regressions for both.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant