fix(state): periodically merge FTS5 segments to curb write-lock contention

The message triggers append one FTS5 segment per insert into both the
porter and trigram indexes. Nothing ever called the existing
optimize_fts() maintenance helper, so on a long-lived state.db these
segments accumulate without bound (observed: ~34k trigram segments for
~27k messages). Every MATCH then has to scan all segments, and every
insert pays a growing automerge cost that lengthens the WAL write-lock
hold time. Because the gateway and cron agents are separate processes
sharing one state.db, those longer holds exhaust the 1s-timeout x 15-retry
budget in _execute_write and surface as repeated:

    Session DB creation failed (will retry next turn): database is locked
    Session DB append_message failed: database is locked

Wire optimize_fts() into the write path on a coarse cadence
(_OPTIMIZE_EVERY_N_WRITES = 1000), alongside the existing every-50-writes
checkpoint. 'optimize' is effectively free once the index is already
merged, so steady-state cost is negligible; only the first merge of a
neglected index is expensive. The call is best-effort and never fails the
surrounding write.

Tests: cadence fires on the write path; a failing optimize never breaks
the write.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 583647b56e)
This commit is contained in:
Baris Sencan 2026-06-21 12:35:49 +01:00 committed by kshitij
parent a56bfeb2cb
commit 0695a6bcec
2 changed files with 63 additions and 1 deletions

View file

@ -820,6 +820,16 @@ class SessionDB:
_WRITE_RETRY_MAX_S = 0.150 # 150ms
# Attempt a PASSIVE WAL checkpoint every N successful writes.
_CHECKPOINT_EVERY_N_WRITES = 50
# Merge fragmented FTS5 segments every N successful writes. The message
# triggers append one segment per insert; left unmaintained these grow
# into tens of thousands of segments, so every MATCH must scan them all
# and every insert pays a growing automerge cost — which lengthens the
# write-lock hold time and starves competing writers (gateway + cron
# processes share one state.db), surfacing as "database is locked".
# 'optimize' is a no-op once the index is already merged, so an idle DB
# pays almost nothing; the cadence is deliberately coarse so the one-off
# merge cost is amortised far below the checkpoint cadence.
_OPTIMIZE_EVERY_N_WRITES = 1000
def __init__(self, db_path: Path = None, read_only: bool = False):
self.db_path = db_path or DEFAULT_DB_PATH
@ -1088,10 +1098,12 @@ class SessionDB:
except Exception:
pass
raise
# Success — periodic best-effort checkpoint.
# Success — periodic best-effort checkpoint + FTS merge.
self._write_count += 1
if self._write_count % self._CHECKPOINT_EVERY_N_WRITES == 0:
self._try_wal_checkpoint()
if self._write_count % self._OPTIMIZE_EVERY_N_WRITES == 0:
self._try_optimize_fts()
return result
except sqlite3.OperationalError as exc:
err_msg = str(exc).lower()
@ -1142,6 +1154,22 @@ class SessionDB:
except Exception:
pass # Best effort — never fatal.
def _try_optimize_fts(self) -> None:
"""Best-effort FTS5 segment merge. Never raises.
Runs on the ``_OPTIMIZE_EVERY_N_WRITES`` cadence from the write hot
path (off the lock ``optimize_fts`` re-acquires ``self._lock``
itself, mirroring ``_try_wal_checkpoint``). ``read_only`` connections
never reach the write path, so this is implicitly skipped for them.
Once the index is merged the 'optimize' command is close to free, so
the steady-state cost is negligible; the expensive case is only the
first merge of a long-neglected index.
"""
try:
self.optimize_fts()
except Exception:
pass # Best effort — never fatal.
def close(self):
"""Close the database connection.

View file

@ -3838,6 +3838,40 @@ class TestOptimizeFts:
# Search still works after repeated optimization.
assert len(db.search_messages("repeat")) == 1
def test_write_path_optimizes_fts_on_cadence(self, db, monkeypatch):
"""Writes periodically merge FTS segments so they never accumulate
into the tens-of-thousands that lengthen the write-lock hold and
starve competing writers ("database is locked")."""
db._OPTIMIZE_EVERY_N_WRITES = 5
calls = {"n": 0}
real_optimize = db.optimize_fts
def _counting_optimize():
calls["n"] += 1
return real_optimize()
monkeypatch.setattr(db, "optimize_fts", _counting_optimize)
# create_session is write #1; appends are #2.. -> #5 and #10 trigger.
db.create_session(session_id="s1", source="cli")
for i in range(9):
db.append_message(session_id="s1", role="user", content=f"needle {i}")
assert calls["n"] == 2
# The auto-merge is layout-only: search is unaffected.
assert len(db.search_messages("needle")) == 9
def test_write_path_optimize_failure_never_breaks_write(self, db, monkeypatch):
"""A failing periodic optimize must not fail the surrounding write."""
db._OPTIMIZE_EVERY_N_WRITES = 2
def _boom():
raise sqlite3.OperationalError("simulated optimize failure")
monkeypatch.setattr(db, "optimize_fts", _boom)
db.create_session(session_id="s1", source="cli") # write #1
# write #2 trips the cadence; the swallowed failure must not propagate.
db.append_message(session_id="s1", role="user", content="still persists")
assert len(db.get_messages("s1")) == 1
class TestAutoMaintenance:
def _make_old_ended(self, db, sid: str, days_old: int = 100):