Files
hermes-webui/tests/test_cli_sessions_cache_fingerprint.py
T
nesquena-hermes d5ccaf0c27 fix(perf): fingerprint reads read-only + index-only MAX(rowid), no table scan / no lock stall (Codex gate)
Codex CORE findings on the fingerprint: (1) default busy timeout could stall
/api/sessions ~10s on a locked state.db; (2) COUNT(*) forced a full messages
table SCAN (~16-22ms/refresh on 200k rows). Fix: open file:...?mode=ro with
timeout=0.05 + PRAGMA busy_timeout=50 (returns fast, falls back to stat stamp on
lock), and use index-only MAX(rowid) per table (no COUNT, no scan). Verified:
EXPLAIN shows SEARCH not SCAN, 0.36ms on 200k msgs, 0.16ms while EXCLUSIVE-locked.
Fingerprint shape is now (sessions_max_rowid, messages_max_rowid); regression
tests updated.
2026-06-18 22:53:58 +00:00

97 lines
3.7 KiB
Python

"""Regression: a freshly-committed state.db session must invalidate the CLI-session
cache immediately, even when the file-stat stamp would collide (the root cause of
the recurring test_gateway_sync flake).
The CLI-session cache (_CLI_SESSIONS_CACHE) and the session-list cache were keyed
on (st_mtime_ns, st_size) of state.db + its WAL sidecars. Under WAL-mode writes
those stamps can collide, serving a stale cache. The fix adds a commit-reliable
content fingerprint (_sqlite_content_fingerprint) that advances on every commit.
"""
from __future__ import annotations
import sqlite3
import tempfile
from pathlib import Path
def test_content_fingerprint_advances_on_commit():
"""The cache key's content fingerprint must change after any commit, even
when mtime/size would not reliably change (the WAL-collision flake source).
"""
from api.models import _sqlite_file_stat_cache_key, _sqlite_content_fingerprint
d = tempfile.mkdtemp()
p = Path(d) / "state.db"
conn = sqlite3.connect(str(p))
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("CREATE TABLE sessions(id TEXT, source TEXT, started_at REAL)")
conn.execute("CREATE TABLE messages(id INTEGER PRIMARY KEY, session_id TEXT)")
conn.commit()
fp_before = _sqlite_content_fingerprint(p)
key_before = _sqlite_file_stat_cache_key(p)
conn.execute("INSERT INTO sessions VALUES ('gw_new_001', 'weixin', 1)")
conn.execute("INSERT INTO messages (session_id) VALUES ('gw_new_001')")
conn.commit()
fp_after = _sqlite_content_fingerprint(p)
key_after = _sqlite_file_stat_cache_key(p)
conn.close()
assert fp_before != fp_after, (
"content fingerprint must advance after a commit so a freshly-inserted "
"CLI/gateway session is never served from a stale cache"
)
# The full cache key (which embeds the fingerprint) must therefore differ too.
assert key_before != key_after
def test_content_fingerprint_detects_message_only_change():
"""An in-place session row update or message-only insert must also change the
fingerprint (sessions COUNT/MAX alone could miss a same-rowid REPLACE).
"""
from api.models import _sqlite_content_fingerprint
d = tempfile.mkdtemp()
p = Path(d) / "state.db"
conn = sqlite3.connect(str(p))
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("CREATE TABLE sessions(id TEXT, source TEXT, started_at REAL)")
conn.execute("CREATE TABLE messages(id INTEGER PRIMARY KEY, session_id TEXT)")
conn.execute("INSERT INTO sessions VALUES ('s1', 'weixin', 1)")
conn.commit()
fp_before = _sqlite_content_fingerprint(p)
# Add a message to the existing session (sessions table unchanged).
conn.execute("INSERT INTO messages (session_id) VALUES ('s1')")
conn.commit()
fp_after = _sqlite_content_fingerprint(p)
conn.close()
assert fp_before != fp_after, (
"fingerprint must cover the messages table so a message-only commit "
"(e.g. a continued gateway conversation) invalidates the cache"
)
def test_content_fingerprint_safe_on_missing_or_empty_db():
"""The fingerprint must not raise on a missing path or a db without the
expected tables — it returns None / zeroed parts so the stat fallback applies.
"""
from api.models import _sqlite_content_fingerprint
assert _sqlite_content_fingerprint(Path("/nonexistent/state.db")) is None
d = tempfile.mkdtemp()
p = Path(d) / "empty.db"
conn = sqlite3.connect(str(p))
conn.execute("CREATE TABLE unrelated(x)")
conn.commit()
conn.close()
# No sessions/messages tables → None parts (MAX(rowid) on a missing table),
# no exception. Shape is a 2-tuple of (sessions_max_rowid, messages_max_rowid).
fp = _sqlite_content_fingerprint(p)
assert fp == (None, None)