Skip to content

Commit 047efe8

Browse files
perf: add mtime-invalidated LRU session cache; route APIs through it (#90)
* perf: add mtime-invalidated LRU session cache; route APIs through it * fix: address session-cache PR review feedback * 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 * fix: return parsed session when post-parse getmtime fails
1 parent def3280 commit 047efe8

9 files changed

Lines changed: 256 additions & 17 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 & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from models.project import ProjectSessionRowDict, SessionListItemDict
88
from models.session import SessionDict
99
from utils.exclusion_rules import is_session_excluded
10+
from utils.session_cache import get_cached_session
1011
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions, safe_join
1112

1213
projects_bp = Blueprint("projects", __name__)
@@ -80,14 +81,11 @@ def get_project_sessions(project_name: str) -> FlaskReturn:
8081
except ValueError:
8182
return error_response(ErrorCode.INVALID_PATH, "Invalid path", 400)
8283
sessions = list_sessions(project_dir)
83-
# Add summary preview for each session
84-
from utils.jsonl_parser import parse_session
85-
8684
rules = current_app.config.get("EXCLUSION_RULES") or []
8785
result: list[ProjectSessionRowDict] = []
8886
for s in sessions:
8987
try:
90-
parsed = parse_session(s["path"])
88+
parsed = get_cached_session(s["path"])
9189
# Skip untitled sessions (no real conversation)
9290
if parsed["title"] == "Untitled Session":
9391
continue

api/search.py

Lines changed: 4 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__)
@@ -49,12 +49,14 @@ def search() -> FlaskReturn:
4949
rules = current_app.config.get("EXCLUSION_RULES") or []
5050
results: list[SearchHitDict] = []
5151
for project in projects:
52+
if len(results) >= max_results:
53+
break
5254
sessions = list_sessions(project["path"])
5355
for sess_info in sessions:
5456
if len(results) >= max_results:
5557
break
5658
try:
57-
session = parse_session(sess_info["path"])
59+
session = get_cached_session(sess_info["path"])
5860
except Exception:
5961
continue
6062

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: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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+
benchmark.pedantic(get_cached_session, args=(path,), setup=clear_cache)
21+
22+
23+
@pytest.mark.benchmark(group="cache")
24+
def test_cache_warm_hit(benchmark, parse_medium_file: Path) -> None:
25+
path = str(parse_medium_file)
26+
get_cached_session(path)
27+
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 & 5 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
@@ -179,9 +179,7 @@ def test_per_session_error_card_omits_error_detail(self, tmp_path, client, monke
179179
def _boom(*args, **kwargs):
180180
raise KeyError("internal_secret_field_id")
181181

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

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

tests/test_session_cache.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""Unit tests for utils.session_cache."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import shutil
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(
52+
sample_session: Path, monkeypatch: pytest.MonkeyPatch
53+
) -> None:
54+
path = str(sample_session)
55+
get_cached_session(path)
56+
57+
calls = 0
58+
59+
def counting_parse(p: str):
60+
nonlocal calls
61+
calls += 1
62+
return parse_session(p)
63+
64+
monkeypatch.setattr("utils.session_cache.parse_session", counting_parse)
65+
66+
stat = sample_session.stat()
67+
os.utime(sample_session, (stat.st_mtime + 1, stat.st_mtime + 1))
68+
get_cached_session(path)
69+
assert calls == 1
70+
71+
72+
def test_cache_normalizes_relative_and_absolute_paths(
73+
sample_session: Path,
74+
tmp_path: Path,
75+
monkeypatch: pytest.MonkeyPatch,
76+
) -> None:
77+
calls = 0
78+
79+
def counting_parse(p: str):
80+
nonlocal calls
81+
calls += 1
82+
return parse_session(p)
83+
84+
monkeypatch.setattr("utils.session_cache.parse_session", counting_parse)
85+
rel = "session.jsonl"
86+
abs_path = str(sample_session.resolve())
87+
original_cwd = os.getcwd()
88+
try:
89+
os.chdir(tmp_path)
90+
get_cached_session(rel)
91+
assert calls == 1
92+
get_cached_session(abs_path)
93+
assert calls == 1
94+
finally:
95+
os.chdir(original_cwd)
96+
97+
98+
def test_lru_eviction(
99+
sample_session: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
100+
) -> None:
101+
set_max_entries(2)
102+
content = sample_session.read_text(encoding="utf-8")
103+
paths = []
104+
for name in ("a.jsonl", "b.jsonl", "c.jsonl"):
105+
p = tmp_path / name
106+
p.write_text(content, encoding="utf-8")
107+
paths.append(p)
108+
109+
for p in paths:
110+
get_cached_session(str(p))
111+
112+
calls = 0
113+
114+
def counting_parse(p: str):
115+
nonlocal calls
116+
calls += 1
117+
return parse_session(p)
118+
119+
monkeypatch.setattr("utils.session_cache.parse_session", counting_parse)
120+
get_cached_session(str(paths[2]))
121+
assert calls == 0
122+
get_cached_session(str(paths[1]))
123+
assert calls == 0
124+
get_cached_session(str(paths[0]))
125+
assert calls == 1
126+
127+
128+
def test_set_max_entries_rejects_negative() -> None:
129+
with pytest.raises(ValueError, match="non-negative"):
130+
set_max_entries(-1)
131+
132+
133+
def test_returns_parsed_when_mtime_after_parse_raises(
134+
sample_session: Path, monkeypatch: pytest.MonkeyPatch
135+
) -> None:
136+
path = str(sample_session)
137+
real_getmtime = os.path.getmtime
138+
calls = 0
139+
140+
def getmtime_side_effect(p: str) -> float:
141+
nonlocal calls
142+
calls += 1
143+
if calls == 2:
144+
raise OSError("file removed after parse")
145+
return real_getmtime(p)
146+
147+
monkeypatch.setattr(os.path, "getmtime", getmtime_side_effect)
148+
result = get_cached_session(path)
149+
assert result == parse_session(path)

utils/session_cache.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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+
22+
Concurrent requests for different paths proceed in parallel.
23+
Concurrent misses on the *same* path will each parse independently;
24+
the last writer wins. This is safe but may parse the file more than
25+
once under high concurrency for a cold key.
26+
"""
27+
abspath = os.path.abspath(path)
28+
mtime_before = os.path.getmtime(abspath)
29+
with _lock:
30+
hit = _cache.get(abspath)
31+
if hit is not None and hit[0] == mtime_before:
32+
_cache.move_to_end(abspath)
33+
return hit[1]
34+
parsed = parse_session(abspath)
35+
try:
36+
mtime_after = os.path.getmtime(abspath)
37+
except OSError:
38+
return parsed
39+
with _lock:
40+
if _max_entries > 0 and mtime_after == mtime_before:
41+
_cache[abspath] = (mtime_after, parsed)
42+
_cache.move_to_end(abspath)
43+
while len(_cache) > _max_entries:
44+
_cache.popitem(last=False)
45+
return parsed
46+
47+
48+
def clear_cache() -> None:
49+
"""Clear all cached sessions (for tests and debug)."""
50+
with _lock:
51+
_cache.clear()
52+
53+
54+
def set_max_entries(max_entries: int) -> None:
55+
"""Override the LRU capacity (primarily for tests).
56+
57+
A value of 0 disables caching entirely — every access will parse from disk.
58+
"""
59+
if max_entries < 0:
60+
raise ValueError(f"max_entries must be non-negative, got {max_entries}")
61+
global _max_entries
62+
with _lock:
63+
_max_entries = max_entries
64+
while len(_cache) > _max_entries:
65+
_cache.popitem(last=False)

0 commit comments

Comments
 (0)