Skip to content

Commit 40cb91a

Browse files
perf: add mtime-invalidated LRU session cache; route APIs through it
1 parent def3280 commit 40cb91a

9 files changed

Lines changed: 183 additions & 13 deletions

File tree

api/export_api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@
2626
load_export_state_from_disk,
2727
)
2828
from utils.json_exporter import session_to_json
29-
from utils.jsonl_parser import parse_session
3029
from utils.md_exporter import session_to_markdown
30+
from utils.session_cache import get_cached_session
3131
from utils.session_path import get_claude_projects_dir, list_projects, safe_join
3232
from utils.session_stats import compute_stats
3333
from utils.slugify import slugify
@@ -237,7 +237,7 @@ def export_session(project_name: str, session_id: str) -> FlaskReturn:
237237

238238
fmt = request.args.get("format", "md")
239239
try:
240-
session = parse_session(filepath)
240+
session = get_cached_session(filepath)
241241
except _EXPORT_ERRORS:
242242
current_app.logger.exception("Failed to parse session %s for export", session_id)
243243
return error_response(

api/projects.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,13 @@ def get_project_sessions(project_name: str) -> FlaskReturn:
8181
return error_response(ErrorCode.INVALID_PATH, "Invalid path", 400)
8282
sessions = list_sessions(project_dir)
8383
# Add summary preview for each session
84-
from utils.jsonl_parser import parse_session
84+
from utils.session_cache import get_cached_session
8585

8686
rules = current_app.config.get("EXCLUSION_RULES") or []
8787
result: list[ProjectSessionRowDict] = []
8888
for s in sessions:
8989
try:
90-
parsed = parse_session(s["path"])
90+
parsed = get_cached_session(s["path"])
9191
# Skip untitled sessions (no real conversation)
9292
if parsed["title"] == "Untitled Session":
9393
continue

api/search.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from api.error_codes import ErrorCode, error_response
77
from models.search import SearchHitDict
88
from utils.exclusion_rules import is_session_excluded
9-
from utils.jsonl_parser import parse_session
9+
from utils.session_cache import get_cached_session
1010
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions
1111

1212
search_bp = Blueprint("search", __name__)
@@ -54,7 +54,7 @@ def search() -> FlaskReturn:
5454
if len(results) >= max_results:
5555
break
5656
try:
57-
session = parse_session(sess_info["path"])
57+
session = get_cached_session(sess_info["path"])
5858
except Exception:
5959
continue
6060

api/sessions.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from api._flask_types import FlaskReturn, json_response
99
from api.error_codes import ErrorCode, error_response
1010
from utils.exclusion_rules import is_session_excluded
11-
from utils.jsonl_parser import parse_session
11+
from utils.session_cache import get_cached_session
1212
from utils.session_path import get_claude_projects_dir, safe_join
1313
from utils.session_stats import compute_stats
1414

@@ -39,7 +39,7 @@ def get_session(project_name: str, session_id: str) -> FlaskReturn:
3939
)
4040

4141
try:
42-
session = parse_session(filepath)
42+
session = get_cached_session(filepath)
4343
rules = current_app.config.get("EXCLUSION_RULES") or []
4444
if is_session_excluded(rules, session, project_name):
4545
return error_response(
@@ -73,7 +73,7 @@ def get_session_stats(project_name: str, session_id: str) -> FlaskReturn:
7373
)
7474

