Files
hermes-webui/tests/test_display_merge_cache.py
T
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

120 lines
4.5 KiB
Python

"""The display merge for paginated loads must be memoized for inactive sessions.
Before the fix, GET /api/session re-ran merge_session_messages_append_only on
every request (~2-3s for multi-thousand-message transcripts). The cache must:
- return the same merged transcript as a direct merge (correctness),
- serve copies (caller mutation cannot corrupt the cache),
- invalidate when the sidecar file changes on disk,
- invalidate when the state.db rows change,
- never cache ACTIVE sessions (active_stream_id / pending_user_message).
"""
import time
from types import SimpleNamespace
import pytest
@pytest.fixture()
def routes_env(tmp_path, monkeypatch):
import api.config as config
import api.models as models
import api.routes as routes
home = tmp_path / "home"
state_dir = tmp_path / "state"
session_dir = state_dir / "sessions"
session_dir.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_WEBUI_STATE_DIR", str(state_dir))
monkeypatch.setattr(config, "STATE_DIR", state_dir)
monkeypatch.setattr(config, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(routes, "SESSION_DIR", session_dir)
with routes._display_merge_cache_lock:
routes._display_merge_cache.clear()
yield SimpleNamespace(config=config, models=models, routes=routes)
with routes._display_merge_cache_lock:
routes._display_merge_cache.clear()
def _make_session(routes_env, sid="20260101_000000_cache1", n=6):
models = routes_env.models
s = models.Session(session_id=sid)
now = time.time()
for i in range(n):
role = "user" if i % 2 == 0 else "assistant"
s.messages.append({"role": role, "content": f"turn {i}", "timestamp": now + i})
s.save()
return s
def _state_rows(base_ts, n=2):
return [
{"role": "assistant", "content": f"state row {i}", "timestamp": base_ts + 100 + i}
for i in range(n)
]
def test_merge_cached_and_equal_to_direct_merge(routes_env):
routes = routes_env.routes
s = _make_session(routes_env)
rows = _state_rows(s.messages[-1]["timestamp"])
first = routes._limited_webui_messages_for_display_with_sidecar(s, None, rows)
assert routes._display_merge_cache, "expected a cache entry for an inactive session"
second = routes._limited_webui_messages_for_display_with_sidecar(s, None, rows)
assert [m.get("content") for m in first] == [m.get("content") for m in second]
assert any(m.get("content") == "state row 0" for m in second)
def test_cache_hit_returns_copies(routes_env):
routes = routes_env.routes
s = _make_session(routes_env, sid="20260101_000000_cache2")
rows = _state_rows(s.messages[-1]["timestamp"])
first = routes._limited_webui_messages_for_display_with_sidecar(s, None, rows)
first[0]["content"] = "MUTATED"
second = routes._limited_webui_messages_for_display_with_sidecar(s, None, rows)
assert second[0]["content"] != "MUTATED"
def test_cache_invalidated_by_sidecar_write(routes_env):
routes = routes_env.routes
s = _make_session(routes_env, sid="20260101_000000_cache3")
rows = _state_rows(s.messages[-1]["timestamp"])
routes._limited_webui_messages_for_display_with_sidecar(s, None, rows)
s.messages.append({
"role": "assistant", "content": "new turn after write",
"timestamp": time.time() + 50,
})
s.save()
merged = routes._limited_webui_messages_for_display_with_sidecar(s, None, rows)
assert any(m.get("content") == "new turn after write" for m in merged)
def test_cache_invalidated_by_state_rows_change(routes_env):
routes = routes_env.routes
s = _make_session(routes_env, sid="20260101_000000_cache4")
rows = _state_rows(s.messages[-1]["timestamp"])
routes._limited_webui_messages_for_display_with_sidecar(s, None, rows)
rows2 = rows + [{
"role": "assistant", "content": "brand new state row",
"timestamp": rows[-1]["timestamp"] + 5,
}]
merged = routes._limited_webui_messages_for_display_with_sidecar(s, None, rows2)
assert any(m.get("content") == "brand new state row" for m in merged)
def test_active_session_never_cached(routes_env):
routes = routes_env.routes
s = _make_session(routes_env, sid="20260101_000000_cache5")
s.active_stream_id = "stream-live"
rows = _state_rows(s.messages[-1]["timestamp"])
routes._display_merge_cache.clear()
routes._limited_webui_messages_for_display_with_sidecar(s, None, rows)
assert s.session_id not in routes._display_merge_cache