perf(#4718): cache Claude Code transcript parses on the sidebar path

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 <rodboev@users.noreply.github.com>
This commit is contained in:
nesquena-hermes 2026-06-24 07:13:33 +00:00
parent 703d44b168
commit fdb734a6a0
3 changed files with 243 additions and 6 deletions

View file

@ -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

View file

@ -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 []

View file

@ -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]