Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions mempalace/backends/chroma.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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,
)
Comment on lines 597 to 601

Copilot AI Apr 17, 2026

Copy link

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} to get_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.

Copilot uses AI. Check for mistakes.
else:
collection = client.get_collection(collection_name, **ef_kwargs)
_pin_hnsw_threads(collection)
return ChromaCollection(collection)

def close_palace(self, palace) -> None:
Expand Down Expand Up @@ -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

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This metadata pin only applies when callers go through ChromaBackend. The MCP server still creates collections directly via chromadb.PersistentClient.get_or_create_collection(..., metadata={"hnsw:space": "cosine"}) (see mempalace/mcp_server.py, around _get_collection), so that code path would continue to omit hnsw:num_threads=1 and may still hit the HNSW parallel-insert crash. Consider routing MCP server collection creation through ChromaBackend or applying the same metadata update there so the fix covers the primary crash surface described in #974/#965.

Copilot uses AI. Check for mistakes.
return ChromaCollection(collection)

Expand Down
22 changes: 16 additions & 6 deletions mempalace/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment notes that pre-existing palaces must be nuked/re-mined to pick up hnsw:num_threads=1, which means upgrading does not actually protect existing users from #974/#965 unless they manually rebuild. If ChromaDB supports updating metadata on an existing collection (e.g., collection.modify(metadata=...)), consider applying it after get_or_create_collection so older palaces are automatically migrated to single-threaded inserts (best-effort, with graceful fallback if unsupported).

