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.
191 lines
7.2 KiB
Python
191 lines
7.2 KiB
Python
"""Regression tests for #3887 — defensive index prime for the sidebar scan.
|
|
|
|
The sidebar's CLI-session scan (``read_importable_agent_session_rows``) orders
|
|
candidate sessions by a correlated ``MAX(mx.timestamp)`` subquery over the
|
|
``messages`` table. That is fast only when the agent's standard
|
|
``idx_messages_session ON messages(session_id, timestamp)`` index exists. A
|
|
state.db that lost its migrations (older hermes-agent, or a hand-rebuilt /
|
|
reimported db) has no such index and the scan degrades to a full ``messages``
|
|
scan per candidate session — stalling ``/api/sessions`` for seconds on every
|
|
refresh.
|
|
|
|
These tests assert the intent (the index is primed when missing so the listing
|
|
self-heals) and the cross-cell isolation (the prime is a no-op when the index
|
|
already exists, is skipped when the schema lacks the columns, and degrades
|
|
silently on a read-only db without ever failing the listing).
|
|
"""
|
|
import os
|
|
import sqlite3
|
|
import stat
|
|
|
|
import pytest
|
|
|
|
import api.agent_sessions as agent_sessions
|
|
|
|
|
|
def _full_schema_db(path):
|
|
"""A state.db with the columns the scan reads, but NO messages index."""
|
|
conn = sqlite3.connect(str(path))
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"""CREATE TABLE sessions(
|
|
id TEXT PRIMARY KEY, title TEXT, model TEXT, message_count INTEGER,
|
|
started_at REAL, source TEXT, session_source TEXT,
|
|
parent_session_id TEXT, ended_at REAL, end_reason TEXT,
|
|
user_id TEXT, chat_id TEXT, chat_type TEXT, thread_id TEXT,
|
|
session_key TEXT, origin_chat_id TEXT, origin_user_id TEXT,
|
|
platform TEXT)"""
|
|
)
|
|
cur.execute(
|
|
"""CREATE TABLE messages(
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, role TEXT,
|
|
content TEXT, timestamp REAL)"""
|
|
)
|
|
for i in range(3):
|
|
sid = f"sess{i}"
|
|
cur.execute(
|
|
"INSERT INTO sessions(id, title, model, message_count, started_at, "
|
|
"source, session_source) VALUES (?,?,?,?,?,?,?)",
|
|
(sid, f"Session {i}", "m", 2, 1000.0 + i, "cli", "cli"),
|
|
)
|
|
cur.execute(
|
|
"INSERT INTO messages(session_id, role, content, timestamp) "
|
|
"VALUES (?,?,?,?)",
|
|
(sid, "user", "hi", 1001.0 + i),
|
|
)
|
|
cur.execute(
|
|
"INSERT INTO messages(session_id, role, content, timestamp) "
|
|
"VALUES (?,?,?,?)",
|
|
(sid, "assistant", "yo", 1002.0 + i),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def _messages_indexes(path):
|
|
conn = sqlite3.connect(str(path))
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT name FROM sqlite_master "
|
|
"WHERE type='index' AND tbl_name='messages'"
|
|
).fetchall()
|
|
finally:
|
|
conn.close()
|
|
return {r[0] for r in rows}
|
|
|
|
|
|
def test_listing_does_not_create_missing_index(tmp_path, monkeypatch):
|
|
"""A missing index must never turn a sidebar read into a schema write.
|
|
|
|
CREATE INDEX on a multi-GiB ``messages`` table holds the SQLite writer lock
|
|
for minutes; index maintenance belongs to the explicit drained tool
|
|
(``scripts/ensure_state_db_read_indexes.py``), not to a listing.
|
|
"""
|
|
db = tmp_path / "state.db"
|
|
_full_schema_db(db)
|
|
assert "idx_messages_session" not in _messages_indexes(db)
|
|
connect_calls = []
|
|
real_connect = agent_sessions.sqlite3.connect
|
|
|
|
def recording_connect(*args, **kwargs):
|
|
connect_calls.append((args, kwargs))
|
|
return real_connect(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(agent_sessions.sqlite3, "connect", recording_connect)
|
|
|
|
rows = agent_sessions.read_importable_agent_session_rows(
|
|
db, limit=20, exclude_sources=None
|
|
)
|
|
# Snapshot before the verifier below opens its own (non-listing) handle.
|
|
listing_connect_calls = list(connect_calls)
|
|
|
|
# Listing still returns the sessions ...
|
|
assert {r["id"] for r in rows} == {"sess0", "sess1", "sess2"}
|
|
# ... without mutating the schema or opening a write-capable connection.
|
|
assert "idx_messages_session" not in _messages_indexes(db)
|
|
assert len(listing_connect_calls) == 1
|
|
assert "mode=ro" in str(listing_connect_calls[0][0][0])
|
|
assert listing_connect_calls[0][1].get("uri") is True
|
|
|
|
|
|
def test_prime_is_noop_when_index_exists(tmp_path):
|
|
"""When the agent already created the index, the prime must not duplicate
|
|
or error — the existing index is reused untouched."""
|
|
db = tmp_path / "state.db"
|
|
_full_schema_db(db)
|
|
conn = sqlite3.connect(str(db))
|
|
conn.execute(
|
|
"CREATE INDEX idx_messages_session ON messages(session_id, timestamp)"
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
before = _messages_indexes(db)
|
|
|
|
rows = agent_sessions.read_importable_agent_session_rows(
|
|
db, limit=20, exclude_sources=None
|
|
)
|
|
|
|
assert {r["id"] for r in rows} == {"sess0", "sess1", "sess2"}
|
|
# Index set is unchanged — no duplicate, no error.
|
|
assert _messages_indexes(db) == before
|
|
assert "idx_messages_session" in _messages_indexes(db)
|
|
|
|
|
|
def test_prime_skipped_without_timestamp_column(tmp_path):
|
|
"""A minimal messages schema (no timestamp column) must not attempt the
|
|
index and must not crash the listing."""
|
|
db = tmp_path / "state.db"
|
|
conn = sqlite3.connect(str(db))
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"""CREATE TABLE sessions(
|
|
id TEXT PRIMARY KEY, title TEXT, model TEXT, message_count INTEGER,
|
|
started_at REAL, source TEXT)"""
|
|
)
|
|
cur.execute(
|
|
"CREATE TABLE messages(id INTEGER PRIMARY KEY, session_id TEXT, "
|
|
"role TEXT, content TEXT)"
|
|
)
|
|
cur.execute(
|
|
"INSERT INTO sessions(id, title, model, message_count, started_at, "
|
|
"source) VALUES ('s1','T','m',1,1000.0,'cli')"
|
|
)
|
|
cur.execute(
|
|
"INSERT INTO messages(id, session_id, role, content) "
|
|
"VALUES (1,'s1','user','hi')"
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
rows = agent_sessions.read_importable_agent_session_rows(
|
|
db, limit=20, exclude_sources=None
|
|
)
|
|
|
|
# Listing degrades gracefully via the denormalized counts (still surfaces).
|
|
assert {r["id"] for r in rows} == {"s1"}
|
|
# The timestamp-less schema must NOT have an index primed on it.
|
|
assert "idx_messages_session" not in _messages_indexes(db)
|
|
|
|
|
|
def test_prime_degrades_on_readonly_db(tmp_path):
|
|
"""A read-only db (can't create the index) must not raise — the listing
|
|
still returns, just without the perf benefit."""
|
|
# Root bypasses POSIX permission bits, so chmod 0444 doesn't make the file
|
|
# read-only for root and the prime would succeed — validating the wrong path
|
|
# and giving false confidence on root-run CI (greptile). Skip under root; the
|
|
# production handler's `except sqlite3.Error: pass` covers read-only/locked/
|
|
# corrupted/older-schema regardless, and the other cases pin that contract.
|
|
if hasattr(os, "getuid") and os.getuid() == 0:
|
|
pytest.skip("chmod-based read-only test is a no-op under root")
|
|
db = tmp_path / "state.db"
|
|
_full_schema_db(db)
|
|
os.chmod(db, stat.S_IREAD)
|
|
try:
|
|
rows = agent_sessions.read_importable_agent_session_rows(
|
|
db, limit=20, exclude_sources=None
|
|
)
|
|
assert {r["id"] for r in rows} == {"sess0", "sess1", "sess2"}
|
|
finally:
|
|
# Restore write so tmp_path cleanup can remove it.
|
|
os.chmod(db, stat.S_IWRITE | stat.S_IREAD)
|