Compare commits

...

2 Commits

Author SHA1 Message Date
teknium1
a460b905f6 fix(session): extend no-FTS5 degradation to the trigram CJK index
The salvaged contributor commit guarded only messages_fts. Current main
also creates a second virtual table, messages_fts_trigram (CJK substring
search), whose CREATE VIRTUAL TABLE ... USING fts5 still raised
"no such module: fts5" on builds without FTS5 — re-crashing SessionDB
init. Wrap the trigram setup with the same guard, and broaden the test's
no-fts5 mock to fail BOTH tables so the regression test actually
exercises a faithful no-FTS5 build.
2026-05-29 19:09:17 -07:00
LeonSGP43
9914c8f3f7 fix(session): degrade gracefully when SQLite lacks FTS5 2026-05-29 19:09:17 -07:00
2 changed files with 83 additions and 5 deletions

View File

@@ -380,6 +380,7 @@ class SessionDB:
self._lock = threading.Lock()
self._write_count = 0
self._fts_enabled = False
try:
self._conn = sqlite3.connect(
str(self.db_path),
@@ -388,7 +389,6 @@ class SessionDB:
# handles contention instead of sitting in SQLite's internal
# busy handler for up to 30s.
timeout=1.0,
# Autocommit mode: Python's default isolation_level=""
# auto-starts transactions on DML, which conflicts with our
# explicit BEGIN IMMEDIATE. None = we manage transactions
# ourselves.
@@ -724,14 +724,38 @@ class SessionDB:
# FTS5 setup (separate because CREATE VIRTUAL TABLE can't be in executescript with IF NOT EXISTS reliably)
try:
cursor.execute("SELECT * FROM messages_fts LIMIT 0")
except sqlite3.OperationalError:
cursor.executescript(FTS_SQL)
self._fts_enabled = True
except sqlite3.OperationalError as exc:
if "no such table" not in str(exc).lower():
raise
try:
cursor.executescript(FTS_SQL)
self._fts_enabled = True
except sqlite3.OperationalError as fts_exc:
err = str(fts_exc).lower()
if "fts5" not in err and "no such module" not in err:
raise
logger.warning(
"SQLite FTS5 unavailable for %s; full-text search disabled: %s",
self.db_path,
fts_exc,
)
# Trigram FTS5 for CJK/substring search
try:
cursor.execute("SELECT * FROM messages_fts_trigram LIMIT 0")
except sqlite3.OperationalError:
cursor.executescript(FTS_TRIGRAM_SQL)
except sqlite3.OperationalError as exc:
if "no such table" not in str(exc).lower():
raise
try:
cursor.executescript(FTS_TRIGRAM_SQL)
except sqlite3.OperationalError as fts_exc:
err = str(fts_exc).lower()
if "fts5" not in err and "no such module" not in err:
raise
# Same FTS5-unavailable cause already warned about above for
# messages_fts; the trigram table is an additional CJK index,
# so just degrade silently here. CJK search falls back to LIKE.
self._conn.commit()
@@ -2317,6 +2341,9 @@ class SessionDB:
ignores ``sort``. The trigram CJK path honours ``sort`` like the main
FTS5 path.
"""
if not self._fts_enabled:
return []
if not query or not query.strip():
return []

View File

@@ -1,11 +1,35 @@
"""Tests for hermes_state.py — SessionDB SQLite CRUD, FTS5 search, export."""
import sqlite3
import time
import pytest
from hermes_state import SessionDB
class _NoFtsCursor(sqlite3.Cursor):
"""Simulate a SQLite build without the fts5 module."""
def execute(self, sql, parameters=()):
probe = sql.strip()
if probe in (
"SELECT * FROM messages_fts LIMIT 0",
"SELECT * FROM messages_fts_trigram LIMIT 0",
):
raise sqlite3.OperationalError("no such table: " + probe.split()[-3])
return super().execute(sql, parameters)
def executescript(self, sql_script):
if "USING fts5" in sql_script:
raise sqlite3.OperationalError("no such module: fts5")
return super().executescript(sql_script)
class _NoFtsConnection(sqlite3.Connection):
def cursor(self, factory=None):
return super().cursor(factory or _NoFtsCursor)
@pytest.fixture()
def db(tmp_path):
"""Create a SessionDB with a temp database file."""
@@ -135,6 +159,33 @@ class TestSessionLifecycle:
child = db.get_session("child")
assert child["parent_session_id"] == "parent"
def test_db_initializes_without_fts5_module(self, tmp_path, monkeypatch):
real_connect = sqlite3.connect
def connect_without_fts(*args, **kwargs):
kwargs["factory"] = _NoFtsConnection
return real_connect(*args, **kwargs)
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_fts)
db = SessionDB(db_path=tmp_path / "state.db")
try:
assert db._fts_enabled is False
# Neither FTS5 virtual table should have been created on a build
# that lacks the fts5 module — both init paths must degrade.
assert db._fts_table_exists("messages_fts") is False
assert db._fts_table_exists("messages_fts_trigram") is False
db.create_session(session_id="s1", source="cli")
db.append_message("s1", role="user", content="hello from sqlite without fts")
messages = db.get_messages("s1")
assert len(messages) == 1
assert messages[0]["content"] == "hello from sqlite without fts"
assert db.search_messages("hello") == []
finally:
db.close()
# =========================================================================
# Message storage