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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in new issue
Block a user