From 40cb91a64c8e6b84037400ac37c8accbae790530 Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 18 Jun 2026 00:02:30 +0800 Subject: [PATCH 1/4] perf: add mtime-invalidated LRU session cache; route APIs through it --- api/export_api.py | 4 +- api/projects.py | 4 +- api/search.py | 4 +- api/sessions.py | 6 +- tests/benchmarks/test_cache_bench.py | 32 ++++++++++ tests/test_api_routes.py | 2 +- tests/test_error_propagation.py | 6 +- tests/test_session_cache.py | 89 ++++++++++++++++++++++++++++ utils/session_cache.py | 49 +++++++++++++++ 9 files changed, 183 insertions(+), 13 deletions(-) create mode 100644 tests/benchmarks/test_cache_bench.py create mode 100644 tests/test_session_cache.py create mode 100644 utils/session_cache.py diff --git a/api/export_api.py b/api/export_api.py index 92e97b4..15b62c0 100644 --- a/api/export_api.py +++ b/api/export_api.py @@ -26,8 +26,8 @@ load_export_state_from_disk, ) from utils.json_exporter import session_to_json -from utils.jsonl_parser import parse_session from utils.md_exporter import session_to_markdown +from utils.session_cache import get_cached_session from utils.session_path import get_claude_projects_dir, list_projects, safe_join from utils.session_stats import compute_stats from utils.slugify import slugify @@ -237,7 +237,7 @@ def export_session(project_name: str, session_id: str) -> FlaskReturn: fmt = request.args.get("format", "md") try: - session = parse_session(filepath) + session = get_cached_session(filepath) except _EXPORT_ERRORS: current_app.logger.exception("Failed to parse session %s for export", session_id) return error_response( diff --git a/api/projects.py b/api/projects.py index 16eb8bb..ef6c308 100644 --- a/api/projects.py +++ b/api/projects.py @@ -81,13 +81,13 @@ def get_project_sessions(project_name: str) -> FlaskReturn: return error_response(ErrorCode.INVALID_PATH, "Invalid path", 400) sessions = list_sessions(project_dir) # Add summary preview for each session - from utils.jsonl_parser import parse_session + from utils.session_cache import get_cached_session rules = current_app.config.get("EXCLUSION_RULES") or [] result: list[ProjectSessionRowDict] = [] for s in sessions: try: - parsed = parse_session(s["path"]) + parsed = get_cached_session(s["path"]) # Skip untitled sessions (no real conversation) if parsed["title"] == "Untitled Session": continue diff --git a/api/search.py b/api/search.py index d3d4721..786b812 100644 --- a/api/search.py +++ b/api/search.py @@ -6,7 +6,7 @@ from api.error_codes import ErrorCode, error_response from models.search import SearchHitDict from utils.exclusion_rules import is_session_excluded -from utils.jsonl_parser import parse_session +from utils.session_cache import get_cached_session from utils.session_path import get_claude_projects_dir, list_projects, list_sessions search_bp = Blueprint("search", __name__) @@ -54,7 +54,7 @@ def search() -> FlaskReturn: if len(results) >= max_results: break try: - session = parse_session(sess_info["path"]) + session = get_cached_session(sess_info["path"]) except Exception: continue diff --git a/api/sessions.py b/api/sessions.py index dbcbc0f..0e42848 100644 --- a/api/sessions.py +++ b/api/sessions.py @@ -8,7 +8,7 @@ from api._flask_types import FlaskReturn, json_response from api.error_codes import ErrorCode, error_response from utils.exclusion_rules import is_session_excluded -from utils.jsonl_parser import parse_session +from utils.session_cache import get_cached_session from utils.session_path import get_claude_projects_dir, safe_join from utils.session_stats import compute_stats @@ -39,7 +39,7 @@ def get_session(project_name: str, session_id: str) -> FlaskReturn: ) try: - session = parse_session(filepath) + session = get_cached_session(filepath) rules = current_app.config.get("EXCLUSION_RULES") or [] if is_session_excluded(rules, session, project_name): return error_response( @@ -73,7 +73,7 @@ def get_session_stats(project_name: str, session_id: str) -> FlaskReturn: ) try: - session = parse_session(filepath) + session = get_cached_session(filepath) rules = current_app.config.get("EXCLUSION_RULES") or [] if is_session_excluded(rules, session, project_name): return error_response( diff --git a/tests/benchmarks/test_cache_bench.py b/tests/benchmarks/test_cache_bench.py new file mode 100644 index 0000000..6e55c30 --- /dev/null +++ b/tests/benchmarks/test_cache_bench.py @@ -0,0 +1,32 @@ +"""Benchmark cold parse vs warm cache hit for get_cached_session.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from utils.session_cache import clear_cache, get_cached_session + + +@pytest.fixture(autouse=True) +def _reset_cache() -> None: + clear_cache() + + +@pytest.mark.benchmark(group="cache") +def test_cache_cold_parse(benchmark, parse_medium_file: Path) -> None: + path = str(parse_medium_file) + + def cold() -> None: + clear_cache() + get_cached_session(path) + + benchmark(cold) + + +@pytest.mark.benchmark(group="cache") +def test_cache_warm_hit(benchmark, parse_medium_file: Path) -> None: + path = str(parse_medium_file) + get_cached_session(path) + benchmark(get_cached_session, path) diff --git a/tests/test_api_routes.py b/tests/test_api_routes.py index bb2fb30..e454e84 100644 --- a/tests/test_api_routes.py +++ b/tests/test_api_routes.py @@ -66,7 +66,7 @@ def test_session_detail_parse_failure_returns_500_without_leak(client, monkeypat def _boom(*_args, **_kwargs): raise KeyError("internal_secret_field_id") - monkeypatch.setattr("api.sessions.parse_session", _boom) + monkeypatch.setattr("api.sessions.get_cached_session", _boom) resp = client.get("/api/sessions/test-project/session_abc123") assert resp.status_code == 500 body_text = resp.get_data(as_text=True) diff --git a/tests/test_error_propagation.py b/tests/test_error_propagation.py index 57fd9a0..a2dd01f 100644 --- a/tests/test_error_propagation.py +++ b/tests/test_error_propagation.py @@ -109,7 +109,7 @@ def test_500_on_parse_failure_does_not_leak_class_name(self, tmp_path, client, m def _boom(*args, **kwargs): raise KeyError("internal_secret_field_id") - monkeypatch.setattr("api.sessions.parse_session", _boom) + monkeypatch.setattr("api.sessions.get_cached_session", _boom) resp = client.get("/api/sessions/proj/abc") assert resp.status_code == 500 @@ -151,7 +151,7 @@ def test_500_on_parse_failure_does_not_leak_class_name(self, tmp_path, client, m def _boom(*args, **kwargs): raise ValueError("invalid literal: '/private/path/secret.json'") - monkeypatch.setattr("api.sessions.parse_session", _boom) + monkeypatch.setattr("api.sessions.get_cached_session", _boom) resp = client.get("/api/sessions/proj/abc/stats") assert resp.status_code == 500 @@ -181,7 +181,7 @@ def _boom(*args, **kwargs): # api/projects.py imports parse_session inside the handler body, # so patch the source module rather than the consumer. - monkeypatch.setattr("utils.jsonl_parser.parse_session", _boom) + monkeypatch.setattr("utils.session_cache.get_cached_session", _boom) resp = client.get("/api/projects/myproj/sessions") # Pin the response shape so a future wrapper change (e.g. {"sessions": [...]}) diff --git a/tests/test_session_cache.py b/tests/test_session_cache.py new file mode 100644 index 0000000..5272de1 --- /dev/null +++ b/tests/test_session_cache.py @@ -0,0 +1,89 @@ +"""Unit tests for utils.session_cache.""" + +from __future__ import annotations + +import shutil +import time +from pathlib import Path + +import pytest + +from utils.jsonl_parser import parse_session +from utils.session_cache import clear_cache, get_cached_session, set_max_entries + +FIXTURES = Path(__file__).resolve().parent / "fixtures" +SAMPLE_SESSION = FIXTURES / "session_with_tools.jsonl" + + +@pytest.fixture +def sample_session(tmp_path: Path) -> Path: + dest = tmp_path / "session.jsonl" + shutil.copy(SAMPLE_SESSION, dest) + return dest + + +@pytest.fixture(autouse=True) +def _reset_cache() -> None: + clear_cache() + set_max_entries(200) + + +def test_cache_returns_same_data_as_direct_parse(sample_session: Path) -> None: + path = str(sample_session) + assert get_cached_session(path) == parse_session(path) + + +def test_cache_hit_avoids_reparse(sample_session: Path, monkeypatch: pytest.MonkeyPatch) -> None: + path = str(sample_session) + get_cached_session(path) + calls = 0 + + def counting_parse(p: str): + nonlocal calls + calls += 1 + return parse_session(p) + + monkeypatch.setattr("utils.session_cache.parse_session", counting_parse) + get_cached_session(path) + assert calls == 0 + + +def test_cache_invalidates_on_mtime_change(sample_session: Path) -> None: + path = str(sample_session) + first = get_cached_session(path) + time.sleep(0.05) + sample_session.touch() + second = get_cached_session(path) + assert first is not second + + +def test_lru_eviction(sample_session: Path, tmp_path: Path) -> None: + set_max_entries(2) + content = sample_session.read_text(encoding="utf-8") + paths = [] + for name in ("a.jsonl", "b.jsonl", "c.jsonl"): + p = tmp_path / name + p.write_text(content, encoding="utf-8") + paths.append(p) + + for p in paths: + get_cached_session(str(p)) + + calls = 0 + + def counting_parse(p: str): + nonlocal calls + calls += 1 + return parse_session(p) + + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr("utils.session_cache.parse_session", counting_parse) + try: + get_cached_session(str(paths[2])) + assert calls == 0 + get_cached_session(str(paths[1])) + assert calls == 0 + get_cached_session(str(paths[0])) + assert calls == 1 + finally: + monkeypatch.undo() diff --git a/utils/session_cache.py b/utils/session_cache.py new file mode 100644 index 0000000..01af422 --- /dev/null +++ b/utils/session_cache.py @@ -0,0 +1,49 @@ +"""In-memory session parse cache with mtime invalidation and LRU eviction.""" + +from __future__ import annotations + +import os +import threading +from collections import OrderedDict + +from models.session import SessionDict +from utils.jsonl_parser import parse_session + +DEFAULT_MAX_ENTRIES = 200 + +_lock = threading.Lock() +_cache: OrderedDict[str, tuple[float, SessionDict]] = OrderedDict() +_max_entries = DEFAULT_MAX_ENTRIES + + +def get_cached_session(path: str) -> SessionDict: + """Return a parsed session, reusing the cache when mtime is unchanged.""" + abspath = os.path.abspath(path) + mtime = os.path.getmtime(abspath) + with _lock: + hit = _cache.get(abspath) + if hit is not None and hit[0] == mtime: + _cache.move_to_end(abspath) + return hit[1] + parsed = parse_session(abspath) + with _lock: + _cache[abspath] = (mtime, parsed) + _cache.move_to_end(abspath) + while len(_cache) > _max_entries: + _cache.popitem(last=False) + return parsed + + +def clear_cache() -> None: + """Clear all cached sessions (for tests and debug).""" + with _lock: + _cache.clear() + + +def set_max_entries(max_entries: int) -> None: + """Override the LRU capacity (primarily for tests).""" + global _max_entries + with _lock: + _max_entries = max_entries + while len(_cache) > _max_entries: + _cache.popitem(last=False) From abec1a46f326be79758fa7d37a7dd828f61f01d2 Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 18 Jun 2026 00:19:24 +0800 Subject: [PATCH 2/4] fix: address session-cache PR review feedback --- tests/benchmarks/test_cache_bench.py | 7 +----- tests/test_session_cache.py | 37 +++++++++++++++++++++++++--- utils/session_cache.py | 2 ++ 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/tests/benchmarks/test_cache_bench.py b/tests/benchmarks/test_cache_bench.py index 6e55c30..d364389 100644 --- a/tests/benchmarks/test_cache_bench.py +++ b/tests/benchmarks/test_cache_bench.py @@ -17,12 +17,7 @@ def _reset_cache() -> None: @pytest.mark.benchmark(group="cache") def test_cache_cold_parse(benchmark, parse_medium_file: Path) -> None: path = str(parse_medium_file) - - def cold() -> None: - clear_cache() - get_cached_session(path) - - benchmark(cold) + benchmark.pedantic(get_cached_session, args=(path,), setup=clear_cache) @pytest.mark.benchmark(group="cache") diff --git a/tests/test_session_cache.py b/tests/test_session_cache.py index 5272de1..9acf2b5 100644 --- a/tests/test_session_cache.py +++ b/tests/test_session_cache.py @@ -2,8 +2,8 @@ from __future__ import annotations +import os import shutil -import time from pathlib import Path import pytest @@ -51,12 +51,38 @@ def counting_parse(p: str): def test_cache_invalidates_on_mtime_change(sample_session: Path) -> None: path = str(sample_session) first = get_cached_session(path) - time.sleep(0.05) - sample_session.touch() + stat = sample_session.stat() + os.utime(sample_session, (stat.st_mtime + 1, stat.st_mtime + 1)) second = get_cached_session(path) assert first is not second +def test_cache_normalizes_relative_and_absolute_paths( + sample_session: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = 0 + + def counting_parse(p: str): + nonlocal calls + calls += 1 + return parse_session(p) + + monkeypatch.setattr("utils.session_cache.parse_session", counting_parse) + rel = "session.jsonl" + abs_path = str(sample_session.resolve()) + original_cwd = os.getcwd() + try: + os.chdir(tmp_path) + get_cached_session(rel) + assert calls == 1 + get_cached_session(abs_path) + assert calls == 1 + finally: + os.chdir(original_cwd) + + def test_lru_eviction(sample_session: Path, tmp_path: Path) -> None: set_max_entries(2) content = sample_session.read_text(encoding="utf-8") @@ -87,3 +113,8 @@ def counting_parse(p: str): assert calls == 1 finally: monkeypatch.undo() + + +def test_set_max_entries_rejects_negative() -> None: + with pytest.raises(ValueError, match="non-negative"): + set_max_entries(-1) diff --git a/utils/session_cache.py b/utils/session_cache.py index 01af422..8c835f3 100644 --- a/utils/session_cache.py +++ b/utils/session_cache.py @@ -42,6 +42,8 @@ def clear_cache() -> None: def set_max_entries(max_entries: int) -> None: """Override the LRU capacity (primarily for tests).""" + if max_entries < 0: + raise ValueError(f"max_entries must be non-negative, got {max_entries}") global _max_entries with _lock: _max_entries = max_entries From d8b87b0a1265877364a392903d8ab0f7c8484fcc Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 18 Jun 2026 01:58:46 +0800 Subject: [PATCH 3/4] fix: harden session cache mtime handling and address PR #90 review Re-read mtime after parse and only cache when the file was stable during the read; skip cache writes when max_entries is 0. Move get_cached_session to module-level import in projects.py; improve mtime/LRU tests; document concurrent cold-miss behavior. stop search scan after max_results across projects --- api/projects.py | 4 +--- api/search.py | 2 ++ tests/test_error_propagation.py | 4 +--- tests/test_session_cache.py | 40 ++++++++++++++++++++------------- utils/session_cache.py | 27 +++++++++++++++------- 5 files changed, 48 insertions(+), 29 deletions(-) diff --git a/api/projects.py b/api/projects.py index ef6c308..bea220f 100644 --- a/api/projects.py +++ b/api/projects.py @@ -7,6 +7,7 @@ from models.project import ProjectSessionRowDict, SessionListItemDict from models.session import SessionDict from utils.exclusion_rules import is_session_excluded +from utils.session_cache import get_cached_session from utils.session_path import get_claude_projects_dir, list_projects, list_sessions, safe_join projects_bp = Blueprint("projects", __name__) @@ -80,9 +81,6 @@ def get_project_sessions(project_name: str) -> FlaskReturn: except ValueError: return error_response(ErrorCode.INVALID_PATH, "Invalid path", 400) sessions = list_sessions(project_dir) - # Add summary preview for each session - from utils.session_cache import get_cached_session - rules = current_app.config.get("EXCLUSION_RULES") or [] result: list[ProjectSessionRowDict] = [] for s in sessions: diff --git a/api/search.py b/api/search.py index 786b812..425d069 100644 --- a/api/search.py +++ b/api/search.py @@ -49,6 +49,8 @@ def search() -> FlaskReturn: rules = current_app.config.get("EXCLUSION_RULES") or [] results: list[SearchHitDict] = [] for project in projects: + if len(results) >= max_results: + break sessions = list_sessions(project["path"]) for sess_info in sessions: if len(results) >= max_results: diff --git a/tests/test_error_propagation.py b/tests/test_error_propagation.py index a2dd01f..9ddaf5d 100644 --- a/tests/test_error_propagation.py +++ b/tests/test_error_propagation.py @@ -179,9 +179,7 @@ def test_per_session_error_card_omits_error_detail(self, tmp_path, client, monke def _boom(*args, **kwargs): raise KeyError("internal_secret_field_id") - # api/projects.py imports parse_session inside the handler body, - # so patch the source module rather than the consumer. - monkeypatch.setattr("utils.session_cache.get_cached_session", _boom) + monkeypatch.setattr("api.projects.get_cached_session", _boom) resp = client.get("/api/projects/myproj/sessions") # Pin the response shape so a future wrapper change (e.g. {"sessions": [...]}) diff --git a/tests/test_session_cache.py b/tests/test_session_cache.py index 9acf2b5..ed4919d 100644 --- a/tests/test_session_cache.py +++ b/tests/test_session_cache.py @@ -48,13 +48,25 @@ def counting_parse(p: str): assert calls == 0 -def test_cache_invalidates_on_mtime_change(sample_session: Path) -> None: +def test_cache_invalidates_on_mtime_change( + sample_session: Path, monkeypatch: pytest.MonkeyPatch +) -> None: path = str(sample_session) - first = get_cached_session(path) + get_cached_session(path) + + calls = 0 + + def counting_parse(p: str): + nonlocal calls + calls += 1 + return parse_session(p) + + monkeypatch.setattr("utils.session_cache.parse_session", counting_parse) + stat = sample_session.stat() os.utime(sample_session, (stat.st_mtime + 1, stat.st_mtime + 1)) - second = get_cached_session(path) - assert first is not second + get_cached_session(path) + assert calls == 1 def test_cache_normalizes_relative_and_absolute_paths( @@ -83,7 +95,9 @@ def counting_parse(p: str): os.chdir(original_cwd) -def test_lru_eviction(sample_session: Path, tmp_path: Path) -> None: +def test_lru_eviction( + sample_session: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: set_max_entries(2) content = sample_session.read_text(encoding="utf-8") paths = [] @@ -102,17 +116,13 @@ def counting_parse(p: str): calls += 1 return parse_session(p) - monkeypatch = pytest.MonkeyPatch() monkeypatch.setattr("utils.session_cache.parse_session", counting_parse) - try: - get_cached_session(str(paths[2])) - assert calls == 0 - get_cached_session(str(paths[1])) - assert calls == 0 - get_cached_session(str(paths[0])) - assert calls == 1 - finally: - monkeypatch.undo() + get_cached_session(str(paths[2])) + assert calls == 0 + get_cached_session(str(paths[1])) + assert calls == 0 + get_cached_session(str(paths[0])) + assert calls == 1 def test_set_max_entries_rejects_negative() -> None: diff --git a/utils/session_cache.py b/utils/session_cache.py index 8c835f3..66c89ff 100644 --- a/utils/session_cache.py +++ b/utils/session_cache.py @@ -17,20 +17,28 @@ def get_cached_session(path: str) -> SessionDict: - """Return a parsed session, reusing the cache when mtime is unchanged.""" + """Return a parsed session, reusing the cache when mtime is unchanged. + + Concurrent requests for different paths proceed in parallel. + Concurrent misses on the *same* path will each parse independently; + the last writer wins. This is safe but may parse the file more than + once under high concurrency for a cold key. + """ abspath = os.path.abspath(path) - mtime = os.path.getmtime(abspath) + mtime_before = os.path.getmtime(abspath) with _lock: hit = _cache.get(abspath) - if hit is not None and hit[0] == mtime: + if hit is not None and hit[0] == mtime_before: _cache.move_to_end(abspath) return hit[1] parsed = parse_session(abspath) + mtime_after = os.path.getmtime(abspath) with _lock: - _cache[abspath] = (mtime, parsed) - _cache.move_to_end(abspath) - while len(_cache) > _max_entries: - _cache.popitem(last=False) + if _max_entries > 0 and mtime_after == mtime_before: + _cache[abspath] = (mtime_after, parsed) + _cache.move_to_end(abspath) + while len(_cache) > _max_entries: + _cache.popitem(last=False) return parsed @@ -41,7 +49,10 @@ def clear_cache() -> None: def set_max_entries(max_entries: int) -> None: - """Override the LRU capacity (primarily for tests).""" + """Override the LRU capacity (primarily for tests). + + A value of 0 disables caching entirely — every access will parse from disk. + """ if max_entries < 0: raise ValueError(f"max_entries must be non-negative, got {max_entries}") global _max_entries From fe31c5bfcb66fc0b16a0ea321f34dab8a185ed83 Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 18 Jun 2026 02:04:35 +0800 Subject: [PATCH 4/4] fix: return parsed session when post-parse getmtime fails --- tests/test_session_cache.py | 19 +++++++++++++++++++ utils/session_cache.py | 5 ++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/test_session_cache.py b/tests/test_session_cache.py index ed4919d..0a8b344 100644 --- a/tests/test_session_cache.py +++ b/tests/test_session_cache.py @@ -128,3 +128,22 @@ def counting_parse(p: str): def test_set_max_entries_rejects_negative() -> None: with pytest.raises(ValueError, match="non-negative"): set_max_entries(-1) + + +def test_returns_parsed_when_mtime_after_parse_raises( + sample_session: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = str(sample_session) + real_getmtime = os.path.getmtime + calls = 0 + + def getmtime_side_effect(p: str) -> float: + nonlocal calls + calls += 1 + if calls == 2: + raise OSError("file removed after parse") + return real_getmtime(p) + + monkeypatch.setattr(os.path, "getmtime", getmtime_side_effect) + result = get_cached_session(path) + assert result == parse_session(path) diff --git a/utils/session_cache.py b/utils/session_cache.py index 66c89ff..e47969f 100644 --- a/utils/session_cache.py +++ b/utils/session_cache.py @@ -32,7 +32,10 @@ def get_cached_session(path: str) -> SessionDict: _cache.move_to_end(abspath) return hit[1] parsed = parse_session(abspath) - mtime_after = os.path.getmtime(abspath) + try: + mtime_after = os.path.getmtime(abspath) + except OSError: + return parsed with _lock: if _max_entries > 0 and mtime_after == mtime_before: _cache[abspath] = (mtime_after, parsed)