Skip to content

Commit 8df944a

Browse files
felipetrumanigorls
authored andcommitted
fix: best-effort HNSW thread-pin retrofit + drop dead attempt-cap constant
Addresses remaining PR MemPalace#976 review items after rebase on develop. `get_collection(create=False)` previously returned existing collections without re-applying `hnsw:num_threads=1`, so palaces created before the fix kept the unsafe parallel-insert path. Add `_pin_hnsw_threads()` helper that calls `collection.modify(configuration=UpdateCollectionConfiguration( hnsw=UpdateHNSWConfiguration(num_threads=1)))` best-effort on every `get_collection` call (including the MCP server's `_get_collection`). In chromadb 1.5.x the runtime config does not persist to disk across `PersistentClient` reopens, so the retrofit is re-applied each process start rather than being a one-shot migration. Fresh palaces keep the metadata-based pin as primary defense; legacy palaces now also get per-session protection without requiring `mempalace nuke` + re-mine. After the rebase on develop, `hook_precompact` delegates to `_mine_sync` and no longer emits `decision: block`, so the attempt-cap constant was orphaned. Grep confirms 0 usages in the repo — remove it. - `_pin_hnsw_threads` retrofits legacy collection (num_threads None -> 1) - `_pin_hnsw_threads` swallows all errors (never raises) - `ChromaBackend.get_collection(create=False)` applies retrofit on legacy palace - 62 tests pass (10 backends + 6 palace locks + 46 hooks_cli)
1 parent 40d7958 commit 8df944a

4 files changed

Lines changed: 100 additions & 14 deletions

File tree

mempalace/backends/chroma.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,37 @@ def quarantine_stale_hnsw(palace_path: str, stale_seconds: float = 3600.0) -> li
130130
return moved
131131

132132

