* 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>
328 lines
12 KiB
Python
328 lines
12 KiB
Python
"""Perf regression tests — lineage-stitch memoization for GET /api/session.
|
|
|
|
The compression-continuation stitch (`_webui_sidecar_lineage_messages_for_display`)
|
|
re-merged multi-thousand-message parent+child transcripts on EVERY /api/session
|
|
request (seconds per call on real sessions). The merge result is now cached,
|
|
keyed by the exact stat signature of every sidecar involved, so an idle
|
|
historical lineage merges once. These tests pin the contract:
|
|
|
|
- warm calls return the same rows without re-merging;
|
|
- cache hits hand out copies (caller mutation cannot corrupt the cache);
|
|
- any write to child OR snapshot-parent sidecar invalidates the entry.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
import pytest
|
|
|
|
import api.profiles as profiles
|
|
|
|
|
|
@pytest.fixture
|
|
def hermes_home(tmp_path, monkeypatch):
|
|
home = tmp_path / "hermes_home"
|
|
home.mkdir()
|
|
(home / "sessions").mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", home)
|
|
return home
|
|
|
|
|
|
@pytest.fixture
|
|
def lineage(hermes_home, monkeypatch):
|
|
"""A snapshot parent + continuation child pair persisted to disk."""
|
|
import api.routes as routes
|
|
from api.models import Session
|
|
|
|
monkeypatch.setattr(routes, "SESSION_DIR", hermes_home / "sessions")
|
|
import api.models as models
|
|
|
|
monkeypatch.setattr(models, "SESSION_DIR", hermes_home / "sessions")
|
|
# Fresh cache per test.
|
|
routes._lineage_display_cache.clear()
|
|
|
|
parent = Session(
|
|
session_id="lineage_parent",
|
|
title="parent",
|
|
messages=[
|
|
{"role": "user", "content": f"question {i}", "timestamp": 1000 + i * 10}
|
|
if i % 2 == 0
|
|
else {"role": "assistant", "content": f"answer {i}", "timestamp": 1000 + i * 10}
|
|
for i in range(40)
|
|
],
|
|
)
|
|
parent.pre_compression_snapshot = True
|
|
parent.save()
|
|
|
|
child = Session(
|
|
session_id="lineage_child",
|
|
title="child",
|
|
messages=[
|
|
{"role": "user", "content": "suite", "timestamp": 5000},
|
|
{"role": "assistant", "content": "reponse suite", "timestamp": 5010},
|
|
],
|
|
)
|
|
child.parent_session_id = "lineage_parent"
|
|
child.save()
|
|
return routes, Session, child
|
|
|
|
|
|
def test_lineage_stitch_is_cached_and_correct(lineage):
|
|
routes, Session, child = lineage
|
|
m1 = routes._webui_sidecar_lineage_messages_for_display(child)
|
|
assert routes._lineage_display_cache, "merge result must be cached"
|
|
m2 = routes._webui_sidecar_lineage_messages_for_display(child)
|
|
assert [m.get("timestamp") for m in m1] == [m.get("timestamp") for m in m2]
|
|
# Parent turns + child turns all present.
|
|
assert len(m1) == 42
|
|
|
|
|
|
def test_cache_hits_return_copies_not_aliases(lineage):
|
|
routes, Session, child = lineage
|
|
routes._webui_sidecar_lineage_messages_for_display(child)
|
|
m2 = routes._webui_sidecar_lineage_messages_for_display(child)
|
|
m2[0]["__mutated"] = True
|
|
m3 = routes._webui_sidecar_lineage_messages_for_display(child)
|
|
assert "__mutated" not in m3[0], "caller mutation leaked into the cache"
|
|
|
|
|
|
def test_child_write_invalidates_cache(lineage, hermes_home):
|
|
routes, Session, child = lineage
|
|
routes._webui_sidecar_lineage_messages_for_display(child)
|
|
child.messages.append({"role": "user", "content": "new turn", "timestamp": 6000})
|
|
time.sleep(0.01)
|
|
child.save()
|
|
reloaded = Session.load("lineage_child")
|
|
merged = routes._webui_sidecar_lineage_messages_for_display(reloaded)
|
|
assert any(m.get("content") == "new turn" for m in merged), (
|
|
"stale cache served after the child sidecar changed"
|
|
)
|
|
|
|
|
|
def test_parent_write_invalidates_cache(lineage, hermes_home):
|
|
routes, Session, child = lineage
|
|
routes._webui_sidecar_lineage_messages_for_display(child)
|
|
parent = Session.load("lineage_parent")
|
|
parent.messages.append(
|
|
{"role": "assistant", "content": "late parent row", "timestamp": 1999}
|
|
)
|
|
time.sleep(0.01)
|
|
parent.save()
|
|
merged = routes._webui_sidecar_lineage_messages_for_display(child)
|
|
assert any(m.get("content") == "late parent row" for m in merged), (
|
|
"stale cache served after the snapshot parent sidecar changed"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"field,value",
|
|
[
|
|
("active_stream_id", "stream-live"),
|
|
("pending_user_message", {"content": "queued"}),
|
|
],
|
|
)
|
|
def test_active_or_pending_lineage_bypasses_reads_and_writes(lineage, field, value):
|
|
routes, Session, child = lineage
|
|
routes._webui_sidecar_lineage_messages_for_display(child)
|
|
setattr(child, field, value)
|
|
child.messages.append(
|
|
{"role": "assistant", "content": "unsaved live tail", "timestamp": 2000}
|
|
)
|
|
|
|
merged = routes._webui_sidecar_lineage_messages_for_display(child)
|
|
assert any(m.get("content") == "unsaved live tail" for m in merged)
|
|
|
|
with routes._lineage_display_cache_lock:
|
|
routes._lineage_display_cache.pop(child.session_id, None)
|
|
routes._webui_sidecar_lineage_messages_for_display(child)
|
|
with routes._lineage_display_cache_lock:
|
|
assert child.session_id not in routes._lineage_display_cache
|
|
|
|
|
|
def test_lru_eviction_during_parent_validation_cannot_crash(lineage, monkeypatch):
|
|
"""A cache entry may disappear while its parent signatures are checked."""
|
|
routes, Session, child = lineage
|
|
import api.models as models
|
|
|
|
expected = routes._webui_sidecar_lineage_messages_for_display(child)
|
|
real_signature = models._sidecar_stat_signature
|
|
evicted = False
|
|
|
|
def evict_on_parent(path):
|
|
nonlocal evicted
|
|
if not evicted and path.stem == "lineage_parent":
|
|
evicted = True
|
|
with routes._lineage_display_cache_lock:
|
|
routes._lineage_display_cache.pop(child.session_id, None)
|
|
return real_signature(path)
|
|
|
|
monkeypatch.setattr(models, "_sidecar_stat_signature", evict_on_parent)
|
|
got = routes._webui_sidecar_lineage_messages_for_display(child)
|
|
|
|
assert evicted
|
|
assert [m.get("content") for m in got] == [m.get("content") for m in expected]
|
|
|
|
|
|
def test_incomplete_multihop_parent_signatures_disable_cache(hermes_home, monkeypatch):
|
|
import api.models as models
|
|
import api.routes as routes
|
|
from api.models import Session
|
|
|
|
monkeypatch.setattr(routes, "SESSION_DIR", hermes_home / "sessions")
|
|
monkeypatch.setattr(models, "SESSION_DIR", hermes_home / "sessions")
|
|
routes._lineage_display_cache.clear()
|
|
|
|
grandparent = Session(
|
|
session_id="lineage_grandparent",
|
|
messages=[{"role": "user", "content": "grand", "timestamp": 1}],
|
|
)
|
|
grandparent.pre_compression_snapshot = True
|
|
grandparent.save()
|
|
parent = Session(
|
|
session_id="lineage_parent_two",
|
|
messages=[{"role": "assistant", "content": "parent", "timestamp": 2}],
|
|
)
|
|
parent.pre_compression_snapshot = True
|
|
parent.parent_session_id = grandparent.session_id
|
|
parent.save()
|
|
child = Session(
|
|
session_id="lineage_child_two",
|
|
messages=[{"role": "user", "content": "child", "timestamp": 3}],
|
|
)
|
|
child.parent_session_id = parent.session_id
|
|
child.save()
|
|
|
|
real_signature = models._sidecar_stat_signature
|
|
|
|
def incomplete_signature(path):
|
|
if path.stem == grandparent.session_id:
|
|
return None
|
|
return real_signature(path)
|
|
|
|
monkeypatch.setattr(models, "_sidecar_stat_signature", incomplete_signature)
|
|
got = routes._webui_sidecar_lineage_messages_for_display(child)
|
|
|
|
assert [m.get("content") for m in got] == ["grand", "parent", "child"]
|
|
with routes._lineage_display_cache_lock:
|
|
assert child.session_id not in routes._lineage_display_cache
|
|
assert routes._display_merge_cache_key(
|
|
child,
|
|
got,
|
|
[],
|
|
state_db_signature=("db", "stable"),
|
|
) is None
|
|
|
|
|
|
def test_parent_replace_during_load_is_not_published(lineage, monkeypatch):
|
|
"""A parent signature must bracket the snapshot used for the cached stitch."""
|
|
routes, Session, child = lineage
|
|
real_load = Session.load
|
|
replaced = False
|
|
|
|
def load_and_replace(cls, session_id):
|
|
nonlocal replaced
|
|
loaded = real_load(session_id)
|
|
if session_id == "lineage_parent" and not replaced:
|
|
replaced = True
|
|
replacement = real_load(session_id)
|
|
replacement.messages[0] = {
|
|
"role": "user",
|
|
"content": "parent-new",
|
|
"timestamp": 1000,
|
|
}
|
|
replacement.save()
|
|
return loaded
|
|
|
|
monkeypatch.setattr(Session, "load", classmethod(load_and_replace))
|
|
|
|
first = routes._webui_sidecar_lineage_messages_for_display(child)
|
|
assert first[0]["content"] == "question 0"
|
|
|
|
second = routes._webui_sidecar_lineage_messages_for_display(child)
|
|
assert second[0]["content"] == "parent-new"
|
|
|
|
|
|
def test_unverifiable_warm_parent_evicts_lineage_and_blocks_display_hit(
|
|
lineage, monkeypatch
|
|
):
|
|
"""Old provenance cannot survive a parent replacement plus stat failure."""
|
|
routes, Session, child = lineage
|
|
from api import models
|
|
|
|
state_signature = ("target-session-revision",)
|
|
monkeypatch.setattr(
|
|
routes, "_state_db_session_signature", lambda *_a, **_k: state_signature
|
|
)
|
|
old_messages = routes._webui_sidecar_lineage_messages_for_display(child)
|
|
old_key = routes._display_merge_cache_key(
|
|
child,
|
|
old_messages,
|
|
[],
|
|
state_db_signature=state_signature,
|
|
)
|
|
assert old_key is not None
|
|
with routes._display_merge_cache_lock:
|
|
routes._display_merge_cache[child.session_id] = {
|
|
"key": old_key,
|
|
"messages": old_messages,
|
|
"stored_at": 0.0,
|
|
}
|
|
|
|
parent = Session.load("lineage_parent")
|
|
parent.messages[0] = {
|
|
"role": "user",
|
|
"content": "parent-new",
|
|
"timestamp": 1000,
|
|
}
|
|
parent.save()
|
|
real_signature = models._sidecar_stat_signature
|
|
|
|
def unavailable_parent(path):
|
|
if path.stem == "lineage_parent":
|
|
return None
|
|
return real_signature(path)
|
|
|
|
monkeypatch.setattr(models, "_sidecar_stat_signature", unavailable_parent)
|
|
rebuilt = routes._webui_sidecar_lineage_messages_for_display(child)
|
|
|
|
assert rebuilt[0]["content"] == "parent-new"
|
|
with routes._lineage_display_cache_lock:
|
|
assert child.session_id not in routes._lineage_display_cache
|
|
assert routes._display_merge_cached_messages(child, rebuilt) is None
|
|
|
|
|
|
def test_missing_declared_ancestor_disables_cache_until_it_appears(lineage):
|
|
"""Negative lineage provenance must invalidate when the file appears later."""
|
|
routes, Session, child = lineage
|
|
parent = Session.load("lineage_parent")
|
|
parent.parent_session_id = "lineage_grand"
|
|
parent.save()
|
|
|
|
first = routes._webui_sidecar_lineage_messages_for_display(child)
|
|
assert all(row["content"] != "grand" for row in first)
|
|
with routes._lineage_display_cache_lock:
|
|
assert child.session_id not in routes._lineage_display_cache
|
|
|
|
grand = Session(session_id="lineage_grand", workspace=".")
|
|
grand.pre_compression_snapshot = True
|
|
grand.messages = [{"role": "user", "content": "grand", "timestamp": 10}]
|
|
grand.save()
|
|
|
|
second = routes._webui_sidecar_lineage_messages_for_display(child)
|
|
assert second[0]["content"] == "grand"
|
|
|
|
|
|
def test_sessions_without_lineage_do_not_pollute_cache(lineage):
|
|
routes, Session, child = lineage
|
|
routes._lineage_display_cache.clear()
|
|
solo = Session(
|
|
session_id="lineage_solo",
|
|
title="solo",
|
|
messages=[{"role": "user", "content": "hi", "timestamp": 1}],
|
|
)
|
|
solo.save()
|
|
out = routes._webui_sidecar_lineage_messages_for_display(solo)
|
|
assert len(out) == 1
|
|
assert "lineage_solo" not in routes._lineage_display_cache
|