diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fb5586df..bac8af814 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.571] — 2026-06-22 — Release UD (sidebar lineage enrichment cap) + +### Fixed + +- **Faster session sidebar for power users with thousands of sessions.** `GET /api/sessions` could spend ~5 seconds attaching compression-lineage metadata when the session count ran into the thousands. Lineage enrichment is now capped to the top-N most-recent (and pinned) sessions — the window the sidebar actually paints — which keeps the common case fast while older sessions enrich lazily. The cap defaults to 300 and is tunable via `HERMES_WEBUI_LINEAGE_TOP_N` (set to 0 to disable). Thanks @maksym-mishchenko. (#4638) + ## [v0.51.570] — 2026-06-22 — Release UC (Windows restart console suppression) ### Fixed diff --git a/api/models.py b/api/models.py index ebf1e508d..35caeb8da 100644 --- a/api/models.py +++ b/api/models.py @@ -3200,11 +3200,30 @@ def _sidebar_title_is_generic_webui(title: str | None) -> bool: def _enrich_sidebar_lineage_metadata(sessions: list[dict]) -> None: - """Attach state.db compression lineage metadata used by sidebar collapse.""" + """Attach state.db compression lineage metadata used by sidebar collapse. + + Cap the DB lookup to the top-N most recent sessions to bound wall-clock + on power users with thousands of sessions. The sidebar paints chronologically + newest first; older sessions almost never have visible lineage to collapse + (parents are themselves stale and rarely surface in the same render). + Lineage enrichment for those is loaded lazily when the user opens the + history panel. Issue #38914 / 2026-06-21 triage: /api/sessions was spending + 4.9s on lineage_metadata across 2400+ rows. + """ + # 2026-06-21: configurable via env to ease A/B and rollback without a redeploy. + import os as _os + try: + _cap = int(_os.environ.get("HERMES_WEBUI_LINEAGE_TOP_N", "300")) + except (TypeError, ValueError): + _cap = 300 + if _cap > 0 and len(sessions) > _cap: + candidates = sessions[:_cap] + else: + candidates = sessions try: metadata = read_session_lineage_metadata( _active_state_db_path(), - {str(s.get('session_id')) for s in sessions if s.get('session_id')}, + {str(s.get('session_id')) for s in candidates if s.get('session_id')}, ) except Exception: return diff --git a/tests/test_issue4638_lineage_top_n_cap.py b/tests/test_issue4638_lineage_top_n_cap.py new file mode 100644 index 000000000..dde4d00da --- /dev/null +++ b/tests/test_issue4638_lineage_top_n_cap.py @@ -0,0 +1,89 @@ +"""Regression tests for #4638 — cap sidebar lineage enrichment to the top-N sessions. + +On power users with thousands of sessions, GET /api/sessions spent ~5s in +_enrich_sidebar_lineage_metadata probing state.db for EVERY row's compression +lineage. The sidebar paints pinned-first then newest-first, and the caller passes +an already-sorted list, so enriching only the top-N (default 300) most-recent rows +covers the visible window while bounding wall-clock. The cap is env-configurable +(HERMES_WEBUI_LINEAGE_TOP_N) and fails open. + +These tests pin: (1) only the top-N ids are probed when the list exceeds the cap, +(2) the env override is honored, (3) a non-positive / unparseable cap disables the +cap (enrich all), (4) lists at/under the cap probe everything. +""" +from __future__ import annotations + +import api.models as models + + +def _capture_probed_ids(monkeypatch): + """Patch read_session_lineage_metadata to record the id set it is asked for.""" + seen = {} + + def _fake_read(db_path, id_set): + seen["ids"] = set(id_set) + return {} + + monkeypatch.setattr(models, "read_session_lineage_metadata", _fake_read) + monkeypatch.setattr(models, "_active_state_db_path", lambda: ":memory:") + return seen + + +def _sessions(n): + # Caller passes an already pinned-first/newest-first sorted list; index order + # therefore IS paint priority. id "s0" is the most-recent/visible-most. + return [{"session_id": f"s{i}"} for i in range(n)] + + +def test_caps_enrichment_to_top_n_default_300(monkeypatch): + seen = _capture_probed_ids(monkeypatch) + monkeypatch.delenv("HERMES_WEBUI_LINEAGE_TOP_N", raising=False) + models._enrich_sidebar_lineage_metadata(_sessions(1000)) + assert seen["ids"] == {f"s{i}" for i in range(300)}, ( + "Default cap must probe exactly the top-300 (paint-priority) sessions" + ) + + +def test_env_override_changes_cap(monkeypatch): + seen = _capture_probed_ids(monkeypatch) + monkeypatch.setenv("HERMES_WEBUI_LINEAGE_TOP_N", "50") + models._enrich_sidebar_lineage_metadata(_sessions(1000)) + assert seen["ids"] == {f"s{i}" for i in range(50)}, ( + "HERMES_WEBUI_LINEAGE_TOP_N must bound the probed set" + ) + + +def test_non_positive_cap_disables_capping(monkeypatch): + seen = _capture_probed_ids(monkeypatch) + monkeypatch.setenv("HERMES_WEBUI_LINEAGE_TOP_N", "0") + models._enrich_sidebar_lineage_metadata(_sessions(500)) + assert len(seen["ids"]) == 500, "cap<=0 must enrich all sessions (cap disabled)" + + +def test_unparseable_cap_falls_back_to_default(monkeypatch): + seen = _capture_probed_ids(monkeypatch) + monkeypatch.setenv("HERMES_WEBUI_LINEAGE_TOP_N", "not-a-number") + models._enrich_sidebar_lineage_metadata(_sessions(1000)) + assert seen["ids"] == {f"s{i}" for i in range(300)}, ( + "An unparseable cap must fall back to the default 300, not crash" + ) + + +def test_list_under_cap_probes_everything(monkeypatch): + seen = _capture_probed_ids(monkeypatch) + monkeypatch.delenv("HERMES_WEBUI_LINEAGE_TOP_N", raising=False) + models._enrich_sidebar_lineage_metadata(_sessions(120)) + assert seen["ids"] == {f"s{i}" for i in range(120)}, ( + "A list at/under the cap must enrich every session" + ) + + +def test_enrichment_failure_is_swallowed(monkeypatch): + """Lineage enrichment must fail open — a DB error never breaks /api/sessions.""" + def _boom(db_path, id_set): + raise RuntimeError("db down") + + monkeypatch.setattr(models, "read_session_lineage_metadata", _boom) + monkeypatch.setattr(models, "_active_state_db_path", lambda: ":memory:") + # Must not raise. + models._enrich_sidebar_lineage_metadata(_sessions(10))