Readers never upgrade to writers. open_state_db_readonly() opens file:...?mode=ro only; a read-only open failure propagates instead of silently retrying with a write-capable handle on the multi-GiB WAL database the agent is streaming into. The session listing no longer self-heals idx_messages_session with CREATE INDEX (a writer lock held for minutes on a large messages table) — a missing index degrades to the pre-aggregated path. Cron sidebar info, insights and deep health checks in api/routes.py use the same read-only opener. Watcher (api/gateway_watcher.py): _poll_once returns without touching the database when nobody is subscribed, and forces a fresh full projection on the next subscription. Maintenance (scripts/ensure_state_db_read_indexes.py): the covering read indexes (idx_messages_session, idx_messages_session_role with NOCASE, idx_sessions_webui_fingerprint) are created only under --confirm-drained, optionally holding an exclusive flock on --lock-file for deployments that serialise agent turns on a lock file; existing indexes are verified for shape/collation and coverage (EXPLAIN QUERY PLAN), never silently replaced. Tests: tests/test_state_db_readonly_readers.py (read-only failure never retries writable; no poll without subscribers; listing creates no index; index tool requires drain, is idempotent, works without a lock file and refuses a held lock; cron sidebar read is mode=ro). Neighbouring tests that asserted the retired writable fallback / self-heal writer (test_issue3887_index_prime, test_issue5455_listing_readonly_connection, test_issue5455_lineage_readonly_reads, test_issue2628_cli_sessions_perf, test_issue1494_state_db_fd_leak) now assert the read-only contract; test_issue3506 subscribes before polling.
173 lines
6.2 KiB
Python
173 lines
6.2 KiB
Python
"""Regression tests for #5455 — extend read-only state.db opens to the
|
|
remaining pure-read projections.
|
|
|
|
The session-listing path already opens the live agent ``state.db`` read-only
|
|
(``file:...?mode=ro``) so a write-capable handle doesn't add checkpoint/lock
|
|
surface while the agent streams into the same WAL DB
|
|
(see ``test_issue5455_listing_readonly_connection``). The lineage-report and
|
|
lineage-metadata reads, and the gateway-watcher fingerprint projection (a 5s
|
|
poll), were still opening a read-WRITE connection. This shared them onto the
|
|
same ``open_state_db_readonly`` helper.
|
|
"""
|
|
import sqlite3
|
|
from contextlib import closing
|
|
|
|
import pytest
|
|
|
|
import api.agent_sessions as agent_sessions
|
|
from api.agent_sessions import (
|
|
open_state_db_readonly,
|
|
read_session_lineage_metadata,
|
|
read_session_lineage_report,
|
|
)
|
|
|
|
|
|
def _make_lineage_db(path):
|
|
conn = sqlite3.connect(str(path))
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY, parent_session_id TEXT, end_reason TEXT,
|
|
source TEXT, session_source TEXT, title TEXT,
|
|
started_at REAL, ended_at REAL
|
|
)
|
|
"""
|
|
)
|
|
conn.executemany(
|
|
"INSERT INTO sessions (id, parent_session_id, end_reason, source, session_source, "
|
|
"title, started_at, ended_at) VALUES (?,?,?,?,?,?,?,?)",
|
|
[
|
|
("parent-1", None, "compressed", "cli", "cli", "Root", 1000.0, 1100.0),
|
|
("child-1", "parent-1", None, "cli", "cli", "Cont", 1100.0, None),
|
|
],
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def _record_connects(monkeypatch):
|
|
"""Spy on agent_sessions.sqlite3.connect, recording target + uri per call."""
|
|
real_connect = sqlite3.connect
|
|
calls = []
|
|
|
|
def spy(target, *args, **kwargs):
|
|
calls.append({"target": str(target), "uri": bool(kwargs.get("uri"))})
|
|
return real_connect(target, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(agent_sessions.sqlite3, "connect", spy)
|
|
return calls
|
|
|
|
|
|
# ── open_state_db_readonly (the shared helper) ───────────────────────────────
|
|
|
|
def test_helper_opens_read_only_uri(tmp_path, monkeypatch):
|
|
db = tmp_path / "state.db"
|
|
_make_lineage_db(db)
|
|
calls = _record_connects(monkeypatch)
|
|
|
|
with closing(open_state_db_readonly(db)) as conn:
|
|
assert conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] == 2
|
|
|
|
assert calls and calls[0]["uri"] is True
|
|
assert "mode=ro" in calls[0]["target"]
|
|
|
|
|
|
def test_helper_connection_rejects_writes(tmp_path):
|
|
db = tmp_path / "state.db"
|
|
_make_lineage_db(db)
|
|
with closing(open_state_db_readonly(db)) as conn:
|
|
with pytest.raises(sqlite3.OperationalError):
|
|
conn.execute("INSERT INTO sessions (id) VALUES ('x')")
|
|
|
|
|
|
def test_helper_encodes_special_path_chars(tmp_path, monkeypatch):
|
|
db_dir = tmp_path / "state dir #1"
|
|
db_dir.mkdir()
|
|
db = db_dir / "state?.db"
|
|
_make_lineage_db(db)
|
|
calls = _record_connects(monkeypatch)
|
|
|
|
with closing(open_state_db_readonly(db)):
|
|
pass
|
|
|
|
assert calls[0]["target"].startswith("file://")
|
|
assert "%20" in calls[0]["target"] # space
|
|
assert "%23" in calls[0]["target"] # '#'
|
|
assert "%3F" in calls[0]["target"] # '?'
|
|
assert calls[0]["target"].endswith("?mode=ro")
|
|
|
|
|
|
def test_helper_never_falls_back_to_writable(tmp_path, monkeypatch):
|
|
"""A read-only open failure propagates; the helper never retries with a
|
|
write-capable handle on the live WAL database."""
|
|
db = tmp_path / "state.db"
|
|
_make_lineage_db(db)
|
|
calls = []
|
|
|
|
def fail_read_only(target, *args, **kwargs):
|
|
calls.append({"target": str(target), "uri": bool(kwargs.get("uri"))})
|
|
raise sqlite3.OperationalError("synthetic read-only URI failure")
|
|
|
|
monkeypatch.setattr(agent_sessions.sqlite3, "connect", fail_read_only)
|
|
with pytest.raises(sqlite3.OperationalError, match="synthetic read-only URI failure"):
|
|
open_state_db_readonly(db)
|
|
|
|
# Exactly one attempt, and it was the read-only URI form.
|
|
assert len(calls) == 1
|
|
assert calls[0]["uri"] is True
|
|
assert calls[0]["target"].endswith("?mode=ro")
|
|
|
|
|
|
def test_helper_raises_on_missing_db_instead_of_creating_a_ghost(tmp_path):
|
|
"""A missing path must raise FileNotFoundError, not have the writable
|
|
fallback silently create an empty state.db there."""
|
|
missing = tmp_path / "does_not_exist" / "state.db"
|
|
with pytest.raises(FileNotFoundError):
|
|
open_state_db_readonly(missing)
|
|
assert not missing.exists(), "helper created a ghost state.db for a missing path"
|
|
|
|
|
|
# ── lineage reads route through the helper ───────────────────────────────────
|
|
|
|
def test_lineage_report_opens_read_only(tmp_path, monkeypatch):
|
|
db = tmp_path / "state.db"
|
|
_make_lineage_db(db)
|
|
calls = _record_connects(monkeypatch)
|
|
|
|
report = read_session_lineage_report(db, "child-1")
|
|
|
|
assert report # a non-empty report came back
|
|
assert calls and calls[0]["uri"] is True
|
|
assert "mode=ro" in calls[0]["target"]
|
|
|
|
|
|
def test_lineage_metadata_opens_read_only(tmp_path, monkeypatch):
|
|
db = tmp_path / "state.db"
|
|
_make_lineage_db(db)
|
|
calls = _record_connects(monkeypatch)
|
|
|
|
meta = read_session_lineage_metadata(db, ["child-1", "parent-1"])
|
|
|
|
assert isinstance(meta, dict)
|
|
assert calls and calls[0]["uri"] is True
|
|
assert "mode=ro" in calls[0]["target"]
|
|
|
|
|
|
# ── gateway-watcher fingerprint projection routes through the helper ─────────
|
|
|
|
def test_gateway_watcher_fingerprint_opens_read_only(tmp_path, monkeypatch):
|
|
# The 5s watcher poll (_cheap_change_fingerprint) calls open_state_db_readonly,
|
|
# which resolves sqlite3.connect in agent_sessions' namespace — so the same
|
|
# spy catches it. The DB needs a `source` column or the fingerprint bails to None.
|
|
import api.gateway_watcher as gateway_watcher
|
|
|
|
db = tmp_path / "state.db"
|
|
_make_lineage_db(db)
|
|
calls = _record_connects(monkeypatch)
|
|
|
|
fp = gateway_watcher._cheap_change_fingerprint(db)
|
|
|
|
assert fp is not None # a fingerprint (not the schema-bail None) was produced
|
|
assert calls and calls[0]["uri"] is True
|
|
assert "mode=ro" in calls[0]["target"]
|