Files
hermes-webui/tests/test_state_db_session_signature.py
ruizanthonyandAnthony Ruiz 00e4c5af6f perf(api): skip state load on cached transcript reopen (#7212)
* perf(session-load): memoize the compression-lineage display stitch

_webui_sidecar_lineage_messages_for_display re-merged multi-thousand-row
parent+child transcripts on EVERY GET /api/session (measured 5.1s per
request on a real 5.2k-message lineage, py-spy: merge_session_messages_
append_only + _matching_visible_duplicate dominate). The merge result is
now cached per session id, validity keyed by the exact stat signature of
the child sidecar AND every snapshot parent involved, bounded LRU(16).
Any write to any involved sidecar invalidates naturally; cache hits and
the populating call hand out shallow-copied rows so caller-side display
metadata cannot corrupt cached state.

Measured: 5.17s cold -> 0.002s warm on the real session; invalidation
after touch re-merges. Tests: tests/test_lineage_display_cache.py
(correctness, copy isolation, child/parent invalidation, no pollution
for lineage-less sessions).

* perf(api): memoize inactive-session display merges

* perf(api): skip state load on cached transcript reopen

* test(api): remove unused lineage-cache import

* fix(api): reject streaming key without state database

* test(api): isolate display cache fixture

* fix(api): bind cache publication to read signature

* fix(api): harden display cache invalidation

* fix(api): bind cache hits to lineage provenance

* fix(api): close remaining display cache scope races

* fix(api): scope display cache revision to target

* fix(api): evict unverifiable lineage provenance

* fix(api): keep incomplete lineage provenance uncached

---------

Co-authored-by: Anthony Ruiz <ruizanthony@users.noreply.github.com>
2026-08-21 21:49:59 -07:00

305 lines
11 KiB
Python

"""Coverage for the commit-reliable state.db display-cache signature.
The display-merge cache key used to embed ``_state_db_rows_fingerprint``, which
serialises every state.db display row with ``json.dumps``. On a 36k-row session
that cost ~1.4s *per request*, which became the floor cost of even a cache HIT --
the cache could never pay for itself.
``_state_db_session_signature`` now uses a cross-process target-session revision
derived from supported session metadata plus indexed message aggregates. These
tests pin the properties that matter: supported target mutations move the
signature, direct insert/delete/length/timestamp changes remain visible,
unrelated sessions stay stable without a process-local stream marker, and
failures return None so the caller falls back to the exact row fingerprint.
"""
import sqlite3
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from api import routes # noqa: E402
SID = "20260101_120000_abcdef"
def _build_db(path, rows=None):
"""Minimal messages table matching the columns the signature reads."""
conn = sqlite3.connect(str(path))
try:
conn.execute(
"CREATE TABLE messages ("
" id INTEGER PRIMARY KEY,"
" session_id TEXT,"
" role TEXT,"
" content TEXT,"
" timestamp REAL)"
)
conn.execute("CREATE INDEX idx_ms ON messages(session_id, id)")
payload = rows if rows is not None else [
(i, SID, "user" if i % 2 else "assistant",
f"message number {i} with enough text to slice into thirds",
1700000000.0 + i)
for i in range(1, 41)
]
conn.executemany(
"INSERT INTO messages (id, session_id, role, content, timestamp) "
"VALUES (?,?,?,?,?)", payload)
conn.execute(
"CREATE TABLE sessions ("
" id TEXT PRIMARY KEY,"
" message_count INTEGER,"
" last_activity_at REAL,"
" last_read_at REAL,"
" ended_at REAL,"
" end_reason TEXT,"
" rewind_count INTEGER,"
" archived INTEGER)"
)
conn.execute(
"INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?)",
(SID, len(payload), 1700000100.0, None, None, None, 0, 0),
)
conn.commit()
finally:
conn.close()
return path
@pytest.fixture()
def db(tmp_path, monkeypatch):
path = _build_db(tmp_path / "state.db")
monkeypatch.setattr(
"api.models._agent_state_db_path", lambda profile=None: path, raising=False)
monkeypatch.setattr(
"api.models._cli_sessions_streaming_freeze_marker", lambda: None, raising=False)
return path
def _sig(db_path):
return routes._state_db_session_signature(SID, None)
def _mutate(db_path, sql, params=(), *, touch_session=False):
conn = sqlite3.connect(str(db_path))
try:
conn.execute(sql, params)
if touch_session:
conn.execute(
"UPDATE sessions SET "
"message_count=(SELECT COUNT(*) FROM messages WHERE session_id=?), "
"last_activity_at=COALESCE(last_activity_at, 0)+1 "
"WHERE id=?",
(SID, SID),
)
conn.commit()
finally:
conn.close()
# --------------------------------------------------------------------------
# Detection matrix: every one of these must change the signature, otherwise a
# stale transcript can be served from the display-merge cache.
# --------------------------------------------------------------------------
MUTATIONS = [
pytest.param(
"INSERT INTO messages (session_id, role, content, timestamp) "
"VALUES (?, 'user', 'brand new row', 1700009999.0)", (SID,),
id="append_row"),
pytest.param(
"DELETE FROM messages WHERE rowid = "
"(SELECT MAX(rowid) FROM messages WHERE session_id = ?)", (SID,),
id="delete_tail_row"),
pytest.param(
"DELETE FROM messages WHERE rowid = "
"(SELECT MIN(rowid) FROM messages WHERE session_id = ?)", (SID,),
id="delete_head_row"),
pytest.param(
"UPDATE messages SET content = 'short' WHERE rowid = "
"(SELECT MIN(rowid) FROM messages WHERE session_id = ?)", (SID,),
id="edit_content_different_length"),
pytest.param(
"UPDATE messages SET timestamp = 1.0 WHERE rowid = "
"(SELECT MIN(rowid) FROM messages WHERE session_id = ?)", (SID,),
id="edit_timestamp"),
pytest.param(
"UPDATE messages SET role = 'tool' WHERE rowid = "
"(SELECT MIN(rowid) FROM messages WHERE session_id = ?)", (SID,),
id="edit_role_only"),
pytest.param(
"UPDATE messages SET content = 'Z' || SUBSTR(content, 2) WHERE rowid = "
"(SELECT MIN(rowid) FROM messages WHERE session_id = ?)", (SID,),
id="edit_first_char_same_length"),
pytest.param(
"UPDATE messages SET content = "
"SUBSTR(content,1,LENGTH(content)/2-1) || 'Z' || SUBSTR(content,LENGTH(content)/2+1) "
"WHERE rowid = (SELECT MIN(rowid) FROM messages WHERE session_id = ?)", (SID,),
id="edit_middle_char_same_length"),
pytest.param(
"UPDATE messages SET content = "
"SUBSTR(content,1,LENGTH(content)/3-1) || 'Z' || SUBSTR(content,LENGTH(content)/3+1) "
"WHERE rowid = (SELECT MIN(rowid) FROM messages WHERE session_id = ?)", (SID,),
id="edit_third_char_same_length"),
pytest.param(
"UPDATE messages SET content = "
"SUBSTR(content,1,LENGTH(content)-2) || 'Z' || SUBSTR(content,LENGTH(content)) "
"WHERE rowid = (SELECT MIN(rowid) FROM messages WHERE session_id = ?)", (SID,),
id="edit_tail_char_same_length"),
]
@pytest.mark.parametrize("sql,params", MUTATIONS)
def test_signature_detects_mutation(db, sql, params):
"""A stale cache is only safe if the key moves on every visible mutation."""
before = _sig(db)
assert before is not None
_mutate(db, sql, params, touch_session=True)
after = _sig(db)
assert after is not None
assert after != before, "signature did not move -> stale transcript risk"
def test_signature_is_stable_without_mutation(db):
"""No spurious invalidation: repeated reads of an unchanged DB must match."""
first = _sig(db)
assert first is not None
for _ in range(3):
assert _sig(db) == first
def test_signature_is_scoped_to_target_without_process_local_stream_marker(db):
"""Cross-process writes to another session must not churn this cache key."""
first = _sig(db)
assert first is not None
_mutate(
db,
"INSERT INTO messages (session_id, role, content, timestamp) "
"VALUES ('20260202_000000_other', 'user', 'unrelated delta', 1700001111.0)",
)
assert _sig(db) == first
_mutate(
db,
"UPDATE messages SET content = 'Z' || SUBSTR(content, 2) WHERE rowid = "
"(SELECT MIN(rowid) FROM messages WHERE session_id = ?)",
(SID,),
touch_session=True,
)
assert _sig(db) != first, "supported target edit did not advance session revision"
def test_raw_target_tail_length_change_invalidates_without_session_metadata(db):
"""A direct edit of the indexed tail shape still invalidates."""
before = _sig(db)
_mutate(
db,
"UPDATE messages SET content = 'short' WHERE rowid = "
"(SELECT MAX(rowid) FROM messages WHERE session_id = ?)",
(SID,),
)
assert _sig(db) != before
def test_signature_stays_stable_on_other_session_write(db):
"""Session-scoped revision avoids global DB/WAL false invalidations."""
before = _sig(db)
_mutate(
db,
"INSERT INTO messages (session_id, role, content, timestamp) "
"VALUES ('20260202_000000_other', 'user', 'unrelated', 1700001111.0)")
assert _sig(db) == before
# --------------------------------------------------------------------------
# Fail-closed behaviour: the caller must be able to fall back safely.
# --------------------------------------------------------------------------
def test_signature_returns_none_for_unsafe_session_id(db):
assert routes._state_db_session_signature("../../etc/passwd", None) is None
assert routes._state_db_session_signature("", None) is None
assert routes._state_db_session_signature(None, None) is None
def test_signature_returns_none_when_db_missing(tmp_path, monkeypatch):
missing = tmp_path / "absent.db"
monkeypatch.setattr(
"api.models._agent_state_db_path", lambda profile=None: missing, raising=False)
monkeypatch.setattr(
"api.models._cli_sessions_streaming_freeze_marker",
lambda: ("streaming", ("other-active-run",)),
raising=False,
)
assert routes._state_db_session_signature(SID, None) is None
def test_signature_uses_file_fallback_on_incomplete_schema(tmp_path, monkeypatch):
"""File stamps remain a valid key when content summaries are unavailable."""
path = tmp_path / "state.db"
conn = sqlite3.connect(str(path))
try:
conn.execute("CREATE TABLE messages (id INTEGER PRIMARY KEY)")
conn.commit()
finally:
conn.close()
monkeypatch.setattr(
"api.models._agent_state_db_path", lambda profile=None: path, raising=False)
first = routes._state_db_session_signature(SID, None)
assert first is not None
assert routes._state_db_session_signature(SID, None) == first
def test_signature_returns_none_when_all_fingerprints_fail(db, monkeypatch):
monkeypatch.setattr(
routes, "_state_db_target_session_revision", lambda *a, **k: None
)
monkeypatch.setattr(
"api.models._sqlite_file_stat_cache_key", lambda path: None, raising=False)
assert routes._state_db_session_signature(SID, None) is None
def test_signature_returns_none_when_resolver_raises(monkeypatch):
def boom(profile=None):
raise RuntimeError("resolver exploded")
monkeypatch.setattr("api.models._agent_state_db_path", boom, raising=False)
assert routes._state_db_session_signature(SID, None) is None
def test_cache_key_falls_back_to_exact_fingerprint(tmp_path, monkeypatch):
"""When the bounded signature is unavailable, the key must still be built.
This is the fail-closed contract: degraded to the old (slow but exact)
fingerprint rather than disabling the cache or trusting a partial key.
"""
monkeypatch.setattr(
routes, "_state_db_session_signature", lambda *a, **k: None)
calls = []
real = routes._state_db_rows_fingerprint
def spy(rows):
calls.append(len(rows or []))
return real(rows)
monkeypatch.setattr(routes, "_state_db_rows_fingerprint", spy)
class _S:
session_id = SID
profile = None
truncation_watermark = None
truncation_boundary = None
monkeypatch.setattr(
"api.models._sidecar_stat_signature", lambda p: ("sig", 1, 2, 3), raising=False)
rows = [{"role": "user", "content": "x", "timestamp": 1.0}]
key = routes._display_merge_cache_key(_S(), [{"timestamp": 1.0}], rows)
assert calls, "exact fingerprint was not used as fallback"
assert key is not None