fix(chroma): route stale hnsw divergence to sqlite fallback
This commit is contained in:
parent
9f434e0bfd
commit
d704433701
|
|
@ -9,6 +9,7 @@ import os
|
|||
import pickle
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from numbers import Integral
|
||||
from pathlib import Path
|
||||
|
|
@ -606,6 +607,7 @@ def _hnsw_element_count(palace_path: str, segment_id: str) -> Optional[int]:
|
|||
# sync_threshold) from expected steady-state lag.
|
||||
_HNSW_DIVERGENCE_FALLBACK_FLOOR = 2000
|
||||
_HNSW_DIVERGENCE_FRACTION = 0.10
|
||||
_HNSW_PERSISTENT_DIVERGENCE_GRACE_SECONDS = 300.0
|
||||
|
||||
|
||||
def _read_sync_threshold(palace_path: str, collection_name: str) -> int:
|
||||
|
|
@ -649,6 +651,45 @@ def _read_sync_threshold(palace_path: str, collection_name: str) -> int:
|
|||
return 1000
|
||||
|
||||
|
||||
def _collection_has_sync_threshold_metadata(palace_path: str, collection_name: str) -> bool:
|
||||
"""Return True when the collection explicitly stores hnsw:sync_threshold."""
|
||||
|
||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
if not os.path.isfile(db_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM collection_metadata cm
|
||||
JOIN collections c ON cm.collection_id = c.id
|
||||
WHERE c.name = ?
|
||||
AND cm.key = 'hnsw:sync_threshold'
|
||||
LIMIT 1
|
||||
""",
|
||||
(collection_name,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
logger.debug("_collection_has_sync_threshold_metadata failed", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _hnsw_metadata_age_seconds(palace_path: str, segment_id: str) -> Optional[float]:
|
||||
"""Return index_metadata.pickle age in seconds, or None when unreadable."""
|
||||
|
||||
pickle_path = os.path.join(palace_path, segment_id, "index_metadata.pickle")
|
||||
try:
|
||||
return max(0.0, time.time() - os.path.getmtime(pickle_path))
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def hnsw_capacity_status(palace_path: str, collection_name: str = "mempalace_drawers") -> dict:
|
||||
"""Compare sqlite embedding count against HNSW element count.
|
||||
|
||||
|
|
@ -693,11 +734,15 @@ def hnsw_capacity_status(palace_path: str, collection_name: str = "mempalace_dra
|
|||
|
||||
hnsw_count = _hnsw_element_count(palace_path, seg_id)
|
||||
out["hnsw_count"] = hnsw_count
|
||||
|
||||
sync_threshold = _read_sync_threshold(palace_path, collection_name)
|
||||
# Two synchronization windows worth — see comment above
|
||||
# _HNSW_DIVERGENCE_FALLBACK_FLOOR for the rationale.
|
||||
divergence_floor = max(_HNSW_DIVERGENCE_FALLBACK_FLOOR, 2 * sync_threshold)
|
||||
has_explicit_sync_threshold = _collection_has_sync_threshold_metadata(
|
||||
palace_path,
|
||||
collection_name,
|
||||
)
|
||||
metadata_age_seconds = (
|
||||
_hnsw_metadata_age_seconds(palace_path, seg_id) if hnsw_count is not None else None
|
||||
)
|
||||
out["hnsw_metadata_age_seconds"] = metadata_age_seconds
|
||||
|
||||
if hnsw_count is None:
|
||||
# No pickle yet, so this probe cannot measure HNSW capacity.
|
||||
|
|
@ -715,21 +760,52 @@ def hnsw_capacity_status(palace_path: str, collection_name: str = "mempalace_dra
|
|||
|
||||
divergence = sqlite_count - hnsw_count
|
||||
out["divergence"] = divergence
|
||||
threshold = max(divergence_floor, int(sqlite_count * _HNSW_DIVERGENCE_FRACTION))
|
||||
if divergence > threshold:
|
||||
|
||||
# Newer palaces explicitly store mempalace's low sync threshold
|
||||
# (currently 2), so a gap of dozens of rows is far beyond ordinary
|
||||
# flush lag. Older palaces may lack the metadata row; keep the
|
||||
# historical floor for fresh lag there, but do not let a stale pickle
|
||||
# sit below the floor forever (#1816).
|
||||
if has_explicit_sync_threshold:
|
||||
threshold = max(0, 2 * sync_threshold)
|
||||
else:
|
||||
divergence_floor = max(_HNSW_DIVERGENCE_FALLBACK_FLOOR, 2 * sync_threshold)
|
||||
threshold = max(
|
||||
divergence_floor,
|
||||
int(sqlite_count * _HNSW_DIVERGENCE_FRACTION),
|
||||
)
|
||||
|
||||
out["threshold"] = threshold
|
||||
stale_below_threshold = (
|
||||
divergence > 0
|
||||
and metadata_age_seconds is not None
|
||||
and metadata_age_seconds >= _HNSW_PERSISTENT_DIVERGENCE_GRACE_SECONDS
|
||||
)
|
||||
|
||||
if divergence > threshold or stale_below_threshold:
|
||||
out["status"] = "diverged"
|
||||
out["diverged"] = True
|
||||
pct = 100.0 * divergence / max(sqlite_count, 1)
|
||||
if divergence > threshold:
|
||||
reason = f"exceeds threshold {threshold:,}"
|
||||
else:
|
||||
age = metadata_age_seconds or 0.0
|
||||
reason = f"persisted below the old flush-lag floor for {age:.0f}s"
|
||||
out["message"] = (
|
||||
f"HNSW index holds {hnsw_count:,} elements but sqlite has "
|
||||
f"{sqlite_count:,} embeddings — {divergence:,} drawers ({pct:.0f}%) "
|
||||
"are invisible to vector search. Run `mempalace repair` to rebuild."
|
||||
f"{sqlite_count:,} embeddings - {divergence:,} drawers "
|
||||
f"({pct:.0f}%) are missing from the flushed HNSW index "
|
||||
f"({reason}). Vector reads are disabled until "
|
||||
"`mempalace repair` rebuilds it."
|
||||
)
|
||||
else:
|
||||
out["status"] = "ok"
|
||||
out["message"] = (
|
||||
f"HNSW {hnsw_count:,} / sqlite {sqlite_count:,} (within flush-lag tolerance)"
|
||||
)
|
||||
if divergence < 0:
|
||||
out["message"] += " (HNSW has extra flushed elements; treating as safe)"
|
||||
|
||||
except Exception:
|
||||
logger.debug("hnsw_capacity_status failed", exc_info=True)
|
||||
out["message"] = "HNSW capacity probe raised; skipping"
|
||||
|
|
|
|||
|
|
@ -1059,6 +1059,37 @@ def extract_via_sqlite(palace_path: str, collection_name: str) -> Iterator[tuple
|
|||
conn.close()
|
||||
|
||||
|
||||
def _preserve_knowledge_graph_sqlite(source_palace: str, dest_palace: str) -> list[str]:
|
||||
"""Copy KG SQLite sidecars when rebuilding a palace from chroma.sqlite3.
|
||||
|
||||
rebuild_from_sqlite reconstructs Chroma collections into a fresh
|
||||
destination directory. The knowledge graph is a separate SQLite database,
|
||||
so it must be copied explicitly or the repair succeeds while silently
|
||||
dropping KG state (#1816).
|
||||
"""
|
||||
|
||||
copied: list[str] = []
|
||||
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
filename = f"knowledge_graph.sqlite3{suffix}"
|
||||
src = os.path.join(source_palace, filename)
|
||||
dst = os.path.join(dest_palace, filename)
|
||||
|
||||
if not os.path.isfile(src):
|
||||
continue
|
||||
if os.path.abspath(src) == os.path.abspath(dst):
|
||||
continue
|
||||
|
||||
os.makedirs(dest_palace, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
copied.append(filename)
|
||||
|
||||
if copied:
|
||||
print(" Preserved knowledge graph: " + ", ".join(copied))
|
||||
|
||||
return copied
|
||||
|
||||
|
||||
def rebuild_from_sqlite(
|
||||
source_palace: str,
|
||||
dest_palace: str,
|
||||
|
|
@ -1205,6 +1236,7 @@ def rebuild_from_sqlite(
|
|||
)
|
||||
|
||||
os.makedirs(dest_palace, exist_ok=True)
|
||||
_preserve_knowledge_graph_sqlite(source_palace, dest_palace)
|
||||
|
||||
# Backend lifetime is wrapped in try/finally so the dest palace's
|
||||
# PersistentClient handle (opened lazily inside ``create_collection``
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from __future__ import annotations
|
|||
import os
|
||||
import pickle
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -640,3 +641,39 @@ def test_tool_status_via_sqlite_returns_breakdown(palace_with_drawers, monkeypat
|
|||
# ops×2 (incident + repair runbook), design×1 (metaphor).
|
||||
assert out["wings"].get("ops") == 2
|
||||
assert out["wings"].get("design") == 1
|
||||
|
||||
|
||||
def test_capacity_status_flags_small_gap_with_explicit_low_sync_threshold(tmp_path):
|
||||
"""New palaces use a low explicit sync threshold, so 57 missing rows is unsafe."""
|
||||
seg = "seg-1816-explicit-low-sync"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=1768, segment_id=seg, sync_threshold=2)
|
||||
_write_pickle(str(tmp_path), seg, hnsw_count=1711)
|
||||
|
||||
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
|
||||
|
||||
assert info["divergence"] == 57
|
||||
assert info["threshold"] == 4
|
||||
assert info["status"] == "diverged"
|
||||
assert info["diverged"] is True
|
||||
assert "repair" in info["message"].lower()
|
||||
|
||||
|
||||
def test_capacity_status_flags_stale_below_floor_divergence(tmp_path):
|
||||
"""A persistent below-floor sqlite>HNSW gap must not be treated as fresh lag."""
|
||||
from mempalace.backends import chroma
|
||||
|
||||
seg = "seg-1816-stale-below-floor"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=1768, segment_id=seg)
|
||||
_write_pickle(str(tmp_path), seg, hnsw_count=1711)
|
||||
|
||||
pickle_path = tmp_path / seg / "index_metadata.pickle"
|
||||
old = time.time() - chroma._HNSW_PERSISTENT_DIVERGENCE_GRACE_SECONDS - 10
|
||||
os.utime(pickle_path, (old, old))
|
||||
|
||||
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
|
||||
|
||||
assert info["divergence"] == 57
|
||||
assert info["threshold"] >= 2000
|
||||
assert info["status"] == "diverged"
|
||||
assert info["diverged"] is True
|
||||
assert "persisted below" in info["message"]
|
||||
|
|
|
|||
|
|
@ -1995,3 +1995,26 @@ def test_rebuild_index_calls_vacuum(mock_backend_cls, mock_shutil, tmp_path):
|
|||
args, kwargs = mock_vacuum.call_args
|
||||
assert args[0] == str(tmp_path)
|
||||
assert "progress" in kwargs
|
||||
|
||||
|
||||
def test_rebuild_from_sqlite_preserves_knowledge_graph_sidecar(tmp_path):
|
||||
"""The from-sqlite repair path must not drop the KG SQLite sidecar."""
|
||||
src = tmp_path / "source"
|
||||
dest = tmp_path / "dest"
|
||||
src.mkdir()
|
||||
dest.mkdir()
|
||||
|
||||
(src / "knowledge_graph.sqlite3").write_text("kg-db", encoding="utf-8")
|
||||
(src / "knowledge_graph.sqlite3-wal").write_text("kg-wal", encoding="utf-8")
|
||||
(src / "knowledge_graph.sqlite3-shm").write_text("kg-shm", encoding="utf-8")
|
||||
|
||||
copied = repair._preserve_knowledge_graph_sqlite(str(src), str(dest))
|
||||
|
||||
assert copied == [
|
||||
"knowledge_graph.sqlite3",
|
||||
"knowledge_graph.sqlite3-wal",
|
||||
"knowledge_graph.sqlite3-shm",
|
||||
]
|
||||
assert (dest / "knowledge_graph.sqlite3").read_text(encoding="utf-8") == "kg-db"
|
||||
assert (dest / "knowledge_graph.sqlite3-wal").read_text(encoding="utf-8") == "kg-wal"
|
||||
assert (dest / "knowledge_graph.sqlite3-shm").read_text(encoding="utf-8") == "kg-shm"
|
||||
|
|
|
|||
Loading…
Reference in New Issue