* 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>
305 lines
11 KiB
Python
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
|