fix(session): degrade gracefully when SQLite lacks FTS5
This commit is contained in:
+20
-3
@@ -380,6 +380,7 @@ class SessionDB:
|
|||||||
|
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._write_count = 0
|
self._write_count = 0
|
||||||
|
self._fts_enabled = False
|
||||||
try:
|
try:
|
||||||
self._conn = sqlite3.connect(
|
self._conn = sqlite3.connect(
|
||||||
str(self.db_path),
|
str(self.db_path),
|
||||||
@@ -388,7 +389,6 @@ class SessionDB:
|
|||||||
# handles contention instead of sitting in SQLite's internal
|
# handles contention instead of sitting in SQLite's internal
|
||||||
# busy handler for up to 30s.
|
# busy handler for up to 30s.
|
||||||
timeout=1.0,
|
timeout=1.0,
|
||||||
# Autocommit mode: Python's default isolation_level=""
|
|
||||||
# auto-starts transactions on DML, which conflicts with our
|
# auto-starts transactions on DML, which conflicts with our
|
||||||
# explicit BEGIN IMMEDIATE. None = we manage transactions
|
# explicit BEGIN IMMEDIATE. None = we manage transactions
|
||||||
# ourselves.
|
# ourselves.
|
||||||
@@ -724,8 +724,22 @@ class SessionDB:
|
|||||||
# FTS5 setup (separate because CREATE VIRTUAL TABLE can't be in executescript with IF NOT EXISTS reliably)
|
# FTS5 setup (separate because CREATE VIRTUAL TABLE can't be in executescript with IF NOT EXISTS reliably)
|
||||||
try:
|
try:
|
||||||
cursor.execute("SELECT * FROM messages_fts LIMIT 0")
|
cursor.execute("SELECT * FROM messages_fts LIMIT 0")
|
||||||
except sqlite3.OperationalError:
|
self._fts_enabled = True
|
||||||
cursor.executescript(FTS_SQL)
|
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
|
# Trigram FTS5 for CJK/substring search
|
||||||
try:
|
try:
|
||||||
@@ -2317,6 +2331,9 @@ class SessionDB:
|
|||||||
ignores ``sort``. The trigram CJK path honours ``sort`` like the main
|
ignores ``sort``. The trigram CJK path honours ``sort`` like the main
|
||||||
FTS5 path.
|
FTS5 path.
|
||||||
"""
|
"""
|
||||||
|
if not self._fts_enabled:
|
||||||
|
return []
|
||||||
|
|
||||||
if not query or not query.strip():
|
if not query or not query.strip():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
"""Tests for hermes_state.py — SessionDB SQLite CRUD, FTS5 search, export."""
|
"""Tests for hermes_state.py — SessionDB SQLite CRUD, FTS5 search, export."""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from hermes_state import SessionDB
|
from hermes_state import SessionDB
|
||||||
|
|
||||||
|
|
||||||
|
class _NoFtsCursor(sqlite3.Cursor):
|
||||||
|
"""Simulate a SQLite build without the fts5 module."""
|
||||||
|
|
||||||
|
def execute(self, sql, parameters=()):
|
||||||
|
if sql.strip() == "SELECT * FROM messages_fts LIMIT 0":
|
||||||
|
raise sqlite3.OperationalError("no such table: messages_fts")
|
||||||
|
return super().execute(sql, parameters)
|
||||||
|
|
||||||
|
def executescript(self, sql_script):
|
||||||
|
if "CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts 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()
|
@pytest.fixture()
|
||||||
def db(tmp_path):
|
def db(tmp_path):
|
||||||
"""Create a SessionDB with a temp database file."""
|
"""Create a SessionDB with a temp database file."""
|
||||||
@@ -135,6 +155,29 @@ class TestSessionLifecycle:
|
|||||||
child = db.get_session("child")
|
child = db.get_session("child")
|
||||||
assert child["parent_session_id"] == "parent"
|
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
|
||||||
|
|
||||||
|
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
|
# Message storage
|
||||||
|
|||||||
Reference in New Issue
Block a user