Skip to content

Commit ce75a20

Browse files
committed
fix(chroma): stop the client cache invalidating on its own writes
ChromaBackend._client caches a PersistentClient per palace and keys the cache on chroma.sqlite3's (inode, mtime) so that a write by another process forces a rebuild rather than serving a stale HNSW segment. Constructing a PersistentClient writes to chroma.sqlite3, and so do the collection opens that follow it. The stamp was taken immediately after the constructor, so it was already behind by the time the surrounding operation finished its own writes, and the next _client() call read that footprint as an external change. A search opens mempalace_drawers and then mempalace_closets, so the first open moved the mtime the second one checked and the cache missed on essentially every request. Each miss rebuilt the client and reloaded both HNSW segments, and the client being displaced was overwritten in the dict without being closed, so its native index allocation (max_elements * size_data_per_element, ~440 MB per collection on a 165k vector palace) was never released. Re-baseline the stat once the backend's own operation completes, so the recorded value means "chroma.sqlite3 as this backend last left it" and a later difference is genuinely somebody else's write. Cover writes made through ChromaCollection as well, via its existing _write_lock context manager, so the file-a-drawer-then-search cycle stops reloading the index. Close the displaced client on the rebuild path. An external write that lands while one of our own operations is in flight is folded into the new stamp and picked up on the next change. mtime cannot distinguish writers on its own, and PRAGMA data_version does not help: it reports writes by any other connection, and chromadb's connection is foreign to a probe connection, so our own opens would register as external there too. Eight searches against the same palace, HTTP transport: before 951 MB -> 3522 MB after 940 MB -> 965 MB
1 parent 517a7a0 commit ce75a20

3 files changed

Lines changed: 242 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
1010

1111
### Bug Fixes
1212

