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:
Frowtek 2026-07-17 19:54:38 +03:00 • committed by Teknium
parent 96c1511495
commit 11710c51fc
2 changed files with 42 additions and 6 deletions

View file

@ -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).

View file

@ -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")