mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-19 22:20:30 +00:00
Merge commit 'refs/pull/6102/head' of github.com:nesquena/hermes-webui into stage/6102-cachettl
This commit is contained in:
+14
-11
@@ -54,10 +54,12 @@ WEBHOOK_PROJECT_CHIP_LIMIT = 200
|
||||
_CLI_SESSIONS_CACHE_TTL_SECONDS = 5.0
|
||||
# While a turn is actively streaming, hold the CLI/cron projection longer than
|
||||
# one poll interval (mirrors the route-level #4808 hold-down). The frontend
|
||||
# polls /api/sessions every ~5s during a stream; without a wider window the
|
||||
# CLI cache key advances on every streamed message row (see below) and the
|
||||
# expensive state.db CLI/cron projection is re-run on every poll. (#4842)
|
||||
_CLI_SESSIONS_CACHE_STREAMING_TTL_SECONDS = 30.0
|
||||
# polls /api/sessions on the static/sessions.js `_streamingPollMs` cadence.
|
||||
# Pair this wider window with the stable streaming cache key below so repeated
|
||||
# polls reuse the projection instead of re-running the expensive state.db
|
||||
# CLI/cron projection. (#4842) Keep this strictly greater than
|
||||
# `_streamingPollMs`/1000 (see tests/test_streaming_cache_ttl_vs_poll.py).
|
||||
_CLI_SESSIONS_CACHE_STREAMING_TTL_SECONDS = 45.0
|
||||
_CLI_SESSIONS_CACHE_LOCK = threading.Lock()
|
||||
_CLI_SESSIONS_CACHE_INFLIGHT: "dict[tuple, threading.Event]" = {}
|
||||
_CLI_SESSIONS_CACHE_INVALIDATION_VERSION = 0
|
||||
@@ -93,7 +95,7 @@ _CLAUDE_CODE_PARSE_CACHE_MAX = 1000
|
||||
# ~200 sidecar file reads per /api/sessions build — and because the enclosing
|
||||
# _CLI_SESSIONS_CACHE is keyed on a state.db content fingerprint that advances
|
||||
# on every streamed message row, that whole scan was re-paid on essentially
|
||||
# every 5s poll during a live turn (the "100% CPU / multi-second get_cli_sessions"
|
||||
# every streaming poll during a live turn (the "100% CPU / multi-second get_cli_sessions"
|
||||
# in #4842/#4808/#4672). This memoizes the parse result keyed by the sidecar's
|
||||
# (path, mtime_ns, size, ctime_ns) stat signature: a warm projection re-stats
|
||||
# each file (~1 stat) instead of re-reading+parsing it, while any genuine
|
||||
@@ -6514,7 +6516,7 @@ def _reload_cli_sessions_after_inflight(
|
||||
|
||||
def _cli_sessions_cache_ttl_seconds() -> float:
|
||||
# #4842: widen the freshness window while a turn is streaming so the fixed
|
||||
# ~5s streaming poll cadence doesn't force a rebuild on every poll. Paired
|
||||
# streaming poll cadence doesn't force a rebuild on every poll. Paired
|
||||
# with the streaming-freeze cache key (so the key is stable across polls
|
||||
# mid-stream), this bounds the heavy CLI/cron projection to one rebuild per
|
||||
# streaming-TTL window instead of one per poll. Mirrors the route-level
|
||||
@@ -6672,8 +6674,9 @@ def _cli_sessions_streaming_freeze_marker():
|
||||
in-app structural mutation invalidates the cache directly via
|
||||
``clear_cli_sessions_cache``. Externally-driven changes that don't fire that
|
||||
listener (a scheduled cron completing, an external CLI writing rows) surface
|
||||
within one streaming-TTL window (≤30s) rather than instantly — a bounded,
|
||||
self-healing lag that is the deliberate latency/CPU trade-off of the freeze. (#4842)
|
||||
after the streaming TTL expires and a subsequent refresh occurs rather than
|
||||
instantly — a bounded, self-healing lag that is the deliberate latency/CPU
|
||||
trade-off of the freeze. (#4842)
|
||||
"""
|
||||
try:
|
||||
active = _active_stream_ids()
|
||||
@@ -6713,9 +6716,9 @@ def _resolve_cli_sessions_context(source_filter=None, include_claude_code: bool
|
||||
# #4842: while a turn streams, freeze the volatile state.db component of the
|
||||
# key so per-message writes don't bust the CLI cache and re-run the heavy
|
||||
# CLI/cron projection on every poll (mirrors the route-level #4808 freeze).
|
||||
# The wider streaming TTL in get_cli_sessions() still forces a periodic
|
||||
# rebuild so a streaming session's own count stays fresh within that window,
|
||||
# and structural mutations invalidate via clear_cli_sessions_cache().
|
||||
# The wider streaming TTL in get_cli_sessions() still permits a periodic
|
||||
# rebuild after that window, and structural mutations invalidate via
|
||||
# clear_cli_sessions_cache().
|
||||
_streaming_marker = _cli_sessions_streaming_freeze_marker()
|
||||
db_state_key = _streaming_marker if _streaming_marker is not None else _sqlite_file_stat_cache_key(db_path)
|
||||
cache_key = (
|
||||
|
||||
@@ -14,15 +14,16 @@ from api.profiles import _profiles_match
|
||||
|
||||
_SESSIONS_CACHE_TTL_SECONDS = 2.5
|
||||
# #4808: while a turn is actively streaming the frontend polls /api/sessions on a
|
||||
# fixed cadence (static/sessions.js `_streamingPollMs` = 5000ms). With the idle TTL
|
||||
# of 2.5s, every streaming poll lands in a fresh window and forces a full
|
||||
# all_sessions() rebuild on the hot path under the global store LOCK — pinning CPU
|
||||
# and starving token rendering on large stores (recurrence of #4672). Hold the
|
||||
# sidebar cache steady for longer than one poll interval while streaming; live
|
||||
# runtime state (active stream, sort order, pending flags) is overlaid on every
|
||||
# response regardless of cache, and structural/settings changes still evict
|
||||
# immediately via the source stamp.
|
||||
_SESSIONS_CACHE_STREAMING_TTL_SECONDS = 10.0
|
||||
# fixed cadence (static/sessions.js `_streamingPollMs`). With the idle TTL of
|
||||
# 2.5s, the entry expires between streaming polls, so each poll can find it stale
|
||||
# and force a full all_sessions() rebuild on the hot path under the global store
|
||||
# LOCK — pinning CPU and starving token rendering on large stores (recurrence of
|
||||
# #4672). Hold the sidebar cache steady for longer than one poll interval while
|
||||
# streaming; live runtime state (active stream, sort order, pending flags) is
|
||||
# overlaid on every response regardless of cache, and structural/settings changes
|
||||
# still invalidate immediately. Keep this strictly greater than
|
||||
# `_streamingPollMs`/1000 (see tests/test_streaming_cache_ttl_vs_poll.py).
|
||||
_SESSIONS_CACHE_STREAMING_TTL_SECONDS = 45.0
|
||||
_SESSIONS_CACHE_MAX_ENTRIES = 64
|
||||
_SESSIONS_CACHE_WAIT_SECONDS = 0.25
|
||||
_SESSIONS_CACHE_STALE_WAIT_SECONDS = 0.10
|
||||
@@ -176,7 +177,7 @@ def _session_list_cache_get(
|
||||
_SESSIONS_CACHE.pop(key, None)
|
||||
return None, False
|
||||
# #4808: widen the freshness window while a turn is streaming so the fixed
|
||||
# 5s streaming poll cadence doesn't force a full rebuild on every poll.
|
||||
# streaming poll cadence doesn't force a full rebuild on every poll.
|
||||
ttl = _SESSIONS_CACHE_TTL_SECONDS
|
||||
if _session_list_cache_streaming_freeze_marker() is not None:
|
||||
ttl = _SESSIONS_CACHE_STREAMING_TTL_SECONDS
|
||||
|
||||
@@ -15,7 +15,7 @@ On a cron-heavy profile (the #4842 reporter had 200+ cron sessions) that was
|
||||
hundreds of file reads per `/api/sessions` build, and because the enclosing
|
||||
`_CLI_SESSIONS_CACHE` is keyed on a state.db content fingerprint that advances
|
||||
on every streamed message row, the whole scan was re-paid on essentially every
|
||||
5s poll during a live turn — pinning CPU to 100% and making `get_cli_sessions`
|
||||
streaming poll during a live turn — pinning CPU to 100% and making `get_cli_sessions`
|
||||
take multiple seconds (#4842 / #4808 / #4672).
|
||||
|
||||
These tests pin the fix: the expensive per-row work is now O(unique files), the
|
||||
|
||||
@@ -157,7 +157,7 @@ def test_date_group_caret_expanded_down_collapsed_right():
|
||||
|
||||
def test_apperror_path_calls_render_session_list():
|
||||
"""apperror handler must call renderSessionList() to clear the streaming indicator
|
||||
immediately rather than waiting for the 5s streaming poll interval."""
|
||||
immediately rather than waiting for the streaming poll interval."""
|
||||
messages_js = (Path(__file__).resolve().parent.parent / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
apperror_idx = messages_js.find("source.addEventListener('apperror'")
|
||||
assert apperror_idx != -1, "apperror handler not found in messages.js"
|
||||
@@ -166,7 +166,7 @@ def test_apperror_path_calls_render_session_list():
|
||||
apperror_block = messages_js[apperror_idx:warning_idx]
|
||||
assert "renderSessionList()" in apperror_block, (
|
||||
"apperror handler must call renderSessionList() so the streaming indicator "
|
||||
"clears immediately on server errors, not after a 5s poll delay"
|
||||
"clears immediately on server errors, not after the polling fallback delay"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -721,7 +721,7 @@ def _age_cache_entry(key, seconds):
|
||||
|
||||
def test_streaming_widens_cache_freshness_window(monkeypatch):
|
||||
"""#4808: while a turn streams, an entry older than the idle TTL but younger
|
||||
than the streaming TTL must still read FRESH, so the fixed 5s streaming poll
|
||||
than the streaming TTL must still read FRESH, so the fixed streaming poll
|
||||
does not force a rebuild every poll."""
|
||||
routes._session_list_cache_clear()
|
||||
key = _streaming_ttl_key()
|
||||
@@ -729,7 +729,7 @@ def test_streaming_widens_cache_freshness_window(monkeypatch):
|
||||
monkeypatch.setattr(routes, "_session_list_cache_source_stamp", lambda k: ("stable",))
|
||||
|
||||
routes._session_list_cache_set(key, _session_cache_payload("live"))
|
||||
# Age it past the idle 2.5s TTL but under the 10s streaming TTL.
|
||||
# Age it past the idle TTL but keep it under the streaming TTL.
|
||||
_age_cache_entry(key, routes._SESSIONS_CACHE_TTL_SECONDS + 1.0)
|
||||
|
||||
# Idle (no active stream) → stale → miss.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""The streaming session-list caches must outlive one streaming poll interval.
|
||||
|
||||
While a turn is actively streaming, the frontend re-polls ``/api/sessions`` on a
|
||||
fixed cadence (``static/sessions.js`` ``_streamingPollMs``). Both streaming-cache
|
||||
hold-downs — the route-level ``_SESSIONS_CACHE_STREAMING_TTL_SECONDS`` (#4808) and
|
||||
the CLI/cron ``_CLI_SESSIONS_CACHE_STREAMING_TTL_SECONDS`` (#4842) — exist so that
|
||||
those repeated polls read a held entry instead of forcing a full, LOCK-contending
|
||||
rebuild on the streaming hot path (#4672).
|
||||
|
||||
That only holds if each streaming TTL is strictly longer than the poll interval.
|
||||
If a TTL is <= the poll interval, the entry expires between polls, every poll can
|
||||
find it stale, and the hold-down collapses back into per-poll rebuilds — the exact
|
||||
regression these constants were added to prevent.
|
||||
|
||||
This test derives the poll interval from the single source of truth (the JS
|
||||
constant) so the invariant can never silently drift when either side is retuned.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from api import models
|
||||
from api import route_session_list_cache
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
_SESSIONS_JS = _REPO_ROOT / "static" / "sessions.js"
|
||||
|
||||
|
||||
def _streaming_poll_seconds() -> float:
|
||||
"""Parse the real ``const _streamingPollMs = <int>;`` from static/sessions.js.
|
||||
|
||||
Kept as the single source of the poll magic number so the test never encodes
|
||||
a second, drift-prone copy of it.
|
||||
"""
|
||||
source = _SESSIONS_JS.read_text(encoding="utf-8")
|
||||
match = re.search(r"\bconst\s+_streamingPollMs\s*=\s*(\d+)\s*;", source)
|
||||
assert match, "could not find `const _streamingPollMs = <int>;` in static/sessions.js"
|
||||
poll_ms = int(match.group(1))
|
||||
assert poll_ms > 0, f"_streamingPollMs must be a positive integer, got {poll_ms!r}"
|
||||
return poll_ms / 1000.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ttl_seconds, label",
|
||||
[
|
||||
(
|
||||
route_session_list_cache._SESSIONS_CACHE_STREAMING_TTL_SECONDS,
|
||||
"route_session_list_cache._SESSIONS_CACHE_STREAMING_TTL_SECONDS",
|
||||
),
|
||||
(
|
||||
models._CLI_SESSIONS_CACHE_STREAMING_TTL_SECONDS,
|
||||
"models._CLI_SESSIONS_CACHE_STREAMING_TTL_SECONDS",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_streaming_ttl_strictly_exceeds_poll_interval(ttl_seconds, label):
|
||||
poll_seconds = _streaming_poll_seconds()
|
||||
assert ttl_seconds > poll_seconds, (
|
||||
f"{label} ({ttl_seconds}s) must be strictly greater than the streaming "
|
||||
f"poll interval ({poll_seconds}s from static/sessions.js `_streamingPollMs`), "
|
||||
"otherwise the streaming hold-down expires between polls and every poll "
|
||||
"forces a full rebuild on the streaming hot path (#4672/#4808/#4842)."
|
||||
)
|
||||
Reference in New Issue
Block a user