Skip to content

Commit ef0e45a

Browse files
authored
Merge pull request #1105 from mvalentsev/fix/chroma-backend-close-releases-lock
fix(backends/chroma): release SQLite file lock on close_palace/close (#1067)
2 parents 0cfb4b3 + 45df1a2 commit ef0e45a

2 files changed

Lines changed: 72 additions & 3 deletions

File tree

mempalace/backends/chroma.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,20 @@ def _as_list(v: Any) -> list:
676676
return [v]
677677

678678

679+
def _close_client(client) -> None:
680+
"""Call ``PersistentClient.close()`` if available, swallow otherwise.
681+
682+
chromadb 1.5.x exposes ``Client.close()`` to release rust-side SQLite
683+
file locks; older versions relied on GC. Try/except keeps forward-compat.
684+
"""
685+
if client is None:
686+
return
687+
try:
688+
client.close()
689+
except Exception:
690+
logger.debug("client.close() unavailable or failed", exc_info=True)
691+
692+
679693
class ChromaCollection(BaseCollection):
680694
"""Thin adapter translating ChromaDB dict returns into typed results."""
681695

@@ -977,7 +991,7 @@ def _client(self, palace_path: str):
977991
db_path = os.path.join(palace_path, "chroma.sqlite3")
978992
# DB was present when cache was built but is now missing → invalidate.
979993
if cached is not None and not os.path.isfile(db_path):
980-
self._clients.pop(palace_path, None)
994+
_close_client(self._clients.pop(palace_path, None))
981995
self._freshness.pop(palace_path, None)
982996
cached = None
983997
cached_inode, cached_mtime = 0, 0.0
@@ -1134,14 +1148,22 @@ def get_collection(
11341148
return ChromaCollection(collection)
11351149

11361150
def close_palace(self, palace) -> None:
1137-
"""Drop cached handles for ``palace``. Accepts ``PalaceRef`` or legacy path str."""
1151+
"""Drop cached handles for ``palace`` and release its SQLite file lock.
1152+
1153+
Accepts ``PalaceRef`` or legacy path str. chromadb's rust-side file
1154+
lock is held until ``PersistentClient.close()`` is called, so plain
1155+
dict eviction would leave the palace path unreopenable and
1156+
unremovable in the same process.
1157+
"""
11381158
path = palace.local_path if isinstance(palace, PalaceRef) else palace
11391159
if path is None:
11401160
return
1141-
self._clients.pop(path, None)
1161+
_close_client(self._clients.pop(path, None))
11421162
self._freshness.pop(path, None)
11431163

11441164
def close(self) -> None:
1165+
for client in self._clients.values():
1166+
_close_client(client)
11451167
self._clients.clear()
11461168
self._freshness.clear()
11471169
self._closed = True

tests/test_backends.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import shutil
23
import sqlite3
34
from pathlib import Path
45

@@ -206,6 +207,52 @@ def test_query_empty_preserves_embeddings_outer_shape_when_requested():
206207
assert not_requested.embeddings is None
207208

208209

210+
def test_chroma_close_palace_releases_sqlite_lock_for_reopen(tmp_path):
211+
"""close_palace must release chromadb's rust-side SQLite file lock so
212+
a fresh PersistentClient on the same path after shutil.rmtree can
213+
write without hitting SQLITE_READONLY_DBMOVED."""
214+
backend = ChromaBackend()
215+
palace_path = tmp_path / "palace-a"
216+
ref = PalaceRef(id=str(palace_path), local_path=str(palace_path))
217+
218+
col = backend.get_collection(palace=ref, collection_name="mempalace_drawers", create=True)
219+
col.upsert(documents=["hello"], ids=["a"], metadatas=[{"k": "v"}])
220+
221+
backend.close_palace(ref)
222+
shutil.rmtree(palace_path)
223+
224+
col = backend.get_collection(palace=ref, collection_name="mempalace_drawers", create=True)
225+
col.upsert(documents=["world"], ids=["b"], metadatas=[{"k": "v2"}])
226+
assert col.count() == 1
227+
228+
229+
def test_chroma_close_releases_all_cached_clients(tmp_path):
230+
"""close() must release every cached client's SQLite file lock so any
231+
of their palace paths can be reopened by a fresh backend in the same
232+
process."""
233+
backend = ChromaBackend()
234+
palace_a = tmp_path / "palace-a"
235+
palace_b = tmp_path / "palace-b"
236+
ref_a = PalaceRef(id=str(palace_a), local_path=str(palace_a))
237+
ref_b = PalaceRef(id=str(palace_b), local_path=str(palace_b))
238+
239+
for ref in (ref_a, ref_b):
240+
backend.get_collection(palace=ref, collection_name="mempalace_drawers", create=True).upsert(
241+
documents=["x"], ids=["x"], metadatas=[{"k": "v"}]
242+
)
243+
244+
backend.close()
245+
246+
for path in (palace_a, palace_b):
247+
shutil.rmtree(path)
248+
ref = PalaceRef(id=str(path), local_path=str(path))
249+
fresh = ChromaBackend()
250+
col = fresh.get_collection(palace=ref, collection_name="mempalace_drawers", create=True)
251+
col.upsert(documents=["y"], ids=["y"], metadatas=[{"k": "v2"}])
252+
assert col.count() == 1
253+
fresh.close()
254+
255+
209256
def test_chroma_cache_invalidates_when_db_file_missing(tmp_path):
210257
"""A palace rebuild that removes chroma.sqlite3 must drop the stale cache.
211258

0 commit comments

Comments
 (0)