Files
hermes-webui/tests/test_session_sidebar_cache.py
T
a66913ac26 stage #4680 (nesquena-hermes): freeze /api/sessions cache during streaming (fixes #4672) (#4690)
Freezes the session-list cache source-stamp on the SET of active stream ids (not per-write
state) so mid-stream sidebar polls hold the cache instead of rebuilding all_sessions() every
poll and contending for the global LOCK the streaming worker holds (the ~2 tok/s + 15s
/api/sessions cause). Structural mutations still invalidate via publish_session_list_changed;
only a streaming session's own title/message_count lags <=TTL. routes.py + 109-line test.
99 session-list/sidebar/cache tests pass.

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-06-22 02:11:04 -07:00

518 lines
18 KiB
Python

import threading
from types import SimpleNamespace
import api.routes as routes
from api import session_events
class _StageRecorder:
def __init__(self):
self.stages = []
def stage(self, name):
self.stages.append(str(name))
def _session_cache_payload(marker: str, *, all_profiles: bool = False) -> dict:
return {
"sessions": [{"session_id": marker}],
"cli_count": 0,
"all_profiles": all_profiles,
"active_profile": None,
"other_profile_count": 0,
}
def test_session_list_cache_key_separates_profile_and_all_profiles():
routes._session_list_cache_clear()
calls = []
def builder_profile_a():
calls.append("default")
return _session_cache_payload("a")
def builder_profile_a_all():
calls.append("default_all")
return _session_cache_payload("a_all", all_profiles=True)
def builder_profile_b():
calls.append("other")
return _session_cache_payload("b")
key_a = routes._session_list_cache_key(
active_profile="default",
all_profiles=False,
show_cli_sessions=False,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
)
key_a_all = routes._session_list_cache_key(
active_profile="default",
all_profiles=True,
show_cli_sessions=False,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
)
key_b = routes._session_list_cache_key(
active_profile="other",
all_profiles=False,
show_cli_sessions=False,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
)
assert routes._get_cached_session_list_payload(key=key_a, builder=builder_profile_a) == _session_cache_payload("a")
assert calls == ["default"]
assert routes._get_cached_session_list_payload(key=key_a, builder=builder_profile_a) == _session_cache_payload("a")
assert calls == ["default"]
assert routes._get_cached_session_list_payload(key=key_b, builder=builder_profile_b) == _session_cache_payload("b")
assert calls == ["default", "other"]
assert routes._get_cached_session_list_payload(key=key_a_all, builder=builder_profile_a_all) == _session_cache_payload("a_all", all_profiles=True)
assert calls == ["default", "other", "default_all"]
assert routes._get_cached_session_list_payload(key=key_a, builder=builder_profile_a) == _session_cache_payload("a")
assert calls == ["default", "other", "default_all"]
def test_session_list_cache_singleflight_rebuild_once():
routes._session_list_cache_clear()
started = threading.Event()
release = threading.Event()
calls = 0
lock = threading.Lock()
def builder():
nonlocal calls
with lock:
calls += 1
started.set()
release.wait()
return _session_cache_payload("singleflight")
key = routes._session_list_cache_key(
active_profile="default",
all_profiles=False,
show_cli_sessions=False,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
)
results = []
errors = []
def reader():
try:
results.append(routes._get_cached_session_list_payload(key=key, builder=builder))
except Exception as exc:
errors.append(exc)
owner = threading.Thread(target=reader)
follower = threading.Thread(target=reader)
owner.start()
assert started.wait(1.0)
follower.start()
release.set()
owner.join(2)
follower.join(2)
assert not errors
assert len(results) == 2
assert results[0] == _session_cache_payload("singleflight")
assert results[1] == _session_cache_payload("singleflight")
assert calls == 1
def test_session_list_cache_follower_wait_stage_when_rebuild_inflight():
routes._session_list_cache_clear()
started = threading.Event()
release = threading.Event()
key = routes._session_list_cache_key(
active_profile="default",
all_profiles=False,
show_cli_sessions=False,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
)
def builder():
started.set()
release.wait()
return _session_cache_payload("wait")
follower_diag = _StageRecorder()
owner_diag = _StageRecorder()
def owner():
routes._get_cached_session_list_payload(
key=key,
builder=builder,
diag=owner_diag,
)
def follower():
routes._get_cached_session_list_payload(
key=key,
builder=builder,
diag=follower_diag,
)
owner_thread = threading.Thread(target=owner)
follower_thread = threading.Thread(target=follower)
owner_thread.start()
assert started.wait(1.0)
follower_thread.start()
release.set()
owner_thread.join(2)
follower_thread.join(2)
assert "session_list_cache_wait" in follower_diag.stages
assert "session_list_cache_hit" in owner_diag.stages or "session_list_cache_stored" in owner_diag.stages
def test_session_list_cache_follower_reuses_stale_payload_during_slow_rebuild():
routes._session_list_cache_clear()
key = routes._session_list_cache_key(
active_profile="default",
all_profiles=False,
show_cli_sessions=False,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
)
routes._session_list_cache_set(key, _session_cache_payload("stale"))
with routes._SESSIONS_CACHE_LOCK:
ts, stamp, payload = routes._SESSIONS_CACHE[key]
routes._SESSIONS_CACHE[key] = (
ts - routes._SESSIONS_CACHE_TTL_SECONDS - 1.0,
stamp,
payload,
)
started = threading.Event()
release = threading.Event()
owner_result = {}
follower_result = {}
owner_diag = _StageRecorder()
follower_diag = _StageRecorder()
def builder():
started.set()
release.wait()
return _session_cache_payload("fresh")
def owner():
owner_result["payload"] = routes._get_cached_session_list_payload(
key=key,
builder=builder,
diag=owner_diag,
)
def follower():
follower_result["payload"] = routes._get_cached_session_list_payload(
key=key,
builder=builder,
diag=follower_diag,
)
owner_thread = threading.Thread(target=owner)
follower_thread = threading.Thread(target=follower)
owner_thread.start()
assert started.wait(1.0)
follower_thread.start()
follower_thread.join(1.0)
assert not follower_thread.is_alive()
release.set()
owner_thread.join(2.0)
assert follower_result["payload"] == _session_cache_payload("stale")
assert owner_result["payload"] == _session_cache_payload("fresh")
assert "session_list_cache_wait_stale" in follower_diag.stages
assert "session_list_cache_wait_stale_fallback" in follower_diag.stages
def test_session_list_cache_invalidated_on_session_list_publish():
routes._session_list_cache_clear()
key_a = routes._session_list_cache_key(
active_profile="profile-a",
all_profiles=False,
show_cli_sessions=False,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
)
key_b = routes._session_list_cache_key(
active_profile="profile-b",
all_profiles=False,
show_cli_sessions=False,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
)
key_a_all = routes._session_list_cache_key(
active_profile="profile-a",
all_profiles=True,
show_cli_sessions=False,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
)
routes._session_list_cache_set(key_a, _session_cache_payload("a"))
routes._session_list_cache_set(key_b, _session_cache_payload("b"))
routes._session_list_cache_set(key_a_all, _session_cache_payload("a_all", all_profiles=True))
session_events.publish_session_list_changed("session_pin", profile="profile-a")
assert routes._session_list_cache_get(key_a)[0] is None
assert routes._session_list_cache_get(key_a_all)[0] is None
assert routes._session_list_cache_get(key_b)[0] is not None
def test_session_list_cache_rebuild_retries_after_invalidation():
routes._session_list_cache_clear()
key = routes._session_list_cache_key(
active_profile="profile-a",
all_profiles=False,
show_cli_sessions=False,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
)
calls = []
def builder():
calls.append("build")
if len(calls) == 1:
routes._session_list_cache_clear("profile-a")
return _session_cache_payload("stale")
return _session_cache_payload("fresh")
payload = routes._get_cached_session_list_payload(key=key, builder=builder)
assert payload == _session_cache_payload("fresh")
assert calls == ["build", "build"]
def test_session_list_cache_source_stamp_tracks_state_db_wal(tmp_path, monkeypatch):
state_db = tmp_path / "state.db"
state_db.write_text("db", encoding="utf-8")
state_db_wal = tmp_path / "state.db-wal"
state_db_wal.write_text("wal-1", encoding="utf-8")
gateway = tmp_path / "gateway-sessions.json"
gateway.write_text("{}", encoding="utf-8")
session_dir = tmp_path / "sessions"
session_dir.mkdir()
(session_dir / "_index.json").write_text("{}", encoding="utf-8")
settings_file = tmp_path / "settings.json"
settings_file.write_text("{}", encoding="utf-8")
monkeypatch.setattr(routes, "_active_state_db_path", lambda: str(state_db))
monkeypatch.setattr(routes, "_gateway_session_metadata_path", lambda: gateway)
monkeypatch.setattr(routes, "SESSION_DIR", session_dir)
monkeypatch.setattr(routes, "SETTINGS_FILE", settings_file)
key = routes._session_list_cache_key(
active_profile="default",
all_profiles=False,
show_cli_sessions=True,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
)
before = routes._session_list_cache_source_stamp(key)
state_db_wal.write_text("wal-2-more", encoding="utf-8")
after = routes._session_list_cache_source_stamp(key)
assert after != before
def test_session_list_cache_source_stamp_tracks_settings_file(tmp_path, monkeypatch):
state_db = tmp_path / "state.db"
state_db.write_text("db", encoding="utf-8")
gateway = tmp_path / "gateway-sessions.json"
gateway.write_text("{}", encoding="utf-8")
session_dir = tmp_path / "sessions"
session_dir.mkdir()
(session_dir / "_index.json").write_text("{}", encoding="utf-8")
settings_file = tmp_path / "settings.json"
settings_file.write_text('{"show_cli_sessions": false}', encoding="utf-8")
monkeypatch.setattr(routes, "_active_state_db_path", lambda: str(state_db))
monkeypatch.setattr(routes, "_gateway_session_metadata_path", lambda: gateway)
monkeypatch.setattr(routes, "SESSION_DIR", session_dir)
monkeypatch.setattr(routes, "SETTINGS_FILE", settings_file)
key = routes._session_list_cache_key(
active_profile="default",
all_profiles=False,
show_cli_sessions=True,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
)
before = routes._session_list_cache_source_stamp(key)
settings_file.write_text('{"show_cli_sessions": true}', encoding="utf-8")
after = routes._session_list_cache_source_stamp(key)
assert after != before
def test_session_list_payload_to_response_overlays_live_stream_runtime(monkeypatch):
payload = {
"sessions": [
{
"session_id": "streaming-session",
"title": "Live title",
"active_stream_id": None,
"is_streaming": False,
"has_pending_user_message": False,
},
{
"session_id": "stale-session",
"title": "Old title",
"active_stream_id": "stale-stream",
"is_streaming": True,
"has_pending_user_message": True,
},
],
"cli_count": 0,
"all_profiles": False,
"active_profile": "default",
"other_profile_count": 0,
}
monkeypatch.setattr(routes, "_active_stream_ids", lambda: {"live-stream"})
with routes.LOCK:
original = dict(routes.SESSIONS)
routes.SESSIONS.clear()
routes.SESSIONS["streaming-session"] = SimpleNamespace(
active_stream_id="live-stream",
pending_user_message="queued prompt",
)
routes.SESSIONS["stale-session"] = SimpleNamespace(
active_stream_id=None,
pending_user_message=None,
)
try:
response = routes._session_list_payload_to_response(payload)
finally:
with routes.LOCK:
routes.SESSIONS.clear()
routes.SESSIONS.update(original)
by_id = {row["session_id"]: row for row in response["sessions"]}
assert by_id["streaming-session"]["active_stream_id"] == "live-stream"
assert by_id["streaming-session"]["is_streaming"] is True
assert by_id["streaming-session"]["has_pending_user_message"] is True
assert by_id["stale-session"]["active_stream_id"] is None
assert by_id["stale-session"]["is_streaming"] is False
assert by_id["stale-session"]["has_pending_user_message"] is False
def _build_stamp_env(tmp_path, monkeypatch):
"""Wire a self-contained source-stamp environment and return its key."""
state_db = tmp_path / "state.db"
state_db.write_text("db", encoding="utf-8")
state_db_wal = tmp_path / "state.db-wal"
state_db_wal.write_text("wal-1", encoding="utf-8")
gateway = tmp_path / "gateway-sessions.json"
gateway.write_text("{}", encoding="utf-8")
session_dir = tmp_path / "sessions"
session_dir.mkdir()
(session_dir / "_index.json").write_text("{}", encoding="utf-8")
settings_file = tmp_path / "settings.json"
settings_file.write_text("{}", encoding="utf-8")
monkeypatch.setattr(routes, "_active_state_db_path", lambda: str(state_db))
monkeypatch.setattr(routes, "_gateway_session_metadata_path", lambda: gateway)
monkeypatch.setattr(routes, "SESSION_DIR", session_dir)
monkeypatch.setattr(routes, "SETTINGS_FILE", settings_file)
# Make the content fingerprint deterministic and unaffected by the dummy
# text-file state.db (a real sqlite connect would just return None here).
fingerprint = {"value": (1, 1)}
monkeypatch.setattr(
routes,
"_session_list_cache_state_db_fingerprint",
lambda _p: fingerprint["value"],
)
key = routes._session_list_cache_key(
active_profile="default",
all_profiles=False,
show_cli_sessions=True,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
)
return key, state_db_wal, settings_file, fingerprint
def test_source_stamp_freezes_during_streaming_message_writes(tmp_path, monkeypatch):
"""#4672: per-token state.db churn must not bust the cache mid-stream.
With an active stream the volatile state.db-derived components are collapsed
to a stream-set marker, so advancing the WAL stat AND the content
fingerprint (the per-message-write signals) leaves the stamp unchanged.
Before the fix each of these advanced the stamp and forced a rebuild on
every poll.
"""
key, state_db_wal, _settings_file, fingerprint = _build_stamp_env(
tmp_path, monkeypatch
)
monkeypatch.setattr(routes, "_active_stream_ids", lambda: {"turn-1"})
before = routes._session_list_cache_source_stamp(key)
# Simulate the writes an active chat turn makes to state.db: WAL grows and
# the messages-table fingerprint advances.
state_db_wal.write_text("wal-2-grew-a-lot", encoding="utf-8")
fingerprint["value"] = (2, 99)
after = routes._session_list_cache_source_stamp(key)
assert after == before
def test_source_stamp_changes_when_stream_set_transitions(tmp_path, monkeypatch):
"""The hold-down marker tracks the active-stream SET, so a turn starting or
finishing re-validates the cache and the final title/count is picked up."""
key, _wal, _settings, _fp = _build_stamp_env(tmp_path, monkeypatch)
streams = {"value": set()}
monkeypatch.setattr(routes, "_active_stream_ids", lambda: set(streams["value"]))
idle = routes._session_list_cache_source_stamp(key)
streams["value"] = {"turn-1"}
streaming = routes._session_list_cache_source_stamp(key)
streams["value"] = {"turn-1", "turn-2"}
two_streams = routes._session_list_cache_source_stamp(key)
streams["value"] = set()
idle_again = routes._session_list_cache_source_stamp(key)
assert idle != streaming
assert streaming != two_streams
# Returning to idle re-engages the live state.db stamp path.
assert idle_again != streaming
def test_source_stamp_tracks_settings_even_while_streaming(tmp_path, monkeypatch):
"""Settings-file / sidebar-toggle changes must still invalidate the cache
during streaming so user-initiated changes are never held stale."""
key, _wal, settings_file, _fp = _build_stamp_env(tmp_path, monkeypatch)
monkeypatch.setattr(routes, "_active_stream_ids", lambda: {"turn-1"})
before = routes._session_list_cache_source_stamp(key)
settings_file.write_text('{"show_cli_sessions": true}', encoding="utf-8")
after = routes._session_list_cache_source_stamp(key)
assert after != before
def test_source_stamp_still_tracks_wal_when_idle(tmp_path, monkeypatch):
"""Regression guard: with NO active stream the stamp must still advance on a
state.db/WAL write (the original commit-reliable behavior is preserved)."""
key, state_db_wal, _settings, _fp = _build_stamp_env(tmp_path, monkeypatch)
monkeypatch.setattr(routes, "_active_stream_ids", lambda: set())
before = routes._session_list_cache_source_stamp(key)
state_db_wal.write_text("wal-2-more", encoding="utf-8")
after = routes._session_list_cache_source_stamp(key)
assert after != before