Files
hermes-webui/tests/test_issue4842_cli_sessions_streaming_freeze.py
nesquena-hermes b87a605306 perf(#4842): freeze the CLI/cron sidebar cache key during streaming
/api/sessions re-ran the expensive CLI/cron session projection on every
poll while a turn was streaming — a major cause of the multi-second sidebar
latency + 100% CPU on cron-heavy installs (continuing #4672/#4808/#4889).

Root cause: the CLI/cron projection is gated by _CLI_SESSIONS_CACHE, whose
key folds in _sqlite_file_stat_cache_key -> _sqlite_content_fingerprint
(MAX(rowid) FROM messages). During a live turn the gateway writes a message
row per streamed delta, so that fingerprint advances on essentially every
~5s poll — busting the cache and re-running the full candidate-join +
projection (and the lineage-metadata pass) on every poll, contending for the
same SQLite/global lock the streaming worker holds.

The route-level session-list cache already froze its key during streaming
(#4808 _session_list_cache_streaming_freeze_marker), but that freeze never
reached this inner CLI-sessions cache, so the heavy CLI/cron query still
re-ran whenever the outer cache validated.

Fix: while any stream is active, the CLI-sessions cache key folds in the same
stable streaming-freeze marker (keyed only on the set of active stream ids)
instead of the volatile content fingerprint, and the cache TTL widens to a
streaming window. The projection is reused across polls mid-stream and
rebuilt at most once per window. The instant a stream starts/stops the marker
changes, so freshly-finished rows surface promptly. Structural mutations
(cron completion, new/renamed/archived sessions, attention) now also clear
the CLI cache via the existing session-list-changed listener — those signals
never fire per streamed token, which is what makes the freeze safe.

Idle behavior is byte-identical (freeze only engages with active streams).
Applies to both the active-profile and all-profiles cache paths.

Adds tests/test_issue4842_cli_sessions_streaming_freeze.py (6 tests): marker
idle/stable/changes semantics, the core 'key stable across message writes
while streaming' guarantee, idle key still advances with the fingerprint,
streaming TTL > idle TTL, and the structural-mutation listener clears the
CLI cache.
2026-06-25 21:57:36 +00:00

121 lines
5.4 KiB
Python

"""Behavioral test for #4842: the CLI/cron sidebar projection cache must not be
re-run on every poll while a turn is streaming.
Root cause: ``_CLI_SESSIONS_CACHE`` is keyed (via ``_resolve_cli_sessions_context``
-> ``_sqlite_file_stat_cache_key`` -> ``_sqlite_content_fingerprint``) on
``MAX(rowid) FROM messages``. During a live turn the gateway writes a message row
per streamed delta, so that fingerprint advances on essentially every
``/api/sessions`` poll, busting the cache and re-running the expensive CLI/cron
candidate-join + projection (and the lineage-metadata pass) on every poll — the
multi-second ``get_cli_sessions`` in #4842.
Fix: while any stream is active, the cache key folds in a stable streaming-freeze
marker (keyed only on the set of active stream ids) instead of the volatile
content fingerprint, and the cache TTL widens — so the projection is reused across
polls mid-stream and rebuilt at most once per streaming-TTL window. The instant a
stream starts/stops the marker changes, so freshly-finished rows surface promptly.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from api import models as M
def _set_active_streams(monkeypatch, ids):
monkeypatch.setattr(M, "_active_stream_ids", lambda: set(ids))
def test_freeze_marker_none_when_idle(monkeypatch):
_set_active_streams(monkeypatch, [])
assert M._cli_sessions_streaming_freeze_marker() is None
def test_freeze_marker_stable_for_same_streams(monkeypatch):
_set_active_streams(monkeypatch, ["sA", "sB"])
m1 = M._cli_sessions_streaming_freeze_marker()
m2 = M._cli_sessions_streaming_freeze_marker()
assert m1 is not None and m1 == m2
# order-independent
_set_active_streams(monkeypatch, ["sB", "sA"])
assert M._cli_sessions_streaming_freeze_marker() == m1
def test_freeze_marker_changes_when_stream_set_changes(monkeypatch):
_set_active_streams(monkeypatch, ["sA"])
m1 = M._cli_sessions_streaming_freeze_marker()
_set_active_streams(monkeypatch, ["sA", "sB"])
m2 = M._cli_sessions_streaming_freeze_marker()
assert m1 != m2
_set_active_streams(monkeypatch, [])
assert M._cli_sessions_streaming_freeze_marker() is None
def test_cache_key_stable_across_message_writes_while_streaming(monkeypatch, tmp_path):
"""THE core guarantee: with a stream active, the CLI cache key must NOT change
when the state.db content fingerprint advances (a new streamed message row).
Before the fix the key folded in the live fingerprint and changed every write."""
db = tmp_path / "state.db"
db.write_bytes(b"") # exists; fingerprint reader degrades gracefully
monkeypatch.setattr(M, "_default_claude_code_projects_dir", lambda: tmp_path / "projects")
# Simulate the volatile fingerprint advancing on each streamed message.
fp = {"v": 0}
monkeypatch.setattr(M, "_sqlite_file_stat_cache_key", lambda p: ("fp", fp["v"]))
# Active stream -> key should be frozen (independent of fp).
_set_active_streams(monkeypatch, ["live-stream-1"])
_, _, _, key_a = M._resolve_cli_sessions_context(None)
fp["v"] = 1 # a streamed message row landed
_, _, _, key_b = M._resolve_cli_sessions_context(None)
fp["v"] = 2 # another
_, _, _, key_c = M._resolve_cli_sessions_context(None)
assert key_a == key_b == key_c, (
"CLI cache key changed across message writes while streaming — the heavy "
"projection would re-run on every poll (#4842 regression)"
)
# Idle -> key tracks the fingerprint again (so genuine new rows show up).
_set_active_streams(monkeypatch, [])
fp["v"] = 10
_, _, _, key_idle1 = M._resolve_cli_sessions_context(None)
fp["v"] = 11
_, _, _, key_idle2 = M._resolve_cli_sessions_context(None)
assert key_idle1 != key_idle2, (
"When idle the CLI cache key must still advance with the content "
"fingerprint so newly-committed sessions are not served stale"
)
def test_streaming_ttl_wider_than_idle(monkeypatch):
_set_active_streams(monkeypatch, [])
idle = M._cli_sessions_cache_ttl_seconds()
_set_active_streams(monkeypatch, ["s1"])
streaming = M._cli_sessions_cache_ttl_seconds()
assert streaming > idle, (
"streaming TTL must exceed idle TTL so the fixed poll cadence does not "
"force a rebuild on every poll (#4842)"
)
def test_structural_change_listener_clears_cli_cache(monkeypatch):
"""While streaming, the CLI cache is frozen and no longer self-invalidates via
the content fingerprint. A structural mutation (cron completion / new / renamed
/ archived session) must therefore clear the CLI cache directly so the change
surfaces promptly instead of lagging up to the streaming TTL. Those structural
signals fire the session-list-changed listener; per-token message writes never
do — that is exactly what makes the freeze safe."""
from api import routes as R
cleared = {"n": 0}
monkeypatch.setattr(M, "clear_cli_sessions_cache", lambda: cleared.__setitem__("n", cleared["n"] + 1))
# The route module imports the symbol lazily inside the listener, so patching
# api.models.clear_cli_sessions_cache is what the listener resolves.
R._on_session_list_changed("default")
assert cleared["n"] >= 1, (
"_on_session_list_changed must clear the CLI/cron projection cache so a "
"structural mutation isn't masked by the streaming freeze (#4842)"
)