From fdb734a6a0eef26d473415406bf89c2785ccd1e6 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 24 Jun 2026 07:13:33 +0000 Subject: [PATCH] perf(#4718): cache Claude Code transcript parses on the sidebar path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /api/sessions cold build (which a profile switch triggers) was dominated by get_claude_code_sessions() re-parsing every Claude Code JSONL transcript line-by-line on each call — 650-1000ms on a 200-file / ~130MB tree, repeated on every switch, every 5s CLI-cache expiry, and every sidebar poll, because the directory is global but the higher session cache is keyed per active profile. Memoize the per-file parse by (path, mtime_ns, size) so a warm build re-stats files instead of re-parsing them; any genuine edit/append changes the stat signature and invalidates just that file. Also hoist the loop-invariant get_last_workspace() out of the Claude Code row loop (it stats config + probes terminal cwd, ~200 redundant calls per build). Measured /api/sessions cold-rebuild on a 250-session profile: ~670-880ms -> ~100-200ms (verified in-browser). Output byte-identical. Credit @rodboev for the browser-level profiling that isolated the real bottleneck (the prior pre-warm hypothesis could not move it). Co-authored-by: rodboev --- CHANGELOG.md | 2 + api/models.py | 98 +++++++++++- .../test_issue4718_claude_code_parse_cache.py | 149 ++++++++++++++++++ 3 files changed, 243 insertions(+), 6 deletions(-) create mode 100644 tests/test_issue4718_claude_code_parse_cache.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b9b45eca..3079e76e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## [Unreleased] +- **Profile switching and sidebar loads are no longer dominated by re-parsing Claude Code transcripts.** On an installation with a large `~/.claude/projects` history, every `/api/sessions` build (which a profile switch triggers) re-read and JSON-parsed every Claude Code transcript file from scratch — line by line — just to recover each session's title and message count. On a 200-file / ~130MB tree that single step measured 650–1000ms and dominated the cold sidebar latency; because the directory is global but the higher session cache is keyed per active profile, it repeated in full on every switch, on the 5s CLI-cache expiry, and on every sidebar poll. The transcript parse is now memoized per file by its `(path, mtime, size)` signature, so a warm build re-stats the files (~4ms for 200) instead of re-parsing them, while any genuine edit or append transparently invalidates just that one file's entry. The redundant per-row `get_last_workspace()` call (which stats config + probes the terminal cwd) was also hoisted out of the Claude Code row loop. Measured cold-rebuild `/api/sessions` latency on a 250-session profile dropped from ~670–880ms to ~100–200ms. Output is byte-identical to the previous behavior. Thanks @rodboev for the browser-level profiling that isolated the real bottleneck. (#4718, #4662) + ## [v0.51.639] — 2026-06-25 — Release WT (approval cards can be dismissed and stay dismissed) ### Fixed diff --git a/api/models.py b/api/models.py index 90c15fa0..022d02b3 100644 --- a/api/models.py +++ b/api/models.py @@ -41,6 +41,24 @@ _CLI_SESSIONS_CACHE_TTL_SECONDS = 5.0 _CLI_SESSIONS_CACHE_LOCK = threading.Lock() _CLI_SESSIONS_CACHE = {} +# Per-file parse cache for Claude Code JSONL transcripts (#4718/#4662 phase 4). +# ``~/.claude/projects`` is a GLOBAL, profile-independent directory, but the +# sidebar re-derives every Claude Code row from scratch on each /api/sessions +# build — fully re-reading and JSON-parsing up to CLAUDE_CODE_MAX_FILES +# transcripts line-by-line (hundreds of MB) just to recover a title + message +# count. That parse dominates the cold sidebar build (~650-1000ms measured on a +# 200-file / ~130MB tree) and it repeats on every profile switch, on the 5s +# CLI-cache expiry, and on every sidebar poll, because the higher CLI cache is +# keyed per active profile while the underlying transcripts never change between +# switches. This cache memoizes the EXPENSIVE per-file parse result keyed by the +# file's (resolved path, mtime_ns, size); a warm sidebar build then re-stats the +# files (~4ms for 200) instead of re-parsing them. Any external edit/append to a +# transcript changes mtime_ns/size and transparently invalidates just that one +# file's entry. Bounded so a pathological projects tree can't grow it unbounded. +_CLAUDE_CODE_PARSE_CACHE_LOCK = threading.Lock() +_CLAUDE_CODE_PARSE_CACHE: "collections.OrderedDict[tuple, tuple]" = collections.OrderedDict() +_CLAUDE_CODE_PARSE_CACHE_MAX = 1000 + # --------------------------------------------------------------------------- # Stale temp-file cleanup # --------------------------------------------------------------------------- @@ -3871,6 +3889,60 @@ def _parse_claude_code_jsonl(path: Path, *, max_messages: int = CLAUDE_CODE_MAX_ return messages, summary_title, first_ts, last_ts +def _parse_claude_code_jsonl_cached( + path: Path, *, max_messages: int = CLAUDE_CODE_MAX_MESSAGES_PER_FILE +) -> tuple[list[dict], str | None, float | None, float | None]: + """``_parse_claude_code_jsonl`` memoized by the file's (path, mtime_ns, size). + + The transcript files under ``~/.claude/projects`` are global and rarely + change between sidebar builds, but parsing them dominates the cold + /api/sessions latency (and repeats on every profile switch). Caching the + parse result keyed by the file's stat signature collapses the warm cost to a + single ``os.stat`` per file. A genuine append/edit bumps ``mtime_ns``/``size`` + and misses the cache, so staleness is impossible without re-parsing. + + ``max_messages`` is part of the key so a caller asking for a different cap + never reads a result truncated to a smaller one. + """ + try: + st = path.stat() + key = (str(path), st.st_mtime_ns, st.st_size, int(max_messages)) + except OSError: + # Can't stat -> fall back to a direct (uncached) parse; it will also + # likely fail and return the empty tuple, matching prior behavior. + return _parse_claude_code_jsonl(path, max_messages=max_messages) + + with _CLAUDE_CODE_PARSE_CACHE_LOCK: + hit = _CLAUDE_CODE_PARSE_CACHE.get(key) + if hit is not None: + _CLAUDE_CODE_PARSE_CACHE.move_to_end(key) + messages, summary_title, first_ts, last_ts = hit + # Return a shallow copy of the message list so a caller mutating it + # can't corrupt the cached entry; the per-message dicts are treated + # as read-only by all current callers. + return list(messages), summary_title, first_ts, last_ts + + parsed = _parse_claude_code_jsonl(path, max_messages=max_messages) + + with _CLAUDE_CODE_PARSE_CACHE_LOCK: + # Re-check under lock in case a concurrent build populated it; either + # entry is equally valid for the same stat signature. + existing = _CLAUDE_CODE_PARSE_CACHE.get(key) + if existing is None: + _CLAUDE_CODE_PARSE_CACHE[key] = parsed + _CLAUDE_CODE_PARSE_CACHE.move_to_end(key) + while len(_CLAUDE_CODE_PARSE_CACHE) > _CLAUDE_CODE_PARSE_CACHE_MAX: + _CLAUDE_CODE_PARSE_CACHE.popitem(last=False) + messages, summary_title, first_ts, last_ts = parsed + return list(messages), summary_title, first_ts, last_ts + + +def clear_claude_code_parse_cache() -> None: + """Drop all memoized Claude Code transcript parses (test/lifecycle hook).""" + with _CLAUDE_CODE_PARSE_CACHE_LOCK: + _CLAUDE_CODE_PARSE_CACHE.clear() + + def _iter_claude_code_jsonl_files(projects_dir: Path | str | None = None, *, max_files: int = CLAUDE_CODE_MAX_FILES, max_file_bytes: int = CLAUDE_CODE_MAX_FILE_BYTES): root = Path(projects_dir).expanduser() if projects_dir is not None else _default_claude_code_projects_dir() if root is None: @@ -3926,20 +3998,34 @@ def get_claude_code_sessions(projects_dir: Path | str | None = None, *, max_file never read during test runs. """ sessions = [] + # ``get_last_workspace()`` is loop-invariant (the same active workspace for + # every Claude Code row) but internally stats config.yaml + probes terminal + # cwd, so calling it once per row was ~200 redundant stat()s on the cold + # sidebar build (#4718). Resolve it a single time. + cc_workspace = str(get_last_workspace()) for path in _iter_claude_code_jsonl_files(projects_dir, max_files=max_files, max_file_bytes=max_file_bytes) or []: - messages, summary_title, first_ts, last_ts = _parse_claude_code_jsonl(path) + messages, summary_title, first_ts, last_ts = _parse_claude_code_jsonl_cached(path) if not messages: continue sid = _claude_code_session_id(path) + if first_ts is None and last_ts is None: + try: + _mtime = path.stat().st_mtime + except OSError: + _mtime = 0.0 + else: + _mtime = None + created_at = first_ts or last_ts or _mtime + updated_at = last_ts or first_ts or _mtime sessions.append({ 'session_id': sid, 'title': _claude_code_title(messages, summary_title), - 'workspace': str(get_last_workspace()), + 'workspace': cc_workspace, 'model': 'claude-code', 'message_count': len(messages), - 'created_at': first_ts or last_ts or path.stat().st_mtime, - 'updated_at': last_ts or first_ts or path.stat().st_mtime, - 'last_message_at': last_ts or first_ts or path.stat().st_mtime, + 'created_at': created_at, + 'updated_at': updated_at, + 'last_message_at': updated_at, 'pinned': False, 'archived': False, 'project_id': None, @@ -3963,7 +4049,7 @@ def get_claude_code_session_messages(sid, projects_dir: Path | str | None = None for path in _iter_claude_code_jsonl_files(projects_dir) or []: if _claude_code_session_id(path) != sid: continue - messages, _summary_title, _first_ts, _last_ts = _parse_claude_code_jsonl(path) + messages, _summary_title, _first_ts, _last_ts = _parse_claude_code_jsonl_cached(path) return messages return [] diff --git a/tests/test_issue4718_claude_code_parse_cache.py b/tests/test_issue4718_claude_code_parse_cache.py new file mode 100644 index 00000000..2ee14af1 --- /dev/null +++ b/tests/test_issue4718_claude_code_parse_cache.py @@ -0,0 +1,149 @@ +"""Regression tests for the Claude Code transcript parse cache (#4718/#4662). + +The sidebar/profile-switch cold path was dominated by re-parsing every Claude +Code JSONL transcript on each /api/sessions build. ``_parse_claude_code_jsonl`` +is now memoized by the file's (path, mtime_ns, size) so a warm build re-stats +instead of re-parsing, while any genuine edit transparently invalidates just +the changed file. These tests pin that behavior. +""" +from __future__ import annotations + +import json +import time +from pathlib import Path + + +def _write_jsonl(path: Path, rows: list) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(json.dumps(row) for row in rows) + "\n", encoding="utf-8") + + +def _rows(text: str = "hello") -> list: + return [ + {"summary": "Cache QA"}, + {"timestamp": "2026-04-18T12:00:01Z", "message": {"role": "user", "content": text}}, + {"timestamp": "2026-04-18T12:00:02Z", "message": {"role": "assistant", "content": "ok"}}, + ] + + +def test_parse_cache_hit_skips_reparse(tmp_path, monkeypatch): + import api.models as models + + models.clear_claude_code_parse_cache() + fixture = tmp_path / "claude" / "projects" / "p" / "s.jsonl" + _write_jsonl(fixture, _rows()) + + calls = {"n": 0} + real = models._parse_claude_code_jsonl + + def _counting(path, **kw): + calls["n"] += 1 + return real(path, **kw) + + monkeypatch.setattr(models, "_parse_claude_code_jsonl", _counting) + + first = models._parse_claude_code_jsonl_cached(fixture) + second = models._parse_claude_code_jsonl_cached(fixture) + + # Second call is served from cache: underlying parser ran exactly once. + assert calls["n"] == 1 + assert first == second + assert first[0][0]["content"] == "hello" + + +def test_parse_cache_invalidates_on_content_change(tmp_path, monkeypatch): + import api.models as models + + models.clear_claude_code_parse_cache() + fixture = tmp_path / "claude" / "projects" / "p" / "s.jsonl" + _write_jsonl(fixture, _rows("first")) + + calls = {"n": 0} + real = models._parse_claude_code_jsonl + + def _counting(path, **kw): + calls["n"] += 1 + return real(path, **kw) + + monkeypatch.setattr(models, "_parse_claude_code_jsonl", _counting) + + first = models._parse_claude_code_jsonl_cached(fixture) + assert first[0][0]["content"] == "first" + + # Rewrite with different content + a guaranteed-distinct mtime/size so the + # stat signature changes and the cache must miss. + time.sleep(0.01) + _write_jsonl(fixture, _rows("second-edition-longer")) + import os + st = fixture.stat() + os.utime(fixture, ns=(st.st_atime_ns, st.st_mtime_ns + 1_000_000)) + + second = models._parse_claude_code_jsonl_cached(fixture) + + assert calls["n"] == 2 # re-parsed after the edit + assert second[0][0]["content"] == "second-edition-longer" + + +def test_parse_cache_returns_independent_message_lists(tmp_path): + """A caller mutating the returned list must not corrupt the cached entry.""" + import api.models as models + + models.clear_claude_code_parse_cache() + fixture = tmp_path / "claude" / "projects" / "p" / "s.jsonl" + _write_jsonl(fixture, _rows()) + + first_msgs, *_ = models._parse_claude_code_jsonl_cached(fixture) + first_msgs.append({"role": "user", "content": "injected"}) + + second_msgs, *_ = models._parse_claude_code_jsonl_cached(fixture) + assert not any(m.get("content") == "injected" for m in second_msgs) + + +def test_parse_cache_is_bounded(tmp_path, monkeypatch): + import api.models as models + + models.clear_claude_code_parse_cache() + monkeypatch.setattr(models, "_CLAUDE_CODE_PARSE_CACHE_MAX", 5) + + for i in range(12): + f = tmp_path / "claude" / "projects" / "p" / f"s{i}.jsonl" + _write_jsonl(f, _rows(f"msg-{i}")) + models._parse_claude_code_jsonl_cached(f) + + assert len(models._CLAUDE_CODE_PARSE_CACHE) <= 5 + + +def test_parse_cache_handles_missing_file(tmp_path): + import api.models as models + + models.clear_claude_code_parse_cache() + missing = tmp_path / "nope.jsonl" + # Must not raise; matches the empty-tuple contract of the uncached parser. + assert models._parse_claude_code_jsonl_cached(missing) == ([], None, None, None) + + +def test_get_claude_code_sessions_warm_uses_cache(tmp_path, monkeypatch): + """End-to-end: a 2nd get_claude_code_sessions() does not re-parse files.""" + import api.models as models + + models.clear_claude_code_parse_cache() + projects_dir = tmp_path / "claude" / "projects" + for i in range(3): + _write_jsonl(projects_dir / f"proj{i}" / "s.jsonl", _rows(f"row-{i}")) + + calls = {"n": 0} + real = models._parse_claude_code_jsonl + + def _counting(path, **kw): + calls["n"] += 1 + return real(path, **kw) + + monkeypatch.setattr(models, "_parse_claude_code_jsonl", _counting) + + cold = models.get_claude_code_sessions(projects_dir=projects_dir) + cold_calls = calls["n"] + warm = models.get_claude_code_sessions(projects_dir=projects_dir) + + assert cold_calls == 3 # parsed each file once on the cold build + assert calls["n"] == cold_calls # warm build added zero re-parses + assert [s["title"] for s in cold] == [s["title"] for s in warm]