fix(#4808): widen /api/sessions cache TTL while streaming to stop per-poll rebuilds
The frontend polls /api/sessions every 5s while a turn streams (static/sessions.js _streamingPollMs=5000), but the cache TTL was 2.5s (_SESSIONS_CACHE_TTL_SECONDS) — so every streaming poll landed in a fresh window and forced a full all_sessions() rebuild on the hot path under the global store LOCK, pinning CPU to 100% and starving token rendering on large stores (recurrence of #4672). The #4680 freeze-marker only de-dupes rebuilds WITHIN one TTL window; with polls spaced wider than the TTL it never helped. Fix: while _session_list_cache_streaming_freeze_marker() indicates an active stream, use _SESSIONS_CACHE_STREAMING_TTL_SECONDS=10.0 (> the 5s poll) so the sidebar holds steady across streaming polls. 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 — only a streaming session's own persisted title/message_count can lag up to 10s, which the #4672 design already deems acceptable. Idle path byte-for-byte unchanged. Diagnosis credit: investigation reproduced 6 streaming polls -> 6 rebuilds before, 1 after. Co-authored-by: batkuip <reporter>
This commit is contained in:
parent
27b6cd436e
commit
8eed95a25e
2 changed files with 88 additions and 1 deletions
|
|
@ -1572,6 +1572,16 @@ def _clear_live_models_cache() -> None:
|
|||
# ── /api/sessions response cache (hot-sidebar response) ──────────────────────
|
||||
|
||||
_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 (_session_list_cache_overlay_runtime_rows), and
|
||||
# structural/settings changes still evict immediately via the source stamp.
|
||||
_SESSIONS_CACHE_STREAMING_TTL_SECONDS = 10.0
|
||||
_SESSIONS_CACHE_MAX_ENTRIES = 64
|
||||
_SESSIONS_CACHE_WAIT_SECONDS = 0.25
|
||||
_SESSIONS_CACHE_STALE_WAIT_SECONDS = 0.10
|
||||
|
|
@ -1630,7 +1640,12 @@ def _session_list_cache_get(
|
|||
if stamp != current_stamp:
|
||||
_SESSIONS_CACHE.pop(key, None)
|
||||
return None, False
|
||||
fresh = (now - ts) < _SESSIONS_CACHE_TTL_SECONDS
|
||||
# #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.
|
||||
ttl = _SESSIONS_CACHE_TTL_SECONDS
|
||||
if _session_list_cache_streaming_freeze_marker() is not None:
|
||||
ttl = _SESSIONS_CACHE_STREAMING_TTL_SECONDS
|
||||
fresh = (now - ts) < ttl
|
||||
if fresh:
|
||||
_SESSIONS_CACHE.move_to_end(key)
|
||||
return copy.deepcopy(payload), True
|
||||
|
|
|
|||
|
|
@ -515,3 +515,75 @@ def test_source_stamp_still_tracks_wal_when_idle(tmp_path, monkeypatch):
|
|||
after = routes._session_list_cache_source_stamp(key)
|
||||
|
||||
assert after != before
|
||||
|
||||
|
||||
def _streaming_ttl_key():
|
||||
return 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 _age_cache_entry(key, seconds):
|
||||
"""Backdate the cache entry's timestamp by `seconds` (keep stamp+payload)."""
|
||||
with routes._SESSIONS_CACHE_LOCK:
|
||||
ts, stamp, payload = routes._SESSIONS_CACHE[key]
|
||||
routes._SESSIONS_CACHE[key] = (ts - seconds, stamp, payload)
|
||||
|
||||
|
||||
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
|
||||
does not force a rebuild every poll."""
|
||||
routes._session_list_cache_clear()
|
||||
key = _streaming_ttl_key()
|
||||
# Keep the source stamp stable across the get() calls (no structural change).
|
||||
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_cache_entry(key, routes._SESSIONS_CACHE_TTL_SECONDS + 1.0)
|
||||
|
||||
# Idle (no active stream) → stale → miss.
|
||||
monkeypatch.setattr(routes, "_active_stream_ids", lambda: set())
|
||||
payload_idle, fresh_idle = routes._session_list_cache_get(key)
|
||||
assert payload_idle is None and fresh_idle is False
|
||||
|
||||
# Re-seed + re-age, now WITH an active stream → fresh (held).
|
||||
routes._session_list_cache_set(key, _session_cache_payload("live"))
|
||||
_age_cache_entry(key, routes._SESSIONS_CACHE_TTL_SECONDS + 1.0)
|
||||
monkeypatch.setattr(routes, "_active_stream_ids", lambda: {"turn-1"})
|
||||
payload_stream, fresh_stream = routes._session_list_cache_get(key)
|
||||
assert fresh_stream is True
|
||||
assert payload_stream == _session_cache_payload("live")
|
||||
|
||||
|
||||
def test_streaming_window_still_evicts_past_streaming_ttl(monkeypatch):
|
||||
"""Even while streaming, an entry older than the streaming TTL is evicted —
|
||||
the hold-down is bounded, not indefinite."""
|
||||
routes._session_list_cache_clear()
|
||||
key = _streaming_ttl_key()
|
||||
monkeypatch.setattr(routes, "_session_list_cache_source_stamp", lambda k: ("stable",))
|
||||
monkeypatch.setattr(routes, "_active_stream_ids", lambda: {"turn-1"})
|
||||
|
||||
routes._session_list_cache_set(key, _session_cache_payload("old"))
|
||||
_age_cache_entry(key, routes._SESSIONS_CACHE_STREAMING_TTL_SECONDS + 1.0)
|
||||
payload, fresh = routes._session_list_cache_get(key)
|
||||
assert payload is None and fresh is False
|
||||
|
||||
|
||||
def test_streaming_window_does_not_extend_idle_ttl(monkeypatch):
|
||||
"""Regression guard: with NO active stream the idle 2.5s TTL is unchanged —
|
||||
an entry aged just past it must read stale (byte-for-byte idle behavior)."""
|
||||
routes._session_list_cache_clear()
|
||||
key = _streaming_ttl_key()
|
||||
monkeypatch.setattr(routes, "_session_list_cache_source_stamp", lambda k: ("stable",))
|
||||
monkeypatch.setattr(routes, "_active_stream_ids", lambda: set())
|
||||
|
||||
routes._session_list_cache_set(key, _session_cache_payload("idle"))
|
||||
_age_cache_entry(key, routes._SESSIONS_CACHE_TTL_SECONDS + 0.5)
|
||||
payload, fresh = routes._session_list_cache_get(key)
|
||||
assert payload is None and fresh is False
|
||||
|
|
|
|||
Loading…
Reference in a new issue