fix(memory): share one SQLite connection per holographic store database - #43819
fix(memory): share one SQLite connection per holographic store database#43819adambiggs wants to merge 1 commit into
Conversation
|
Verification review — reviewed the diff for correctness. What I checked:
The fix correctly eliminates the cross-connection WAL writer contention that caused intermittent "database is locked" failures when multiple Clean PR. No issues found. |
|
Been running Hermes for weeks with holographic memory enabled. Hit the "database is locked" wall hard today — traced it to cron jobs opening separate SQLite connections while the gateway holds the write lock. Found this PR. Read the description. 1182 tests pass. Reviewer approved it. Autocommit isolation level is the right fix. And yet it has been sitting here since June 10. Patched it locally because I cannot wait for upstream. My gateway runs 24/7. This is not a nice-to-have — it is a data integrity issue. The old shutdown() never closes the connection, just drops the reference and prays to GC. On a long-running process that is not a bug, it is a time bomb. Please merge this. Or at minimum merge #44066 (the shutdown fix) which is even simpler. Users should not have to reverse-engineer their own PRs from GitHub to keep their agent working. |
|
Update: this also explains a 32M token burn on a long-running gateway with barely any active chatting. Every failed fact_store write returns "database is locked" to the model. The model sees the error, retries, tries different approaches, reasons about what went wrong — all of that generates tokens. Meanwhile on_memory_write mirrors every memory tool add into SQLite too, so even MEMORY.md writes hit the same lock. And prefetch runs search_facts before every turn, which tries to increment retrieval_count — another write that fails silently. So the model is burning tokens on: failed writes, error recovery, compensatory reasoning with less context, and retry loops. All because shutdown() never closed the connection and cron jobs kept opening new ones. 32 million tokens of a model arguing with a locked database. This is not cosmetic. This hits users in the wallet. |
c5e758a to
8305dfb
Compare
|
@aurorabotticus-svg Thanks for the detailed report — the 24/7-gateway + cron trace and the token-burn analysis were genuinely useful. You were right about the shutdown gap: this PR added a refcount-guarded Now addressed in this PR (head
The header-validation half of #44066 is a separate hardening concern and intentionally out of scope here. With the sharing + autocommit + deterministic release combined, the "database is locked" loop you traced (failed |
8305dfb to
59f34c3
Compare
Every MemoryStore instance opened its own SQLite connection guarded by its own RLock. Several providers coexist in one process (the main agent plus every delegate_task subagent), so instances pointing at the same memory_store.db raced as independent WAL writers. Combined with writes that were not rolled back on error, one connection could leave an open write transaction that pinned the write lock and made every other connection's writes fail with "database is locked" for the full busy timeout. Instances for the same database now share ONE process-wide connection and ONE re-entrant lock, so access is fully serialized and cross-connection contention is impossible. The shared connection is refcounted: closing one instance never tears it out from under a live sibling, and the last close releases it. The connection runs in autocommit (isolation_level=None) so a write that raises mid-method can never leave a dangling transaction holding the write lock; the existing explicit commit() calls become harmless no-ops. The provider's shutdown() now calls the refcount-guarded close() instead of just dropping the reference: leaving finalization to GC kept the connection (and its write lock) alive indefinitely on long-running gateways, prolonging the exact contention this fix removes. The last provider now releases the connection deterministically while siblings stay live; regression tests fail without the wiring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
59f34c3 to
4236be9
Compare
Related PR cluster for the holographic-memory "database is locked" symptom class (same goal, different mechanisms — NOT duplicates; a maintainer should pick the canonical fix):
This PR looks the most comprehensive of the cluster (connection sharing + autocommit + deterministic release, third-party diff verification, regression tests covering the failed-write and teardown paths). A production report on this thread traced a persistent write-lock on a 24/7 gateway to ~32M tokens of retry/error-recovery burn and patched it locally. |
Follow-ups for salvaged PR #43819: the registry key was str(Path(db_path).expanduser()) — a symlinked or relative path to the same DB file got its own connection, silently reintroducing the exact multi-writer contention the registry prevents. Key on Path.resolve() (OSError-tolerant fallback). Adds a symlink regression test and the AUTHOR_MAP entry for adambiggs.
Follow-ups for salvaged PR #43819: the registry key was str(Path(db_path).expanduser()) — a symlinked or relative path to the same DB file got its own connection, silently reintroducing the exact multi-writer contention the registry prevents. Key on Path.resolve() (OSError-tolerant fallback). Adds a symlink regression test and the AUTHOR_MAP entry for adambiggs.
Follow-ups for salvaged PR #43819: the registry key was str(Path(db_path).expanduser()) — a symlinked or relative path to the same DB file got its own connection, silently reintroducing the exact multi-writer contention the registry prevents. Key on Path.resolve() (OSError-tolerant fallback). Adds a symlink regression test and the AUTHOR_MAP entry for adambiggs.
|
Merged via PR #61726 — your commit was cherry-picked onto current main with your authorship preserved in git log (rebase merge, commit a801046). Thanks @adambiggs — this was the most comprehensive fix in the locking cluster (#40167/#55521/#55503): the refcounted shared-connection registry plus autocommit removes the in-process |
Follow-ups for salvaged PR NousResearch#43819: the registry key was str(Path(db_path).expanduser()) — a symlinked or relative path to the same DB file got its own connection, silently reintroducing the exact multi-writer contention the registry prevents. Key on Path.resolve() (OSError-tolerant fallback). Adds a symlink regression test and the AUTHOR_MAP entry for adambiggs.
Follow-ups for salvaged PR NousResearch#43819: the registry key was str(Path(db_path).expanduser()) — a symlinked or relative path to the same DB file got its own connection, silently reintroducing the exact multi-writer contention the registry prevents. Key on Path.resolve() (OSError-tolerant fallback). Adds a symlink regression test and the AUTHOR_MAP entry for adambiggs.
Follow-ups for salvaged PR NousResearch#43819: the registry key was str(Path(db_path).expanduser()) — a symlinked or relative path to the same DB file got its own connection, silently reintroducing the exact multi-writer contention the registry prevents. Key on Path.resolve() (OSError-tolerant fallback). Adds a symlink regression test and the AUTHOR_MAP entry for adambiggs.
Follow-ups for salvaged PR NousResearch#43819: the registry key was str(Path(db_path).expanduser()) — a symlinked or relative path to the same DB file got its own connection, silently reintroducing the exact multi-writer contention the registry prevents. Key on Path.resolve() (OSError-tolerant fallback). Adds a symlink regression test and the AUTHOR_MAP entry for adambiggs.
Follow-ups for salvaged PR NousResearch#43819: the registry key was str(Path(db_path).expanduser()) — a symlinked or relative path to the same DB file got its own connection, silently reintroducing the exact multi-writer contention the registry prevents. Key on Path.resolve() (OSError-tolerant fallback). Adds a symlink regression test and the AUTHOR_MAP entry for adambiggs.
What
MemoryStoreinstances in the holographic memory plugin that point at the same database file now share a single process-wide SQLite connection and a single re-entrant lock, with refcountedclose()semantics. The shared connection runs in autocommit (isolation_level=None), and the provider'sshutdown()now releases its reference deterministically instead of leaving the connection to GC.Why
Each
MemoryStoreopened its own connection guarded by its own per-instanceRLock. Several providers coexist in one process — the main agent plus everydelegate_tasksubagent — so instances on the samememory_store.dbraced as independent WAL writers. Combined with writes that were not rolled back on error, one connection could leave an open write transaction that pinned the write lock, making every other connection's writes fail withsqlite3.OperationalError: database is lockedfor the full 10s busy timeout. We hit this repeatedly in production once delegated subagents started recording memories concurrently with the main agent.Details of the fix:
RLockper database path, held in a class-level registry, so all access is serialized and cross-connection contention is impossible.close()releases it.close()is idempotent.commit()calls become harmless no-ops, so the diff stays minimal.HolographicMemoryProvider.shutdown()now calls the refcount-guardedclose()instead of just dropping the reference. Previously the connection (and its write lock) stayed alive until non-deterministic GC finalization — on a long-running gateway this prolongs the exact contention this PR removes, and it is the failure mode reported in the comments below (24/7 gateway + cron subprocesses). Same gap fix(memory): close SQLite in holographic shutdown + header validation (#44037) #44066 targeted.How to test
New tests cover: connection sharing per path, cross-instance write visibility, refcounted close (sibling survives, last close releases, idempotent double-close), context-manager release, reopen-after-close, 8 threads × 15 facts concurrent multi-instance writes with zero
database is lockederrors, write-lock release after a mid-write failure (in_transactionstays false), and providershutdown()releasing the shared connection while a sibling provider stays live (both shutdown tests fail without theshutdown()wiring).Full
tests/pluginssuite: 1182 passed, 0 failed.To reproduce the original failure manually: run two Hermes agents (main + a
delegate_tasksubagent) with the holographic memory provider on the sameHERMES_HOMEand have both store facts in a tight loop — pre-fix this intermittently raisesdatabase is locked.Platforms
Tested on Linux (x86_64, Python 3.11). Pure-stdlib
sqlite3/threadingchange, no platform-specific I/O.🤖 Generated with Claude Code