7575
try:
76-
session = parse_session(filepath)
76+
session = get_cached_session(filepath)
7777
rules = current_app.config.get("EXCLUSION_RULES") or []
7878
if is_session_excluded(rules, session, project_name):
7979
return error_response(
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Benchmark cold parse vs warm cache hit for get_cached_session."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
from utils.session_cache import clear_cache, get_cached_session
10+
11+
12+
@pytest.fixture(autouse=True)
13+
def _reset_cache() -> None:
14+
clear_cache()
15+
16+
17+
@pytest.mark.benchmark(group="cache")
18+
def test_cache_cold_parse(benchmark, parse_medium_file: Path) -> None:
19+
path = str(parse_medium_file)
20+
21+
def cold() -> None:
22+
clear_cache()
23+
get_cached_session(path)
24+
25+
benchmark(cold)
26+
27+
28+
@pytest.mark.benchmark(group="cache")
29+
def test_cache_warm_hit(benchmark, parse_medium_file: Path) -> None:
30+
path = str(parse_medium_file)
31+
get_cached_session(path)
32+
benchmark(get_cached_session, path)

tests/test_api_routes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def test_session_detail_parse_failure_returns_500_without_leak(client, monkeypat
6666
def _boom(*_args, **_kwargs):
6767
raise KeyError("internal_secret_field_id")
6868

69-
monkeypatch.setattr("api.sessions.parse_session", _boom)
69+
monkeypatch.setattr("api.sessions.get_cached_session", _boom)
7070
resp = client.get("/api/sessions/test-project/session_abc123")
7171
assert resp.status_code == 500
7272
body_text = resp.get_data(as_text=True)

tests/test_error_propagation.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def test_500_on_parse_failure_does_not_leak_class_name(self, tmp_path, client, m
109109
def _boom(*args, **kwargs):
110110
raise KeyError("internal_secret_field_id")
111111

112-
monkeypatch.setattr("api.sessions.parse_session", _boom)
112+
monkeypatch.setattr("api.sessions.get_cached_session", _boom)
113113

114114
resp = client.get("/api/sessions/proj/abc")
115115
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
151151
def _boom(*args, **kwargs):
152152
raise ValueError("invalid literal: '/private/path/secret.json'")
153153

154-
monkeypatch.setattr("api.sessions.parse_session", _boom)
154+
monkeypatch.setattr("api.sessions.get_cached_session", _boom)
155155

156156
resp = client.get("/api/sessions/proj/abc/stats")
157157
assert resp.status_code == 500
@@ -181,7 +181,7 @@ def _boom(*args, **kwargs):
181181

182182
# api/projects.py imports parse_session inside the handler body,
183183
# so patch the source module rather than the consumer.
184-
monkeypatch.setattr("utils.jsonl_parser.parse_session", _boom)
184+
monkeypatch.setattr("utils.session_cache.get_cached_session", _boom)
185185

186186
resp = client.get("/api/projects/myproj/sessions")
187187
# Pin the response shape so a future wrapper change (e.g. {"sessions": [...]})

tests/test_session_cache.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Unit tests for utils.session_cache."""
2+
3+
from __future__ import annotations
4+
5+
import shutil
6+
import time
7+
from pathlib import Path
8+
9+
import pytest
10+
11+
from utils.jsonl_parser import parse_session
12+
from utils.session_cache import clear_cache, get_cached_session, set_max_entries
13+
14+
FIXTURES = Path(__file__).resolve().parent / "fixtures"
15+
SAMPLE_SESSION = FIXTURES / "session_with_tools.jsonl"
16+
17+
18+
@pytest.fixture
19+
def sample_session(tmp_path: Path) -> Path:
20+
dest = tmp_path / "session.jsonl"
21+
shutil.copy(SAMPLE_SESSION, dest)
22+
return dest
23+
24+
25+
@pytest.fixture(autouse=True)
26+
def _reset_cache() -> None:
27+
clear_cache()
28+
set_max_entries(200)
29+
30+
31+
def test_cache_returns_same_data_as_direct_parse(sample_session: Path) -> None:
32+
path = str(sample_session)
33+
assert get_cached_session(path) == parse_session(path)
34+
35+
36+
def test_cache_hit_avoids_reparse(sample_session: Path, monkeypatch: pytest.MonkeyPatch) -> None:
37+
path = str(sample_session)
38+
get_cached_session(path)
39+
calls = 0
40+
41+
def counting_parse(p: str):
42+
nonlocal calls
43+
calls += 1
44+
return parse_session(p)
45+
46+
monkeypatch.setattr("utils.session_cache.parse_session", counting_parse)
47+
get_cached_session(path)
48+
assert calls == 0
49+
50+
51+
def test_cache_invalidates_on_mtime_change(sample_session: Path) -> None:
52+
path = str(sample_session)
53+
first = get_cached_session(path)
54+
time.sleep(0.05)
55+
sample_session.touch()
56+
second = get_cached_session(path)
57+
assert first is not second
58+
59+
60+
def test_lru_eviction(sample_session: Path, tmp_path: Path) -> None:
61+
set_max_entries(2)
62+
content = sample_session.read_text(encoding="utf-8")
63+
paths = []
64+
for name in ("a.jsonl", "b.jsonl", "c.jsonl"):
65+
p = tmp_path / name
66+
p.write_text(content, encoding="utf-8")
67+
paths.append(p)
68+
69+
for p in paths:
70+
get_cached_session(str(p))
71+
72+
calls = 0
73+
74+
def counting_parse(p: str):
75+
nonlocal calls
76+
calls += 1
77+
return parse_session(p)
78+
79+
monkeypatch = pytest.MonkeyPatch()
80+
monkeypatch.setattr("utils.session_cache.parse_session", counting_parse)
81+
try:
82+
get_cached_session(str(paths[2]))
83+
assert calls == 0
84+
get_cached_session(str(paths[1]))
85+
assert calls == 0
86+
get_cached_session(str(paths[0]))
87+
assert calls == 1
88+
finally:
89+
monkeypatch.undo()

utils/session_cache.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""In-memory session parse cache with mtime invalidation and LRU eviction."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import threading
7+
from collections import OrderedDict
8+
9+
from models.session import SessionDict
10+
from utils.jsonl_parser import parse_session
11+
12+
DEFAULT_MAX_ENTRIES = 200
13+
14+
_lock = threading.Lock()
15+
_cache: OrderedDict[str, tuple[float, SessionDict]] = OrderedDict()
16+
_max_entries = DEFAULT_MAX_ENTRIES
17+
18+
19+
def get_cached_session(path: str) -> SessionDict:
20+
"""Return a parsed session, reusing the cache when mtime is unchanged."""
21+
abspath = os.path.abspath(path)
22+
mtime = os.path.getmtime(abspath)
23+
with _lock:
24+
hit = _cache.get(abspath)
25+
if hit is not None and hit[0] == mtime:
26+
_cache.move_to_end(abspath)
27+
return hit[1]
28+
parsed = parse_session(abspath)
29+
with _lock:
30+
_cache[abspath] = (mtime, parsed)
31+
_cache.move_to_end(abspath)
32+
while len(_cache) > _max_entries:
33+
_cache.popitem(last=False)
34+
return parsed
35+
36+
37+
def clear_cache() -> None:
38+
"""Clear all cached sessions (for tests and debug)."""
39+
with _lock:
40+
_cache.clear()
41+
42+
43+
def set_max_entries(max_entries: int) -> None:
44+
"""Override the LRU capacity (primarily for tests)."""
45+
global _max_entries
46+
with _lock:
47+
_max_entries = max_entries
48+
while len(_cache) > _max_entries:
49+
_cache.popitem(last=False)

0 commit comments

Comments
 (0)