HERMES-GIELIE/tests/test_cli_sessions_cache_fingerprint.py
nesquena-hermes f8d5ad2b1e fix(sessions): commit-reliable state.db content fingerprint in cache keys (real flake root cause)
Codex root-cause consult confirmed: the CLI-session cache (_CLI_SESSIONS_CACHE,
5s TTL) and session-list cache were keyed on (st_mtime_ns, st_size) of state.db
+ WAL sidecars. Under WAL-mode writes those stamps collide (same mtime-ns bucket
+ WAL frame size after checkpoint), serving a stale cache -> freshly-committed
gateway/CLI session intermittently absent from /api/sessions (the recurring
test_gateway_sync flake AND a real up-to-5s sidebar delay for gateway writes).

PRAGMA data_version is NOT usable here (fresh per-request connection always
reports its own initial value, never advances — verified). Fix = a cheap
content fingerprint (COUNT/MAX(rowid) over sessions+messages) read on a fresh
connection, which DOES advance on every commit and is immune to mtime
granularity. Wired into _sqlite_file_stat_cache_key (models) + the routes-layer
source stamp. + 3 regression tests + corrected the earlier false root-cause
comments.
2026-06-18 22:48:07 +00:00

95 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 → zeroed parts, no exception.
fp = _sqlite_content_fingerprint(p)
assert fp == ((0, 0), (0, 0))