mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-20 22:51:07 +00:00
d5ccaf0c27
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.
97 lines
3.7 KiB
Python
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)
|