{"repo":"NousResearch/hermes-agent","public_id":"55503","display_label":"SQLite locking issues in holographic memory store","summary":"These items address database write lock and duplicate write problems in the holographic memory provider, focusing on sharing a single SQLite connection to prevent contention on memory_store.db.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":61726,"merged_at":"2026-07-10T01:17:40+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-10T01:17:40+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":55503,"pr_number":61726,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-10T01:17:40+00:00"}],"members":[{"kind":"issue","number":55503,"title":"[BUG] Holographic memory provider permanent write lock on memory_store.db","url":null,"state":"closed"},{"kind":"pr","number":55521,"title":"fix(memory): prevent holographic duplicate writes from locking db","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a80104666","number":0},{"kind":"pr","value":"#61726","number":61726},{"kind":"pr","value":"#43819","number":43819}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":61726,"title":"fix(memory): share one SQLite connection per holographic store database","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/memory/holographic/__init__.py","plugins/memory/holographic/store.py","scripts/release.py","tests/plugins/memory/test_holographic_store.py"],"fix_hunks":[{"file":"plugins/memory/holographic/__init__.py","hunks":["@@ -251,12 +251,12 @@ def on_memory_write(self, action: str, target: str, content: str) -> None:\n                 logger.debug(\"Holographic memory_write mirror failed: %s\", e)\n \n     def shutdown(self) -> None:\n-        # Close the SQLite connection deterministically instead of leaking it\n-        # to GC. MemoryStore opens its connection with check_same_thread=False\n-        # (store.py), so without an explicit close() the sqlite3.Connection's\n-        # fd is released by refcount/GC at a non-deterministic time on a\n-        # non-deterministic thread, churning a DB fd through the kernel's free\n-        # pool on every session teardown. close() already exists and is cheap.\n+        # Release the shared SQLite connection deterministically on the\n+        # caller's thread. Dropping the reference alone leaves fd finalization\n+        # to GC, which keeps the connection (and its write lock) alive on a\n+        # long-running gateway and prolongs the \"database is locked\" contention\n+        # this store's shared-connection refcounting is meant to eliminate.\n+        # close() is idempotent and refcount-guarded, so siblings stay safe.\n         if self._store is not None:\n             try:\n                 self._store.close()"]},{"file":"plugins/memory/holographic/store.py","hunks":["@@ -98,6 +98,21 @@ def _clamp_trust(value: float) -> float:\n class MemoryStore:\n     \"\"\"SQLite-backed fact store with entity resolution and trust scoring.\"\"\"\n \n+    # --- Process-wide shared connection registry -------------------------\n+    # SQLite permits only one writer at a time. Each MemoryStore instance used\n+    # to open its own connection guarded by its own RLock, so the several\n+    # providers that coexist in one process (the main agent plus every\n+    # delegate_task subagent) raced as independent WAL writers. Combined with\n+    # writes that were not rolled back on error, one connection could leave an\n+    # open write transaction that pinned the write lock and made every other\n+    # connection's write fail with \"database is locked\" for the full busy\n+    # timeout. All instances for the same database now share ONE connection and\n+    # ONE re-entrant lock, so access is fully serialized and cross-connection\n+    # contention is impossible. The shared connection is refcounted, so closing\n+    # one instance never tears the connection out from under a live sibling.\n+    _shared: dict = {}\n+    _shared_guard = threading.Lock()\n+\n     def __init__(\n         self,\n         db_path: \"str | Path | None\" = None,","@@ -112,14 +127,41 @@ def __init__(\n         self.default_trust = _clamp_trust(default_trust)\n         self.hrr_dim = hrr_dim\n         self._hrr_available = hrr._HAS_NUMPY\n-        self._conn: sqlite3.Connection = sqlite3.connect(\n-            str(self.db_path),\n-            check_same_thread=False,\n-            timeout=10.0,\n-        )\n-        self._lock = threading.RLock()\n-        self._conn.row_factory = sqlite3.Row\n-        self._init_db()\n+\n+        # Acquire (or open) the process-wide shared connection for this DB.\n+        # resolve() (not just expanduser) so symlinked/relative paths to the\n+        # same file share ONE connection instead of silently reintroducing\n+        # the multi-writer contention this registry exists to prevent.\n+        try:\n+            self._key = str(self.db_path.resolve())\n+        except OSError:\n+            self._key = str(self.db_path)\n+        with MemoryStore._shared_guard:\n+            entry = MemoryStore._shared.get(self._key)\n+            if entry is None:\n+                conn = sqlite3.connect(\n+                    self._key,\n+                    check_same_thread=False,\n+                    timeout=10.0,\n+                    # Autocommit: every statement is its own transaction, so a\n+                    # write that raises mid-method can never leave a dangling\n+                    # transaction (and its write lock) open. The explicit\n+                    # commit() calls below become harmless no-ops.\n+                    isolation_level=None,\n+                )\n+                conn.row_factory = sqlite3.Row\n+                entry = {\"conn\": conn, \"lock\": threading.RLock(), \"refs\": 0, \"ready\": False}\n+                MemoryStore._shared[self._key] = entry\n+            entry[\"refs\"] += 1\n+            self._entry = entry\n+            self._conn = entry[\"conn\"]\n+            self._lock = entry[\"lock\"]\n+\n+        # Initialise the schema once per shared connection.\n+        with self._lock:\n+            if not self._entry[\"ready\"]:\n+                self._init_db()\n+                self._entry[\"ready\"] = True\n \n     # ------------------------------------------------------------------\n     # Initialisation","@@ -575,8 +617,25 @@ def _row_to_dict(self, row: sqlite3.Row) -> dict:\n         return dict(row)\n \n     def close(self) -> None:\n-        \"\"\"Close the database connection.\"\"\"\n-        self._conn.close()\n+        \"\"\"Release this instance's reference to the shared connection.\n+\n+        The underlying connection is closed only when the last MemoryStore\n+        referencing the same database is closed, so closing one instance can\n+        never break sibling instances that still hold it. Idempotent.\n+        \"\"\"\n+        if getattr(self, \"_entry\", None) is None:\n+            return\n+        with MemoryStore._shared_guard:\n+            entry = self._entry\n+            if entry is None:\n+                return\n+            entry[\"refs\"] -= 1\n+            if entry[\"refs\"] <= 0:\n+                try:\n+                    entry[\"conn\"].close()\n+                finally:\n+                    MemoryStore._shared.pop(self._key, None)\n+            self._entry = None\n \n     def __enter__(self) -> \"MemoryStore\":\n         return self"]},{"file":"scripts/release.py","hunks":["@@ -258,6 +258,7 @@\n     \"[redacted:email]\": \"necoweb3\",\n     \"[redacted:email]\": \"hydracoco7\",  # PR #61382 salvage (id-less cron job freeze)\n     \"AlexFucuson9@users.noreply.github.com\": \"AlexFucuson9\",  # PR #61209 salvage (hygiene compression data loss)\n+    \"[redacted:email]\": \"adambiggs\",  # PR #43819 salvage (holographic shared SQLite connection)\n     \"[redacted:email]\": \"cypctlinux\",  # PR #52403 salvage (Slack bot/workflow auth before no-user-id guard)\n     \"30854794+YLChen-007@users.noreply.github.com\": \"YLChen-007\",  # PR #26965 (approval remote command substitution)\n     \"1078345+egilewski@users.noreply.github.com\": \"egilewski\",  # co-author, PR #40663"]},{"file":"tests/plugins/memory/test_holographic_store.py","hunks":["@@ -0,0 +1,243 @@\n+\"\"\"Tests for the holographic MemoryStore shared-connection registry.\n+\n+MemoryStore instances pointing at the same database file must share one\n+process-wide SQLite connection and one re-entrant lock. Multiple providers\n+coexist in a single process (the main agent plus every delegate_task\n+subagent); when each instance owned a private connection they raced as\n+independent WAL writers and intermittently failed with \"database is locked\".\n+\n+Covers: connection sharing/refcounting, close() semantics, cross-instance\n+visibility, concurrent multi-instance writers, and write-lock release after\n+a failed write.\n+\"\"\"\n+\n+import sqlite3\n+import threading\n+\n+import pytest\n+\n+from plugins.memory.holographic.store import MemoryStore\n+\n+\n[redacted:email](autouse=True)\n+def _clean_shared_registry():\n+    \"\"\"Each test starts and ends with an empty shared-connection registry.\"\"\"\n+    # Drop any leakage from earlier tests in the same process.\n+    for entry in list(MemoryStore._shared.values()):\n+        try:\n+            entry[\"conn\"].close()\n+        except sqlite3.Error:\n+            pass\n+    MemoryStore._shared.clear()\n+    yield\n+    leaked = list(MemoryStore._shared)\n+    for entry in list(MemoryStore._shared.values()):\n+        try:\n+            entry[\"conn\"].close()\n+        except sqlite3.Error:\n+            pass\n+    MemoryStore._shared.clear()\n+    assert not leaked, f\"test leaked shared connections: {leaked}\"\n+\n+\n[redacted:email]\n+def db_path(tmp_path):\n+    return tmp_path / \"memory_store.db\"\n+\n+\n+class TestSharedConnection:\n+    def test_same_path_shares_one_connection(self, db_path):\n+        a = MemoryStore(db_path)\n+        b = MemoryStore(db_path)\n+        try:\n+            assert a._conn is b._conn\n+            assert a._lock is b._lock\n+            assert len(MemoryStore._shared) == 1\n+            assert MemoryStore._shared[str(a.db_path)][\"refs\"] == 2\n+        finally:\n+            a.close()\n+            b.close()\n+\n+    def test_different_paths_get_distinct_connections(self, tmp_path):\n+        a = MemoryStore(tmp_path / \"one.db\")\n+        b = MemoryStore(tmp_path / \"two.db\")\n+        try:\n+            assert a._conn is not b._conn\n+            assert len(MemoryStore._shared) == 2\n+        finally:\n+            a.close()\n+            b.close()\n+\n+    def test_symlinked_path_shares_connection(self, tmp_path):\n+        \"\"\"A symlink to the same DB file must hit the same registry entry —\n+        otherwise two connections to one file silently reintroduce the\n+        multi-writer contention the registry exists to prevent.\"\"\"\n+        real_dir = tmp_path / \"real\"\n+        real_dir.mkdir()\n+        link_dir = tmp_path / \"link\"\n+        link_dir.symlink_to(real_dir)\n+\n+        a = MemoryStore(real_dir / \"memory_store.db\")\n+        b = MemoryStore(link_dir / \"memory_store.db\")\n+        try:\n+            assert a._conn is b._conn\n+            assert len(MemoryStore._shared) == 1\n+        finally:\n+            a.close()\n+            b.close()\n+\n+    def test_writes_visible_across_instances(self, db_path):\n+        a = MemoryStore(db_path)\n+        b = MemoryStore(db_path)\n+        try:\n+            fact_id = a.add_fact(\"Hermes likes shared connections\", category=\"test\")\n+            facts = b.list_facts(category=\"test\")\n+            assert [f[\"fact_id\"] for f in facts] == [fact_id]\n+        finally:\n+            a.close()\n+            b.close()\n+\n+    def test_schema_initialised_once_per_connection(self, db_path):\n+        a = MemoryStore(db_path)\n+        b = MemoryStore(db_path)  # must not re-run schema init / WAL probe\n+        try:\n+            assert MemoryStore._shared[str(a.db_path)][\"ready\"] is True\n+            b.add_fact(\"schema still works\")\n+        finally:\n+            a.close()\n+            b.close()\n+\n+\n+class TestCloseSemantics:\n+    def test_closing_one_instance_keeps_sibling_alive(self, db_path):\n+        a = MemoryStore(db_path)\n+        b = MemoryStore(db_path)\n+        a.close()\n+        try:\n+            # The shared connection must survive the sibling's close().\n+            fact_id = b.add_fact(\"survivor write\")\n+            assert fact_id > 0\n+        finally:\n+            b.close()\n+\n+    def test_last_close_releases_connection(self, db_path):\n+        a = MemoryStore(db_path)\n+        b = MemoryStore(db_path)\n+        conn = a._conn\n+        a.close()\n+        b.close()\n+        assert MemoryStore._shared == {}\n+        with pytest.raises(sqlite3.ProgrammingError):\n+            conn.execute(\"SELECT 1\")\n+\n+    def test_close_is_idempotent(self, db_path):\n+        a = MemoryStore(db_path)\n+        b = MemoryStore(db_path)\n+        a.close()\n+        a.close()  # double close must not steal b's reference\n+        try:\n+            b.add_fact(\"still alive after double close\")\n+            assert MemoryStore._shared[str(b.db_path)][\"refs\"] == 1\n+        finally:\n+            b.close()\n+\n+    def test_context_manager_releases_reference(self, db_path):\n+        with MemoryStore(db_path) as store:\n+            store.add_fact(\"context managed\")\n+        assert MemoryStore._shared == {}\n+\n+    def test_reopen_after_full_close(self, db_path):\n+        with MemoryStore(db_path) as store:\n+            store.add_fact(\"first lifetime\")\n+        with MemoryStore(db_path) as store:\n+            facts = store.list_facts()\n+        assert [f[\"content\"] for f in facts] == [\"first lifetime\"]\n+\n+\n+class TestConcurrency:\n+    def test_concurrent_multi_instance_writers(self, db_path):\n+        \"\"\"Many instances writing from many threads must never hit\n+        'database is locked' — the failure mode of per-instance connections.\"\"\"\n+        n_threads, n_facts = 8, 15\n+        errors: list[BaseException] = []\n+\n+        def writer(idx: int) -> None:\n+            store = MemoryStore(db_path)\n+            try:\n+                for i in range(n_facts):\n+                    store.add_fact(f\"fact thread={idx} seq={i}\", category=\"load\")\n+            except BaseException as exc:  # noqa: BLE001 - recorded for assert\n+                errors.append(exc)\n+            finally:\n+                store.close()\n+\n+        threads = [threading.Thread(target=writer, args=(i,)) for i in range(n_threads)]\n+        for t in threads:\n+            t.start()\n+        for t in threads:\n+            t.join()\n+\n+        assert not errors, f\"concurrent writers failed: {errors[:3]}\"\n+        with MemoryStore(db_path) as store:\n+            facts = store.list_facts(category=\"load\", limit=500)\n+        assert len(facts) == n_threads * n_facts\n+        assert MemoryStore._shared == {}\n+\n+    def test_failed_write_does_not_pin_write_lock(self, db_path, monkeypatch):\n+        \"\"\"A write that raises mid-method must not leave an open transaction\n+        holding the SQLite write lock (autocommit isolation_level=None).\"\"\"\n+        broken = MemoryStore(db_path)\n+        sibling = MemoryStore(db_path)\n+        try:\n+            monkeypatch.setattr(\n+                MemoryStore,\n+                \"_rebuild_bank\",\n+                lambda self, category: (_ for _ in ()).throw(RuntimeError(\"boom\")),\n+            )\n+            with pytest.raises(RuntimeError, match=\"boom\"):\n+                broken.add_fact(\"write that fails after the INSERT\")\n+            monkeypatch.undo()\n+\n+            # No dangling transaction: the connection reports autocommit state\n+            # and the sibling can write immediately.\n+            assert broken._conn.in_transaction is False\n+            sibling.add_fact(\"sibling write right after the failure\")\n+        finally:\n+            broken.close()\n+            sibling.close()\n+\n+\n+class TestProviderShutdown:\n+    \"\"\"The provider's shutdown() must release its shared connection, not just\n+    drop the reference. Leaving finalization to GC keeps the connection (and\n+    its write lock) alive on a long-running gateway, which is exactly the\n+    \"database is locked\" contention the shared-connection registry removes.\"\"\"\n+\n+    def test_shutdown_releases_shared_connection(self, db_path):\n+        from plugins.memory.holographic import HolographicMemoryProvider\n+\n+        provider = HolographicMemoryProvider(config={\"db_path\": str(db_path)})\n+        provider.initialize(\"session-shutdown\")\n+        assert MemoryStore._shared[str(db_path)][\"refs\"] == 1\n+\n+        provider.shutdown()\n+\n+        assert provider._store is None\n+        assert MemoryStore._shared == {}\n+\n+    def test_shutdown_keeps_sibling_provider_alive(self, db_path):\n+        from plugins.memory.holographic import HolographicMemoryProvider\n+\n+        a = HolographicMemoryProvider(config={\"db_path\": str(db_path)})\n+        b = HolographicMemoryProvider(config={\"db_path\": str(db_path)})\n+        a.initialize(\"session-a\")\n+        b.initialize(\"session-b\")\n+        assert MemoryStore._shared[str(db_path)][\"refs\"] == 2\n+\n+        a.shutdown()\n+        # Sibling still holds a live, writable connection.\n+        assert MemoryStore._shared[str(db_path)][\"refs\"] == 1\n+        assert b._store is not None\n+        b._store.add_fact(\"write after sibling shutdown\")\n+        b.shutdown()\n+        assert MemoryStore._shared == {}\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}