Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

1573 lines
60 KiB
Python

"""
Coverage tests for LibraryRAGService.
Focuses on logic paths not exercised by the existing test_library_rag_service.py:
- _get_index_hash edge cases
- _get_index_path cache directory details
- _deduplicate_chunks order preservation and empty input
- _get_or_create_rag_index new vs existing
- load_or_create_faiss_index HNSW/IVF/L2/IP variants, integrity failure, dimension mismatch,
load failure, corrupted unlink failure
- index_document chunk indexing, empty text, skip already indexed
- index_all_documents with progress callback, stores settings
"""
import hashlib
import threading
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from langchain_core.documents import Document as LangchainDocument
# ---------------------------------------------------------------------------
# Module-level patch path prefix
# ---------------------------------------------------------------------------
_MOD = "local_deep_research.research_library.services.library_rag_service"
def _make_service(**overrides):
"""Create a LibraryRAGService with all external deps mocked out."""
with (
patch(f"{_MOD}.LocalEmbeddingManager") as _lem,
patch(f"{_MOD}.get_user_db_session"),
patch(f"{_MOD}.FileIntegrityManager") as _fim,
patch(f"{_MOD}.get_text_splitter") as _gts,
):
_lem.return_value.embeddings = MagicMock()
from local_deep_research.research_library.services.library_rag_service import (
LibraryRAGService,
)
defaults = dict(username="testuser", db_password="pw")
defaults.update(overrides)
svc = LibraryRAGService(**defaults)
return svc
# =========================================================================
# _get_index_hash
# =========================================================================
class TestGetIndexHash:
"""Tests for _get_index_hash determinism and sensitivity."""
def test_hash_is_deterministic(self):
svc = _make_service()
h1 = svc._get_index_hash("col_a", "model_x", "sentence_transformers")
h2 = svc._get_index_hash("col_a", "model_x", "sentence_transformers")
assert h1 == h2
def test_hash_changes_with_collection_name(self):
svc = _make_service()
h1 = svc._get_index_hash("col_a", "model_x", "sentence_transformers")
h2 = svc._get_index_hash("col_b", "model_x", "sentence_transformers")
assert h1 != h2
def test_hash_changes_with_model(self):
svc = _make_service()
h1 = svc._get_index_hash("col_a", "model_x", "sentence_transformers")
h2 = svc._get_index_hash("col_a", "model_y", "sentence_transformers")
assert h1 != h2
def test_hash_changes_with_provider(self):
svc = _make_service()
h1 = svc._get_index_hash("col_a", "model_x", "sentence_transformers")
h2 = svc._get_index_hash("col_a", "model_x", "ollama")
assert h1 != h2
def test_hash_is_sha256_hex(self):
svc = _make_service()
h = svc._get_index_hash("c", "m", "p")
expected = hashlib.sha256("c:m:p".encode()).hexdigest()
assert h == expected
def test_hash_length_is_64_chars(self):
svc = _make_service()
h = svc._get_index_hash("x", "y", "z")
assert len(h) == 64
# =========================================================================
# _get_index_path
# =========================================================================
class TestGetIndexPath:
"""Tests for _get_index_path."""
@patch(f"{_MOD}.get_cache_directory")
def test_path_under_rag_indices_subdir(self, mock_cache_dir, tmp_path):
mock_cache_dir.return_value = tmp_path
svc = _make_service()
p = svc._get_index_path("abc123")
assert p.parent.name == "rag_indices"
@patch(f"{_MOD}.get_cache_directory")
def test_path_filename_contains_hash(self, mock_cache_dir, tmp_path):
mock_cache_dir.return_value = tmp_path
svc = _make_service()
p = svc._get_index_path("abc123")
assert p.name == "abc123.faiss"
@patch(f"{_MOD}.get_cache_directory")
def test_path_creates_directory(self, mock_cache_dir, tmp_path):
mock_cache_dir.return_value = tmp_path
svc = _make_service()
svc._get_index_path("abc123")
assert (tmp_path / "rag_indices").is_dir()
@patch(f"{_MOD}.get_cache_directory")
def test_path_returns_path_object(self, mock_cache_dir, tmp_path):
mock_cache_dir.return_value = tmp_path
svc = _make_service()
p = svc._get_index_path("somehash")
assert isinstance(p, Path)
# =========================================================================
# _deduplicate_chunks (static method)
# =========================================================================
class TestDeduplicateChunks:
"""Tests for _deduplicate_chunks."""
def _doc(self, text):
return LangchainDocument(page_content=text)
def test_empty_input_returns_empty(self):
from local_deep_research.research_library.services.library_rag_service import (
LibraryRAGService,
)
chunks, ids = LibraryRAGService._deduplicate_chunks([], [])
assert chunks == []
assert ids == []
def test_no_duplicates_preserved(self):
from local_deep_research.research_library.services.library_rag_service import (
LibraryRAGService,
)
d1, d2 = self._doc("a"), self._doc("b")
chunks, ids = LibraryRAGService._deduplicate_chunks(
[d1, d2], ["id1", "id2"]
)
assert ids == ["id1", "id2"]
assert chunks == [d1, d2]
def test_duplicate_ids_keeps_first(self):
from local_deep_research.research_library.services.library_rag_service import (
LibraryRAGService,
)
d1, d2, d3 = self._doc("a"), self._doc("b"), self._doc("c")
chunks, ids = LibraryRAGService._deduplicate_chunks(
[d1, d2, d3], ["id1", "id1", "id2"]
)
assert ids == ["id1", "id2"]
assert chunks == [d1, d3]
def test_existing_ids_excluded(self):
from local_deep_research.research_library.services.library_rag_service import (
LibraryRAGService,
)
d1, d2 = self._doc("a"), self._doc("b")
chunks, ids = LibraryRAGService._deduplicate_chunks(
[d1, d2], ["id1", "id2"], existing_ids={"id1"}
)
assert ids == ["id2"]
assert chunks == [d2]
def test_none_existing_ids_allows_all(self):
from local_deep_research.research_library.services.library_rag_service import (
LibraryRAGService,
)
d1 = self._doc("a")
chunks, ids = LibraryRAGService._deduplicate_chunks(
[d1], ["id1"], existing_ids=None
)
assert ids == ["id1"]
def test_order_preservation(self):
from local_deep_research.research_library.services.library_rag_service import (
LibraryRAGService,
)
docs = [self._doc(str(i)) for i in range(5)]
id_list = ["e", "d", "c", "b", "a"]
chunks, ids = LibraryRAGService._deduplicate_chunks(docs, id_list)
assert ids == ["e", "d", "c", "b", "a"]
def test_all_existing_returns_empty(self):
from local_deep_research.research_library.services.library_rag_service import (
LibraryRAGService,
)
d1, d2 = self._doc("a"), self._doc("b")
chunks, ids = LibraryRAGService._deduplicate_chunks(
[d1, d2], ["id1", "id2"], existing_ids={"id1", "id2"}
)
assert chunks == []
assert ids == []
# =========================================================================
# _get_or_create_rag_index
# =========================================================================
class TestGetOrCreateRagIndex:
"""Tests for _get_or_create_rag_index."""
@patch(f"{_MOD}.get_user_db_session")
def test_returns_existing_index(self, mock_session_ctx):
svc = _make_service()
mock_session = MagicMock()
mock_session_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_session_ctx.return_value.__exit__ = MagicMock(return_value=None)
existing_index = MagicMock()
mock_session.query.return_value.filter_by.return_value.first.return_value = existing_index
result = svc._get_or_create_rag_index("coll-123")
assert result is existing_index
# Should NOT call session.add since index already existed
mock_session.add.assert_not_called()
@patch(f"{_MOD}.get_cache_directory")
@patch(f"{_MOD}.get_user_db_session")
def test_creates_new_index_when_none_exists(
self, mock_session_ctx, mock_cache_dir, tmp_path
):
mock_cache_dir.return_value = tmp_path
svc = _make_service()
svc.embedding_manager = MagicMock()
svc.embedding_manager.embeddings.embed_query.return_value = [0.0] * 384
mock_session = MagicMock()
mock_session_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_session_ctx.return_value.__exit__ = MagicMock(return_value=None)
# No existing index found
mock_session.query.return_value.filter_by.return_value.first.return_value = None
svc._get_or_create_rag_index("coll-456")
# Should have called session.add for the new RAGIndex
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
mock_session.refresh.assert_called_once()
@patch(f"{_MOD}.get_cache_directory")
@patch(f"{_MOD}.get_user_db_session")
def test_embeds_test_string_for_dimension(
self, mock_session_ctx, mock_cache_dir, tmp_path
):
mock_cache_dir.return_value = tmp_path
svc = _make_service()
svc.embedding_manager = MagicMock()
svc.embedding_manager.embeddings.embed_query.return_value = [0.1] * 768
mock_session = MagicMock()
mock_session_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_session_ctx.return_value.__exit__ = MagicMock(return_value=None)
mock_session.query.return_value.filter_by.return_value.first.return_value = None
svc._get_or_create_rag_index("coll-789")
svc.embedding_manager.embeddings.embed_query.assert_called_once_with(
"test"
)
# =========================================================================
# load_or_create_faiss_index
# =========================================================================
class TestLoadOrCreateFaissIndex:
"""Tests for load_or_create_faiss_index."""
def _patch_get_or_create(self, svc, rag_index):
"""Patch _get_or_create_rag_index on a service instance."""
svc._get_or_create_rag_index = MagicMock(return_value=rag_index)
def _make_rag_index(self, index_path="/tmp/test.faiss", dim=384):
idx = MagicMock()
idx.index_path = index_path
idx.embedding_dimension = dim
idx.id = "rag-idx-1"
return idx
@patch(f"{_MOD}.FAISS")
@patch(f"{_MOD}.InMemoryDocstore")
@patch(f"{_MOD}.IndexFlatIP")
def test_creates_flat_ip_for_cosine(
self, mock_flat_ip, mock_docstore, mock_faiss
):
svc = _make_service(distance_metric="cosine", index_type="flat")
rag_idx = self._make_rag_index(index_path="/nonexistent/test.faiss")
self._patch_get_or_create(svc, rag_idx)
svc.embedding_manager = MagicMock()
svc.load_or_create_faiss_index("coll-1")
mock_flat_ip.assert_called_once_with(384)
@patch(f"{_MOD}.FAISS")
@patch(f"{_MOD}.InMemoryDocstore")
@patch(f"{_MOD}.IndexFlatL2")
def test_creates_flat_l2_for_l2_metric(
self, mock_flat_l2, mock_docstore, mock_faiss
):
svc = _make_service(distance_metric="l2", index_type="flat")
rag_idx = self._make_rag_index(index_path="/nonexistent/test.faiss")
self._patch_get_or_create(svc, rag_idx)
svc.embedding_manager = MagicMock()
svc.load_or_create_faiss_index("coll-1")
mock_flat_l2.assert_called_once_with(384)
@patch(f"{_MOD}.FAISS")
@patch(f"{_MOD}.InMemoryDocstore")
@patch(f"{_MOD}.IndexHNSWFlat")
def test_creates_hnsw_index(self, mock_hnsw, mock_docstore, mock_faiss):
svc = _make_service(index_type="hnsw")
rag_idx = self._make_rag_index(index_path="/nonexistent/test.faiss")
self._patch_get_or_create(svc, rag_idx)
svc.embedding_manager = MagicMock()
svc.load_or_create_faiss_index("coll-1")
mock_hnsw.assert_called_once_with(384, 32)
@patch(f"{_MOD}.FAISS")
@patch(f"{_MOD}.InMemoryDocstore")
@patch(f"{_MOD}.IndexFlatIP")
def test_ivf_falls_back_to_flat_ip_for_cosine(
self, mock_flat_ip, mock_docstore, mock_faiss
):
svc = _make_service(index_type="ivf", distance_metric="cosine")
rag_idx = self._make_rag_index(index_path="/nonexistent/test.faiss")
self._patch_get_or_create(svc, rag_idx)
svc.embedding_manager = MagicMock()
svc.load_or_create_faiss_index("coll-1")
mock_flat_ip.assert_called_once_with(384)
@patch(f"{_MOD}.FAISS")
@patch(f"{_MOD}.InMemoryDocstore")
@patch(f"{_MOD}.IndexFlatL2")
def test_ivf_falls_back_to_flat_l2_for_l2(
self, mock_flat_l2, mock_docstore, mock_faiss
):
svc = _make_service(index_type="ivf", distance_metric="l2")
rag_idx = self._make_rag_index(index_path="/nonexistent/test.faiss")
self._patch_get_or_create(svc, rag_idx)
svc.embedding_manager = MagicMock()
svc.load_or_create_faiss_index("coll-1")
mock_flat_l2.assert_called_once_with(384)
@patch(f"{_MOD}.safe_load_faiss")
@patch(f"{_MOD}.FAISS")
@patch(f"{_MOD}.InMemoryDocstore")
@patch(f"{_MOD}.IndexFlatIP")
def test_verified_load_returns_existing_index(
self, mock_flat_ip, mock_docstore, mock_faiss_cls, mock_safe_load
):
svc = _make_service()
svc.embedding_manager = MagicMock()
svc.embedding_manager.embeddings.embed_query.return_value = [0.0] * 384
svc.integrity_manager = MagicMock()
svc.integrity_manager.verify_file.return_value = (True, None)
rag_idx = self._make_rag_index(dim=384)
rag_idx.index_path = "/tmp/existing.faiss"
self._patch_get_or_create(svc, rag_idx)
mock_loaded = MagicMock()
mock_safe_load.return_value = mock_loaded
with patch.object(Path, "exists", return_value=True):
result = svc.load_or_create_faiss_index("coll-1")
# Verified index is loaded via the restricted-unpickler loader,
# never via the dangerous FAISS.load_local.
assert result is mock_loaded
mock_safe_load.assert_called_once()
mock_faiss_cls.load_local.assert_not_called()
@patch(f"{_MOD}.FAISS")
@patch(f"{_MOD}.InMemoryDocstore")
@patch(f"{_MOD}.IndexFlatIP")
def test_integrity_failure_quarantines_and_creates_new_index(
self, mock_flat_ip, mock_docstore, mock_faiss_cls, tmp_path
):
"""Verify-failure path: corrupt .faiss + .pkl are RENAMED to
.corrupt-<ns>, NOT unlinked. Then fresh index is created.
Regression for #4197 data-loss bug.
"""
svc = _make_service()
svc.embedding_manager = MagicMock()
svc.integrity_manager = MagicMock()
svc.integrity_manager.verify_file.return_value = (
False,
"hash mismatch",
)
# Use real on-disk files so .exists() in the quarantine
# collision check returns False naturally for the .corrupt-*
# paths, not True-for-everything.
idx_path = tmp_path / "corrupt.faiss"
pkl_path = tmp_path / "corrupt.pkl"
idx_path.write_bytes(b"faiss-bytes")
pkl_path.write_bytes(b"pkl-bytes")
rag_idx = self._make_rag_index()
rag_idx.index_path = str(idx_path)
self._patch_get_or_create(svc, rag_idx)
with patch.object(Path, "unlink") as mock_unlink:
svc.load_or_create_faiss_index("coll-1")
# Should NOT have unlinked anything in the verify-failure path
mock_unlink.assert_not_called()
# Originals quarantined (renamed away from their paths)
assert not idx_path.exists()
assert not pkl_path.exists()
# Both files preserved under .corrupt-<ns> names
corrupt_faiss = list(tmp_path.glob("corrupt.faiss.corrupt-*"))
corrupt_pkl = list(tmp_path.glob("corrupt.pkl.corrupt-*"))
assert len(corrupt_faiss) == 1
assert len(corrupt_pkl) == 1
assert corrupt_faiss[0].read_bytes() == b"faiss-bytes"
# Should return a new FAISS instance, not load_local
mock_faiss_cls.load_local.assert_not_called()
mock_faiss_cls.assert_called_once()
@patch(f"{_MOD}.FAISS")
@patch(f"{_MOD}.InMemoryDocstore")
@patch(f"{_MOD}.IndexFlatIP")
def test_quarantine_rename_oserror_re_raises(
self, mock_flat_ip, mock_docstore, mock_faiss_cls, tmp_path
):
"""Disk-full / read-only fs during quarantine MUST propagate.
Silently falling through would let the next save_local truncate
the corrupt bytes, recreating the very data loss #4197 fixes.
"""
svc = _make_service()
svc.embedding_manager = MagicMock()
svc.integrity_manager = MagicMock()
svc.integrity_manager.verify_file.return_value = (False, "bad hash")
idx_path = tmp_path / "corrupt.faiss"
idx_path.write_bytes(b"faiss-bytes")
(tmp_path / "corrupt.pkl").write_bytes(b"pkl-bytes")
rag_idx = self._make_rag_index()
rag_idx.index_path = str(idx_path)
self._patch_get_or_create(svc, rag_idx)
with patch.object(Path, "rename", side_effect=OSError("ENOSPC")):
with pytest.raises(OSError, match="ENOSPC"):
svc.load_or_create_faiss_index("coll-1")
# Fresh index must NOT have been created — corrupt bytes are
# still on disk and creating one would let the next save
# overwrite them.
mock_faiss_cls.assert_not_called()
@patch(f"{_MOD}.get_user_db_session")
@patch(f"{_MOD}.FAISS")
@patch(f"{_MOD}.InMemoryDocstore")
@patch(f"{_MOD}.IndexFlatIP")
def test_dimension_mismatch_deletes_and_rebuilds(
self, mock_flat_ip, mock_docstore, mock_faiss_cls, mock_session_ctx
):
svc = _make_service()
svc.embedding_manager = MagicMock()
# Current model returns dim 768 but index was stored with 384
svc.embedding_manager.embeddings.embed_query.return_value = [0.0] * 768
svc.integrity_manager = MagicMock()
svc.integrity_manager.verify_file.return_value = (True, None)
mock_session = MagicMock()
mock_session_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_session_ctx.return_value.__exit__ = MagicMock(return_value=None)
mock_session.query.return_value.filter_by.return_value.first.return_value = MagicMock()
rag_idx = self._make_rag_index(dim=384)
rag_idx.index_path = "/tmp/old_dim.faiss"
self._patch_get_or_create(svc, rag_idx)
with (
patch.object(Path, "exists", return_value=True),
patch.object(Path, "unlink") as mock_unlink,
patch.object(Path, "with_suffix") as mock_with_suffix,
):
mock_pkl = MagicMock()
mock_pkl.exists.return_value = True
mock_with_suffix.return_value = mock_pkl
svc.load_or_create_faiss_index("coll-1")
# The old file should have been unlinked
mock_unlink.assert_called()
# A new FAISS should be created (not loaded)
mock_faiss_cls.assert_called_once()
@patch(f"{_MOD}.safe_load_faiss")
@patch(f"{_MOD}.FAISS")
@patch(f"{_MOD}.InMemoryDocstore")
@patch(f"{_MOD}.IndexFlatIP")
def test_load_failure_quarantines_and_creates_new_index(
self,
mock_flat_ip,
mock_docstore,
mock_faiss_cls,
mock_safe_load,
tmp_path,
):
"""When the loader raises (torn .pkl, malformed pickle, or a
rejected/tampered pickle), the .faiss and .pkl are quarantined
before falling through to a fresh index. Previously the old code
silently discarded the broken-state files without preserving
evidence.
"""
svc = _make_service()
svc.embedding_manager = MagicMock()
svc.embedding_manager.embeddings.embed_query.return_value = [0.0] * 384
svc.integrity_manager = MagicMock()
svc.integrity_manager.verify_file.return_value = (True, None)
idx_path = tmp_path / "broken.faiss"
pkl_path = tmp_path / "broken.pkl"
idx_path.write_bytes(b"faiss-bytes")
pkl_path.write_bytes(b"pkl-bytes")
rag_idx = self._make_rag_index(dim=384)
rag_idx.index_path = str(idx_path)
self._patch_get_or_create(svc, rag_idx)
mock_safe_load.side_effect = RuntimeError("corrupted file")
svc.load_or_create_faiss_index("coll-1")
# Both files quarantined (renamed away)
assert not idx_path.exists()
assert not pkl_path.exists()
assert len(list(tmp_path.glob("broken.faiss.corrupt-*"))) == 1
assert len(list(tmp_path.glob("broken.pkl.corrupt-*"))) == 1
# Should fall through and create new index
mock_faiss_cls.assert_called_once()
@patch(f"{_MOD}.FAISS")
@patch(f"{_MOD}.InMemoryDocstore")
@patch(f"{_MOD}.IndexFlatIP")
def test_embedding_provider_failure_raises_and_preserves_index(
self, mock_flat_ip, mock_docstore, mock_faiss_cls, tmp_path
):
"""When the embedding provider is unreachable (e.g. Ollama down),
the dimension-check embed_query raises. That says nothing about
the index files, so the error must propagate WITHOUT quarantining
the healthy index or replacing it with an empty one.
"""
svc = _make_service()
svc.embedding_manager = MagicMock()
svc.embedding_manager.embeddings.embed_query.side_effect = (
ConnectionError("Ollama connection refused")
)
svc.integrity_manager = MagicMock()
svc.integrity_manager.verify_file.return_value = (True, None)
idx_path = tmp_path / "healthy.faiss"
pkl_path = tmp_path / "healthy.pkl"
idx_path.write_bytes(b"faiss-bytes")
pkl_path.write_bytes(b"pkl-bytes")
rag_idx = self._make_rag_index(dim=384)
rag_idx.index_path = str(idx_path)
self._patch_get_or_create(svc, rag_idx)
with pytest.raises(ConnectionError, match="Ollama"):
svc.load_or_create_faiss_index("coll-1")
# The healthy index files must be untouched — not quarantined
assert idx_path.read_bytes() == b"faiss-bytes"
assert pkl_path.read_bytes() == b"pkl-bytes"
assert list(tmp_path.glob("*.corrupt-*")) == []
# And no empty replacement index was created
mock_faiss_cls.load_local.assert_not_called()
mock_faiss_cls.assert_not_called()
@patch(f"{_MOD}.FAISS")
@patch(f"{_MOD}.InMemoryDocstore")
@patch(f"{_MOD}.IndexFlatIP")
def test_normalize_vectors_passed_to_faiss(
self, mock_flat_ip, mock_docstore, mock_faiss_cls
):
svc = _make_service(normalize_vectors=False)
rag_idx = self._make_rag_index(index_path="/nonexistent/test.faiss")
self._patch_get_or_create(svc, rag_idx)
svc.embedding_manager = MagicMock()
svc.load_or_create_faiss_index("coll-1")
call_kwargs = mock_faiss_cls.call_args
assert call_kwargs[1]["normalize_L2"] is False
# =========================================================================
# index_document
# =========================================================================
class TestIndexDocument:
"""Tests for index_document."""
@patch(f"{_MOD}.get_user_db_session")
def test_returns_error_when_document_not_found(self, mock_session_ctx):
svc = _make_service()
mock_session = MagicMock()
mock_session_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_session_ctx.return_value.__exit__ = MagicMock(return_value=None)
mock_session.query.return_value.filter_by.return_value.first.return_value = None
result = svc.index_document("doc-1", "coll-1")
assert result["status"] == "error"
assert "not found" in result["error"]
@patch(f"{_MOD}.ensure_in_collection")
@patch(f"{_MOD}.get_user_db_session")
def test_returns_error_when_no_text_content(
self, mock_session_ctx, mock_ensure
):
svc = _make_service()
mock_session = MagicMock()
mock_session_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_session_ctx.return_value.__exit__ = MagicMock(return_value=None)
mock_document = MagicMock()
mock_document.text_content = None
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_document
mock_ensure.return_value = MagicMock(indexed=False, chunk_count=0)
result = svc.index_document("doc-1", "coll-1")
assert result["status"] == "error"
assert "no text content" in result["error"]
@patch(f"{_MOD}.ensure_in_collection")
@patch(f"{_MOD}.get_user_db_session")
def test_skips_already_indexed_document(
self, mock_session_ctx, mock_ensure
):
svc = _make_service()
mock_session = MagicMock()
mock_session_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_session_ctx.return_value.__exit__ = MagicMock(return_value=None)
mock_document = MagicMock()
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_document
mock_ensure.return_value = MagicMock(indexed=True, chunk_count=42)
result = svc.index_document("doc-1", "coll-1", force_reindex=False)
assert result["status"] == "skipped"
assert result["chunk_count"] == 42
# =========================================================================
# index_all_documents (index_collection in the spec)
# =========================================================================
class TestIndexAllDocuments:
"""Tests for index_all_documents."""
@patch(f"{_MOD}.get_user_db_session")
def test_no_documents_returns_info(self, mock_session_ctx):
svc = _make_service()
mock_session = MagicMock()
mock_session_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_session_ctx.return_value.__exit__ = MagicMock(return_value=None)
mock_session.query.return_value.filter_by.return_value.filter_by.return_value.all.return_value = []
result = svc.index_all_documents("coll-1")
assert result["status"] == "info"
assert result["successful"] == 0
@patch(f"{_MOD}.get_user_db_session")
def test_progress_callback_invoked(self, mock_session_ctx):
svc = _make_service()
mock_session = MagicMock()
mock_session_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_session_ctx.return_value.__exit__ = MagicMock(return_value=None)
mock_dc1 = MagicMock()
mock_dc1.document_id = "doc-1"
mock_dc2 = MagicMock()
mock_dc2.document_id = "doc-2"
mock_doc = MagicMock()
mock_doc.title = "Test Doc"
# filter_by(collection_id=...) -> filter_by(indexed=False) -> all()
mock_session.query.return_value.filter_by.return_value.filter_by.return_value.all.return_value = [
mock_dc1,
mock_dc2,
]
# query(Document).filter_by(id=...).first() for title lookup
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_doc
# Mock index_document to return success
svc.index_document = MagicMock(
return_value={"status": "success", "chunk_count": 10}
)
callback = MagicMock()
svc.index_all_documents("coll-1", progress_callback=callback)
assert callback.call_count == 2
# Check the callback was called with (idx, total, title, status)
first_call = callback.call_args_list[0]
assert first_call[0][0] == 1 # idx
assert first_call[0][1] == 2 # total
assert first_call[0][3] == "success" # status
@patch(f"{_MOD}.get_user_db_session")
def test_counts_successful_skipped_failed(self, mock_session_ctx):
svc = _make_service()
mock_session = MagicMock()
mock_session_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_session_ctx.return_value.__exit__ = MagicMock(return_value=None)
mock_dc1 = MagicMock()
mock_dc1.document_id = "doc-1"
mock_dc2 = MagicMock()
mock_dc2.document_id = "doc-2"
mock_dc3 = MagicMock()
mock_dc3.document_id = "doc-3"
mock_doc = MagicMock()
mock_doc.title = "Title"
# With force_reindex=True the code does:
# query(DocumentCollection).filter_by(collection_id=...).all()
# (no second filter_by for indexed=False)
mock_query = MagicMock()
mock_query.filter_by.return_value.all.return_value = [
mock_dc1,
mock_dc2,
mock_dc3,
]
# Document title lookup
mock_query.filter_by.return_value.first.return_value = mock_doc
mock_session.query.return_value = mock_query
results_sequence = [
{"status": "success", "chunk_count": 5},
{
"status": "skipped",
"message": "already indexed",
"chunk_count": 3,
},
{"status": "error", "error": "something broke"},
]
svc.index_document = MagicMock(side_effect=results_sequence)
result = svc.index_all_documents("coll-1", force_reindex=True)
assert result["successful"] == 1
assert result["skipped"] == 1
assert result["failed"] == 1
assert len(result["errors"]) == 1
# =========================================================================
# db_password property
# =========================================================================
class TestDbPasswordProperty:
"""Tests for the db_password property propagation."""
def test_getter_returns_value(self):
svc = _make_service()
svc._db_password = "secret"
assert svc.db_password == "secret"
def test_setter_propagates_to_embedding_manager(self):
svc = _make_service()
svc.embedding_manager = MagicMock()
svc.integrity_manager = MagicMock()
svc.db_password = "new_pw"
assert svc.embedding_manager.db_password == "new_pw"
def test_setter_propagates_to_integrity_manager(self):
svc = _make_service()
svc.embedding_manager = MagicMock()
svc.integrity_manager = MagicMock()
svc.db_password = "new_pw"
assert svc.integrity_manager.password == "new_pw"
def test_setter_handles_none_managers(self):
svc = _make_service()
svc.embedding_manager = None
svc.integrity_manager = None
# Should not raise
svc.db_password = "pw"
assert svc._db_password == "pw"
# =========================================================================
# Context manager / close
# =========================================================================
class TestContextManager:
"""Tests for context manager protocol."""
def test_enter_returns_self(self):
svc = _make_service()
assert svc.__enter__() is svc
def test_exit_calls_close(self):
svc = _make_service()
svc.close = MagicMock()
svc.__exit__(None, None, None)
svc.close.assert_called_once()
def test_exit_returns_false(self):
svc = _make_service()
result = svc.__exit__(None, None, None)
assert result is False
# =========================================================================
# Quarantine helper + concurrent-write locks (#4197)
# =========================================================================
def _import_module():
import local_deep_research.research_library.services.library_rag_service as m
return m
class TestCorruptionQuarantine:
"""Verify the verify-failure and load-failure paths preserve
corrupted bytes instead of deleting them.
"""
def _make_idx(self, tmp_path, name="idx"):
idx = tmp_path / f"{name}.faiss"
pkl = tmp_path / f"{name}.pkl"
idx.write_bytes(b"faiss-bytes")
pkl.write_bytes(b"pkl-bytes")
return idx, pkl
def test_renames_both_faiss_and_pkl(self, tmp_path):
svc = _make_service()
idx, pkl = self._make_idx(tmp_path)
svc._quarantine_corrupt_index(idx, "test")
# Original files are gone from their paths
assert not idx.exists()
assert not pkl.exists()
# Both quarantined as .corrupt-*
corrupted_faiss = list(tmp_path.glob("idx.faiss.corrupt-*"))
corrupted_pkl = list(tmp_path.glob("idx.pkl.corrupt-*"))
assert len(corrupted_faiss) == 1
assert len(corrupted_pkl) == 1
assert corrupted_faiss[0].read_bytes() == b"faiss-bytes"
assert corrupted_pkl[0].read_bytes() == b"pkl-bytes"
def test_skips_pkl_when_missing(self, tmp_path):
svc = _make_service()
idx = tmp_path / "idx.faiss"
idx.write_bytes(b"faiss-bytes")
# No .pkl present
svc._quarantine_corrupt_index(idx, "test")
assert not idx.exists()
corrupted = list(tmp_path.glob("idx.faiss.corrupt-*"))
assert len(corrupted) == 1
# No orphan pkl created
assert not any(tmp_path.glob("idx.pkl.corrupt-*"))
def test_rename_oserror_re_raises(self, tmp_path):
"""Disk-full / permission failure must propagate, not be swallowed."""
svc = _make_service()
idx, _pkl = self._make_idx(tmp_path)
with patch.object(Path, "rename", side_effect=OSError("EROFS")):
with pytest.raises(OSError, match="EROFS"):
svc._quarantine_corrupt_index(idx, "test")
def test_collision_increments_suffix(self, tmp_path):
"""Pre-existing quarantine path → loop increments to -1, -2, ..."""
svc = _make_service()
idx, pkl = self._make_idx(tmp_path)
# Force a specific timestamp so we can predict collision targets
with patch.object(
_import_module().time,
"time_ns",
return_value=12345,
):
# Pre-create the base collision target
(tmp_path / "idx.faiss.corrupt-12345").write_bytes(b"old1")
svc._quarantine_corrupt_index(idx, "test")
# Original moved out
assert not idx.exists()
# New corrupt-12345-1 created (the -1 increment)
moved = tmp_path / "idx.faiss.corrupt-12345-1"
assert moved.exists()
assert moved.read_bytes() == b"faiss-bytes"
def test_collision_cap_raises(self, tmp_path):
"""If 32 collisions in a row, surface an OSError, don't hang."""
svc = _make_service()
mod = _import_module()
idx, pkl = self._make_idx(tmp_path)
with patch.object(mod.time, "time_ns", return_value=999):
# Pre-create base + 32 collisions
(tmp_path / "idx.faiss.corrupt-999").write_bytes(b"")
for n in range(1, mod._QUARANTINE_SUFFIX_RETRY_CAP + 1):
(tmp_path / f"idx.faiss.corrupt-999-{n}").write_bytes(b"")
with pytest.raises(OSError, match="collisions exceeded"):
svc._quarantine_corrupt_index(idx, "test")
class TestQuarantineRetention:
"""Retention sweep keeps the rag_indices/ directory from filling
with old .corrupt-* files on systems that experience recurring
corruption.
"""
def _make_idx(self, tmp_path, name="idx"):
idx = tmp_path / f"{name}.faiss"
pkl = tmp_path / f"{name}.pkl"
idx.write_bytes(b"faiss-bytes")
pkl.write_bytes(b"pkl-bytes")
return idx, pkl
def _make_quarantined(self, tmp_path, name, ns, mtime):
"""Create a paired (.faiss, .pkl) quarantined-style file at
the given ns suffix and stamp it with ``mtime`` so the
retention sweep's age sort is deterministic.
"""
faiss_f = tmp_path / f"{name}.faiss.corrupt-{ns}"
pkl_f = tmp_path / f"{name}.pkl.corrupt-{ns}"
faiss_f.write_bytes(b"")
pkl_f.write_bytes(b"")
import os
os.utime(faiss_f, (mtime, mtime))
os.utime(pkl_f, (mtime, mtime))
return faiss_f, pkl_f
def test_prune_keeps_most_recent_drops_older(self, tmp_path):
"""After N+2 simulated past corruption events, pruning leaves
exactly N (most recent by mtime) and removes the oldest 2.
"""
svc = _make_service()
mod = _import_module()
keep = mod._QUARANTINE_KEEP_RECENT
# Pre-seed keep+2 quarantined pairs with increasing mtimes
# (later ns → newer mtime).
for i in range(keep + 2):
self._make_quarantined(
tmp_path, "idx", ns=1000 + i, mtime=1000.0 + i
)
# Trigger pruning via the public quarantine call (which now
# invokes the sweep at the end).
idx, _pkl = self._make_idx(tmp_path)
svc._quarantine_corrupt_index(idx, "test")
faiss_corrupts = sorted(tmp_path.glob("idx.faiss.corrupt-*"))
pkl_corrupts = sorted(tmp_path.glob("idx.pkl.corrupt-*"))
# keep pre-existing + 1 fresh = keep total on each side
assert len(faiss_corrupts) == keep
assert len(pkl_corrupts) == keep
# Oldest two pre-existing pairs (ns 1000, 1001) must be gone.
for stale_ns in (1000, 1001):
assert not (tmp_path / f"idx.faiss.corrupt-{stale_ns}").exists()
assert not (tmp_path / f"idx.pkl.corrupt-{stale_ns}").exists()
def test_prune_skipped_when_under_threshold(self, tmp_path):
"""With fewer than the retention cap, no pruning happens."""
svc = _make_service()
mod = _import_module()
keep = mod._QUARANTINE_KEEP_RECENT
# Pre-seed (keep - 2) quarantined pairs. After this call we'll
# add 1 more via the quarantine, ending at keep - 1 — still
# under the cap.
for i in range(keep - 2):
self._make_quarantined(
tmp_path, "idx", ns=2000 + i, mtime=2000.0 + i
)
idx, _pkl = self._make_idx(tmp_path)
svc._quarantine_corrupt_index(idx, "test")
# All pre-existing + the new one survive.
assert len(list(tmp_path.glob("idx.faiss.corrupt-*"))) == keep - 1
def test_prune_only_targets_same_base(self, tmp_path):
"""Pruning of ``idx.faiss.corrupt-*`` must NOT touch
``other.faiss.corrupt-*`` — different base paths are
independent.
"""
svc = _make_service()
mod = _import_module()
keep = mod._QUARANTINE_KEEP_RECENT
# Many old corrupts for the other base — these MUST be untouched.
for i in range(keep + 3):
self._make_quarantined(
tmp_path, "other", ns=3000 + i, mtime=3000.0 + i
)
# Trigger many corrupts for our base so the sweep runs.
for i in range(keep + 2):
self._make_quarantined(
tmp_path, "idx", ns=4000 + i, mtime=4000.0 + i
)
idx, _pkl = self._make_idx(tmp_path)
svc._quarantine_corrupt_index(idx, "test")
# Other base untouched
other_corrupts = list(tmp_path.glob("other.faiss.corrupt-*"))
assert len(other_corrupts) == keep + 3
# Our base pruned
idx_corrupts = list(tmp_path.glob("idx.faiss.corrupt-*"))
assert len(idx_corrupts) == keep
def test_prune_failure_does_not_propagate(self, tmp_path):
"""If unlink raises during the sweep, the quarantine call
still succeeds — retention is best-effort.
"""
svc = _make_service()
mod = _import_module()
keep = mod._QUARANTINE_KEEP_RECENT
# Seed enough corrupts to trigger pruning.
for i in range(keep + 2):
self._make_quarantined(
tmp_path, "idx", ns=5000 + i, mtime=5000.0 + i
)
before_count = len(list(tmp_path.glob("idx.faiss.corrupt-*")))
idx, _pkl = self._make_idx(tmp_path)
# Allow the quarantine rename to succeed, but make unlink raise
# for the prune sweep. The quarantine itself uses rename, not
# unlink, so this only affects the retention path.
real_unlink = Path.unlink
def selective_unlink(self):
if ".corrupt-" in self.name:
raise PermissionError(f"denied: {self}")
return real_unlink(self)
with patch.object(Path, "unlink", selective_unlink):
# Must not raise — sweep is best-effort.
svc._quarantine_corrupt_index(idx, "test")
# Quarantine still produced a new pair even though the sweep
# was unable to delete anything (count went up, not down).
after_count = len(list(tmp_path.glob("idx.faiss.corrupt-*")))
assert after_count == before_count + 1, (
f"expected one new corrupt file; before={before_count}, "
f"after={after_count}"
)
def test_retention_sort_uses_filename_ns_not_mtime(self, tmp_path):
"""On low-resolution filesystems (FAT32, SMB, ext3) mtime can
round to whole seconds, making same-second quarantines sort
non-deterministically by ``st_mtime``. The embedded
``-<ns>`` suffix is the authoritative ordering — confirm
retention drops the file with the *lowest* ns even when
its mtime would mark it as the *newest*.
"""
svc = _make_service()
mod = _import_module()
keep = mod._QUARANTINE_KEEP_RECENT
import os
# Seed exactly `keep` corrupts. The OLDEST ns gets the NEWEST
# mtime; any retention that sorts by mtime would keep it. A
# sort by ns must prune it once one more quarantine pushes us
# over the cap.
ns_values = list(range(6000, 6000 + keep))
mtimes = list(reversed([7000.0 + i for i in range(keep)]))
for ns, mtime in zip(ns_values, mtimes):
self._make_quarantined(tmp_path, "idx", ns=ns, mtime=mtime)
# Add one more quarantine via the real helper. Its ns is
# `time.time_ns()` which is far larger than 6000+, so by ns
# it's the freshest. Force its mtime to ancient so any
# mtime-based sort would prune *it* instead of the seeded
# lowest-ns file — that's the regression this test guards.
idx, _pkl = self._make_idx(tmp_path)
original_rename = Path.rename
def rename_then_age(self, target):
result = original_rename(self, target)
os.utime(target, (1.0, 1.0))
return result
with patch.object(Path, "rename", rename_then_age):
svc._quarantine_corrupt_index(idx, "test")
# The lowest-ns seeded file (ns=6000) must be the one pruned.
# The other seeded files (ns=6001..6004) plus the brand-new
# one (highest ns) survive.
assert not (tmp_path / f"idx.faiss.corrupt-{ns_values[0]}").exists()
for ns in ns_values[1:]:
assert (tmp_path / f"idx.faiss.corrupt-{ns}").exists(), (
f"ns={ns} should still exist after retention sort"
)
class TestFaissWriteLock:
"""Lock infrastructure: identity, parallelism across keys,
cleanup on user-close.
"""
def _reset_locks(self):
mod = _import_module()
with mod._faiss_write_locks_lock:
mod._faiss_write_locks.clear()
def test_same_key_returns_same_lock(self, tmp_path):
self._reset_locks()
mod = _import_module()
p = str(tmp_path / "shared.faiss")
a = mod._get_faiss_write_lock("u1", p)
b = mod._get_faiss_write_lock("u1", p)
assert a is b
def test_different_key_returns_different_lock(self, tmp_path):
self._reset_locks()
mod = _import_module()
a = mod._get_faiss_write_lock("u1", str(tmp_path / "a.faiss"))
b = mod._get_faiss_write_lock("u1", str(tmp_path / "b.faiss"))
c = mod._get_faiss_write_lock("u2", str(tmp_path / "a.faiss"))
assert a is not b
assert a is not c
def test_pop_removes_only_target_user(self, tmp_path):
self._reset_locks()
mod = _import_module()
u1_lock = mod._get_faiss_write_lock("u1", str(tmp_path / "a.faiss"))
mod._get_faiss_write_lock("u1", str(tmp_path / "b.faiss"))
u2_lock = mod._get_faiss_write_lock("u2", str(tmp_path / "a.faiss"))
mod.pop_faiss_locks_for_user("u1")
# u1 locks gone (new lookup yields a fresh lock object)
new_u1 = mod._get_faiss_write_lock("u1", str(tmp_path / "a.faiss"))
assert new_u1 is not u1_lock
# u2 lock untouched
same_u2 = mod._get_faiss_write_lock("u2", str(tmp_path / "a.faiss"))
assert same_u2 is u2_lock
def test_concurrent_holders_serialised_for_same_key(self, tmp_path):
"""Two threads acquiring ``_get_faiss_write_lock`` for the same
``(username, index_path)`` MUST observe non-overlapping critical
sections. Regression for the #4197 race.
"""
self._reset_locks()
mod = _import_module()
index_path = tmp_path / "idx.faiss"
in_flight = 0
max_in_flight = 0
observed_overlap = False
lock_for_counters = threading.Lock()
first_inside = threading.Event()
first_can_finish = threading.Event()
first_holder = [True] # mutable flag to mark which thread enters first
def worker():
with mod._get_faiss_write_lock("u1", str(index_path)):
nonlocal in_flight, max_in_flight, observed_overlap
with lock_for_counters:
in_flight += 1
if in_flight > max_in_flight:
max_in_flight = in_flight
if in_flight > 1:
observed_overlap = True
am_first = first_holder[0]
first_holder[0] = False
if am_first:
first_inside.set()
# Hold the critical section open so the second
# thread blocks behind us, not races us.
first_can_finish.wait(timeout=2.0)
with lock_for_counters:
in_flight -= 1
t1 = threading.Thread(target=worker)
t2 = threading.Thread(target=worker)
t1.start()
assert first_inside.wait(timeout=2.0)
t2.start()
first_can_finish.set()
t1.join(timeout=3.0)
t2.join(timeout=3.0)
assert not observed_overlap, (
"Two threads held the same FAISS write lock concurrently."
)
assert max_in_flight == 1
def test_different_keys_run_in_parallel(self, tmp_path):
"""Two threads acquiring locks for different
``(username, index_path)`` keys must run simultaneously.
"""
self._reset_locks()
mod = _import_module()
barrier = threading.Barrier(2, timeout=2.0)
idx1 = tmp_path / "a.faiss"
idx2 = tmp_path / "b.faiss"
def worker(index_path):
with mod._get_faiss_write_lock("u1", str(index_path)):
# If the lock blocked us, barrier.wait would time out
barrier.wait()
t1 = threading.Thread(target=worker, args=(idx1,))
t2 = threading.Thread(target=worker, args=(idx2,))
t1.start()
t2.start()
t1.join(timeout=3.0)
t2.join(timeout=3.0)
# If barrier broke (timeout), one of the threads would be alive
assert not t1.is_alive()
assert not t2.is_alive()
class TestNoWalCheckpointInIndexingPath:
"""Regression guard: the per-document PRAGMA wal_checkpoint(FULL)
has been removed (#4197 secondary fix from PR #3539). Re-adding it
would re-introduce 'database is locked' errors under concurrent
bulk indexing.
"""
def test_module_source_has_no_executable_wal_checkpoint(self):
import inspect
mod = _import_module()
src = inspect.getsource(mod)
# Strip out comment / docstring lines so the regression test
# tolerates explanatory references like 'PRAGMA wal_checkpoint'
# in comments, but fails if any code line re-adds the call.
for lineno, line in enumerate(src.splitlines(), 1):
stripped = line.strip()
if not stripped:
continue
if stripped.startswith("#"):
continue
assert "wal_checkpoint(" not in stripped, (
f"library_rag_service.py:{lineno} re-introduced "
f"a wal_checkpoint() call: {line!r}"
)
class TestMergeAndPersistLocked:
"""Read-modify-write under the FAISS write lock.
Regression for the AI-review concern on #4200: two workers
indexing different documents into the same collection used to
both load on-disk state X, each add their own chunk in memory,
then save in sequence — last writer wins, the loser's chunks
were lost from the FAISS file (chunks survived in the DB so a
rebuild recovered them, but the index file was wrong until then).
The helper reloads from disk under the lock before adding, so
the second writer sees the first writer's save and merges
instead of overwriting.
"""
def _reset_locks(self):
mod = _import_module()
with mod._faiss_write_locks_lock:
mod._faiss_write_locks.clear()
def test_reload_picks_up_concurrent_writers_save(self, tmp_path):
"""The merge helper must reload from disk before adding so
chunks committed by a concurrent writer between the caller's
in-memory load and this lock acquisition are preserved.
"""
self._reset_locks()
mod = _import_module()
index_path = tmp_path / "idx.faiss"
# The on-disk file must exist + verify so the helper actually
# takes the reload branch (not "no on-disk version, keep
# stale in-memory").
index_path.touch()
svc = _make_service()
svc.embedding_manager = MagicMock()
svc.integrity_manager = MagicMock()
svc.integrity_manager.verify_file.return_value = (True, None)
svc.rag_index_record = MagicMock(id=42)
# Caller's stale in-memory state knows about no chunks.
stale_index = MagicMock()
stale_index.docstore._dict = {}
svc.faiss_index = stale_index
# Disk state (loaded by the reload step) already has another
# writer's chunk "concurrent-id".
fresh_index = MagicMock()
fresh_index.docstore._dict = {"concurrent-id": MagicMock()}
new_chunk = LangchainDocument(page_content="our new content")
with patch.object(mod, "safe_load_faiss", return_value=fresh_index):
stats = svc._merge_and_persist_locked(
index_path,
[new_chunk],
["our-id"],
force_reindex=False,
)
# Helper reloaded → svc.faiss_index is now the fresh state
assert svc.faiss_index is fresh_index
# Our chunk was added to the fresh state (not the stale one)
fresh_index.add_documents.assert_called_once()
added_ids = fresh_index.add_documents.call_args[1]["ids"]
assert added_ids == ["our-id"]
# The stale state was NOT mutated (otherwise we'd be saving it)
stale_index.add_documents.assert_not_called()
# save_local was called on the fresh (merged) state
fresh_index.save_local.assert_called_once()
assert stats["added"] == 1
assert stats["added_ids"] == ["our-id"]
def test_reload_skips_when_existing_id_now_on_disk(self, tmp_path):
"""If the caller wanted to add id X but a concurrent writer
already saved X to disk, dedup against the fresh state must
drop X (idempotent re-index).
"""
self._reset_locks()
mod = _import_module()
index_path = tmp_path / "idx.faiss"
index_path.touch()
svc = _make_service()
svc.embedding_manager = MagicMock()
svc.integrity_manager = MagicMock()
svc.integrity_manager.verify_file.return_value = (True, None)
svc.rag_index_record = MagicMock(id=42)
svc.faiss_index = MagicMock() # stale, irrelevant
# Disk state already has the same id we're about to add.
fresh_index = MagicMock()
fresh_index.docstore._dict = {"shared-id": MagicMock()}
with patch.object(mod, "safe_load_faiss", return_value=fresh_index):
stats = svc._merge_and_persist_locked(
index_path,
[LangchainDocument(page_content="duplicate")],
["shared-id"],
force_reindex=False,
)
# No add (already on disk); save still happens (idempotent +
# touches mtime so the integrity record updates).
fresh_index.add_documents.assert_not_called()
fresh_index.save_local.assert_called_once()
assert stats["added"] == 0
assert stats["skipped"] == 1
def test_force_reindex_deletes_after_reload(self, tmp_path):
"""force_reindex deletes the matching IDs from the FRESH
on-disk state (not the caller's stale in-memory state)
before re-adding.
"""
self._reset_locks()
mod = _import_module()
index_path = tmp_path / "idx.faiss"
index_path.touch()
svc = _make_service()
svc.embedding_manager = MagicMock()
svc.integrity_manager = MagicMock()
svc.integrity_manager.verify_file.return_value = (True, None)
svc.rag_index_record = MagicMock(id=42)
svc.faiss_index = MagicMock() # stale
fresh_index = MagicMock()
fresh_index.docstore._dict = {"to-update": MagicMock()}
with patch.object(mod, "safe_load_faiss", return_value=fresh_index):
svc._merge_and_persist_locked(
index_path,
[LangchainDocument(page_content="updated")],
["to-update"],
force_reindex=True,
)
# Old copy removed from FRESH state
fresh_index.delete.assert_called_once_with(["to-update"])
# New copy added (no dedup under force_reindex)
fresh_index.add_documents.assert_called_once()
added_ids = fresh_index.add_documents.call_args[1]["ids"]
assert added_ids == ["to-update"]
def test_reload_failure_falls_back_to_in_memory(self, tmp_path):
"""If the loader raises (torn write, partial pickle),
the merge helper keeps the caller's in-memory state instead
of losing the write entirely.
"""
self._reset_locks()
mod = _import_module()
index_path = tmp_path / "idx.faiss"
index_path.touch()
svc = _make_service()
svc.embedding_manager = MagicMock()
svc.integrity_manager = MagicMock()
svc.integrity_manager.verify_file.return_value = (True, None)
svc.rag_index_record = MagicMock(id=42)
in_memory = MagicMock()
in_memory.docstore._dict = {}
svc.faiss_index = in_memory
with patch.object(
mod, "safe_load_faiss", side_effect=RuntimeError("torn pickle")
):
svc._merge_and_persist_locked(
index_path,
[LangchainDocument(page_content="kept")],
["kept-id"],
force_reindex=False,
)
# Reload failed → kept the in-memory object, added to it,
# saved it. The write is preserved.
assert svc.faiss_index is in_memory
in_memory.add_documents.assert_called_once()
in_memory.save_local.assert_called_once()
def test_concurrent_writers_both_chunks_survive(self, tmp_path):
"""End-to-end regression: two workers each call
``_merge_and_persist_locked`` for the same on-disk index.
Each starts from a stale in-memory snapshot. After both
complete, the on-disk state must contain BOTH chunks.
Before the read-modify-write fix, this was last-writer-wins:
worker A's chunk was overwritten by worker B's save because
B never reloaded A's write.
"""
self._reset_locks()
mod = _import_module()
index_path = tmp_path / "shared.faiss"
index_path.touch()
# Single shared "disk state" as a dict — proxies for what
# would be on disk via FAISS.load_local/save_local.
disk_state: dict = {}
disk_lock = threading.Lock()
def make_index_proxy():
"""Each call returns a FAISS-like proxy reading/writing
the shared disk_state. Mimics a fresh load_local()."""
ix = MagicMock()
# Snapshot disk_state at load time (proxy for what
# FAISS.load_local would return).
snapshot = dict(disk_state)
ix.docstore = MagicMock()
ix.docstore._dict = snapshot
def add(chunks, ids):
for cid in ids:
snapshot[cid] = "doc"
def save(_folder, index_name=None):
# Atomically replace disk_state with this index's
# current snapshot — mirrors save_local's truncating
# semantics for the test.
with disk_lock:
disk_state.clear()
disk_state.update(snapshot)
ix.add_documents.side_effect = add
ix.save_local.side_effect = save
return ix
def worker(cid: str, start_event: threading.Event):
svc = _make_service()
svc.embedding_manager = MagicMock()
svc.integrity_manager = MagicMock()
svc.integrity_manager.verify_file.return_value = (True, None)
svc.rag_index_record = MagicMock(id=99)
# Pre-merge in-memory state is stale (empty)
svc.faiss_index = MagicMock()
svc.faiss_index.docstore = MagicMock()
svc.faiss_index.docstore._dict = {}
start_event.wait() # Race start
svc._merge_and_persist_locked(
index_path,
[LangchainDocument(page_content=f"chunk-{cid}")],
[cid],
force_reindex=False,
)
go = threading.Event()
t1 = threading.Thread(target=worker, args=("A", go))
t2 = threading.Thread(target=worker, args=("B", go))
# Patch safe_load_faiss ONCE, around both threads. Patching
# inside each worker (the original shape) is a hidden race:
# ``patch.object`` rewrites a module attribute and is not
# thread-safe — when the first worker's
# ``with patch.object(...)`` block exits, it restores whatever
# it captured as "the original" at entry, which can be the
# second worker's lambda OR the real ``safe_load_faiss``
# (depending on which thread entered patch first). If the real
# ``safe_load_faiss`` becomes active mid-test, the second worker's
# reload step raises on the empty touched ``shared.faiss``,
# the production code's ``except`` branch falls back to the
# caller's stale in-memory MagicMock, and the worker's save is
# a no-op (the setup MagicMock has no ``save_local`` side
# effect). Result: that worker's chunk never reaches
# ``disk_state``, the assertion fails, and the test wrongly
# blames a regression in the production lock. Applying the
# patch around both threads removes the race entirely — the
# patched value is the only value visible while either worker
# runs.
with patch.object(
mod,
"safe_load_faiss",
side_effect=lambda *a, **kw: make_index_proxy(),
):
t1.start()
t2.start()
go.set()
t1.join(timeout=5.0)
t2.join(timeout=5.0)
# Both writers' chunks present — read-modify-write under the
# lock did its job. Without the fix, only one of {A, B} would
# remain.
assert "A" in disk_state, (
f"Worker A's chunk lost; disk_state={disk_state}. "
f"Read-modify-write race regressed."
)
assert "B" in disk_state, (
f"Worker B's chunk lost; disk_state={disk_state}. "
f"Read-modify-write race regressed."
)