"""Regression coverage for the cron sidebar-projection I/O blowup (#4842). `get_cli_sessions()` projects state.db rows into sidebar entries in two passes: a visible-window pass and a higher-capped (CRON_PROJECT_CHIP_LIMIT=200) cron-only second pass. For EACH projected row the old code did three pieces of redundant per-row I/O: 1. `_state_projection_sidecar_metadata(sid)` — an uncached open() + 64KB read + JSON-key scan of the session's sidecar JSON. 2. `get_last_workspace()` — up to two file reads + an is_dir()/remote probe, returning the SAME active workspace for every row. 3. A full read + parse of `cron/jobs.json` per untitled cron row. On a cron-heavy profile (the #4842 reporter had 200+ cron sessions) that was hundreds of file reads per `/api/sessions` build, and because the enclosing `_CLI_SESSIONS_CACHE` is keyed on a state.db content fingerprint that advances on every streamed message row, the whole scan was re-paid on essentially every streaming poll during a live turn — pinning CPU to 100% and making `get_cli_sessions` take multiple seconds (#4842 / #4808 / #4672). These tests pin the fix: the expensive per-row work is now O(unique files), the sidecar parse is memoized across builds (so a streaming re-poll stays warm), and the jobs.json parse + workspace resolve happen once per build. """ import pathlib import sqlite3 import time from unittest import mock import api.models as models REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] def _make_cron_state_db(path, *, cron_count=30): """state.db with a batch of titled cron sessions.""" conn = sqlite3.connect(str(path)) conn.executescript( """ CREATE TABLE sessions ( id TEXT PRIMARY KEY, source TEXT, session_source TEXT, title TEXT, model TEXT, started_at REAL NOT NULL, message_count INTEGER DEFAULT 0, parent_session_id TEXT, ended_at REAL, end_reason TEXT ); CREATE INDEX idx_sessions_started ON sessions(started_at); CREATE TABLE messages ( id TEXT PRIMARY KEY, session_id TEXT, role TEXT, content TEXT, timestamp REAL ); CREATE INDEX idx_messages_session ON messages(session_id, timestamp); """ ) now = time.time() for i in range(cron_count): sid = f"cron_job{i:04d}_{int(now) + i}" started = now + i conn.execute( "INSERT INTO sessions (id, source, session_source, title, model," " started_at, message_count, parent_session_id, ended_at, end_reason)" " VALUES (?, 'cron', 'cron', NULL, 'deepseek/deepseek-chat', ?, 1," " NULL, NULL, NULL)", (sid, started), ) conn.execute( "INSERT INTO messages (id, session_id, role, content, timestamp)" " VALUES (?, ?, 'user', 'cron task', ?)", (f"cron_msg_{i:04d}", sid, started), ) conn.commit() conn.close() def test_sidecar_metadata_cached_across_rebuilds(tmp_path): """The expensive sidecar read happens once per file, not once per build. Simulates the streaming re-poll: build the projection twice (the inner CLI cache is bypassed here by calling _load_cli_sessions_uncached directly, which is exactly what happens when the state.db fingerprint busts the 5s cache on every streamed token). The second build must NOT re-read sidecars. """ models.clear_sidecar_metadata_cache() db = tmp_path / "state.db" _make_cron_state_db(db, cron_count=30) # Give every cron session a real WebUI sidecar JSON so the projection's # load_metadata_only() path actually opens a file per row. session_dir = tmp_path / "sessions" session_dir.mkdir() sids = [] conn = sqlite3.connect(str(db)) for (sid,) in conn.execute("SELECT id FROM sessions"): sids.append(sid) (session_dir / f"{sid}.json").write_text( '{"session_id": "%s", "title": "Renamed %s", "created_at": 1.0,' ' "updated_at": 2.0, "archived": false, "messages": []}' % (sid, sid), encoding="utf-8", ) conn.close() real_prefix = models._read_metadata_json_prefix reads = {"n": 0} def _counting_prefix(p, *a, **k): reads["n"] += 1 return real_prefix(p, *a, **k) with ( mock.patch("api.models.get_claude_code_sessions", return_value=[]), mock.patch("api.models.get_last_workspace", return_value=str(tmp_path)), mock.patch("api.models.ensure_cron_project", return_value="cron-pid"), mock.patch("api.models.SESSION_DIR", session_dir), mock.patch("api.models._read_metadata_json_prefix", side_effect=_counting_prefix), ): first = models._load_cli_sessions_uncached(tmp_path, db, _cli_profile=None) cold_reads = reads["n"] second = models._load_cli_sessions_uncached(tmp_path, db, _cli_profile=None) # Cold build read each sidecar (at least once per unique file). assert cold_reads >= len(sids) # Warm rebuild re-stats but does NOT re-read any sidecar — the streaming # re-poll no longer re-pays the per-row file read. assert reads["n"] == cold_reads, ( f"warm rebuild re-read sidecars ({reads['n'] - cold_reads} extra reads); " "the projection cache is not engaging across builds" ) # The renamed sidecar title is projected onto the cron rows in both builds. titles_first = {s["session_id"]: s["title"] for s in first} titles_second = {s["session_id"]: s["title"] for s in second} assert titles_first == titles_second assert any(t.startswith("Renamed ") for t in titles_first.values()) def test_sidecar_cache_invalidates_on_rename(tmp_path): """A sidecar rename/archive bumps the stat signature → fresh read, fresh title.""" models.clear_sidecar_metadata_cache() session_dir = tmp_path / "sessions" session_dir.mkdir() sid = "cron_jobAAAA_111" sidecar = session_dir / f"{sid}.json" sidecar.write_text( '{"session_id": "%s", "title": "First", "created_at": 1.0,' ' "updated_at": 2.0, "archived": false, "messages": []}' % sid, encoding="utf-8", ) with mock.patch("api.models.SESSION_DIR", session_dir): first = models._state_projection_sidecar_metadata(sid) assert first["title"] == "First" assert first["archived"] is False # Rewrite with a new title + archived flag; sleep so ctime/mtime move. time.sleep(0.02) sidecar.write_text( '{"session_id": "%s", "title": "Renamed", "created_at": 1.0,' ' "updated_at": 3.0, "archived": true, "messages": []}' % sid, encoding="utf-8", ) second = models._state_projection_sidecar_metadata(sid) assert second["title"] == "Renamed" assert second["archived"] is True def test_sidecar_metadata_returns_independent_copies(tmp_path): """A caller mutating the returned dict must not corrupt the cached entry.""" models.clear_sidecar_metadata_cache() session_dir = tmp_path / "sessions" session_dir.mkdir() sid = "cron_jobBBBB_222" (session_dir / f"{sid}.json").write_text( '{"session_id": "%s", "title": "Keep", "created_at": 1.0,' ' "updated_at": 2.0, "archived": false, "messages": []}' % sid, encoding="utf-8", ) with mock.patch("api.models.SESSION_DIR", session_dir): first = models._state_projection_sidecar_metadata(sid) first["title"] = "MUTATED" first["archived"] = True second = models._state_projection_sidecar_metadata(sid) assert second["title"] == "Keep" assert second["archived"] is False def test_missing_sidecar_returns_default_without_caching_growth(tmp_path): """A pure state.db row with no sidecar returns the default and is cheap.""" models.clear_sidecar_metadata_cache() session_dir = tmp_path / "sessions" session_dir.mkdir() with mock.patch("api.models.SESSION_DIR", session_dir): meta = models._state_projection_sidecar_metadata("cron_nope_999") # No sidecar means no opinion on archived (None), so the projection falls back # to the state.db row's archived flag (#7548) instead of forcing it to False. assert meta == {"title": None, "archived": None, "project_id": None} # No file → nothing cached (so the cache can't be poisoned by absent files). assert len(models._SIDECAR_METADATA_CACHE) == 0 def test_jobs_json_parsed_once_per_build(tmp_path): """cron/jobs.json is read+parsed once per build, not once per untitled row.""" models.clear_sidecar_metadata_cache() db = tmp_path / "state.db" _make_cron_state_db(db, cron_count=25) cron_dir = tmp_path / "cron" cron_dir.mkdir() jobs = {"jobs": [{"id": f"job{i:04d}", "name": f"My Job {i}"} for i in range(25)]} import json as _json (cron_dir / "jobs.json").write_text(_json.dumps(jobs), encoding="utf-8") real_read_text = pathlib.Path.read_text jobs_reads = {"n": 0} def _counting_read_text(self, *a, **k): if self.name == "jobs.json": jobs_reads["n"] += 1 return real_read_text(self, *a, **k) with ( mock.patch("api.models.get_claude_code_sessions", return_value=[]), mock.patch("api.models.get_last_workspace", return_value=str(tmp_path)), mock.patch("api.models.ensure_cron_project", return_value="cron-pid"), mock.patch("api.models.Session.load_metadata_only", return_value=None), mock.patch.object(pathlib.Path, "read_text", _counting_read_text), ): result = models._load_cli_sessions_uncached(tmp_path, db, _cli_profile=None) # jobs.json read at most once despite 25 untitled cron rows across both passes. assert jobs_reads["n"] <= 1, f"jobs.json read {jobs_reads['n']} times (expected <=1)" # The friendly job names were still applied. titles = {s["title"] for s in result if s["source_tag"] == "cron"} assert any(t.startswith("My Job ") for t in titles) def test_get_last_workspace_called_once_per_build(tmp_path): """get_last_workspace() is resolved once, not once per projected row.""" models.clear_sidecar_metadata_cache() db = tmp_path / "state.db" _make_cron_state_db(db, cron_count=20) ws_calls = {"n": 0} def _counting_ws(): ws_calls["n"] += 1 return str(tmp_path) with ( mock.patch("api.models.get_claude_code_sessions", return_value=[]), mock.patch("api.models.get_last_workspace", side_effect=_counting_ws), mock.patch("api.models.ensure_cron_project", return_value="cron-pid"), mock.patch("api.models.Session.load_metadata_only", return_value=None), ): result = models._load_cli_sessions_uncached(tmp_path, db, _cli_profile=None) assert len(result) > 0 # One resolve for the whole build (lazy, memoized) regardless of row count. assert ws_calls["n"] <= 1, ( f"get_last_workspace() called {ws_calls['n']} times for {len(result)} rows" ) def test_clear_cli_sessions_cache_also_clears_sidecar_cache(tmp_path): """The lifecycle reset clears the sidecar projection cache too.""" models.clear_sidecar_metadata_cache() session_dir = tmp_path / "sessions" session_dir.mkdir() sid = "cron_jobCCCC_333" (session_dir / f"{sid}.json").write_text( '{"session_id": "%s", "title": "X", "created_at": 1.0,' ' "updated_at": 2.0, "archived": false, "messages": []}' % sid, encoding="utf-8", ) with mock.patch("api.models.SESSION_DIR", session_dir): models._state_projection_sidecar_metadata(sid) assert len(models._SIDECAR_METADATA_CACHE) == 1 models.clear_cli_sessions_cache() assert len(models._SIDECAR_METADATA_CACHE) == 0