Skip to content

Commit 4aa93e8

Browse files
authored
Merge pull request #1173 from jphein/fix/quarantine-on-make-client
fix: call quarantine_stale_hnsw() in make_client(); lower threshold to 5min
2 parents 9d18a1c + 43aa1aa commit 4aa93e8

3 files changed

Lines changed: 283 additions & 36 deletions

File tree

mempalace/backends/chroma.py

Lines changed: 137 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -49,41 +49,105 @@ def _validate_where(where: Optional[dict]) -> None:
4949
stack.extend(x for x in v if isinstance(x, dict))
5050

5151

52-
def quarantine_stale_hnsw(palace_path: str, stale_seconds: float = 3600.0) -> list[str]:
53-
"""Rename HNSW segment dirs whose files are stale vs. chroma.sqlite3.
52+
def _segment_appears_healthy(seg_dir: str) -> bool:
53+
"""Return True if a chromadb HNSW segment dir looks intact.
54+
55+
Sniff-tests the chromadb-written segment metadata file
56+
(``index_metadata.pickle``) for its expected format bytes without
57+
parsing it. ChromaDB writes that file after a successful HNSW flush;
58+
a complete write starts with byte ``0x80`` and ends with byte
59+
``0x2e`` (the protocol/terminator byte sequence chromadb serializes
60+
with). If both bytes are present and the file is non-trivially sized,
61+
chromadb will load the segment cleanly even when its on-disk mtime
62+
trails ``chroma.sqlite3`` — which is the *steady state* under
63+
chromadb 1.5.x's async batched flush, not corruption.
64+
65+
A missing metadata file is treated as "fresh / never-flushed" and
66+
considered healthy. Renaming an empty dir orphans nothing, and a
67+
real corruption case manifests as a present-but-malformed file or a
68+
chromadb load error caught downstream by palace-daemon's
69+
``_auto_repair`` retry path.
70+
71+
Deliberately format-sniffs only; never deserializes. Deserialization
72+
can execute arbitrary code, and the byte-sniff is sufficient to
73+
distinguish a complete write from truncation, zero-fill, or
74+
partial-flush corruption.
75+
76+
Assumes pickle protocol >= 2 (``0x80`` PROTO marker). Matches what
77+
chromadb writes today; if a future chromadb version emits protocol
78+
0/1 segments, this check would start returning False on healthy
79+
files and quarantine_stale_hnsw would conservatively rename them
80+
out of the way (lazy rebuild on next open recovers).
81+
"""
82+
meta_path = os.path.join(seg_dir, "index_metadata.pickle")
83+
if not os.path.isfile(meta_path):
84+
# No metadata file yet — segment hasn't flushed (fresh / empty).
85+
# Renaming would orphan nothing; consider healthy.
86+
return True
87+
try:
88+
size = os.path.getsize(meta_path)
89+
# A real chromadb metadata file is at least tens of bytes; a
90+
# smaller-than-floor file is almost certainly truncated.
91+
if size < 16:
92+
return False
93+
with open(meta_path, "rb") as f:
94+
head = f.read(2)
95+
f.seek(-1, 2) # last byte
96+
tail = f.read(1)
97+
except OSError:
98+
return False
99+
return len(head) == 2 and head[0] == 0x80 and tail == b"\x2e"
54100

55-
When a ChromaDB 1.5.x PersistentClient opens a palace whose on-disk
56-
HNSW segment is significantly older than ``chroma.sqlite3``, the Rust
57-
graph-walk can dereference dangling neighbor pointers for entries that
58-
exist in the metadata segment but not in the HNSW index, and segfault
59-
in a background thread on the next ``count()`` or ``query(...)`` call.
60101

61-
This is the same failure mode reported at #823 (semantic search stale
102+
def quarantine_stale_hnsw(palace_path: str, stale_seconds: float = 300.0) -> list[str]:
103+
"""Rename HNSW segment dirs that are both stale-by-mtime AND fail an
104+
integrity sniff-test.
105+
106+
Catches the segfault failure mode from #823 (semantic search stale
62107
after ``add_drawer``), observed at neo-cortex-mcp#2 (SIGSEGV on
63108
``count()`` with chromadb 1.5.5), and acknowledged as by-design at
64-
chroma-core/chroma#2594. On one fork palace (135K drawers), the drift
65-
caused a 65–85% crash rate on fresh-process opens; fresh-process
66-
crash rate dropped to 0% after the segment dir was renamed out of the
67-
way and ChromaDB rebuilt lazily.
68-
69-
Heuristic: if ``chroma.sqlite3`` is more than ``stale_seconds`` newer
70-
than the segment's ``data_level0.bin``, the segment is considered
71-
suspect and renamed to ``<uuid>.drift-<timestamp>``. ChromaDB reopens
72-
cleanly without it and writes fresh index files on next use. The
73-
original directory is renamed, not deleted, so recovery remains
74-
possible if the heuristic misfires.
75-
76-
The default threshold (1h) is deliberately conservative — ChromaDB's
77-
HNSW flush cadence means legitimate drift is normally on the order of
78-
seconds to minutes. A segment that is more than an hour out of date is
79-
almost certainly in a "crashed mid-write" state.
109+
chroma-core/chroma#2594. Renaming a corrupt segment lets chromadb
110+
rebuild lazily on next open instead of segfaulting.
111+
112+
Two-stage check:
113+
114+
1. **mtime gate.** If ``chroma.sqlite3`` is less than
115+
``stale_seconds`` newer than the segment's ``data_level0.bin``,
116+
skip — chromadb is in normal write-path territory.
117+
118+
2. **Integrity gate** (``_segment_appears_healthy``). Even when the
119+
mtime gap exceeds the threshold, a segment whose
120+
``index_metadata.pickle`` passes a format sniff-test is healthy:
121+
chromadb 1.5.x flushes HNSW state asynchronously and a clean
122+
shutdown does NOT force-flush, so the on-disk HNSW is *always*
123+
somewhat older than ``chroma.sqlite3``. Production observation
124+
(2026-04-26 disks daemon): three of three segments quarantined
125+
on every cold start, with 538-557s gaps, leaving the 151K-drawer
126+
palace with vector_ranked=0 until rebuild. Renaming a healthy
127+
segment based on mtime alone destroys a valid index — chromadb
128+
creates an empty replacement, orphaning every drawer in sqlite
129+
from vector recall until the operator runs ``mempalace repair
130+
--mode rebuild`` (15+ min on a 151K palace).
131+
132+
Only segments that pass stage 1 (suspiciously stale) AND fail stage
133+
2 (metadata file truncated, zero-filled, or absent-with-data) are
134+
renamed to ``<uuid>.drift-<timestamp>``. The original directory is
135+
renamed, not deleted, so recovery remains possible if the heuristic
136+
misfires.
137+
138+
The default threshold (5 min) is advisory under daemon-strict; the
139+
integrity gate is what actually distinguishes corruption from flush
140+
lag. The threshold still matters for the cross-machine replication
141+
case (#823), where it bounds how stale a Syncthing-replicated
142+
segment can be before we look harder at it.
80143
81144
Args:
82145
palace_path: path to the palace directory containing ``chroma.sqlite3``
83-
stale_seconds: minimum mtime gap to treat a segment as stale
146+
stale_seconds: minimum mtime gap to *consider* a segment for quarantine
84147
85148
Returns:
86-
List of paths that were quarantined (empty if nothing drifted).
149+
List of paths that were quarantined (empty if nothing actually
150+
looked corrupt).
87151
"""
88152
db_path = os.path.join(palace_path, "chroma.sqlite3")
89153
if not os.path.isfile(db_path):
@@ -114,19 +178,35 @@ def quarantine_stale_hnsw(palace_path: str, stale_seconds: float = 3600.0) -> li
114178
continue
115179
if sqlite_mtime - hnsw_mtime < stale_seconds:
116180
continue
181+
182+
# Stage 2: integrity gate. mtime drift is necessary but not
183+
# sufficient — chromadb's async flush makes drift the steady-
184+
# state condition. A healthy segment metadata file proves
185+
# chromadb can open the segment without segfault; don't
186+
# quarantine a healthy index.
187+
if _segment_appears_healthy(seg_dir):
188+
logger.info(
189+
"HNSW mtime gap %.0fs on %s exceeds threshold but segment "
190+
"metadata file is intact — flush-lag, not corruption. "
191+
"Leaving in place.",
192+
sqlite_mtime - hnsw_mtime,
193+
seg_dir,
194+
)
195+
continue
196+
117197
stamp = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
118198
target = f"{seg_dir}.drift-{stamp}"
119199
try:
120200
os.rename(seg_dir, target)
121201
moved.append(target)
122202
logger.warning(
123-
"Quarantined stale HNSW segment %s (sqlite %.0fs newer than HNSW); renamed to %s",
203+
"Quarantined corrupt HNSW segment %s (sqlite %.0fs newer than HNSW, integrity check failed); renamed to %s",
124204
seg_dir,
125205
sqlite_mtime - hnsw_mtime,
126206
target,
127207
)
128208
except OSError:
129-
logger.exception("Failed to quarantine stale HNSW segment %s", seg_dir)
209+
logger.exception("Failed to quarantine corrupt HNSW segment %s", seg_dir)
130210
return moved
131211

132212

@@ -535,15 +615,44 @@ def _client(self, palace_path: str):
535615
# Public static helpers (legacy; prefer :meth:`get_collection`)
536616
# ------------------------------------------------------------------
537617

618+
# Per-process record of palaces that have already had quarantine_stale_hnsw
619+
# invoked at least once. The proactive drift check is a *cold-start*
620+
# protection — it catches HNSW segments that arrived stale relative to
621+
# ``chroma.sqlite3`` (e.g. cross-machine replication, partial restore,
622+
# crashed-mid-write). Once a long-running process has opened the palace
623+
# cleanly, re-firing on every reconnect is a *runtime thrash*: the
624+
# daemon's own writes bump sqlite mtime but HNSW flushes batch on
625+
# chromadb's internal cadence, so the mtime gap naturally exceeds the
626+
# threshold under steady write load even though nothing is corrupt.
627+
# Real runtime drift is still handled — palace-daemon's ``_auto_repair``
628+
# calls :func:`quarantine_stale_hnsw` directly on observed HNSW errors,
629+
# which bypasses this gate.
630+
#
631+
# Thread-safety: this set is mutated without a lock. Two concurrent
632+
# ``make_client()`` calls for the same palace can both pass the
633+
# membership check and both invoke ``quarantine_stale_hnsw``. That's
634+
# safe because the function is idempotent (mtime check + timestamped
635+
# rename of distinct directories), so the worst-case race produces
636+
# one redundant rename attempt that no-ops. Idempotency is the
637+
# safety property; locking would add cost without correctness gain.
638+
_quarantined_paths: set[str] = set()
639+
538640
@staticmethod
539641
def make_client(palace_path: str):
540642
"""Create a fresh ``PersistentClient`` (fixes BLOB seq_ids first).
541643
542644
Deprecated-ish: exposed for legacy long-lived callers that manage their
543645
own client cache. New code should obtain a collection through
544646
:meth:`get_collection` which manages caching internally.
647+
648+
Quarantines stale HNSW segments **once per palace per process**. See
649+
:attr:`_quarantined_paths` for the rationale (cold-start protection
650+
vs. runtime thrash on steady-write daemons).
545651
"""
546652
_fix_blob_seq_ids(palace_path)
653+
if palace_path not in ChromaBackend._quarantined_paths:
654+
quarantine_stale_hnsw(palace_path)
655+
ChromaBackend._quarantined_paths.add(palace_path)
547656
return chromadb.PersistentClient(path=palace_path)
548657

549658
@staticmethod

tests/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ def _clear_cache():
4646
mcp_server._collection_cache = None
4747
except (ImportError, AttributeError):
4848
pass
49+
try:
50+
# Reset the per-process quarantine gate so tests don't leak
51+
# state through ChromaBackend._quarantined_paths.
52+
from mempalace.backends.chroma import ChromaBackend
53+
54+
ChromaBackend._quarantined_paths.clear()
55+
except (ImportError, AttributeError):
56+
pass
4957

5058
_clear_cache()
5159
yield

0 commit comments

Comments
 (0)