fix(gateway): require FTS provenance before transcript rebuild-and-retry

Widen #96038's fail-closed classifier to the gateway transcript retry
path: SessionStore._is_fts_corruption_error no longer treats a generic
'database disk image is malformed' as FTS-only damage. It now delegates
to SessionDB._is_fts_write_corruption_error (SQLITE_CORRUPT_VTAB result
code or explicit fts5 corrupt-structure text) and only keeps the
messages_fts-named cases. Structural corruption falls through to the
bounded retry/backoff path instead of rebuilding FTS and retrying writes
against a damaged database.

Sibling site spotted in PR #98090 by @fangliquanflq.
This commit is contained in:
Teknium
2026-08-31 11:26:42 -07:00
parent 96739033c4
commit f680a4dc7d
3 changed files with 37 additions and 16 deletions
+1
View File
@@ -0,0 +1 @@
diatche
+19 -13
View File
@@ -3930,22 +3930,28 @@ class SessionStore:
@staticmethod
def _is_fts_corruption_error(exc: Exception) -> bool:
"""True if *exc* looks like an FTS index corruption error.
"""True only when the failure is provably scoped to the FTS index.
Matches the specific SQLite error strings for malformed disk images
and FTS table corruption — not bare ``"fts"`` substrings which match
unrelated words like ``"shifts"`` or ``"gifts"``.
A generic ``database disk image is malformed`` (bare SQLITE_CORRUPT)
can mean structural damage to canonical B-trees, not just the FTS
shadow tables — treating it as FTS-only here made the store rebuild
the index and retry transcript writes against a structurally corrupt
database (#97940). Only errors that name ``messages_fts`` or carry
FTS provenance per ``SessionDB._is_fts_write_corruption_error``
(``SQLITE_CORRUPT_VTAB`` result code, or explicit ``fts5:`` corrupt
structure text) may authorize the one-shot rebuild-and-retry.
Everything else falls through to the bounded retry/backoff path.
"""
text = str(exc).lower()
return any(
marker in text
for marker in (
"database disk image is malformed",
"malformed database schema",
"messages_fts",
"no such table: messages_fts",
)
)
if "messages_fts" in text:
return True
import sqlite3
from hermes_state import SessionDB
if isinstance(exc, sqlite3.DatabaseError):
return SessionDB._is_fts_write_corruption_error(exc)
return False
def _rebuild_fts_once(self) -> bool:
"""Attempt FTS5 ``rebuild`` command once per store lifetime.
+17 -3
View File
@@ -1541,15 +1541,29 @@ class TestGatewaySessionDbRecovery:
assert "child" not in store._dirty_transcripts
def test_fts_corruption_error_does_not_match_false_positives(self):
"""_is_fts_corruption_error must not match unrelated error strings
def test_fts_corruption_error_requires_fts_provenance(self):
"""_is_fts_corruption_error must not treat a generic malformed-image
error as FTS-scoped (#97940): bare SQLITE_CORRUPT can mean canonical
B-tree damage. It must also not match unrelated error strings
containing 'fts' as a substring (e.g. 'shifts', 'gifts')."""
assert SessionStore._is_fts_corruption_error(
import sqlite3
# Generic structural corruption: no FTS provenance -> fail closed.
assert not SessionStore._is_fts_corruption_error(
RuntimeError("database disk image is malformed")
)
assert not SessionStore._is_fts_corruption_error(
sqlite3.DatabaseError("database disk image is malformed")
)
# FTS-scoped errors remain eligible for the one-shot rebuild.
assert SessionStore._is_fts_corruption_error(
RuntimeError("no such table: messages_fts")
)
assert SessionStore._is_fts_corruption_error(
sqlite3.DatabaseError(
'fts5: corrupt structure record for table "messages_fts"'
)
)
assert not SessionStore._is_fts_corruption_error(
RuntimeError("shifts were applied")
)