-
Notifications
You must be signed in to change notification settings - Fork 7.6k
fix: HNSW graph corruption, PreCompact deadlock, mine fan-out (closes #974, #965, #955) #976
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7e18a70
99b820c
1998aed
40d7958
8df944a
7773432
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -130,6 +130,35 @@ def quarantine_stale_hnsw(palace_path: str, stale_seconds: float = 3600.0) -> li | |
| return moved | ||
|
|
||
|
|
||
| def _pin_hnsw_threads(collection) -> None: | ||
| """Best-effort retrofit: pin ``hnsw:num_threads=1`` on an existing collection. | ||
|
|
||
| Fresh collections set this via ``metadata=`` at creation. Legacy palaces | ||
| built before that change keep the default (parallel insert) and can hit | ||
| the HNSW race described in #974/#965. ChromaDB's | ||
| ``collection.modify(configuration=...)`` lets us re-apply ``num_threads=1`` | ||
| in memory at load time so every new process is protected. | ||
|
|
||
| Note: in chromadb 1.5.x the modified ``configuration_json["hnsw"]`` does | ||
| not persist to disk across ``PersistentClient`` reopens, so this must | ||
| run on every ``get_collection`` call, not just once. | ||
| """ | ||
| try: | ||
| from chromadb.api.collection_configuration import ( | ||
| UpdateCollectionConfiguration, | ||
| UpdateHNSWConfiguration, | ||
| ) | ||
| except ImportError: | ||
| logger.debug("_pin_hnsw_threads skipped: chromadb too old", exc_info=True) | ||
| return | ||
| try: | ||
| collection.modify( | ||
| configuration=UpdateCollectionConfiguration(hnsw=UpdateHNSWConfiguration(num_threads=1)) | ||
| ) | ||
| except Exception: | ||
| logger.debug("_pin_hnsw_threads modify failed", exc_info=True) | ||
|
|
||
|
|
||
| def _fix_blob_seq_ids(palace_path: str) -> None: | ||
| """Fix ChromaDB 0.6.x -> 1.5.x migration bug: BLOB seq_ids -> INTEGER. | ||
|
|
||
|
|
@@ -566,10 +595,13 @@ def get_collection( | |
|
|
||
| if create: | ||
| collection = client.get_or_create_collection( | ||
| collection_name, metadata={"hnsw:space": hnsw_space}, **ef_kwargs | ||
| collection_name, | ||
| metadata={"hnsw:space": hnsw_space, "hnsw:num_threads": 1}, | ||
| **ef_kwargs, | ||
| ) | ||
| else: | ||
| collection = client.get_collection(collection_name, **ef_kwargs) | ||
| _pin_hnsw_threads(collection) | ||
| return ChromaCollection(collection) | ||
|
|
||
| def close_palace(self, palace) -> None: | ||
|
|
@@ -613,7 +645,9 @@ def create_collection( | |
| ef = self._resolve_embedding_function() | ||
| ef_kwargs = {"embedding_function": ef} if ef is not None else {} | ||
| collection = self._client(palace_path).create_collection( | ||
| collection_name, metadata={"hnsw:space": hnsw_space}, **ef_kwargs | ||
| collection_name, | ||
| metadata={"hnsw:space": hnsw_space, "hnsw:num_threads": 1}, | ||
| **ef_kwargs, | ||
| ) | ||
|
Comment on lines
647
to
651
|
||
| return ChromaCollection(collection) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -57,7 +57,7 @@ | |
| sanitize_content, | ||
| ) | ||
| from .version import __version__ # noqa: E402 | ||
| from .backends.chroma import ChromaBackend, ChromaCollection # noqa: E402 | ||
| from .backends.chroma import ChromaBackend, ChromaCollection, _pin_hnsw_threads # noqa: E402 | ||
| from .query_sanitizer import sanitize_query # noqa: E402 | ||
| from .searcher import search_memories # noqa: E402 | ||
| from .palace_graph import ( # noqa: E402 | ||
|
|
@@ -217,15 +217,25 @@ def _get_collection(create=False): | |
| try: | ||
| client = _get_client() | ||
| if create: | ||
| _collection_cache = ChromaCollection( | ||
| client.get_or_create_collection( | ||
| _config.collection_name, metadata={"hnsw:space": "cosine"} | ||
| ) | ||
| # hnsw:num_threads=1 disables ChromaDB's multi-threaded ParallelFor | ||
| # HNSW insert path, which has a race in repairConnectionsForUpdate / | ||
| # addPoint (see issues #974, #965). Set via metadata on fresh | ||
| # collections and re-applied via _pin_hnsw_threads() for legacy | ||
| # palaces whose collections were created before this fix (the | ||
| # runtime config does not persist cross-process in chromadb 1.5.x, | ||
| # so the retrofit runs every time _get_collection opens a cache). | ||
| raw = client.get_or_create_collection( | ||
| _config.collection_name, | ||
| metadata={"hnsw:space": "cosine", "hnsw:num_threads": 1}, | ||
| ) | ||
|
Comment on lines
+220
to
230
|
||
| _pin_hnsw_threads(raw) | ||
| _collection_cache = ChromaCollection(raw) | ||
| _metadata_cache = None | ||
| _metadata_cache_time = 0 | ||
| elif _collection_cache is None: | ||
| _collection_cache = ChromaCollection(client.get_collection(_config.collection_name)) | ||
| raw = client.get_collection(_config.collection_name) | ||
| _pin_hnsw_threads(raw) | ||
| _collection_cache = ChromaCollection(raw) | ||
| _metadata_cache = None | ||
| _metadata_cache_time = 0 | ||
| return _collection_cache | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -310,6 +310,88 @@ def mine_lock(source_file: str): | |||||||||||||||||||||||||||||||||||||||||
| lf.close() | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| class MineAlreadyRunning(RuntimeError): | ||||||||||||||||||||||||||||||||||||||||||
| """Raised when another `mempalace mine` already holds the per-palace lock.""" | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| @contextlib.contextmanager | ||||||||||||||||||||||||||||||||||||||||||
| def mine_palace_lock(palace_path: str): | ||||||||||||||||||||||||||||||||||||||||||
| """Per-palace non-blocking lock around the full `mine` pipeline. | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| The per-file `mine_lock` only protects delete+insert interleave for a | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+317
to
+321
|
||||||||||||||||||||||||||||||||||||||||||
| single source; it does not prevent N copies of `mempalace mine <dir>` | ||||||||||||||||||||||||||||||||||||||||||
| from being spawned concurrently by hooks. When that happens, each copy | ||||||||||||||||||||||||||||||||||||||||||
| drives ChromaDB HNSW inserts in parallel against the same palace, | ||||||||||||||||||||||||||||||||||||||||||
| which (combined with chromadb's multi-threaded ParallelFor) can | ||||||||||||||||||||||||||||||||||||||||||
| corrupt the HNSW graph and produce sparse link_lists.bin blowups. | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| The lock file is keyed by sha256(palace_path) so mines against | ||||||||||||||||||||||||||||||||||||||||||
| *different* palaces can still run in parallel — we only serialize | ||||||||||||||||||||||||||||||||||||||||||
| writes into the same palace, which is the correctness boundary. | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| The key is derived from a fully normalized form of the path: | ||||||||||||||||||||||||||||||||||||||||||
| `realpath` resolves symlinks and `..` segments, and `normcase` folds | ||||||||||||||||||||||||||||||||||||||||||
| case on Windows (which has a case-insensitive filesystem). Without | ||||||||||||||||||||||||||||||||||||||||||
| normcase, `C:\\Palace` and `c:\\palace` would hash to different keys | ||||||||||||||||||||||||||||||||||||||||||
| on Windows and let two concurrent mines touch the same on-disk palace. | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+328
to
+336
|
||||||||||||||||||||||||||||||||||||||||||
| The lock file is keyed by sha256(palace_path) so mines against | |
| *different* palaces can still run in parallel — we only serialize | |
| writes into the same palace, which is the correctness boundary. | |
| The key is derived from a fully normalized form of the path: | |
| `realpath` resolves symlinks and `..` segments, and `normcase` folds | |
| case on Windows (which has a case-insensitive filesystem). Without | |
| normcase, `C:\\Palace` and `c:\\palace` would hash to different keys | |
| on Windows and let two concurrent mines touch the same on-disk palace. | |
| The lock file is keyed by the first 16 hex chars of | |
| `sha256(normcase(realpath(expanduser(palace_path))))` so mines against | |
| *different* palaces can still run in parallel — we only serialize | |
| writes into the same palace, which is the correctness boundary. | |
| The key is derived from a fully normalized form of the path: | |
| `expanduser` resolves `~`, `realpath` resolves symlinks and `..` | |
| segments, and `normcase` folds case on Windows (which has a | |
| case-insensitive filesystem). Without normcase, `C:\\Palace` and | |
| `c:\\palace` would hash to different keys on Windows and let two | |
| concurrent mines touch the same on-disk palace. |
Copilot
AI
Apr 17, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mine_global_lock() uses a single global lock file (~/.mempalace/locks/mine_global.lock), which serializes all mempalace mine runs across all palaces/projects (even if they use different --palace paths). If the intent is only to prevent concurrent writes to the same palace, consider keying the lock filename by palace_path (e.g., hash of resolved palace path) or otherwise scoping the lock so independent palaces can be mined in parallel.
Copilot
AI
Apr 17, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PR description calls this a process-wide mine_global_lock(); the code implements a per-palace lock (mine_palace_lock(palace_path)) and keeps mine_global_lock only as an alias. Please align the PR description/public API docs with the shipped behavior (per-palace serialization), and consider clarifying the alias comment/name so callers don’t assume it serializes all mines regardless of palace path.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Passing
metadata={... "hnsw:num_threads": 1}toget_or_create_collection()typically does not update metadata for an already-existing collection, so palaces created before this change may keep the unsafe default thread setting. To make the fix effective for existing users, consider explicitly updating the collection after retrieval (e.g.,collection.modify(metadata=...)when supported, or at least detect missing/incorrect metadata and attempt to set it) for both the create and non-create code paths.