mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-10-20 05:15:31 +00:00
fix(state): self-heal FTS corruption on the SessionDB search path too
Complements #66296 (self-heal on the write path): search_messages()'s main FTS5 MATCH query caught only sqlite3.OperationalError (a query-syntax error → return empty). A corrupt FTS index raises the malformed / "fts5: corrupt structure record" class, which is a sqlite3.DatabaseError — the parent of OperationalError, so it was NOT caught and propagated straight out of search_messages, crashing session/history search. The write path now rebuilds and retries on that class, but a read-only session (cron/CLI history search, or a search issued before any write) never triggers a write, so its search stayed broken until the next process restart ran the offline repair. Catch the DatabaseError corruption class on the search MATCH read too and route it through the existing one-shot _try_runtime_fts_rebuild(), then retry the query. The catch is moved outside `with self._lock` so rebuild_fts() can re-acquire the lock (mirrors _execute_write). The one-shot guard is shared with the write path, so a single instance never loops on a genuinely unrecoverable index. OperationalError syntax handling is unchanged (caught first). Adds a regression test: with a corrupted messages_fts and no post-corruption write, search_messages() rebuilds in place and returns the match; without the fix it raises DatabaseError.
This commit is contained in:
parent
96c1511495
commit
11710c51fc
2 changed files with 42 additions and 6 deletions
|
|
@ -5758,13 +5758,26 @@ class SessionDB:
|
|||
like_cursor = self._conn.execute(like_sql, like_params)
|
||||
matches = [dict(row) for row in like_cursor.fetchall()]
|
||||
else:
|
||||
with self._lock:
|
||||
try:
|
||||
try:
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(sql, params)
|
||||
matches = [dict(row) for row in cursor.fetchall()]
|
||||
except sqlite3.OperationalError:
|
||||
# FTS5 query syntax error despite sanitization — return empty
|
||||
return []
|
||||
except sqlite3.DatabaseError as exc:
|
||||
# A corrupt FTS index raises the malformed / "fts5: corrupt
|
||||
# structure record" class on the MATCH read, the same class the
|
||||
# write path self-heals (#66296). OperationalError (query
|
||||
# syntax) is a subclass caught above; this arm is the corruption
|
||||
# parent. Rebuild the index in place once — the lock is released
|
||||
# here, so rebuild_fts() can re-acquire it — and retry, so
|
||||
# search self-heals for read-only sessions (cron/CLI history
|
||||
# search) that never trigger a write to repair it first.
|
||||
if not self._try_runtime_fts_rebuild(exc):
|
||||
raise
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(sql, params)
|
||||
except sqlite3.OperationalError:
|
||||
# FTS5 query syntax error despite sanitization — return empty
|
||||
return []
|
||||
else:
|
||||
matches = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
# Add surrounding context (1 message before + after each match).
|
||||
|
|
|
|||
|
|
@ -93,6 +93,29 @@ class TestRuntimeFtsRebuild:
|
|||
raw.close()
|
||||
assert len(hits) == 1
|
||||
|
||||
def test_search_messages_self_heals_after_fts_corruption(self, db, tmp_path):
|
||||
"""A read-only session that only SEARCHES (no write after corruption)
|
||||
must self-heal too. The MATCH read raises the corruption class
|
||||
(DatabaseError / 'fts5: corrupt structure record'), NOT the
|
||||
OperationalError that search_messages caught — so before this fix the
|
||||
search crashed until a write or restart rebuilt the index.
|
||||
"""
|
||||
if not db._fts_enabled:
|
||||
pytest.skip("FTS5 unavailable in this build")
|
||||
db.create_session("s1", source="test")
|
||||
db.append_message("s1", "user", "a searchable needle here")
|
||||
|
||||
_corrupt_fts(tmp_path / "state.db")
|
||||
# Injected via a raw connection, so no write on THIS instance has
|
||||
# consumed the one-shot rebuild yet.
|
||||
assert db._fts_runtime_rebuild_attempted is False
|
||||
|
||||
results = db.search_messages("needle")
|
||||
|
||||
assert db._fts_runtime_rebuild_attempted is True # the search rebuilt it
|
||||
assert results # non-empty: the rebuilt index matched the query
|
||||
assert any("needle" in (r.get("snippet") or "") for r in results)
|
||||
|
||||
def test_rebuild_is_one_shot_per_instance(self, db, tmp_path):
|
||||
if not db._fts_enabled:
|
||||
pytest.skip("FTS5 unavailable in this build")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue