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:
1 parent
f8d5ad2b1e
commit
d5ccaf0c27
2 files changed
+33
-7
No files matched your search
+30
-5
@@ -3804,18 +3804,43 @@ def _sqlite_content_fingerprint(db_path: Path):
|
||||
return None
|
||||
try:
|
||||
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 = []
|
||||
for table in ("sessions", "messages"):
|
||||
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(
|
||||
f"SELECT COUNT(*), COALESCE(MAX(rowid), 0) FROM {table}"
|
||||
f"SELECT MAX(rowid) FROM {table}"
|
||||
).fetchone()
|
||||
parts.append((row[0], row[1]))
|
||||
parts.append(row[0] if row else None)
|
||||
except Exception:
|
||||
# Table may not exist yet on a fresh/partial db — treat as empty.
|
||||
parts.append((0, 0))
|
||||
parts.append(None)
|
||||
return tuple(parts)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ def test_content_fingerprint_safe_on_missing_or_empty_db():
|
||||
conn.execute("CREATE TABLE unrelated(x)")
|
||||
conn.commit()
|
||||
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)
|
||||
assert fp == ((0, 0), (0, 0))
|
||||
assert fp == (None, None)
|
||||
Reference in new issue
Block a user