133+
def _pin_hnsw_threads(collection) -> None:
134+
"""Best-effort retrofit: pin ``hnsw:num_threads=1`` on an existing collection.
135+
136+
Fresh collections set this via ``metadata=`` at creation. Legacy palaces
137+
built before that change keep the default (parallel insert) and can hit
138+
the HNSW race described in #974/#965. ChromaDB's
139+
``collection.modify(configuration=...)`` lets us re-apply ``num_threads=1``
140+
in memory at load time so every new process is protected.
141+
142+
Note: in chromadb 1.5.x the modified ``configuration_json["hnsw"]`` does
143+
not persist to disk across ``PersistentClient`` reopens, so this must
144+
run on every ``get_collection`` call, not just once.
145+
"""
146+
try:
147+
from chromadb.api.collection_configuration import (
148+
UpdateCollectionConfiguration,
149+
UpdateHNSWConfiguration,
150+
)
151+
except ImportError:
152+
logger.debug("_pin_hnsw_threads skipped: chromadb too old", exc_info=True)
153+
return
154+
try:
155+
collection.modify(
156+
configuration=UpdateCollectionConfiguration(
157+
hnsw=UpdateHNSWConfiguration(num_threads=1)
158+
)
159+
)
160+
except Exception:
161+
logger.debug("_pin_hnsw_threads modify failed", exc_info=True)
162+
163+
133164
def _fix_blob_seq_ids(palace_path: str) -> None:
134165
"""Fix ChromaDB 0.6.x -> 1.5.x migration bug: BLOB seq_ids -> INTEGER.
135166
@@ -572,6 +603,7 @@ def get_collection(
572603
)
573604
else:
574605
collection = client.get_collection(collection_name, **ef_kwargs)
606+
_pin_hnsw_threads(collection)
575607
return ChromaCollection(collection)
576608

577609
def close_palace(self, palace) -> None:

mempalace/hooks_cli.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -643,9 +643,6 @@ def hook_session_start(data: dict, harness: str):
643643
_output({})
644644

645645

646-
MAX_PRECOMPACT_BLOCK_ATTEMPTS = 2
647-
648-
649646
def hook_precompact(data: dict, harness: str):
650647
"""Precompact hook: mine transcript synchronously, then allow compaction."""
651648
parsed = _parse_harness_input(data, harness)

mempalace/mcp_server.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
sanitize_content,
5858
)
5959
from .version import __version__ # noqa: E402
60-
from .backends.chroma import ChromaBackend, ChromaCollection # noqa: E402
60+
from .backends.chroma import ChromaBackend, ChromaCollection, _pin_hnsw_threads # noqa: E402
6161
from .query_sanitizer import sanitize_query # noqa: E402
6262
from .searcher import search_memories # noqa: E402
6363
from .palace_graph import ( # noqa: E402
@@ -219,20 +219,23 @@ def _get_collection(create=False):
219219
if create:
220220
# hnsw:num_threads=1 disables ChromaDB's multi-threaded ParallelFor
221221
# HNSW insert path, which has a race in repairConnectionsForUpdate /
222-
# addPoint (see issues #974, #965). The setting is only honored at
223-
# collection creation time — pre-existing palaces created before
224-
# this fix keep the unsafe default; users must `mempalace nuke` +
225-
# re-mine to get the protection on legacy palaces.
226-
_collection_cache = ChromaCollection(
227-
client.get_or_create_collection(
228-
_config.collection_name,
229-
metadata={"hnsw:space": "cosine", "hnsw:num_threads": 1},
230-
)
222+
# addPoint (see issues #974, #965). Set via metadata on fresh
223+
# collections and re-applied via _pin_hnsw_threads() for legacy
224+
# palaces whose collections were created before this fix (the
225+
# runtime config does not persist cross-process in chromadb 1.5.x,
226+
# so the retrofit runs every time _get_collection opens a cache).
227+
raw = client.get_or_create_collection(
228+
_config.collection_name,
229+
metadata={"hnsw:space": "cosine", "hnsw:num_threads": 1},
231230
)
231+
_pin_hnsw_threads(raw)
232+
_collection_cache = ChromaCollection(raw)
232233
_metadata_cache = None
233234
_metadata_cache_time = 0
234235
elif _collection_cache is None:
235-
_collection_cache = ChromaCollection(client.get_collection(_config.collection_name))
236+
raw = client.get_collection(_config.collection_name)
237+
_pin_hnsw_threads(raw)
238+
_collection_cache = ChromaCollection(raw)
236239
_metadata_cache = None
237240
_metadata_cache_time = 0
238241
return _collection_cache

tests/test_backends.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
ChromaBackend,
1717
ChromaCollection,
1818
_fix_blob_seq_ids,
19+
_pin_hnsw_threads,
1920
quarantine_stale_hnsw,
2021
)
2122

@@ -443,3 +444,56 @@ def test_quarantine_stale_hnsw_skips_already_quarantined(tmp_path):
443444
moved = quarantine_stale_hnsw(str(palace), stale_seconds=3600.0)
444445
assert moved == []
445446
assert drift.exists()
447+
448+
449+
# ── _pin_hnsw_threads ─────────────────────────────────────────────────────
450+
451+
452+
def test_pin_hnsw_threads_retrofits_legacy_collection(tmp_path):
453+
"""Legacy collections (created without num_threads) get the retrofit applied."""
454+
palace_path = tmp_path / "legacy-palace"
455+
palace_path.mkdir()
456+
457+
client = chromadb.PersistentClient(path=str(palace_path))
458+
col = client.create_collection(
459+
"mempalace_drawers",
460+
metadata={"hnsw:space": "cosine"}, # no num_threads — legacy
461+
)
462+
assert col.configuration_json.get("hnsw", {}).get("num_threads") is None
463+
464+
_pin_hnsw_threads(col)
465+
466+
assert col.configuration_json["hnsw"]["num_threads"] == 1
467+
468+
469+
def test_pin_hnsw_threads_swallows_all_errors():
470+
"""Retrofit never raises even when collection.modify explodes."""
471+
472+
class _ExplodingCollection:
473+
def modify(self, *args, **kwargs):
474+
raise RuntimeError("boom")
475+
476+
_pin_hnsw_threads(_ExplodingCollection()) # must not raise
477+
478+
479+
def test_get_collection_applies_retrofit_on_existing_palace(tmp_path):
480+
"""ChromaBackend.get_collection(create=False) applies the retrofit."""
481+
palace_path = tmp_path / "palace"
482+
palace_path.mkdir()
483+
484+
# Simulate a legacy palace: create collection without num_threads
485+
bootstrap_client = chromadb.PersistentClient(path=str(palace_path))
486+
bootstrap_client.create_collection(
487+
"mempalace_drawers", metadata={"hnsw:space": "cosine"}
488+
)
489+
del bootstrap_client # drop reference so a fresh client reopens cleanly
490+
491+
wrapper = ChromaBackend().get_collection(
492+
str(palace_path),
493+
collection_name="mempalace_drawers",
494+
create=False,
495+
)
496+
497+
assert (
498+
wrapper._collection.configuration_json["hnsw"]["num_threads"] == 1
499+
)

0 commit comments

Comments
 (0)