Skip to content

ChromaBackend._clients.pop() does not close() underlying PersistentClient; causes SQLITE_READONLY_DBMOVED on reopen #1067

@xelauvas

Description

@xelauvas

Summary

mempalace.backends.chroma.ChromaBackend caches PersistentClient instances in self._clients: dict[palace_path -> PersistentClient]. There's no public API to evict a client cleanly — the dict is the only handle downstream code has to "release this palace." Downstream code that pops from _clients (to bound cache growth or to tear down a palace) and then relies on Python GC to finalize the client hits SQLite file-lock issues on chromadb 1.5.x because chromadb requires an explicit client.close() before GC.

Repro

Minimum sequence within a single Python process:

from mempalace.backends.chroma import _DEFAULT_BACKEND

# 1. Open a palace (caches a PersistentClient under this path).
col = _DEFAULT_BACKEND.get_collection("/tmp/palace-a", create=True)
col.upsert(documents=["hello"], ids=["a"], metadatas=[{}])

# 2. "Evict" the palace — pop from _clients, let GC finish.
_DEFAULT_BACKEND._clients.pop("/tmp/palace-a", None)

# 3. Simulate a full teardown + re-create for the same path.
import shutil, os
shutil.rmtree("/tmp/palace-a")
os.makedirs("/tmp/palace-a")

# 4. Open again — fresh PersistentClient, fresh sqlite file.
col = _DEFAULT_BACKEND.get_collection("/tmp/palace-a", create=True)

# 5. First write fails.
col.upsert(documents=["world"], ids=["b"], metadatas=[{}])
# chromadb.errors.InternalError: Query error: Database error:
# error returned from database: (code: 1032) attempt to write a readonly database

SQLite error code 1032 is SQLITE_READONLY_DBMOVED — "the database file has been moved since it was opened." The rust-side chromadb bindings detect the inode change between the un-GC'd predecessor client and the fresh successor client and go into readonly mode.

Root cause

chromadb's Client.close() (introduced to release SQLite file locks explicitly; its docstring: "This is particularly important for PersistentClient to avoid SQLite file locking issues.") must be called on each cached client before it's removed from the dict. Dict-pop + Python GC is insufficient — chromadb retains rust-side state until the explicit close.

mempalace.backends.chroma.ChromaBackend currently offers no way to do this at the library boundary. Downstream integrators popping from _clients directly (the only handle available) end up holding the dict key change without the explicit close, which is the failure mode.

Proposed fix

Add a public close method to ChromaBackend that pops AND calls client.close():

# mempalace/backends/chroma.py (around existing _client method)
def close(self, palace_path: str) -> None:
    """Evict the cached PersistentClient for `palace_path` and release
    its SQLite file locks. Required before `shutil.rmtree` on the palace
    dir or before opening a fresh PersistentClient for the same path
    within the same process lifetime.
    """
    client = self._clients.pop(palace_path, None)
    if client is not None:
        try:
            client.close()
        except Exception:
            # Forward-compat with older chromadb versions that didn't
            # expose close(); GC fallback is what those versions used.
            pass

Rename or supplement _clients.pop usage across the codebase to route through this method. Downstream integrators then call _DEFAULT_BACKEND.close(palace_path) and get the correct lifecycle.

Workaround downstream

Currently patching at our integration boundary:

# our PalaceHandle.close() equivalent
client = _DEFAULT_BACKEND._clients.pop(str(palace_path), None)
if client is not None:
    try:
        client.close()
    except Exception:
        pass

Works correctly, but every integrator will re-discover this (the symptom is invisible unless the integration explicitly tests close-then-reopen-to-same-path). Discovered during MemPalace-sidecar integration work (xelauvas/xelasphere, April 2026) when a GDPR-style DELETE + reopen test failed with SQLITE_READONLY_DBMOVED; happy to share the full benchmark/repro module.

Version

mempalace==3.3.1 (also checked v3.3.2 changelog — no fix mentioned).
chromadb==1.5.8.
Python 3.12.3, Linux.

Note on the related chromadb issue

Filed a separate issue upstream at chromadb (chroma-core/chroma#6941) about DefaultEmbeddingFunction.__call__ constructing a fresh ONNXMiniLM_L6_V2 per call (10× slowdown). That's an orthogonal concern — this issue is about ChromaBackend lifecycle, that one is about embedding-function caching.

Changelog observation

Scanning v3.0.0 → v3.3.2 release notes, I don't see a documented migration from "GC-sufficient close" to "explicit-close-required." If chromadb changed the contract between versions that MemPalace targets, a changelog/migration-guide entry would save the next integrator from replaying the diagnostic. Not blocking this issue; just flagging.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingstorage

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions