Skip to content

Commit 90c5aa7

Browse files
Add disk-backed session summary cache and display-name cache
1 parent 1f79b30 commit 90c5aa7

5 files changed

Lines changed: 44 additions & 44 deletions

File tree

api/projects.py

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from api._flask_types import FlaskReturn, json_response
66
from api.error_codes import ErrorCode, error_response
77
from models.project import ProjectSessionRowDict, SessionListItemDict
8-
from models.session import SessionDict
98
from utils.exclusion_rules import is_session_excluded
109
from utils.jsonl_parser import quick_session_info
1110
from utils.session_cache import get_cached_session
@@ -23,23 +22,6 @@
2322
projects_bp = Blueprint("projects", __name__)
2423

2524

26-
def _session_row_ok(s: SessionListItemDict, parsed: SessionDict) -> ProjectSessionRowDict:
27-
meta = parsed["metadata"]
28-
models = meta.get("models_used", [])
29-
return {
30-
"id": s["id"],
31-
"path": s["path"],
32-
"size_bytes": s["size_bytes"],
33-
"modified": s["modified"],
34-
"title": parsed["title"],
35-
"models": sorted(models) if isinstance(models, set) else list(models),
36-
"tokens": meta["total_input_tokens"] + meta["total_output_tokens"],
37-
"tool_calls": meta["total_tool_calls"],
38-
"first_timestamp": meta["first_timestamp"],
39-
"last_timestamp": meta["last_timestamp"],
40-
}
41-
42-
4325
def _session_row_error(s: SessionListItemDict) -> ProjectSessionRowDict:
4426
return {
4527
"id": s["id"],
@@ -85,6 +67,10 @@ def get_projects() -> FlaskReturn:
8567
if ts and (latest_ts is None or ts > latest_ts):
8668
latest_ts = ts
8769
except Exception:
70+
current_app.logger.exception(
71+
"Failed to peek session summary for project %s",
72+
project["name"],
73+
)
8874
titled_count += 1
8975
project["session_count"] = titled_count
9076
if latest_ts:
@@ -119,7 +105,7 @@ def get_project_sessions(project_name: str) -> FlaskReturn:
119105
put_summary(s["path"], s["modified"], rules_fp, row)
120106
if row["is_untitled"] or excluded:
121107
continue
122-
result.append(_session_row_ok(s, parsed))
108+
result.append(session_row_from_summary(s, row))
123109
except Exception:
124110
current_app.logger.exception("Failed to parse session %s", s["id"])
125111
result.append(_session_row_error(s))

tests/test_api_integration.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,7 @@ def test_project_session_count_matches_list(client, summary_cache_db):
148148
assert project["session_count"] == len(sessions)
149149

150150

151-
def test_project_sessions_uses_disk_cache_on_second_request(
152-
client, summary_cache_db, monkeypatch
153-
):
151+
def test_project_sessions_uses_disk_cache_on_second_request(client, summary_cache_db, monkeypatch):
154152
client.get("/api/projects/test-project/sessions")
155153
calls = 0
156154

@@ -164,4 +162,3 @@ def counting_get_cached(path: str):
164162
monkeypatch.setattr("api.projects.get_cached_session", counting_get_cached)
165163
client.get("/api/projects/test-project/sessions")
166164
assert calls == 0
167-

tests/test_session_summary_cache.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import os
66
import shutil
7+
from collections.abc import Iterator
78
from pathlib import Path
89

910
import pytest
@@ -31,7 +32,7 @@ def sample_session(tmp_path: Path) -> Path:
3132

3233

3334
@pytest.fixture
34-
def cache_db(tmp_path: Path) -> Path:
35+
def cache_db(tmp_path: Path) -> Iterator[Path]:
3536
db = tmp_path / "summary.sqlite"
3637
reset_connection_for_tests(db)
3738
yield db
@@ -120,7 +121,6 @@ def test_lru_eviction(
120121
"none",
121122
summary_from_peek(quick_session_info(str(p))),
122123
)
123-
get_summary(str(p), mtime, "none")
124124

125125
third = paths[2]
126126
third_mtime = third.stat().st_mtime
@@ -136,6 +136,19 @@ def test_lru_eviction(
136136
assert get_summary(str(third), third_mtime, "none") is not None
137137

138138

139+
def test_put_summary_drops_stale_mtime_rows(sample_session: Path, cache_db: Path) -> None:
140+
path = str(sample_session)
141+
mtime = sample_session.stat().st_mtime
142+
parsed = parse_session(path)
143+
put_summary(path, mtime, "none", summary_from_session(parsed, is_excluded=False))
144+
stat = sample_session.stat()
145+
os.utime(sample_session, (stat.st_mtime + 10, stat.st_mtime + 10))
146+
new_mtime = sample_session.stat().st_mtime
147+
put_summary(path, new_mtime, "none", summary_from_session(parsed, is_excluded=False))
148+
assert get_summary(path, mtime, "none") is None
149+
assert get_summary(path, new_mtime, "none") is not None
150+
151+
139152
def test_clear_cache(sample_session: Path, cache_db: Path) -> None:
140153
path = str(sample_session)
141154
mtime = sample_session.stat().st_mtime

utils/session_path.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,9 @@ def clear_display_name_cache() -> None:
4141
_display_name_cache.clear()
4242

4343

44-
def _project_jsonl_max_mtime(project_dir: str, jsonl_files: list[str]) -> float:
45-
return max(os.path.getmtime(os.path.join(project_dir, jf)) for jf in jsonl_files)
46-
47-
48-
def _resolve_display_name(project_dir: str, jsonl_files: list[str], fallback: str) -> str:
49-
max_mtime = _project_jsonl_max_mtime(project_dir, jsonl_files)
44+
def _resolve_display_name(
45+
project_dir: str, jsonl_files: list[str], fallback: str, max_mtime: float
46+
) -> str:
5047
with _display_name_lock:
5148
hit = _display_name_cache.get(project_dir)
5249
if hit is not None and hit[0] == max_mtime:
@@ -83,7 +80,7 @@ def list_projects(base_dir: str | None = None) -> list[ProjectDict]:
8380
from datetime import datetime, timezone
8481

8582
last_modified = datetime.fromtimestamp(latest_mtime, tz=timezone.utc).isoformat()
86-
display_name = _resolve_display_name(project_dir, jsonl_files, name)
83+
display_name = _resolve_display_name(project_dir, jsonl_files, name, latest_mtime)
8784
projects.append(
8885
{
8986
"name": name,

utils/session_summary_cache.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@
1616

1717
DEFAULT_MAX_ROWS = 2000
1818

19+
20+
def max_cache_rows() -> int:
21+
"""Return LRU capacity (override via CLAUDE_CODE_CHAT_BROWSER_SUMMARY_CACHE_MAX_ROWS)."""
22+
raw = os.environ.get("CLAUDE_CODE_CHAT_BROWSER_SUMMARY_CACHE_MAX_ROWS", "").strip()
23+
if not raw:
24+
return DEFAULT_MAX_ROWS
25+
return max(1, int(raw))
26+
27+
1928
_lock = threading.Lock()
2029
_conn: sqlite3.Connection | None = None
2130

@@ -54,7 +63,9 @@ def _ensure_connection() -> sqlite3.Connection:
5463
return _conn
5564
path = cache_db_path()
5665
path.parent.mkdir(parents=True, exist_ok=True)
57-
conn = sqlite3.connect(str(path), check_same_thread=False)
66+
conn = sqlite3.connect(str(path), check_same_thread=False, timeout=30.0)
67+
conn.execute("PRAGMA journal_mode=WAL")
68+
conn.execute("PRAGMA busy_timeout=30000")
5869
conn.execute(
5970
"""
6071
CREATE TABLE IF NOT EXISTS summary_cache (
@@ -102,15 +113,6 @@ def get_summary(path: str, mtime: float, rules_fingerprint: str) -> SummaryCache
102113
).fetchone()
103114
if row is None:
104115
return None
105-
now = time.time()
106-
conn.execute(
107-
(
108-
"UPDATE summary_cache SET accessed_at = ? "
109-
"WHERE path = ? AND mtime = ? AND rules_fp = ?"
110-
),
111-
(now, abspath, mtime, rules_fingerprint),
112-
)
113-
conn.commit()
114116
return _payload_to_row(str(row[0]))
115117

116118

@@ -126,6 +128,10 @@ def put_summary(
126128
payload = _row_to_payload(row)
127129
with _lock:
128130
conn = _ensure_connection()
131+
conn.execute(
132+
"DELETE FROM summary_cache WHERE path = ? AND rules_fp = ? AND mtime != ?",
133+
(abspath, rules_fingerprint, mtime),
134+
)
129135
conn.execute(
130136
"""
131137
INSERT INTO summary_cache (path, mtime, rules_fp, payload, accessed_at)
@@ -137,7 +143,8 @@ def put_summary(
137143
(abspath, mtime, rules_fingerprint, payload, now),
138144
)
139145
count = conn.execute("SELECT COUNT(*) FROM summary_cache").fetchone()
140-
if count is not None and int(count[0]) > DEFAULT_MAX_ROWS:
146+
limit = max_cache_rows()
147+
if count is not None and int(count[0]) > limit:
141148
conn.execute(
142149
"""
143150
DELETE FROM summary_cache
@@ -147,7 +154,7 @@ def put_summary(
147154
LIMIT ?
148155
)
149156
""",
150-
(int(count[0]) - DEFAULT_MAX_ROWS,),
157+
(int(count[0]) - limit,),
151158
)
152159
conn.commit()
153160

0 commit comments

Comments
 (0)