hermes-agent/tests/test_session_db_read_path_split.py
kshitij kapoor f228e145ba fix: follow-up for PR #65541 — track read conns, convert remaining read paths, mark WAL tests
- Route _get_read_conn through _connect_tracked_db so per-thread
  read-only connections are registered with the POSIX lock-safety
  guard (connect_tracked), matching the writer and existing read-only
  paths.  Without this, byte-level probes of state.db could close() an
  fd that cancels locks held by an untracked read connection.
- Convert _search_unindexed_gap, _run_trigram_search, CJK-bigram FTS
  search, and get_meta to _read_ctx — these are pure SELECT queries
  called from search_messages that were still taking self._lock,
  defeating the PR's contention fix for those paths.
- Add @pytest.mark.requires_wal to the 5 tests that assume WAL is
  active.  Hermes disables WAL on SQLite < 3.51.3 (WAL-reset bug),
  so these tests fail on the venv's SQLite 3.46.0 without the marker.
- Remove unused 'time' import.
2026-07-28 20:29:44 +05:30

160 lines
5.4 KiB
Python

"""Tests for the SessionDB read-path split (per-thread read-only connections).
The gateway shares ONE SessionDB across every agent, so recall/browse reads
used to queue behind writer flushes on self._lock — a measured production
convoy (a 0.2s FTS query stretched to 112s while 6-8 concurrent turns
flushed tool results). These tests pin the new contract: reads run on a
per-thread read-only connection under WAL, never touch self._lock, and fall
back to the legacy locked path when WAL or the read connection is missing.
"""
import threading
import pytest
from hermes_state import SessionDB
@pytest.fixture()
def db(tmp_path):
d = SessionDB(db_path=tmp_path / "state.db")
d.create_session(session_id="s1", source="cli", model="m")
d.append_message("s1", role="user", content="hello graphiti world")
d.append_message("s1", role="assistant", content="the neo4j daemon is healthy")
yield d
d.close()
@pytest.mark.requires_wal
def test_read_conn_is_per_thread(db):
conns = {}
def grab(key):
conns[key] = db._get_read_conn()
t1 = threading.Thread(target=grab, args=(1,))
t2 = threading.Thread(target=grab, args=(2,))
t1.start(); t2.start(); t1.join(); t2.join()
assert conns[1] is not None and conns[2] is not None
assert conns[1] is not conns[2]
def test_read_conn_reused_within_thread(db):
assert db._get_read_conn() is db._get_read_conn()
@pytest.mark.requires_wal
def test_reads_do_not_take_writer_lock(db):
"""Reads must complete while another thread holds self._lock."""
acquired = db._lock.acquire()
assert acquired
try:
done = {}
def reader():
done["session"] = db.get_session("s1")
done["search"] = db.search_messages("graphiti", limit=10)
done["messages"] = db.get_messages("s1")
t = threading.Thread(target=reader)
t.start()
t.join(timeout=5.0)
assert not t.is_alive(), "read path blocked on writer lock"
assert done["session"]["id"] == "s1"
assert any("graphiti" in (m.get("snippet") or "") for m in done["search"])
assert len(done["messages"]) == 2
finally:
db._lock.release()
@pytest.mark.requires_wal
def test_title_resolution_does_not_take_writer_lock(db):
"""Exact-title and numbered-variant resolution must not block on self._lock."""
db.create_session(session_id="t1", source="cli", model="m")
db.set_session_title("t1", "ops sync")
db.create_session(session_id="t2", source="cli", model="m")
db.set_session_title("t2", "ops sync #2")
acquired = db._lock.acquire()
assert acquired
try:
done = {}
def reader():
done["exact"] = db.get_session_by_title("ops sync")
done["resolved"] = db.resolve_session_by_title("ops sync")
t = threading.Thread(target=reader)
t.start()
t.join(timeout=5.0)
assert not t.is_alive(), "title resolution blocked on writer lock"
assert done["exact"]["id"] == "t1"
# Lineage rule: the latest numbered variant wins over the exact match.
assert done["resolved"] == "t2"
finally:
db._lock.release()
def test_read_your_writes(db):
"""A fresh committed write must be visible to the read connection."""
db.append_message("s1", role="user", content="zanzibar checkpoint")
rows = db.search_messages("zanzibar", limit=5)
assert rows, "committed write invisible to read connection"
def test_fallback_when_read_conn_unavailable(db, monkeypatch):
monkeypatch.setattr(db, "_get_read_conn", lambda: None)
assert db.get_session("s1")["id"] == "s1"
assert db.search_messages("graphiti", limit=5)
def test_non_wal_uses_locked_path(db):
db._wal_active = False
assert db._get_read_conn() is None
# And queries still work via the legacy path.
assert db.get_session("s1")["id"] == "s1"
@pytest.mark.requires_wal
def test_read_conn_open_failure_marks_thread(db, monkeypatch, tmp_path):
"""A failed read-conn open must not retry per query; fallback still works."""
import sqlite3 as _sqlite3
calls = {"n": 0}
real_connect = _sqlite3.connect
def failing_connect(*a, **k):
if a and isinstance(a[0], str) and a[0].startswith("file:") and "mode=ro" in a[0]:
calls["n"] += 1
raise _sqlite3.OperationalError("simulated open failure")
return real_connect(*a, **k)
fresh = SessionDB(db_path=tmp_path / "state2.db")
try:
fresh.create_session(session_id="x", source="cli", model="m")
monkeypatch.setattr("hermes_state.sqlite3.connect", failing_connect)
assert fresh.get_session("x")["id"] == "x"
assert fresh.get_session("x")["id"] == "x"
assert calls["n"] == 1, "open failure should be remembered per thread"
finally:
fresh.close()
@pytest.mark.requires_wal
def test_anchored_view_and_around_use_read_path(db):
msgs = db.get_messages("s1")
anchor = msgs[0]["id"]
acquired = db._lock.acquire()
try:
done = {}
def reader():
done["around"] = db.get_messages_around("s1", anchor, window=2)
done["view"] = db.get_anchored_view("s1", anchor, window=2, bookend=1)
t = threading.Thread(target=reader)
t.start(); t.join(timeout=5.0)
assert not t.is_alive(), "anchored reads blocked on writer lock"
assert done["around"]["window"]
assert done["view"]["window"]
finally:
db._lock.release()