Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 2 additions & 2 deletions api/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 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 @@ -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

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
32 changes: 32 additions & 0 deletions tests/benchmarks/test_cache_bench.py
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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)
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
6 changes: 3 additions & 3 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 @@ -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": [...]})
Expand Down
89 changes: 89 additions & 0 deletions tests/test_session_cache.py
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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()
49 changes: 49 additions & 0 deletions utils/session_cache.py
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading