fix(state): extend search-path FTS self-heal to the CJK/trigram branch

The trigram MATCH branch in search_messages() had the same
OperationalError-only catch that #66420 fixed on the main FTS5 branch: a
corrupt messages_fts_trigram shadow table raises the malformed /
'fts5: corrupt structure record' class (sqlite3.DatabaseError, parent of
OperationalError), which propagated straight out of search_messages and
crashed CJK session/history search for read-only sessions.

Route that class through the shared one-shot _try_runtime_fts_rebuild()
and retry the trigram query (catch moved outside self._lock so
rebuild_fts() can re-acquire it, mirroring the main branch). If the
rebuild is refused (guard consumed / FTS disabled / different error) or
the retry fails, fall through to the existing LIKE substring fallback —
which reads only the canonical messages table — instead of raising, so
CJK search degrades gracefully rather than crashing.

Adds two regression tests: trigram search self-heals in place after
shadow-table corruption (answers from the rebuilt trigram index, not the
LIKE fallback), and degrades to LIKE without raising when the one-shot
rebuild was already consumed.

Follow-up to #66420; refs #66296 #66724
This commit is contained in:
Teknium 2026-07-21 05:40:25 -07:00
parent 11710c51fc
commit 373ec23e37
2 changed files with 99 additions and 6 deletions

View File

@ -5701,15 +5701,47 @@ class SessionDB:
LIMIT ? OFFSET ?
"""
tri_params.extend([limit, offset])
with self._lock:
try:
try:
with self._lock:
tri_cursor = self._conn.execute(tri_sql, tri_params)
except sqlite3.OperationalError:
# Trigram query failed at runtime — fall through to LIKE.
pass
else:
matches = [dict(row) for row in tri_cursor.fetchall()]
_trigram_succeeded = True
except sqlite3.OperationalError:
# Trigram query failed at runtime — fall through to LIKE.
pass
except sqlite3.DatabaseError as exc:
# Same corruption class the main FTS5 MATCH branch
# self-heals above: a corrupt trigram shadow table raises
# malformed / "fts5: corrupt structure record", which is a
# DatabaseError (parent of the OperationalError syntax arm
# caught first). Rebuild once outside the lock — the lock
# is released here so rebuild_fts() can re-acquire it —
# and retry the trigram query. If the rebuild is refused
# (already attempted / FTS disabled / different error
# class) or the retry fails again, fall through to the
# LIKE substring path, which reads only the canonical
# messages table, so CJK search stays available.
if self._try_runtime_fts_rebuild(exc):
try:
with self._lock:
tri_cursor = self._conn.execute(
tri_sql, tri_params
)
matches = [
dict(row) for row in tri_cursor.fetchall()
]
_trigram_succeeded = True
except sqlite3.DatabaseError:
logger.warning(
"Trigram FTS search still failing after "
"in-place rebuild; falling back to LIKE."
)
else:
logger.warning(
"Trigram FTS search hit a corruption error (%s) "
"and no in-place rebuild was possible; falling "
"back to LIKE.", exc,
)
if not _trigram_succeeded:
# Short / mixed CJK query, trigram unavailable, or trigram
# <3 CJK chars. Fall back to LIKE substring search.

View File

@ -38,6 +38,16 @@ def _corrupt_fts(db_path):
raw.close()
def _corrupt_trigram_fts(db_path):
raw = sqlite3.connect(str(db_path))
raw.execute(
"UPDATE messages_fts_trigram_data "
"SET block = X'DEADBEEFDEADBEEFDEADBEEFDEADBEEF'"
)
raw.commit()
raw.close()
def _message_contents(db_path):
raw = sqlite3.connect(str(db_path))
rows = raw.execute("SELECT content FROM messages ORDER BY id").fetchall()
@ -116,6 +126,57 @@ class TestRuntimeFtsRebuild:
assert results # non-empty: the rebuilt index matched the query
assert any("needle" in (r.get("snippet") or "") for r in results)
def test_trigram_search_self_heals_after_fts_corruption(self, db, tmp_path):
"""The CJK/trigram MATCH branch has the same read-corruption exposure
as the main FTS5 branch: it caught only OperationalError (query
syntax), so a corrupt trigram shadow table raised DatabaseError
straight out of search_messages. It must self-heal via the shared
one-shot rebuild and answer from the rebuilt trigram index.
"""
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
if not db._trigram_available:
pytest.skip("trigram tokenizer unavailable in this build")
db.create_session("s1", source="test")
db.append_message("s1", "user", "关于大别山项目的进展报告")
_corrupt_trigram_fts(tmp_path / "state.db")
assert db._fts_runtime_rebuild_attempted is False
# >=3 CJK chars per token → routed to the trigram branch.
results = db.search_messages("大别山项目")
assert db._fts_runtime_rebuild_attempted is True # search rebuilt it
assert results
# The rebuilt trigram index answered (trigram snippets use >>> <<<),
# i.e. we did not silently degrade to the LIKE fallback.
assert any(">>>" in (r.get("snippet") or "") for r in results)
def test_trigram_search_falls_back_to_like_when_rebuild_consumed(
self, db, tmp_path
):
"""When the one-shot rebuild was already consumed, a corrupt trigram
index must NOT crash search_messages it degrades to the LIKE
substring fallback, which reads only the canonical messages table.
"""
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
if not db._trigram_available:
pytest.skip("trigram tokenizer unavailable in this build")
db.create_session("s1", source="test")
db.append_message("s1", "user", "关于大别山项目的进展报告")
# Consume the one-shot guard, then corrupt again.
_corrupt_trigram_fts(tmp_path / "state.db")
db.append_message("s1", "user", "seed to trigger write-path heal")
assert db._fts_runtime_rebuild_attempted is True
_corrupt_trigram_fts(tmp_path / "state.db")
# Before the fix this raised sqlite3.DatabaseError.
results = db.search_messages("大别山项目")
assert results # LIKE fallback found the canonical row
assert any("大别山项目" 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")