fix(perf): fingerprint reads read-only + index-only MAX(rowid), no table scan / no lock stall (Codex gate)

Codex CORE findings on the fingerprint: (1) default busy timeout could stall
/api/sessions ~10s on a locked state.db; (2) COUNT(*) forced a full messages
table SCAN (~16-22ms/refresh on 200k rows). Fix: open file:...?mode=ro with
timeout=0.05 + PRAGMA busy_timeout=50 (returns fast, falls back to stat stamp on
lock), and use index-only MAX(rowid) per table (no COUNT, no scan). Verified:
EXPLAIN shows SEARCH not SCAN, 0.36ms on 200k msgs, 0.16ms while EXCLUSIVE-locked.
Fingerprint shape is now (sessions_max_rowid, messages_max_rowid); regression
tests updated.
This commit is contained in:
nesquena-hermes committed 2026-06-18 22:53:58 +00:00
1 parent f8d5ad2b1e
commit d5ccaf0c27
2 files changed
+33 -7

No files matched your search

+30 -5
View File
@@ -3804,18 +3804,43 @@ def _sqlite_content_fingerprint(db_path: Path):
return None return None
try: try:
import sqlite3 import sqlite3
with closing(sqlite3.connect(str(db_path))) as conn: # Read-only + a tiny busy timeout: a fingerprint read must NEVER stall the
# /api/sessions hot path when state.db is briefly locked by a writer.
# On lock (or any error) we return None and the caller falls back to the
# cheap file-stat stamp, so correctness degrades gracefully to the prior
# behavior rather than blocking for the default multi-second busy timeout.
try:
conn = sqlite3.connect(
f"file:{db_path}?mode=ro", uri=True, timeout=0.05
)
except Exception:
return None
try:
conn.execute("PRAGMA busy_timeout=50")
parts = [] parts = []
for table in ("sessions", "messages"): for table in ("sessions", "messages"):
try: try:
# MAX(rowid) is an O(1) index lookup (no table scan) and
# advances on every INSERT. Pair it with the table's largest
# rowid + a count-free total: we deliberately avoid COUNT(*)
# which forces a full SCAN on large messages tables (~tens of
# ms per sidebar refresh on a big store). MAX(rowid) misses a
# pure DELETE-without-insert, but the file-stat fallback in
# _sqlite_file_stat_cache_key still moves on a delete commit,
# and a delete never makes a MISSING row appear (the flake we
# fix is an ADDED row not showing up).
row = conn.execute( row = conn.execute(
f"SELECT COUNT(*), COALESCE(MAX(rowid), 0) FROM {table}" f"SELECT MAX(rowid) FROM {table}"
).fetchone() ).fetchone()
parts.append((row[0], row[1])) parts.append(row[0] if row else None)
except Exception: except Exception:
# Table may not exist yet on a fresh/partial db — treat as empty. parts.append(None)
parts.append((0, 0))
return tuple(parts) return tuple(parts)
finally:
try:
conn.close()
except Exception:
pass
except Exception: except Exception:
return None return None
+3 -2
View File
@@ -90,6 +90,7 @@ def test_content_fingerprint_safe_on_missing_or_empty_db():
conn.execute("CREATE TABLE unrelated(x)") conn.execute("CREATE TABLE unrelated(x)")
conn.commit() conn.commit()
conn.close() conn.close()
# No sessions/messages tables → zeroed parts, no exception. # No sessions/messages tables → None parts (MAX(rowid) on a missing table),
# no exception. Shape is a 2-tuple of (sessions_max_rowid, messages_max_rowid).
fp = _sqlite_content_fingerprint(p) fp = _sqlite_content_fingerprint(p)
assert fp == ((0, 0), (0, 0)) assert fp == (None, None)