"""Regression tests for LocalFileStore / FaissLocalFileStore consistency.""" # pylint: disable=protected-access,too-many-lines import asyncio import base64 import datetime import json import os import tempfile import threading import time import numpy as np import pytest from reme.components.file_store import FaissLocalFileStore, LocalFileStore, ZvecLocalFileStore from reme.components.file_store import local_file_store as local_file_store_module from reme.components.embedding_store import LocalEmbeddingStore from reme.schema import FileChunk, FileNode from reme.utils.jsonl_zst import read_jsonl_zst, write_jsonl_zst class temp_chdir: """Temporarily chdir into a test workspace.""" def __init__(self, path): self.path = path self.old = None def __enter__(self): self.old = os.getcwd() os.chdir(self.path) return self def __exit__(self, *exc): os.chdir(self.old) class FakeEmbeddingStore: """Small deterministic embedding provider used by file-store tests.""" dimensions = 2 max_batch_size = 10 def _embed(self, text: str) -> np.ndarray: if "beta" in text or "fresh" in text: return np.array([0.0, 1.0], dtype=np.float16) return np.array([1.0, 0.0], dtype=np.float16) async def health_check(self, _timeout: float = 2.0) -> bool: """Report the fake embedding service as healthy.""" return True async def get_embedding(self, input_text: str, **_kwargs) -> np.ndarray: """Return a deterministic embedding for a single text.""" return self._embed(input_text) async def get_node_embeddings(self, nodes: list[FileChunk], **_kwargs) -> list[FileChunk]: """Attach deterministic embeddings to file chunks.""" for chunk_node in nodes: chunk_node.embedding = self._embed(chunk_node.text) return nodes class CountingFakeEmbeddingStore(FakeEmbeddingStore): """Fake embedding store that records node backfill requests.""" def __init__(self): self.node_embedding_calls: list[list[str]] = [] self.is_healthy = True async def get_node_embeddings(self, nodes: list[FileChunk], **_kwargs) -> list[FileChunk]: self.node_embedding_calls.append([node.id for node in nodes]) return await super().get_node_embeddings(nodes, **_kwargs) class UnhealthyCountingEmbeddingStore(CountingFakeEmbeddingStore): """Fake embedding store that fails the backfill health gate.""" def __init__(self): super().__init__() self.is_healthy = False async def health_check(self, _timeout: float = 2.0) -> bool: return False class RecoveringEmbeddingStore(CountingFakeEmbeddingStore): """Fake provider that starts unhealthy and records real recoveries.""" def __init__(self): super().__init__() self.is_healthy = False self.health_calls = 0 async def health_check(self, _timeout: float = 2.0) -> bool: self.health_calls += 1 return False async def get_embedding(self, input_text: str, **kwargs) -> np.ndarray: self.is_healthy = True return await super().get_embedding(input_text, **kwargs) async def get_node_embeddings(self, nodes: list[FileChunk], **kwargs) -> list[FileChunk]: self.is_healthy = True return await super().get_node_embeddings(nodes, **kwargs) class HealthCountingEmbeddingStore(FakeEmbeddingStore): """Fake provider that records eager health checks.""" def __init__(self): self.health_calls = 0 async def health_check(self, _timeout: float = 2.0) -> bool: self.health_calls += 1 return True class BlockingEmbeddingStore(FakeEmbeddingStore): """Fake provider that proves startup does not await remote backfill.""" def __init__(self): self.started = asyncio.Event() self.release = asyncio.Event() async def get_node_embeddings(self, nodes: list[FileChunk], **kwargs) -> list[FileChunk]: self.started.set() await self.release.wait() return await super().get_node_embeddings(nodes, **kwargs) class CancellationResistantHealthStore(CountingFakeEmbeddingStore): """Startup probe that completes stale after cancellation is requested.""" def __init__(self): super().__init__() self.is_healthy = True self.health_started = asyncio.Event() self.release_health = asyncio.Event() async def health_check(self, _timeout: float = 2.0) -> bool: self.health_started.set() try: await self.release_health.wait() except asyncio.CancelledError: await self.release_health.wait() self.is_healthy = False return False class DelayedOldVectorStore(CountingFakeEmbeddingStore): """First batch returns an old-space vector after rebuild was requested.""" def __init__(self): super().__init__() self.first_batch_started = asyncio.Event() self.release_first_batch = asyncio.Event() async def get_node_embeddings(self, nodes: list[FileChunk], **_kwargs) -> list[FileChunk]: self.node_embedding_calls.append([node.id for node in nodes]) if len(self.node_embedding_calls) == 1: self.first_batch_started.set() await self.release_first_batch.wait() for chunk_node in nodes: chunk_node.embedding = np.array([0.0, 1.0], dtype=np.float16) return nodes return await FakeEmbeddingStore.get_node_embeddings(self, nodes) class WrongDimEmbeddingStore(FakeEmbeddingStore): """Fake embedding store that returns vectors with the wrong dimension.""" async def get_embedding(self, input_text: str, **_kwargs) -> np.ndarray: return np.array([1.0], dtype=np.float16) async def get_node_embeddings(self, nodes: list[FileChunk], **_kwargs) -> list[FileChunk]: for chunk_node in nodes: chunk_node.embedding = np.array([1.0], dtype=np.float16) return nodes class CountOnlyKeywordIndex: """Keyword backend that knows its size but cannot expose document IDs.""" def __init__(self, n_docs: int): self.n_docs = n_docs self.reset_docs = None @property def document_ids(self): """Signal that exact live IDs are unavailable.""" raise NotImplementedError async def reset_index(self, docs): """Record the documents requested for rebuilding.""" self.reset_docs = docs def run(coro): """Run an async test body.""" return asyncio.run(coro) def node(path: str) -> FileNode: """Build a minimal file node.""" return FileNode(path=path, st_mtime=1.0) def chunk(chunk_id: str, path: str, text: str, **metadata) -> FileChunk: """Build a minimal file chunk.""" return FileChunk(id=chunk_id, path=path, text=text, start_line=1, end_line=1, metadata=metadata) def _new_local_store(name, **kwargs): """Construct a LocalFileStore with embedding disabled at bind time.""" return LocalFileStore(name=name, embedding_store="", **kwargs) def _new_faiss_store(name, **kwargs): """Construct a FAISS store when the optional backend is installed.""" try: store = FaissLocalFileStore(name=name, embedding_store="", **kwargs) except ImportError: pytest.skip("faiss is not installed") return store def _new_zvec_store(name, **kwargs): """Construct a zvec store with embedding disabled at bind time.""" try: store = ZvecLocalFileStore(name=name, embedding_store="", **kwargs) except ImportError: pytest.skip("zvec is not installed") return store def _ensure_zvec_collection(store): """Materialize the zvec collection once an embedding backend is attached. Fresh zvec stores start with no collection because ``embedding_store=""``. Tests that attach a fake provider after ``start()`` must explicitly create the collection before the first upsert, otherwise vectors are accepted by the parent but never synced into zvec. """ if isinstance(store, ZvecLocalFileStore) and store._collection is None and store.embedding_store is not None: store._collection = store._create_collection() async def set_chunks_with_graph(store: LocalFileStore, chunks: dict[str, FileChunk]) -> None: """Seed a graph/chunk snapshot that satisfies the persistence invariant.""" store.file_chunks = chunks chunk_ids_by_path: dict[str, list[str]] = {} for chunk_node in chunks.values(): chunk_ids_by_path.setdefault(chunk_node.path, []).append(chunk_node.id) nodes = [] for path, chunk_ids in chunk_ids_by_path.items(): file_node = node(path) file_node.chunk_ids = chunk_ids nodes.append(file_node) await store.file_graph.upsert_nodes(nodes) def test_keyword_only_upsert_removes_old_chunks_and_docs(): """Keyword-only upsert removes stale chunks and keyword documents.""" async def go(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = LocalFileStore(name="t_keyword_only", embedding_store="") await store.start() await store.upsert([(node("note.md"), [chunk("old", "note.md", "obsoleteword only")])]) assert [c.id for c in await store.keyword_search("obsoleteword", 5, {})] == ["old"] await store.upsert([(node("note.md"), [chunk("new", "note.md", "freshword only")])]) assert "old" not in store.file_chunks assert await store.keyword_search("obsoleteword", 5, {}) == [] assert [c.id for c in await store.keyword_search("freshword", 5, {})] == ["new"] await store.close() run(go()) def test_start_does_not_health_check_embedding_without_backfill(): """Hot startup keeps local vector retrieval independent of provider health.""" async def go(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = LocalFileStore(name="t_lazy_embedding_health", embedding_store="") embedding_store = HealthCountingEmbeddingStore() store.embedding_store = embedding_store await store.start() assert embedding_store.health_calls == 0 assert store.embedding_store is embedding_store await store.close() run(go()) def test_load_rebuilds_keyword_index_from_persisted_chunks_when_missing(): """Loading persisted chunks repairs a missing keyword index.""" async def go(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = LocalFileStore(name="t_keyword_repair", embedding_store="") await store.start() await store.upsert( [ (node("a.md"), [chunk("a", "a.md", "uniquerepairword stock")]), (node("b.md"), [chunk("b", "b.md", "work preference")]), ], ) await store.dump() await store.keyword_index.clear() assert not store.keyword_index.index_file.exists() assert await store.keyword_search("uniquerepairword", 5, {}) == [] store.file_chunks.clear() await store.load() assert store.keyword_index.index_file.exists() assert [c.id for c in await store.keyword_search("uniquerepairword", 5, {})] == ["a"] await store.close() run(go()) def test_load_clears_graph_when_persisted_chunks_are_missing(): """A surviving graph must not hide a missing chunk store from reindex.""" async def go(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): seed = LocalFileStore(name="t_missing_chunks", embedding_store="") await seed.start() indexed_node = node("memory.md") await seed.upsert( [(indexed_node, [chunk("memory-chunk", "memory.md", "remember this")])], ) await seed.close() seed.chunks_path.unlink() store = LocalFileStore(name="t_missing_chunks", embedding_store="") await store.start() assert store.file_chunks == {} assert await store.get_nodes() == [] assert set(store.keyword_index.document_ids) == set() await store.close() run(go()) def test_load_clears_graph_and_chunks_when_chunk_sets_partially_diverge(): """Missing and orphaned chunks invalidate the atomic derived snapshot.""" async def go(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = LocalFileStore(name="t_torn_chunks", embedding_store="") await store.start() indexed_node = node("memory.md") indexed_node.chunk_ids = ["kept", "missing"] await store.file_graph.upsert_nodes([indexed_node]) store.file_chunks = { "kept": chunk("kept", "memory.md", "kept text"), "orphaned": chunk("orphaned", "old.md", "orphaned text"), } repaired = await store._repair_graph_chunk_consistency() assert repaired is True assert store.file_chunks == {} assert await store.get_nodes() == [] await store.close() run(go()) def test_load_clears_stale_keyword_index_when_chunks_are_empty(): """An empty chunk store is still an exact state BM25 must mirror.""" async def go(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): seed = LocalFileStore(name="t_empty_chunk_keyword", embedding_store="") await seed.start() await seed.keyword_index.add_docs({"stale": "stale keyword document"}) await seed.close() store = LocalFileStore(name="t_empty_chunk_keyword", embedding_store="") await store.start() assert store.file_chunks == {} assert set(store.keyword_index.document_ids) == set() assert not store.keyword_index.index_file.exists() await store.close() run(go()) def test_keyword_sync_rebuilds_when_backend_only_exposes_matching_count(): """Matching counts cannot prove that a backend contains the expected IDs.""" async def go(): store = LocalFileStore(name="t_count_only_keyword", embedding_store="") store.file_chunks = { "expected": chunk("expected", "expected.md", "expected content"), } keyword_index = CountOnlyKeywordIndex(n_docs=1) store.keyword_index = keyword_index await store._sync_keyword_index_from_chunks() assert keyword_index.reset_docs == {"expected": "expected content"} run(go()) def test_keyword_sync_ignores_nonempty_chunk_with_no_indexable_tokens(monkeypatch): """A tokenless chunk omitted by BM25 must not trigger a perpetual rebuild.""" async def go(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = LocalFileStore(name="t_tokenless_keyword", embedding_store="") await store.start() store.file_chunks = { "indexed": chunk("indexed", "data.jsonl", "searchable content"), "tokenless": chunk("tokenless", "data.jsonl", "\u2028"), } await store.keyword_index.clear() await store.keyword_index.add_docs({cid: item.text for cid, item in store.file_chunks.items()}) assert set(store.keyword_index.document_ids) == {"indexed"} async def unexpected_rebuild(_docs): raise AssertionError("tokenless BM25 content must not trigger a rebuild") monkeypatch.setattr(store, "_rebuild_keyword_index", unexpected_rebuild) await store._sync_keyword_index_from_chunks() await store.close() run(go()) def test_keyword_sync_rebuilds_in_progress_batches(monkeypatch): """Foreground keyword repair uses bounded batches suitable for progress reporting.""" async def go(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = LocalFileStore(name="t_keyword_progress", embedding_store="") await store.start() store.file_chunks = {str(index): chunk(str(index), f"{index}.md", f"content {index}") for index in range(5)} await store.keyword_index.clear() batch_sizes = [] original_add_docs = store.keyword_index.add_docs async def recording_add_docs(docs): batch_sizes.append(len(docs)) await original_add_docs(docs) monkeypatch.setattr(local_file_store_module, "_KEYWORD_REBUILD_BATCH_SIZE", 2) monkeypatch.setattr(store.keyword_index, "add_docs", recording_add_docs) await store._sync_keyword_index_from_chunks() assert batch_sizes == [2, 2, 1] assert set(store.keyword_index.document_ids) == set(store.file_chunks) await store.close() run(go()) def test_chunk_persistence_uses_compact_embedding_and_round_trips(): """Chunk persistence avoids JSON float lists while preserving float16 vectors.""" async def go(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = LocalFileStore(name="t_compact_embedding", embedding_store="") await store.start() original = chunk("a", "a.md", "alpha text", source="test") original.embedding = np.array([0.25, -1.5, 3.0], dtype=np.float16) await set_chunks_with_graph(store, {original.id: original}) await store.dump() payload = json.loads(next(read_jsonl_zst(store.chunks_path))) assert "embedding" not in payload assert isinstance(payload["_embedding_f16_b64"], str) assert base64.b64decode(payload["_embedding_f16_b64"]) == original.embedding.astype(" [1,0]). store_a = _new_faiss_store("t_faiss_stale_sidecar") await store_a.start() store_a.embedding_store = FakeEmbeddingStore() store_a._faiss_index = store_a._new_index() await store_a.upsert([(node("a.md"), [chunk("c1", "a.md", "alpha topic")])]) await store_a.dump() # t1: same-ID in-place update (c1 = beta -> [0,1]). await store_a.upsert([(node("a.md"), [chunk("c1", "a.md", "beta topic")])]) # t2: simulate a crash between the two writes in dump(): only the # parent's JSONL write lands; the sidecar stays at the alpha # generation. (No close() -- the process is presumed dead.) await LocalFileStore.dump(store_a) # t3: restart. The stale sidecar must be rejected by the digest. store_b = _new_faiss_store("t_faiss_stale_sidecar") await store_b.start() store_b.embedding_store = FakeEmbeddingStore() assert store_b.file_chunks["c1"].text == "beta topic" assert await store_b._try_load_sidecar() is False assert not store_b.faiss_path.exists() # sidecar wiped on rejection store_b._rebuild_index() # t4: the rebuilt index serves the current generation: a beta query # scores ~1.0 instead of 0.0 against the stale alpha vector. results = await store_b.vector_search("beta", 5, {}) assert [c.id for c in results] == ["c1"] assert results[0].scores["vector"] > 0.5 await store_b.close() run(go()) def test_faiss_ef_construction_hot_update_without_rebuild(): """Reopening with a different hnsw_ef_construction must NOT rebuild: it only affects future add() edge formation; the live efConstruction is hot-updated.""" async def go(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): # Phase 1: build and persist with efConstruction=40. store_a = _new_faiss_store("t_faiss_ef_hot", hnsw_ef_construction=40) await store_a.start() store_a.embedding_store = FakeEmbeddingStore() store_a._faiss_index = store_a._new_index() await store_a.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])]) assert store_a._faiss_index.hnsw.efConstruction == 40 await store_a.close() # Phase 2: reopen with efConstruction=128 (M unchanged). store_b = _new_faiss_store("t_faiss_ef_hot", hnsw_ef_construction=128) await store_b.start() store_b.embedding_store = FakeEmbeddingStore() # Sidecar loads successfully — M matches, only efConstruction differs. assert await store_b._try_load_sidecar() is True assert store_b.faiss_path.exists() # sidecar retained (no rebuild) # efConstruction was hot-updated to the new config value. assert store_b._faiss_index.hnsw.efConstruction == 128 # Search still works on the loaded (not rebuilt) index. assert [c.id for c in await store_b.vector_search("alpha", 5, {})] == ["a"] await store_b.close() run(go()) def test_faiss_small_index_uses_brute_force_scan(): """Below the brute-force threshold, vector_search does an exact scan via index.storage; the HNSW path (_set_ef_search) is used only when large enough.""" async def go(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = _new_faiss_store("t_faiss_brute_force") await store.start() store.embedding_store = FakeEmbeddingStore() store._faiss_index = store._new_index() # 80 chunks with valid date paths so we can test filtered search too. # With M=32 (default): limit=5 -> threshold=sqrt(25)*32=160 > 80 # -> brute-force. limit=1 -> threshold=sqrt(5)*32~=72 < 80 -> HNSW. base = datetime.date(2026, 1, 1) files = [] for i in range(80): d = base + datetime.timedelta(days=i) path = f"daily/{d.isoformat()}/note.md" files.append((node(path), [chunk(f"c{i}", path, "alpha text")])) await store.upsert(files) # _set_ef_search is only called on the HNSW path. Spying on it # tells us which branch vector_search took. ef_calls: list[int] = [] original_set_ef = store._set_ef_search def spy_set_ef(idx, lim): ef_calls.append(lim) original_set_ef(idx, lim) store._set_ef_search = spy_set_ef # ntotal=80, limit=5 -> threshold=sqrt(25)*32=160 -> 80 < 160 -> brute-force. results = await store.vector_search("alpha", 5, {}) assert len(results) == 5 assert not ef_calls # brute-force path taken # Brute-force + filter: scans all vectors, _collect_hits filters. filt = {"start_date": "2026-01-15", "end_date": "2026-01-17"} results = await store.vector_search("alpha", 5, filt) assert {r.id for r in results} == {"c14", "c15", "c16"} assert not ef_calls # still brute-force # ntotal=80, limit=1 -> threshold=sqrt(5)*32~=72 -> 80 >= 72 -> HNSW graph search. results = await store.vector_search("alpha", 1, {}) assert len(results) == 1 assert len(ef_calls) >= 1 # HNSW path taken, efSearch was set store._set_ef_search = original_set_ef await store.close() run(go()) # -- Async reindex tests ----------------------------------------------------- def _new_faiss_store(name, **kwargs): """Construct a started FAISS store with a fake embedding backend and empty index.""" try: store = FaissLocalFileStore(name=name, embedding_store="", **kwargs) except ImportError: pytest.skip("faiss is not installed") return store async def _settle_reindex(store, timeout=5.0): """Wait until no async reindex is pending or in flight.""" deadline = time.monotonic() + timeout while store._reindex_event.is_set() or store._reindex_busy: if time.monotonic() > deadline: raise AssertionError("async reindex did not settle in time") await asyncio.sleep(0.005) def test_faiss_async_reindex_disabled_by_default(): """Default store keeps the synchronous compaction path: no background worker.""" async def go(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = _new_faiss_store("t_faiss_sync_default", max_tombstones=2) assert store.async_reindex is False await store.start() store.embedding_store = FakeEmbeddingStore() store._faiss_index = store._new_index() files = [(node(f"n{i}.md"), [chunk(f"c{i}", f"n{i}.md", "alpha text")]) for i in range(4)] await store.upsert(files) await store.delete([f"n{i}.md" for i in range(3)]) # Synchronous rebuild ran inline; no background worker was created. assert store._reindex_worker_task is None assert store._tombstones == set() assert set(store._id_to_row) == {"c3"} assert [c.id for c in await store.vector_search("alpha", 10, {})] == ["c3"] await store.close() run(go()) def test_faiss_async_reindex_triggered_by_compaction(): """Crossing the tombstone threshold submits a background rebuild whose result matches a synchronous rebuild (deleted ids gone, tombstones cleared).""" async def go(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = _new_faiss_store("t_faiss_async_compact", async_reindex=True, max_tombstones=2) await store.start() store.embedding_store = FakeEmbeddingStore() store._faiss_index = store._new_index() files = [(node(f"n{i}.md"), [chunk(f"c{i}", f"n{i}.md", "alpha text")]) for i in range(4)] await store.upsert(files) assert store._reindex_worker_task is None # below threshold, nothing submitted yet await store.delete([f"n{i}.md" for i in range(3)]) # 3 tombstones >= 2 -> submit assert store._reindex_worker_task is not None await _settle_reindex(store) assert set(store._id_to_row) == {"c3"} assert store._tombstones == set() assert [c.id for c in await store.vector_search("alpha", 10, {})] == ["c3"] await store.close() run(go()) def test_faiss_async_reindex_no_lost_writes_during_build(): """Writes that land while an async rebuild is in flight are not lost: a follow-up rebuild folds them in (eventual consistency).""" async def go(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = _new_faiss_store("t_faiss_async_nolost", async_reindex=True) await store.start() store.embedding_store = FakeEmbeddingStore() store._faiss_index = store._new_index() await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])]) assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"] started = threading.Event() release = threading.Event() real_build = store._build_index_blocking def gated_build(dim, vectors): started.set() release.wait() return real_build(dim, vectors) store._build_index_blocking = gated_build store._submit_reindex() # snapshot == {a} while not started.is_set(): await asyncio.sleep(0.005) # Concurrent writes on the live index while the build is blocked: await store.upsert([(node("b.md"), [chunk("b", "b.md", "beta text")])]) # brand new await store.upsert([(node("a.md"), [chunk("a", "a.md", "beta text")])]) # changed text release.set() await _settle_reindex(store) # After the follow-up rebuild the index reflects both concurrent writes; # the changed chunk now embeds as "beta". assert set(store._id_to_row) == {"a", "b"} assert {c.id for c in await store.vector_search("beta", 5, {})} == {"a", "b"} await store.close() run(go()) def test_faiss_async_reindex_single_worker_coalesces(): """Only one reindex runs at a time; repeated submissions coalesce and the worker stays a single long-lived task.""" async def go(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = _new_faiss_store("t_faiss_async_single", async_reindex=True) await store.start() store.embedding_store = FakeEmbeddingStore() store._faiss_index = store._new_index() await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])]) started = threading.Event() release = threading.Event() active = {"n": 0} peak = {"n": 0} real_build = store._build_index_blocking def gated_build(dim, vectors): active["n"] += 1 peak["n"] = max(peak["n"], active["n"]) started.set() release.wait() try: return real_build(dim, vectors) finally: active["n"] -= 1 store._build_index_blocking = gated_build store._submit_reindex() while not started.is_set(): await asyncio.sleep(0.005) worker = store._reindex_worker_task # Several more submissions while the first build is blocked collapse into # the flag rather than spawning parallel builds or a second worker. for _ in range(5): store._submit_reindex() assert store._reindex_worker_task is worker release.set() await _settle_reindex(store) assert peak["n"] == 1 # never two builds at once assert set(store._id_to_row) == {"a"} await store.close() run(go()) def test_faiss_async_reindex_cancelled_on_close(): """close() stops an in-flight reindex without hanging on the worker thread.""" async def go(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = _new_faiss_store("t_faiss_async_close", async_reindex=True) await store.start() store.embedding_store = FakeEmbeddingStore() store._faiss_index = store._new_index() await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])]) started = threading.Event() def slow_build(_dim, _vectors): started.set() while not store._closing: time.sleep(0.005) store._build_index_blocking = slow_build store._submit_reindex() while not started.is_set(): await asyncio.sleep(0.005) await store.close() # sets _closing, cancels the worker; the build thread exits assert store._reindex_worker_task is None run(go()) def test_faiss_close_does_not_leave_orphan_reindex(): """The final dump in _close() must not submit a background reindex.""" async def go(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = _new_faiss_store("t_faiss_close_orphan", async_reindex=True, max_tombstones=2) await store.start() store.embedding_store = FakeEmbeddingStore() store._faiss_index = store._new_index() files = [(node(f"n{i}.md"), [chunk(f"c{i}", f"n{i}.md", "alpha text")]) for i in range(4)] await store.upsert(files) # Block the build so the reindex is still in flight (tombstones uncompacted) # when close runs. started = threading.Event() def slow_build(_dim, _vectors): started.set() while not store._closing: time.sleep(0.005) store._build_index_blocking = slow_build await store.delete([f"n{i}.md" for i in range(3)]) # 3 tombstones >= 2 -> submit while not started.is_set(): await asyncio.sleep(0.005) assert len(store._tombstones) >= store.max_tombstones # not compacted yet # Closing cancels the in-flight worker; the _closing guard stops the # final dump from submitting an orphan reindex. await store.close() assert store._reindex_worker_task is None run(go()) def test_faiss_async_close_persists_stale_snapshot_on_same_id_update(): """Regression: close() during a follow-up rebuild can persist a stale snapshot. Bug: async rebuild snapshots alpha; "a" is updated to beta; the first build swaps stale alpha back; close() cancels the follow-up rebuild before it can swap beta; the stale alpha index is persisted and accepted on reopen. Expected after fix: searching with the beta vector scores ~1.0. """ async def go(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = _new_faiss_store("t_faiss_stale_close", async_reindex=True) await store.start() store.embedding_store = FakeEmbeddingStore() store._faiss_index = store._new_index() # Chunk "a" with alpha text -> embedding [1.0, 0.0]. await store.upsert([(node("note.md"), [chunk("a", "note.md", "alpha text")])]) assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"] first_started = threading.Event() release_first = threading.Event() second_started = threading.Event() real_build = store._build_index_blocking build_count = {"n": 0} def gated_build(dim, vectors): build_count["n"] += 1 if build_count["n"] == 1: # First build: hold the alpha snapshot. first_started.set() release_first.wait() else: # Follow-up build: signal started, then hold until close(). second_started.set() while not store._closing: time.sleep(0.005) return real_build(dim, vectors) store._build_index_blocking = gated_build # Step 1: submit the first rebuild (snapshot == {a: alpha}). store._submit_reindex() while not first_started.is_set(): await asyncio.sleep(0.005) await store.upsert([(node("note.md"), [chunk("a", "note.md", "beta text")])]) release_first.set() while not second_started.is_set(): await asyncio.sleep(0.005) await store.close() assert store._reindex_worker_task is None # Step 6: reopen and verify the persisted state. reopened = _new_faiss_store("t_faiss_stale_close", async_reindex=True) await reopened.start() reopened.embedding_store = FakeEmbeddingStore() # The authoritative chunk JSONL has "beta text". assert reopened.file_chunks["a"].text == "beta text" # The stale sidecar is accepted because the ID set is unchanged. assert await reopened._try_load_sidecar() is True results = await reopened.vector_search("beta", 5, {}) assert len(results) == 1 assert results[0].id == "a" score = results[0].scores["vector"] assert score > 0.5, ( f"BUG: persisted FAISS index has stale alpha vector; " f"beta query scored {score:.4f} instead of ~1.0" ) await reopened.close() run(go()) def test_faiss_clear_waits_for_in_flight_dump(): """clear() serializes with dump() through _faiss_dump_lock.""" async def go(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = _new_faiss_store("t_faiss_clear_lock") await store.start() store.embedding_store = FakeEmbeddingStore() store._faiss_index = store._new_index() await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])]) first_started = asyncio.Event() release = asyncio.Event() original_write_sidecar = store._write_sidecar async def blocking_write_sidecar(): first_started.set() await release.wait() store._write_sidecar = blocking_write_sidecar dump_task = asyncio.create_task(store.dump()) await first_started.wait() clear_task = asyncio.create_task(store.clear()) await asyncio.sleep(0.02) assert not clear_task.done() # blocked on _faiss_dump_lock held by dump release.set() await asyncio.gather(dump_task, clear_task) assert store._id_to_row == {} store._write_sidecar = original_write_sidecar await store.close() run(go()) def test_faiss_delete_queries_graph_once(): """delete() resolves nodes once and reuses them (no redundant get_nodes).""" async def go(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = _new_faiss_store("t_faiss_delete_once") await store.start() store.embedding_store = FakeEmbeddingStore() store._faiss_index = store._new_index() await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])]) calls: list = [] original_get_nodes = store.file_graph.get_nodes async def counting_get_nodes(paths=None): calls.append(paths) return await original_get_nodes(paths) store.file_graph.get_nodes = counting_get_nodes await store.delete("a.md") assert len(calls) == 1 assert "a" not in store._id_to_row store.file_graph.get_nodes = original_get_nodes await store.close() run(go())