Copilot uses AI. Check for mistakes.
_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
Expand Down
48 changes: 48 additions & 0 deletions mempalace/miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@
from .palace import (
NORMALIZE_VERSION,
SKIP_DIRS,
MineAlreadyRunning,
build_closet_lines,
file_already_mined,
get_closets_collection,
get_collection,
mine_lock,
mine_palace_lock,
purge_file_closets,
upsert_closet_lines,
)
Expand Down Expand Up @@ -993,6 +995,52 @@ def mine(
``mine`` walks the tree itself just like before.
"""

if dry_run:
return _mine_impl(
project_dir,
palace_path,
wing_override=wing_override,
agent=agent,
limit=limit,
dry_run=dry_run,
respect_gitignore=respect_gitignore,
include_ignored=include_ignored,
files=files,
)

try:
with mine_palace_lock(palace_path):
return _mine_impl(
project_dir,
palace_path,
wing_override=wing_override,
agent=agent,
limit=limit,
dry_run=dry_run,
respect_gitignore=respect_gitignore,
include_ignored=include_ignored,
files=files,
)
except MineAlreadyRunning:
print(
f"mempalace: another `mine` is already running against "
f"{palace_path} — exiting cleanly.",
file=sys.stderr,
)
return


def _mine_impl(
project_dir: str,
palace_path: str,
wing_override: str = None,
agent: str = "mempalace",
limit: int = 0,
dry_run: bool = False,
respect_gitignore: bool = True,
include_ignored: list = None,
files: list = None,
):
project_path = Path(project_dir).expanduser().resolve()
config = load_config(project_dir)

Expand Down
82 changes: 82 additions & 0 deletions mempalace/palace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mine_global_lock() introduces an important cross-process synchronization mechanism, but there doesn't appear to be automated coverage for its non-blocking behavior (second acquire raises MineAlreadyRunning) and reusability after release. Since the repo already tests mine_lock inter-process behavior, adding similar tests for mine_global_lock would help prevent future regressions.

Copilot uses AI. Check for mistakes.
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

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring says the lock file is keyed by sha256(palace_path), but the implementation hashes a normalized, fully-resolved path (normcase(realpath(expanduser(palace_path)))). Please update the docstring to match the actual key derivation to avoid misleading future callers/debugging.

Suggested change
The lock file is keyed by sha256(palace_path) so mines against
*different* palaces can still run in parallelwe 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 parallelwe 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 uses AI. Check for mistakes.

Non-blocking: if another `mine` is already writing to this palace,
raise MineAlreadyRunning so the caller can exit cleanly instead of
piling up as a waiting worker.
"""
lock_dir = os.path.join(os.path.expanduser("~"), ".mempalace", "locks")
os.makedirs(lock_dir, exist_ok=True)
resolved = os.path.realpath(os.path.expanduser(palace_path))
lock_key_source = os.path.normcase(resolved)
palace_key = hashlib.sha256(lock_key_source.encode()).hexdigest()[:16]
lock_path = os.path.join(lock_dir, f"mine_palace_{palace_key}.lock")

Comment on lines +342 to +348

Copilot AI Apr 17, 2026

Copy link

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 uses AI. Check for mistakes.
lf = open(lock_path, "w")
acquired = False
try:
if os.name == "nt":
import msvcrt

try:
msvcrt.locking(lf.fileno(), msvcrt.LK_NBLCK, 1)
acquired = True
except OSError as exc:
raise MineAlreadyRunning(
f"another `mempalace mine` is already running against {resolved}"
) from exc
else:
import fcntl

try:
fcntl.flock(lf, fcntl.LOCK_EX | fcntl.LOCK_NB)
acquired = True
except BlockingIOError as exc:
raise MineAlreadyRunning(
f"another `mempalace mine` is already running against {resolved}"
) from exc
yield
finally:
if acquired:
try:
if os.name == "nt":
import msvcrt

msvcrt.locking(lf.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl

fcntl.flock(lf, fcntl.LOCK_UN)
except Exception:
pass
lf.close()


# Backward-compatible alias (previous patch iteration used a single global
# lock). Kept so third-party callers that imported it continue to work; new
# code should use `mine_palace_lock(palace_path)` for per-palace scoping.
mine_global_lock = mine_palace_lock
Comment on lines +389 to +392

Copilot AI Apr 17, 2026

Copy link

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.

Copilot uses AI. Check for mistakes.


def file_already_mined(collection, source_file: str, check_mtime: bool = False) -> bool:
"""Check if a file has already been filed in the palace.

Expand Down
50 changes: 50 additions & 0 deletions tests/test_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
ChromaBackend,
ChromaCollection,
_fix_blob_seq_ids,
_pin_hnsw_threads,
quarantine_stale_hnsw,
)

Expand Down Expand Up @@ -443,3 +444,52 @@ def test_quarantine_stale_hnsw_skips_already_quarantined(tmp_path):
moved = quarantine_stale_hnsw(str(palace), stale_seconds=3600.0)
assert moved == []
assert drift.exists()


# ── _pin_hnsw_threads ─────────────────────────────────────────────────────


def test_pin_hnsw_threads_retrofits_legacy_collection(tmp_path):
"""Legacy collections (created without num_threads) get the retrofit applied."""
palace_path = tmp_path / "legacy-palace"
palace_path.mkdir()

client = chromadb.PersistentClient(path=str(palace_path))
col = client.create_collection(
"mempalace_drawers",
metadata={"hnsw:space": "cosine"}, # no num_threads — legacy
)
assert col.configuration_json.get("hnsw", {}).get("num_threads") is None

_pin_hnsw_threads(col)

assert col.configuration_json["hnsw"]["num_threads"] == 1


def test_pin_hnsw_threads_swallows_all_errors():
"""Retrofit never raises even when collection.modify explodes."""

class _ExplodingCollection:
def modify(self, *args, **kwargs):
raise RuntimeError("boom")

_pin_hnsw_threads(_ExplodingCollection()) # must not raise


def test_get_collection_applies_retrofit_on_existing_palace(tmp_path):
"""ChromaBackend.get_collection(create=False) applies the retrofit."""
palace_path = tmp_path / "palace"
palace_path.mkdir()

# Simulate a legacy palace: create collection without num_threads
bootstrap_client = chromadb.PersistentClient(path=str(palace_path))
bootstrap_client.create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"})
del bootstrap_client # drop reference so a fresh client reopens cleanly

wrapper = ChromaBackend().get_collection(
str(palace_path),
collection_name="mempalace_drawers",
create=False,
)

assert wrapper._collection.configuration_json["hnsw"]["num_threads"] == 1
Loading