13+
- **A long-running MCP server no longer grows by ~440 MB per collection open.** `ChromaBackend._client` keys its client cache on `chroma.sqlite3`'s inode and mtime to detect writes by another process, but constructing a `chromadb.PersistentClient` writes to that file itself, and so do the collection opens that follow — so the stamp taken at construction time was already stale by the time the next open compared against it. Every search opens `mempalace_drawers` and then `mempalace_closets`, and the first open moved the mtime the second one checked, so the cache missed on essentially every request: each search rebuilt the client and reloaded both HNSW segments, and the displaced client was dropped from the dict without being closed, so its native index memory was never returned. On a palace of ~165k vectors that was ~650 MB of resident growth per search; a server left running for an afternoon reached 3.9 GB. The freshness stat is now re-baselined once the backend's own operation finishes — including writes through `ChromaCollection`, so the file-a-drawer-then-search cycle stops reloading the index too — which makes the recorded value mean "`chroma.sqlite3` as this backend last left it", and a genuine external change still rebuilds. The rebuild path also closes the client it replaces. Measured over eight searches on the same palace: 951 MB → 3522 MB before, 940 MB → 965 MB after. An external write landing *while* one of our own operations is in flight is absorbed into the new stamp and picked up on the next change; mtime cannot distinguish writers, and `PRAGMA data_version` does not help because chromadb's own connection reads as foreign to a probe connection. (#2307)
1314
- **`sweep` books a failed `stat` as a failure again.** The non-regular-file gate added in 3.7.1 reads `stat.S_ISREG(f.stat().st_mode)` inside a `try`, and its `except OSError` printed `SKIP` and moved on. A dangling symlink, a symlink loop and a file unlinked between `rglob` and the gate all raise there, and before the gate existed every one of them reached `sweep()` and was booked in `failures` — so `sweep` went from reporting a transcript it could not read to reporting success. A failed probe is now an error, not a benign file type: it is logged, printed as `WARNING`, and appended to `failures`, while a probe that succeeds and says "not regular" still skips silently. (#2221)
1415
- **`mempalace init` no longer tracebacks on a directory it cannot enter.** `_parse_gradle`'s `is_file()` gate sat in front of the `try` that the parser's own `except OSError` provides, so a manifest under a directory with `r` but no `x` raised `PermissionError` out of a call that used to answer "no manifest name". The gate moved inside that `try`, and `_collect_manifest_names` stats through `os.path.isfile`, which reports rather than raises. (#2221)
1516
- **`split` no longer blocks on a FIFO at its own output name, nor writes through a broken link.** The type gate in `main` covers the files the glob listed; `split_file` builds its output names itself, so a pre-existing named pipe at one of them wedged `write_text` in the kernel waiting for a reader. The check asks about the link itself rather than its target, because a dangling symlink at an output name reads as "nothing there" and `write_text` would create the target — landing a chunk outside the output directory. Output names that are anything but a regular file are now skipped with a `SKIP` line. (#2221)

mempalace/backends/chroma.py

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1595,15 +1595,27 @@ class ChromaCollection(BaseCollection):
15951595
directly without going through ``ChromaBackend``.
15961596
"""
15971597

1598-
def __init__(self, collection, palace_path: Optional[str] = None):
1598+
def __init__(self, collection, palace_path: Optional[str] = None, backend=None):
15991599
self._collection = collection
16001600
self._palace_path = palace_path
1601+
# Owning ChromaBackend, when this collection came through one. Used
1602+
# only to re-baseline that backend's freshness stat after our writes
1603+
# (see _write_lock). None for directly-constructed test doubles.
1604+
self._backend = backend
16011605

16021606
@contextlib.contextmanager
16031607
def _write_lock(self):
16041608
"""Acquire ``mine_palace_lock`` for the configured palace, if any.
16051609
16061610
No-op (yields immediately) when ``self._palace_path`` is None.
1611+
1612+
On exit, re-baselines the owning backend's client-cache freshness stat.
1613+
A write moves ``chroma.sqlite3``'s mtime, and that stat is how
1614+
:meth:`ChromaBackend._client` detects an *external* change; without the
1615+
re-baseline our own upsert looks like somebody else's write and the
1616+
next collection open rebuilds the client, reloading every HNSW segment
1617+
it had already paid for. That made the file-a-drawer-then-search cycle
1618+
reload the whole index each time.
16071619
"""
16081620
if self._palace_path is None:
16091621
yield
@@ -1612,7 +1624,11 @@ def _write_lock(self):
16121624
from ..palace import mine_palace_lock
16131625

16141626
with mine_palace_lock(self._palace_path):
1615-
yield
1627+
try:
1628+
yield
1629+
finally:
1630+
if self._backend is not None:
1631+
self._backend._restamp(self._palace_path)
16161632

16171633
# ------------------------------------------------------------------
16181634
# Writes
@@ -2327,6 +2343,18 @@ def _client(self, palace_path: str):
23272343
# change. Gated on genuine external change (not first open) so
23282344
# cold opens never pay the global-evict cost.
23292345
_clear_chroma_system_cache()
2346+
# Release the client we are about to displace. Each live
2347+
# PersistentClient pins its own copy of every HNSW segment it has
2348+
# opened (``max_elements * size_data_per_element`` bytes -- ~440 MB
2349+
# per collection on a 165k-drawer palace), and neither dict
2350+
# eviction nor _clear_chroma_system_cache() returns that native
2351+
# memory. Dropping it here keeps a long-lived server flat across
2352+
# rebuilds instead of accumulating one orphaned index set per
2353+
# external change. Any ChromaCollection handed out before this
2354+
# point is invalidated -- which is the intent: the rebuild only
2355+
# fires when the palace changed underneath us, and serving the
2356+
# pre-change segment is the stale-index class of #2002/#2028.
2357+
_close_client(self._clients.pop(palace_path, None))
23302358
ChromaBackend._prepare_palace_for_open(palace_path)
23312359
cached = chromadb.PersistentClient(path=palace_path)
23322360
self._clients[palace_path] = cached
@@ -2336,6 +2364,40 @@ def _client(self, palace_path: str):
23362364
self._freshness[palace_path] = self._db_stat(palace_path)
23372365
return cached
23382366

2367+
def _restamp(self, palace_path: str) -> None:
2368+
"""Re-baseline the freshness stat after this backend's own writes.
2369+
2370+
Opening a ``chromadb.PersistentClient`` writes to ``chroma.sqlite3``,
2371+
and so do the collection opens that follow it, so the mtime this cache
2372+
keys on moves *while we are using it*. Stamping only at
2373+
client-construction time (as :meth:`_client` does above) therefore left
2374+
the recorded value stale the moment the surrounding operation finished
2375+
its own writes, and the next ``_client()`` call read our own footprint
2376+
as an external change.
2377+
2378+
The effect was a cache that essentially never hit: a single search
2379+
opens ``mempalace_drawers`` and then ``mempalace_closets``, and the
2380+
first open bumped the mtime that the second one checked, so every
2381+
search rebuilt the client and reloaded both HNSW segments.
2382+
2383+
Stamping again once the operation is done makes the recorded value mean
2384+
"``chroma.sqlite3`` as this backend last left it", so a later
2385+
difference is genuinely somebody else's write.
2386+
2387+
Trade-off: an external write that lands *while* one of our operations
2388+
is in flight is absorbed into the new stamp and will not trigger a
2389+
rebuild until the next change. That window is one collection open wide.
2390+
It cannot be closed with mtime alone, and ``PRAGMA data_version`` does
2391+
not help -- it reports writes by any other *connection*, and chromadb's
2392+
own connection is foreign to a probe connection, so our own opens would
2393+
register as external there too.
2394+
2395+
No-ops when the path has no cached client, so an eviction that races
2396+
the operation (``close_palace``) is not resurrected as a stale stamp.
2397+
"""
2398+
if palace_path in self._freshness:
2399+
self._freshness[palace_path] = self._db_stat(palace_path)
2400+
23392401
# ------------------------------------------------------------------
23402402
# Public static helpers (legacy; prefer :meth:`get_collection`)
23412403
# ------------------------------------------------------------------
@@ -2476,7 +2538,11 @@ def get_collection(
24762538
raise ValueError(explanation) from e
24772539
raise
24782540
_pin_hnsw_threads(collection)
2479-
return ChromaCollection(collection, palace_path=palace_path)
2541+
# Our own client construction and collection open just wrote to
2542+
# chroma.sqlite3; re-baseline so the next _client() call does not read
2543+
# that as an external change and rebuild the client.
2544+
self._restamp(palace_path)
2545+
return ChromaCollection(collection, palace_path=palace_path, backend=self)
24802546

24812547
def close_palace(self, palace) -> None:
24822548
"""Drop cached handles for ``palace`` and release its SQLite file lock.
@@ -2538,6 +2604,7 @@ def get_or_create_collection(self, palace_path: str, collection_name: str) -> Ch
25382604
def delete_collection(self, palace_path: str, collection_name: str) -> None:
25392605
"""Delete ``collection_name`` from the palace at ``palace_path``."""
25402606
self._client(palace_path).delete_collection(collection_name)
2607+
self._restamp(palace_path)
25412608

25422609
def create_collection(
25432610
self, palace_path: str, collection_name: str, hnsw_space: str = "cosine"
@@ -2550,7 +2617,8 @@ def create_collection(
25502617
metadata=_hnsw_creation_metadata({"hnsw_space": hnsw_space}),
25512618
**ef_kwargs,
25522619
)
2553-
return ChromaCollection(collection, palace_path=palace_path)
2620+
self._restamp(palace_path)
2621+
return ChromaCollection(collection, palace_path=palace_path, backend=self)
25542622

25552623

25562624
def _normalize_get_collection_args(args, kwargs):

tests/test_backends.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
available_backends,
1919
get_backend,
2020
)
21+
from mempalace.backends import chroma as chroma_module
2122
from mempalace.backends.chroma import (
2223
ChromaBackend,
2324
ChromaCollection,
@@ -455,6 +456,174 @@ def test_chroma_cache_picks_up_db_created_after_first_open(tmp_path):
455456
assert backend._freshness[str(palace_path)] != (0, 0.0)
456457

457458

459+
def _count_persistent_clients(monkeypatch):
460+
"""Patch ``chromadb.PersistentClient`` with a counting passthrough.
461+
462+
Returns the list whose length is the number of constructions so far.
463+
"""
464+
built = []
465+
real = chromadb.PersistentClient
466+
467+
def counting(*args, **kwargs):
468+
built.append(1)
469+
return real(*args, **kwargs)
470+
471+
monkeypatch.setattr(chromadb, "PersistentClient", counting)
472+
return built
473+
474+
475+
def test_chroma_client_cache_survives_its_own_writes(tmp_path, monkeypatch):
476+
"""Opening several collections must not rebuild the client each time.
477+
478+
Regression for the unbounded-memory bug: ``PersistentClient(...)`` writes
479+
to ``chroma.sqlite3``, so an mtime stamp taken at construction time was
480+
already stale by the time the *next* open checked it. A search opens
481+
``mempalace_drawers`` then ``mempalace_closets``, so the first open
482+
invalidated the cache for the second, and every search rebuilt the client
483+
and reloaded both HNSW segments -- ~440 MB per collection on a large
484+
palace, never released, until the server sat on multiple GB.
485+
486+
The invariant that matters is construction *count*: one client for a
487+
palace that nothing else is writing to, no matter how many opens.
488+
"""
489+
palace_path = tmp_path / "palace"
490+
palace_path.mkdir()
491+
ref = PalaceRef(id=str(palace_path), local_path=str(palace_path))
492+
backend = ChromaBackend()
493+
built = _count_persistent_clients(monkeypatch)
494+
495+
# A tmp palace with no drawers opens in well under the 0.01s epsilon, so
496+
# its own writes land inside a single mtime tick and the bug stays hidden.
497+
# Model the real palace instead: push the mtime forward from inside
498+
# get_collection (_pin_hnsw_threads is the last step before the restamp)
499+
# so every open leaves the DB visibly newer than the stamp that preceded
500+
# it -- which is exactly what a 165k-drawer palace does on its own.
501+
real_pin = chroma_module._pin_hnsw_threads
502+
503+
def pin_and_touch(collection):
504+
result = real_pin(collection)
505+
db_file = palace_path / "chroma.sqlite3"
506+
if db_file.is_file():
507+
st = db_file.stat()
508+
os.utime(db_file, (st.st_atime, st.st_mtime + 1))
509+
return result
510+
511+
monkeypatch.setattr(chroma_module, "_pin_hnsw_threads", pin_and_touch)
512+
513+
try:
514+
for _ in range(3):
515+
for name in ("mempalace_drawers", "mempalace_closets"):
516+
backend.get_collection(palace=ref, collection_name=name, create=True)
517+
assert len(built) == 1, f"rebuilt the client {len(built)} times for one palace"
518+
finally:
519+
backend.close()
520+
521+
522+
def test_chroma_collection_write_does_not_rebuild_client(tmp_path, monkeypatch):
523+
"""Filing a drawer must not make the next open reload the index.
524+
525+
A write through ChromaCollection moves chroma.sqlite3's mtime, which is
526+
the same signal _client() reads to detect an external change. Without a
527+
re-baseline the file-then-search cycle -- the hot path for hook-driven
528+
filing -- rebuilt the client on every iteration and reloaded every HNSW
529+
segment it had already paid for.
530+
"""
531+
palace_path = tmp_path / "palace"
532+
palace_path.mkdir()
533+
ref = PalaceRef(id=str(palace_path), local_path=str(palace_path))
534+
backend = ChromaBackend()
535+
built = _count_persistent_clients(monkeypatch)
536+
537+
try:
538+
col = backend.get_collection(palace=ref, collection_name="mempalace_drawers", create=True)
539+
assert len(built) == 1
540+
541+
for i in range(3):
542+
col.upsert(documents=[f"doc {i}"], ids=[f"id{i}"], metadatas=[{"k": "v"}])
543+
backend.get_collection(palace=ref, collection_name="mempalace_drawers", create=True)
544+
assert len(built) == 1, f"a write forced {len(built) - 1} client rebuild(s)"
545+
finally:
546+
backend.close()
547+
548+
549+
def test_chroma_client_cache_still_rebuilds_on_external_write(tmp_path, monkeypatch):
550+
"""Re-stamping our own writes must not blind the cache to somebody else's.
551+
552+
The memory fix works by treating the freshness stat as "the DB as this
553+
backend last left it". That is only safe if a genuine external change --
554+
a peer sync, a concurrent ``mine``, a restore -- still forces the rebuild
555+
that #2002/#2028 rely on to avoid serving a stale HNSW segment.
556+
"""
557+
palace_path = tmp_path / "palace"
558+
palace_path.mkdir()
559+
ref = PalaceRef(id=str(palace_path), local_path=str(palace_path))
560+
backend = ChromaBackend()
561+
built = _count_persistent_clients(monkeypatch)
562+
563+
try:
564+
backend.get_collection(palace=ref, collection_name="mempalace_drawers", create=True)
565+
assert len(built) == 1
566+
567+
# Somebody else writes the palace. Move the mtime well past the 0.01s
568+
# epsilon so the change is unambiguous on coarse-grained filesystems.
569+
db_file = palace_path / "chroma.sqlite3"
570+
st = db_file.stat()
571+
os.utime(db_file, (st.st_atime, st.st_mtime + 60))
572+
573+
backend.get_collection(palace=ref, collection_name="mempalace_drawers", create=True)
574+
assert len(built) == 2, "external write did not invalidate the cached client"
575+
finally:
576+
backend.close()
577+
578+
579+
def test_chroma_restamp_ignores_uncached_palace(tmp_path):
580+
"""``_restamp`` must not resurrect a stamp for an evicted palace.
581+
582+
``close_palace`` can race an in-flight ``get_collection``; if the restamp
583+
that follows re-added a freshness entry with no client behind it, the next
584+
open would compare against a stamp it never earned.
585+
"""
586+
palace_path = tmp_path / "palace"
587+
palace_path.mkdir()
588+
(palace_path / "chroma.sqlite3").write_bytes(b"")
589+
590+
backend = ChromaBackend()
591+
backend._restamp(str(palace_path))
592+
assert str(palace_path) not in backend._freshness
593+
594+
595+
def test_chroma_client_rebuild_closes_displaced_client(tmp_path, monkeypatch):
596+
"""A rebuild must close the client it replaces, not just drop the reference.
597+
598+
The displaced client pins its own copy of every HNSW segment it opened;
599+
dict eviction alone leaves that native memory allocated for the life of
600+
the process.
601+
"""
602+
palace_path = tmp_path / "palace"
603+
palace_path.mkdir()
604+
db_file = palace_path / "chroma.sqlite3"
605+
db_file.write_bytes(b"")
606+
st = db_file.stat()
607+
608+
closed = []
609+
610+
class _Sentinel:
611+
def close(self):
612+
closed.append(1)
613+
614+
backend = ChromaBackend()
615+
backend._clients[str(palace_path)] = _Sentinel()
616+
backend._freshness[str(palace_path)] = (st.st_ino, st.st_mtime)
617+
# External write forces the rebuild branch.
618+
os.utime(db_file, (st.st_atime, st.st_mtime + 60))
619+
620+
try:
621+
backend._client(str(palace_path))
622+
assert closed == [1], "displaced client was dropped without being closed"
623+
finally:
624+
backend.close()
625+
626+
458627
def test_base_collection_update_default_rejects_mismatched_lengths():
459628
"""The ABC default update() raises ValueError rather than silently misaligning."""
460629
from mempalace.backends.base import BaseCollection

0 commit comments

Comments
 (0)