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:
parent
703d44b168
commit
fdb734a6a0
3 changed files with 243 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
||||
|
|
|
|||
149
tests/test_issue4718_claude_code_parse_cache.py
Normal file
149
tests/test_issue4718_claude_code_parse_cache.py
Normal 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]
|
||||
Loading…
Reference in a new issue