mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
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:
parent
a56bfeb2cb
commit
0695a6bcec
2 changed files with 63 additions and 1 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue