Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions api/export_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 2 additions & 4 deletions api/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -80,14 +81,11 @@ 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.jsonl_parser import parse_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
Expand Down
6 changes: 4 additions & 2 deletions api/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -49,12 +49,14 @@ 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:
break
try:
session = parse_session(sess_info["path"])
session = get_cached_session(sess_info["path"])
except Exception:
continue

Expand Down
6 changes: 3 additions & 3 deletions api/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
27 changes: 27 additions & 0 deletions tests/benchmarks/test_cache_bench.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""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)
benchmark.pedantic(get_cached_session, args=(path,), setup=clear_cache)


@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)
2 changes: 1 addition & 1 deletion tests/test_api_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 3 additions & 5 deletions tests/test_error_propagation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.jsonl_parser.parse_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": [...]})
Expand Down
130 changes: 130 additions & 0 deletions tests/test_session_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Unit tests for utils.session_cache."""

from __future__ import annotations

import os
import shutil
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, 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)

stat = sample_session.stat()
os.utime(sample_session, (stat.st_mtime + 1, stat.st_mtime + 1))
get_cached_session(path)
assert calls == 1


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, monkeypatch: pytest.MonkeyPatch
) -> 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.setattr("utils.session_cache.parse_session", counting_parse)
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:
with pytest.raises(ValueError, match="non-negative"):
set_max_entries(-1)
62 changes: 62 additions & 0 deletions utils/session_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""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.

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_before = os.path.getmtime(abspath)
with _lock:
hit = _cache.get(abspath)
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:
if _max_entries > 0 and mtime_after == mtime_before:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
_cache[abspath] = (mtime_after, 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).

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
with _lock:
_max_entries = max_entries
while len(_cache) > _max_entries:
_cache.popitem(last=False)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading