Skip to content

perf(#4842): freeze the CLI/cron sidebar cache key during streaming (stop the per-poll re-query that pins CPU) - #4908

Closed
nesquena-hermes wants to merge 1 commit into
masterfrom
perf/4842-cli-sessions-streaming-freeze
Closed

nesquena-hermes wants to merge 1 commit into
masterfrom
perf/4842-cli-sessions-streaming-freeze

Conversation

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Summary

A structural performance fix for #4842 (continuing the #4672 / #4808 / #4889 line): /api/sessions re-runs the expensive CLI/cron session projection on every poll while a turn is streaming, which is the dominant cost in the multi-second sidebar latency + 100% CPU that cron-heavy installs keep reporting.

This is a PR candidate — opening for review, not auto-merge. It addresses a layer the prior fixes did not reach (see "Why the earlier fixes didn't fully land" below).

Root cause

The CLI/cron sidebar projection is cached in _CLI_SESSIONS_CACHE. Its key is built in _resolve_cli_sessions_context → _sqlite_file_stat_cache_key → _sqlite_content_fingerprint, which folds in MAX(rowid) FROM messages. During an active chat turn the gateway/CLI writes a message row per streamed delta, so that fingerprint advances on essentially every /api/sessions poll. The frontend polls every ~5s while streaming → every poll misses the cache → the full candidate-join + projection (and the visible_lineage_metadata pass) re-runs, contending for the same SQLite / global lock the streaming worker holds.

A reporter's profiler trace on v0.51.645 shows this directly: get_cli_sessions at ~5000ms and visible_lineage_metadata at ~1200ms per request, with the main thread blocked in read_importable_agent_session_rows → _project_agent_session_rows → cur.fetchall().

Why the earlier fixes didn't fully land

Fix

Propagate the same streaming-freeze idea to the inner cache:

  1. New _cli_sessions_streaming_freeze_marker() in api/models.py — keyed only on the set of active stream ids (mirrors the route-level Performance issues are back #4808 marker).
  2. While any stream is active, _resolve_cli_sessions_context (and the all-profiles path) fold that stable marker into the cache key instead of the volatile content fingerprint — so per-token message writes no longer bust the cache.
  3. _cli_sessions_cache_ttl_seconds() widens the TTL (5s → 30s) while streaming, so the fixed poll cadence can't force a rebuild on every poll.
  4. _on_session_list_changed (the existing structural-mutation listener) now also clears the CLI cache. Structural signals — cron completion, new/renamed/archived sessions, attention — fire this listener; per streamed token never does. That is what makes the freeze safe: real changes still surface promptly, but the streaming write storm doesn't.

Net effect: while a turn streams, the heavy CLI/cron projection is reused across polls and rebuilt at most once per streaming window instead of once per poll. The instant a stream starts/stops, the marker changes and the just-finished turn's rows are picked up. Idle behavior is byte-identical (the freeze only engages when there are active streams).

Tests

tests/test_issue4842_cli_sessions_streaming_freeze.py (6 tests):

  • marker is None when idle, stable for the same stream set (order-independent), changes when the set changes;
  • the core guarantee: the CLI cache key is stable across simulated streamed-message writes while streaming, and still advances with the fingerprint when idle (so newly-committed sessions aren't served stale);
  • streaming TTL > idle TTL;
  • the structural-mutation listener clears the CLI cache.

Regression sweep (all green): test_issue4842_cron_projection_perf, test_issue4385_cron_archive_reappears, test_cli_sessions_cache_fingerprint, test_issue3930_source_filter_pushdown, test_claude_code_session_import (incl. test_get_cli_sessions_cache_invalidates_when_sqlite_wal_changes), test_session_sidebar_cache, test_session_events, test_session_events_http_integration.

Honest scope note

I could not reproduce the full 5s locally — my dev state.db's cron sessions carry ~0 messages each, so the join is cheap here; the reporter's clearly carry far more. This fix is targeted at the mechanism the trace points at (per-poll re-query under streaming write contention), which is sound and net-positive for every large/cron-heavy install regardless of that one reporter. It is not claimed as a verified end-to-end cure for that specific user until it can be confirmed against their data shape.

Refs #4842.

/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.
@nesquena-hermes nesquena-hermes added the size:M Medium PR (≤10 files, ≤250 LOC) label Jun 25, 2026
@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR addresses the dominant CPU and latency cost in the CLI/cron sidebar for cron-heavy installs: during an active turn, the gateway writes one SQLite message row per streamed delta, causing the MAX(rowid) fingerprint in the CLI/cron projection cache key to advance on every frontend poll and forcing a full expensive re-query each time. The fix propagates the existing route-level streaming freeze (#4808) into the inner _CLI_SESSIONS_CACHE layer that earlier fixes never reached.

  • Introduces _cli_sessions_streaming_freeze_marker(), which returns a stable tuple keyed on the set of active stream IDs while any turn is streaming and None when idle. Both the single-profile (_resolve_cli_sessions_context) and all-profiles (get_cli_sessions(all_profiles=True)) paths substitute this frozen marker for the volatile state.db fingerprint component of the cache key, bounding projection rebuilds to once per 30-second window during streaming instead of once per 5-second poll.
  • Wires clear_cli_sessions_cache() into _on_session_list_changed so structural sidebar mutations (cron completion, renames, archives, attention) still bypass the freeze and trigger an immediate fresh load; per-token message writes never fire this listener.
  • Six targeted unit tests verify the marker's stability, TTL widening, and cache-clearing contract; idle behavior is untouched by design.

Confidence Score: 4/5

Safe to merge for cron-heavy installs; idle behavior is structurally unchanged. The two observations are minor: a CHANGELOG.md direct edit that conflicts with the repo's release-agent policy, and an unintended sidecar-cache clearing on every structural mutation introduced by routing clear_cli_sessions_cache through the session-list-changed listener.

The freeze mechanism is logically correct — the streaming marker is stable within a turn and changes the instant any stream starts or stops, so freshly-completed session rows are always picked up. The _on_session_list_changed → clear_cli_sessions_cache() path ensures structural mutations still force a fresh projection even while the freeze is active. The two findings are non-blocking: the CHANGELOG.md edit is a policy concern, and the sidecar cache side-effect is harmless because that cache is self-invalidating.

api/routes.py warrants a quick look at the clear_cli_sessions_cache call inside _on_session_list_changed — specifically whether the implicit clear_sidecar_metadata_cache() side-effect on every structural mutation is acceptable for the install's access patterns. CHANGELOG.md should be reverted per repo policy.

Important Files Changed

Filename Overview
api/models.py Core logic of the streaming freeze: adds _cli_sessions_streaming_freeze_marker(), modifies _cli_sessions_cache_ttl_seconds() and _resolve_cli_sessions_context() to use the stable marker instead of the volatile state.db fingerprint during streaming. Logic is sound; idle behavior is unchanged.
api/routes.py Wires the CLI cache invalidation into _on_session_list_changed so structural mutations (cron completion, renames, archives) clear the frozen CLI cache promptly. Side effect: also clears sidecar_metadata_cache on every structural mutation (harmless but unintended).
tests/test_issue4842_cli_sessions_streaming_freeze.py Six focused unit tests covering the marker (idle/stable/changing), the core guarantee (key stability across message writes while streaming), TTL widening, and structural-mutation cache clearing. Coverage directly addresses the regression risk.
CHANGELOG.md Adds a detailed entry for the #4842 fix. The repo policy requires CHANGELOG.md to be maintained exclusively by the release agent — individual contributor PRs should not touch it directly.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant FE as Frontend (polls /api/sessions ~5s)
    participant Route as routes.py
    participant Cache as _CLI_SESSIONS_CACHE
    participant Marker as _cli_sessions_streaming_freeze_marker()
    participant DB as state.db (SQLite)

    Note over FE,DB: BEFORE FIX (per-poll cache bust during streaming)
    FE->>Route: GET /api/sessions (poll N)
    Route->>DB: "MAX(rowid) -> fingerprint Fn (volatile)"
    Route->>Cache: "lookup(key=...Fn...) miss"
    Route->>DB: full CLI/cron projection (~5000ms)
    DB-->>Route: sessions
    Route-->>FE: response

    FE->>Route: GET /api/sessions (poll N+1, new token written)
    Route->>DB: "MAX(rowid) -> Fn+1 (different!)"
    Route->>Cache: "lookup(key=...Fn+1...) miss every time"
    Route->>DB: full CLI/cron projection again
    DB-->>Route: sessions

    Note over FE,DB: AFTER FIX (frozen key during streaming)
    FE->>Route: GET /api/sessions (poll N)
    Route->>Marker: _cli_sessions_streaming_freeze_marker()
    Marker-->>Route: (streaming, stream-1) stable
    Route->>Cache: "lookup(key=frozen-marker) miss first time"
    Route->>DB: full CLI/cron projection (once per 30s window)
    DB-->>Route: sessions
    Route->>Cache: "store(key=frozen-marker, TTL=30s)"
    Route-->>FE: response

    FE->>Route: GET /api/sessions (poll N+1, new token written)
    Route->>Marker: _cli_sessions_streaming_freeze_marker()
    Marker-->>Route: (streaming, stream-1) same!
    Route->>Cache: "lookup(key=frozen-marker) HIT"
    Route-->>FE: response (no DB query)

    Note over Route,DB: Structural mutation (cron complete / rename / archive)
    Route->>Route: _on_session_list_changed()
    Route->>Cache: clear_cli_sessions_cache() force refresh on next poll
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant FE as Frontend (polls /api/sessions ~5s)
    participant Route as routes.py
    participant Cache as _CLI_SESSIONS_CACHE
    participant Marker as _cli_sessions_streaming_freeze_marker()
    participant DB as state.db (SQLite)

    Note over FE,DB: BEFORE FIX (per-poll cache bust during streaming)
    FE->>Route: GET /api/sessions (poll N)
    Route->>DB: "MAX(rowid) -> fingerprint Fn (volatile)"
    Route->>Cache: "lookup(key=...Fn...) miss"
    Route->>DB: full CLI/cron projection (~5000ms)
    DB-->>Route: sessions
    Route-->>FE: response

    FE->>Route: GET /api/sessions (poll N+1, new token written)
    Route->>DB: "MAX(rowid) -> Fn+1 (different!)"
    Route->>Cache: "lookup(key=...Fn+1...) miss every time"
    Route->>DB: full CLI/cron projection again
    DB-->>Route: sessions

    Note over FE,DB: AFTER FIX (frozen key during streaming)
    FE->>Route: GET /api/sessions (poll N)
    Route->>Marker: _cli_sessions_streaming_freeze_marker()
    Marker-->>Route: (streaming, stream-1) stable
    Route->>Cache: "lookup(key=frozen-marker) miss first time"
    Route->>DB: full CLI/cron projection (once per 30s window)
    DB-->>Route: sessions
    Route->>Cache: "store(key=frozen-marker, TTL=30s)"
    Route-->>FE: response

    FE->>Route: GET /api/sessions (poll N+1, new token written)
    Route->>Marker: _cli_sessions_streaming_freeze_marker()
    Marker-->>Route: (streaming, stream-1) same!
    Route->>Cache: "lookup(key=frozen-marker) HIT"
    Route-->>FE: response (no DB query)

    Note over Route,DB: Structural mutation (cron complete / rename / archive)
    Route->>Route: _on_session_list_changed()
    Route->>Cache: clear_cli_sessions_cache() force refresh on next poll
Loading

Reviews (1): Last reviewed commit: "perf(#4842): freeze the CLI/cron sidebar..." | Re-trigger Greptile

Comment thread CHANGELOG.md
Comment on lines +6 to +8
### Fixed

- **`/api/sessions` no longer re-runs the expensive CLI/cron session projection on every poll while a turn is streaming**, a major cause of the multi-second sidebar latency and 100% CPU on cron-heavy installs (#4842, continuing #4672/#4808/#4889). The CLI/cron sidebar projection is cached, but its cache key folded in a state.db content fingerprint (`MAX(rowid) FROM messages`) that advances on every streamed message row — so during a live turn the frontend's ~5s poll always missed the cache and re-ran the full candidate-join + projection (and the lineage-metadata pass), contending for the same SQLite/global lock the streaming worker holds. The route-level session-list cache already froze its key during streaming (#4808), but that freeze never reached this inner CLI-sessions cache. Now, while any turn is streaming, the CLI-sessions cache key folds in the same stable streaming-freeze marker (keyed only on the set of active stream ids) and its TTL widens, so the heavy projection is reused across polls and rebuilt at most once per streaming window instead of once per poll. Structural sidebar mutations (cron completion, new/renamed/archived sessions, attention) clear the cache directly, so nothing user-visible lags under the freeze; idle behavior is unchanged.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 CHANGELOG.md edited directly in a contributor PR

Per this repo's policy, CHANGELOG.md is maintained exclusively by the release process via release: vX.Y.Z commits authored by the release agent — individual contributor PRs don't touch it directly. If the release agent picks up ## [Unreleased] sections from PR bodies or separate tooling, this direct edit may conflict with or duplicate that output.

Rule Used: Do not flag missing CHANGELOG.md updates on indivi... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread api/routes.py
Comment on lines +216 to +218
try:
from api.models import clear_cli_sessions_cache
clear_cli_sessions_cache()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 clear_cli_sessions_cache silently also clears sidecar_metadata_cache on every structural mutation

clear_cli_sessions_cache() unconditionally calls clear_sidecar_metadata_cache() as well (by design for "explicit reset" paths like test isolation). Wiring it into _on_session_list_changed now clears the sidecar projection cache on every cron completion, rename, archive, or attention event — not just when the CLI cache itself needs a cold start. The sidecar cache is stat-keyed and self-invalidating, so no data is lost, but it forces an unnecessary cold rebuild on the first post-mutation access. If sidecar reads are on the hot path, consider calling _CLI_SESSIONS_CACHE.clear() directly here (with the lock) rather than going through clear_cli_sessions_cache, to avoid the sidecar side-effect.

@rodboev

rodboev commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

I profiled this locally against the PR head (2d1e1dac) on my logged-in WebUI at http://127.0.0.1:8787, using Chrome DevTools and real profile switches between default and test-ep-sprint31.

On the profile-switch path, the #4842 mechanism looks fixed here. The switch request itself is quick, /api/profile/switch was about 199 ms into test-ep-sprint31 and about 334 ms back to default. The sidebar session reloads were also quick, /api/sessions?sidebar_source=webui&exclude_hidden=1 was about 67-152 ms in the two passes I recorded.

The long tail has moved elsewhere. The slowest post-switch request in my traces was /api/models, about 4.3 s switching into test-ep-sprint31 and about 1.3 s switching back to default. /api/projects, /api/workspaces, and /api/settings were all effectively noise by comparison.

So on this path, I am no longer seeing the repeated CLI/cron sidebar rebuild as the bottleneck. #4908 appears to have done its job for the profile-switch case, and the remaining latency I can reproduce is dominated by model-list reload rather than /api/sessions churn.

@rodboev

rodboev commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Follow-up from local profiling on the #4908 head (2d1e1dac) against my logged-in WebUI at http://127.0.0.1:8787:

I can no longer reproduce #4842 on the profile-switch path.

Switching default -> test-ep-sprint31 put /api/profile/switch at about 199 ms, with the sidebar reloads on /api/sessions?sidebar_source=webui&exclude_hidden=1 at about 118 ms and 67 ms. Switching back test-ep-sprint31 -> default put /api/profile/switch at about 334 ms, with the sidebar reload at about 152 ms.

The remaining delay in these traces is elsewhere. The slowest post-switch request was /api/models, about 4.3 s switching into test-ep-sprint31 and about 1.3 s switching back to default. /api/projects, /api/workspaces, and /api/settings were negligible by comparison.

So on this path, the old sidebar/session rebuild bottleneck is no longer showing up locally. If nobody still has a repro on the intended streaming/sidebar path, this PR looks safe to close.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator Author

Read the full diff at api/models.py and api/routes.py on this branch against origin/master, plus the route-level prior art it mirrors. The mechanism is sound and the layering argument in the PR body checks out.

What this actually changes

The inner CLI/cron cache key is built in _resolve_cli_sessions_context and previously always folded in the volatile _sqlite_file_stat_cache_key(db_path) (→ _sqlite_content_fingerprint → MAX(rowid) FROM messages). The new code swaps that one component for a stable marker while streaming:

_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)

The new _cli_sessions_streaming_freeze_marker() is a near-exact mirror of the already-shipped route-level _session_list_cache_streaming_freeze_marker() (routes.py:1770) from #4808 — same _active_stream_ids() source, same ("streaming", tuple(sorted(...))) shape, same start/stop semantics. That parity is the reassuring part: the route layer has run this exact freeze keyed on the active-stream set in production since #4808, so extending it to the inner cache is a known-safe pattern, not a new one.

Two things I checked that make the freeze safe

  1. _active_stream_ids() (models.py:424) unions STREAMS with _cfg.ACTIVE_RUNS, so the marker stays non-None even when the SSE entry detaches but the worker is still in-flight (blocked in provider / unwinding cancel). That means the freeze can't lift prematurely mid-turn and re-trigger the per-poll rebuild storm.

  2. Structural invalidation still works. Because the frozen key no longer self-invalidates on the content fingerprint mid-stream, the PR correctly adds clear_cli_sessions_cache() to _on_session_list_changed (routes.py:205). Cron completion / new / renamed / archived / attention all fire that listener and no per-streamed-token write does, so real changes still surface within the structural signal, not at TTL expiry. The 5s→30s streaming TTL is then just a backstop, not the freshness path.

Reviewer validation + the remaining tail

rodboev's two profiling passes against this head (2d1e1daca) report /api/sessions?sidebar_source=webui&exclude_hidden=1 down to ~67–152ms on profile switch and no reproduction of #4842 on that path. Worth being precise about scope: their remaining ~4.3s tail is on /api/models, which is a different subsystem (provider/model-list reload) entirely untouched by this PR — so it neither validates nor invalidates the streaming-freeze win, it's just the next bottleneck on a different request. That's a separate issue, not a reason to hold this.

Verdict

CI is green across the 3.11/3.12/3.13 matrix + browser-smoke + lint, the new test_issue4842_cli_sessions_streaming_freeze.py asserts the core guarantee (stable key across simulated streamed writes, still advances when idle), and the honest scope note correctly states this targets the trace's mechanism rather than claiming an end-to-end cure for one reporter's data shape. This looks merge-ready. The one follow-up I'd suggest tracking separately: the all-profiles path replaces context_cache_key wholesale with ('streaming-frozen', _streaming_marker) — confirm that a structural mutation in profile B (new cron session) during a stream in profile A still invalidates via the listener and isn't masked by the frozen all-profiles key, since the listener clears the whole _CLI_SESSIONS_CACHE it should be fine, but it's the one edge worth a line of test coverage.

nesquena-hermes added a commit that referenced this pull request Jun 25, 2026
…aming (#4908, #4842)

Release XU (v0.51.665): freeze CLI/cron sidebar cache key during streaming (#4908, #4842)
@nesquena-hermes

Copy link
Copy Markdown
Collaborator Author

Shipped in v0.51.665 (Release XU, just deployed) — greenlit by Nathan to move through. Freezes the inner _CLI_SESSIONS_CACHE key during streaming (the layer #4672/#4808/#4889 didn't reach), so /api/sessions stops re-running the heavy CLI/cron projection on every poll mid-stream. Gate: Codex SAFE + Opus SHIP (applied one doc-only accuracy fix re the 30s TTL backstop for externally-driven changes), suite 10614. Verified on prod (freeze marker live).

pull Bot pushed a commit to latipun7/hermes-webui that referenced this pull request Jun 25, 2026
… — TTL is the backstop for externally-driven changes

Opus SHIP-WITH-FIXES (no code change required): the comment/docstring overstated
that the session-list-change listener covers ALL structural mutations. In-app
mutations fire it, but externally-driven changes (scheduled cron completion,
external CLI writes) don't — for those the 30s streaming TTL is the load-bearing
backstop (≤30s bounded, self-healing lag). Comment-only accuracy fix; logic
unchanged (both gates: perf fix correct, freeze effective, staleness bounded).
pull Bot pushed a commit to latipun7/hermes-webui that referenced this pull request Jun 25, 2026
@nesquena-hermes
nesquena-hermes deleted the perf/4842-cli-sessions-streaming-freeze branch June 28, 2026 06:10
Du7chManiac pushed a commit to TheCouchCoder-com/hermes-webui that referenced this pull request Jul 5, 2026
v0.51.665 — Release XU: freeze CLI/cron sidebar cache key during streaming (nesquena#4908, nesquena#4842)

# Conflicts:
#	CHANGELOG.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M Medium PR (≤10 files, ≤250 LOC)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants