@@ -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
0 commit comments