531 lines
18 KiB
Python
531 lines
18 KiB
Python
"""Unit tests for QdrantVectorDBStorage's deferred-embedding flush pipeline.
|
|
|
|
All tests use mocks — no running Qdrant instance required.
|
|
Mirrors the structure of tests/kg/opensearch_impl/test_opensearch_storage.py's
|
|
TestVectorStorageBatching to keep behaviour aligned across backends.
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
pytest.importorskip(
|
|
"qdrant_client",
|
|
reason="qdrant-client is required for Qdrant storage tests",
|
|
)
|
|
|
|
from lightrag.kg.qdrant_impl import ( # noqa: E402
|
|
QdrantVectorDBStorage,
|
|
compute_mdhash_id_for_qdrant,
|
|
)
|
|
|
|
pytestmark = pytest.mark.offline
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures and helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class MockEmbeddingFunc:
|
|
def __init__(self, dim=8):
|
|
self.embedding_dim = dim
|
|
self.max_token_size = 512
|
|
self.model_name = "mock-embed"
|
|
|
|
async def __call__(self, texts, **kwargs):
|
|
return np.random.rand(len(texts), self.embedding_dim).astype(np.float32)
|
|
|
|
|
|
class CountingEmbeddingFunc(MockEmbeddingFunc):
|
|
def __init__(self, dim=8, fail_times=0):
|
|
super().__init__(dim=dim)
|
|
self.fail_times = fail_times
|
|
self.call_count = 0
|
|
self.batches: list[list[str]] = []
|
|
self.texts: list[str] = []
|
|
|
|
async def __call__(self, texts, **kwargs):
|
|
self.call_count += 1
|
|
batch = list(texts)
|
|
self.batches.append(batch)
|
|
self.texts.extend(batch)
|
|
if self.fail_times > 0:
|
|
self.fail_times -= 1
|
|
raise RuntimeError("embedding failed")
|
|
return await super().__call__(texts, **kwargs)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def patch_namespace_lock():
|
|
"""Cache real asyncio.Locks per (namespace, workspace) for shared semantics."""
|
|
cache: dict[tuple[str, str | None], asyncio.Lock] = {}
|
|
|
|
def factory(namespace, workspace=None, enable_logging=False):
|
|
key = (namespace, workspace or "")
|
|
lock = cache.get(key)
|
|
if lock is None:
|
|
lock = asyncio.Lock()
|
|
cache[key] = lock
|
|
return lock
|
|
|
|
with patch("lightrag.kg.qdrant_impl.get_namespace_lock", side_effect=factory):
|
|
yield cache
|
|
|
|
|
|
def _make_storage(
|
|
embed_func,
|
|
*,
|
|
namespace="entities",
|
|
workspace="test_ws",
|
|
meta_fields=None,
|
|
):
|
|
if meta_fields is None:
|
|
meta_fields = {"content", "entity_name", "src_id", "tgt_id"}
|
|
|
|
# Bypass real initialization paths (e.g. model suffix generation),
|
|
# mirroring the existing pattern in test_qdrant_upsert_batching.py.
|
|
storage = QdrantVectorDBStorage.__new__(QdrantVectorDBStorage)
|
|
storage.workspace = workspace
|
|
storage.namespace = namespace
|
|
storage.effective_workspace = workspace
|
|
storage.model_suffix = "mock"
|
|
storage.final_namespace = f"lightrag_vdb_{namespace}_mock"
|
|
storage.meta_fields = meta_fields
|
|
storage.embedding_func = embed_func
|
|
storage._max_batch_size = 10
|
|
storage._max_upsert_payload_bytes = 16 * 1024 * 1024
|
|
storage._max_upsert_points_per_batch = 128
|
|
storage._max_delete_points_per_batch = 1000
|
|
storage._pending_vector_docs = {}
|
|
storage._pending_vector_deletes = set()
|
|
|
|
storage._client = MagicMock()
|
|
storage._client.upsert = MagicMock()
|
|
storage._client.delete = MagicMock()
|
|
# drop() looks up a legacy collection to clear its workspace points;
|
|
# default to "no legacy collection" so unrelated tests are unaffected.
|
|
storage._client.collection_exists = MagicMock(return_value=False)
|
|
storage._client.retrieve = MagicMock(return_value=[])
|
|
storage._client.scroll = MagicMock(return_value=([], None))
|
|
|
|
from lightrag.kg.qdrant_impl import get_namespace_lock
|
|
|
|
storage._flush_lock = get_namespace_lock(
|
|
namespace=storage.final_namespace, workspace=storage.effective_workspace
|
|
)
|
|
return storage
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upsert_buffers_without_embedding():
|
|
embed = CountingEmbeddingFunc()
|
|
s = _make_storage(embed)
|
|
|
|
await s.upsert({"v1": {"content": "hello"}, "v2": {"content": "world"}})
|
|
|
|
assert embed.call_count == 0
|
|
assert set(s._pending_vector_docs.keys()) == {"v1", "v2"}
|
|
assert s._pending_vector_docs["v1"].vector is None
|
|
s._client.upsert.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_index_done_callback_triggers_flush():
|
|
embed = CountingEmbeddingFunc()
|
|
s = _make_storage(embed)
|
|
|
|
await s.upsert({"v1": {"content": "hello"}, "v2": {"content": "world"}})
|
|
await s.index_done_callback()
|
|
|
|
assert embed.call_count == 1
|
|
s._client.upsert.assert_called_once()
|
|
kwargs = s._client.upsert.call_args.kwargs
|
|
assert kwargs["collection_name"] == s.final_namespace
|
|
points = kwargs["points"]
|
|
assert len(points) == 2
|
|
expected_ids = {
|
|
compute_mdhash_id_for_qdrant("v1", prefix=s.effective_workspace),
|
|
compute_mdhash_id_for_qdrant("v2", prefix=s.effective_workspace),
|
|
}
|
|
assert {p.id for p in points} == expected_ids
|
|
assert s._pending_vector_docs == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_repeated_upsert_same_id_embeds_once():
|
|
embed = CountingEmbeddingFunc()
|
|
s = _make_storage(embed)
|
|
|
|
await s.upsert({"v1": {"content": "first"}})
|
|
await s.upsert({"v1": {"content": "second"}})
|
|
await s.upsert({"v1": {"content": "third"}})
|
|
await s.index_done_callback()
|
|
|
|
assert embed.call_count == 1
|
|
assert embed.texts == ["third"]
|
|
s._client.upsert.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deferred_embeddings_respect_batch_size():
|
|
embed = CountingEmbeddingFunc()
|
|
s = _make_storage(embed)
|
|
s._max_batch_size = 2
|
|
|
|
await s.upsert({f"v{i}": {"content": f"doc {i}"} for i in range(5)})
|
|
await s.index_done_callback()
|
|
|
|
assert embed.call_count == 3
|
|
assert [len(b) for b in embed.batches] == [2, 2, 1]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_vectors_by_ids_lazy_embed_then_reuse_in_flush():
|
|
embed = CountingEmbeddingFunc()
|
|
s = _make_storage(embed)
|
|
|
|
await s.upsert({"v1": {"content": "hello"}})
|
|
vectors = await s.get_vectors_by_ids(["v1"])
|
|
assert "v1" in vectors
|
|
assert embed.call_count == 1
|
|
assert s._pending_vector_docs["v1"].vector is not None
|
|
|
|
await s.index_done_callback()
|
|
assert embed.call_count == 1
|
|
s._client.upsert.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_failure_keeps_buffer_no_double_embed_on_retry():
|
|
embed = CountingEmbeddingFunc(fail_times=1)
|
|
s = _make_storage(embed)
|
|
|
|
await s.upsert({"v1": {"content": "hello"}})
|
|
|
|
with pytest.raises(RuntimeError, match="embedding failed"):
|
|
await s.index_done_callback()
|
|
|
|
assert "v1" in s._pending_vector_docs
|
|
assert s._pending_vector_docs["v1"].vector is None
|
|
s._client.upsert.assert_not_called()
|
|
|
|
await s.index_done_callback()
|
|
assert embed.call_count == 2
|
|
s._client.upsert.assert_called_once()
|
|
assert s._pending_vector_docs == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_server_upsert_failure_keeps_buffer():
|
|
embed = CountingEmbeddingFunc()
|
|
s = _make_storage(embed)
|
|
s._client.upsert.side_effect = RuntimeError("qdrant down")
|
|
|
|
await s.upsert({"v1": {"content": "hello"}})
|
|
|
|
with pytest.raises(RuntimeError, match="qdrant down"):
|
|
await s.index_done_callback()
|
|
|
|
assert "v1" in s._pending_vector_docs
|
|
assert s._pending_vector_docs["v1"].vector is not None
|
|
|
|
s._client.upsert.side_effect = None
|
|
await s.index_done_callback()
|
|
assert embed.call_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_finalize_raises_when_buffer_unflushed():
|
|
embed = CountingEmbeddingFunc()
|
|
s = _make_storage(embed)
|
|
s._client.upsert.side_effect = RuntimeError("transient qdrant error")
|
|
|
|
await s.upsert({"v1": {"content": "hello"}})
|
|
|
|
with pytest.raises(RuntimeError, match="finalize.*flush raised"):
|
|
await s.finalize()
|
|
assert "v1" in s._pending_vector_docs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_then_upsert_same_id_keeps_upsert():
|
|
embed = CountingEmbeddingFunc()
|
|
s = _make_storage(embed)
|
|
|
|
await s.delete(["v1"])
|
|
assert "v1" in s._pending_vector_deletes
|
|
|
|
await s.upsert({"v1": {"content": "hello"}})
|
|
assert "v1" in s._pending_vector_docs
|
|
assert "v1" not in s._pending_vector_deletes
|
|
|
|
await s.index_done_callback()
|
|
s._client.upsert.assert_called_once()
|
|
s._client.delete.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upsert_then_delete_same_id_keeps_delete():
|
|
embed = CountingEmbeddingFunc()
|
|
s = _make_storage(embed)
|
|
|
|
await s.upsert({"v1": {"content": "hello"}})
|
|
await s.delete(["v1"])
|
|
assert "v1" not in s._pending_vector_docs
|
|
assert "v1" in s._pending_vector_deletes
|
|
|
|
await s.index_done_callback()
|
|
s._client.upsert.assert_not_called()
|
|
s._client.delete.assert_called_once()
|
|
qdrant_delete_ids = s._client.delete.call_args.kwargs["points_selector"].points
|
|
assert qdrant_delete_ids == [
|
|
compute_mdhash_id_for_qdrant("v1", prefix=s.effective_workspace)
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_entity_relation_raises_on_server_failure():
|
|
"""scroll-then-delete pattern: server-side failure must bubble up."""
|
|
embed = CountingEmbeddingFunc()
|
|
s = _make_storage(embed)
|
|
fake_point = MagicMock()
|
|
fake_point.id = "qid1"
|
|
s._client.scroll.return_value = ([fake_point], None)
|
|
s._client.delete.side_effect = RuntimeError("qdrant delete failed")
|
|
|
|
with pytest.raises(RuntimeError, match="qdrant delete failed"):
|
|
await s.delete_entity_relation("X")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_entity_relation_prunes_pending_buffer():
|
|
embed = CountingEmbeddingFunc()
|
|
s = _make_storage(embed)
|
|
|
|
await s.upsert(
|
|
{
|
|
"rel-A-B": {"content": "A→B", "src_id": "A", "tgt_id": "B"},
|
|
"rel-C-D": {"content": "C→D", "src_id": "C", "tgt_id": "D"},
|
|
}
|
|
)
|
|
s._client.scroll.return_value = ([], None)
|
|
|
|
await s.delete_entity_relation("A")
|
|
assert "rel-A-B" not in s._pending_vector_docs
|
|
assert "rel-C-D" in s._pending_vector_docs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_by_id_reads_pending_buffer_without_vector():
|
|
embed = CountingEmbeddingFunc()
|
|
s = _make_storage(embed)
|
|
|
|
await s.upsert({"v1": {"content": "hello", "entity_name": "E1"}})
|
|
doc = await s.get_by_id("v1")
|
|
assert doc is not None
|
|
assert doc.get("entity_name") == "E1"
|
|
assert "vector" not in doc
|
|
s._client.retrieve.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_by_id_returns_none_for_pending_delete():
|
|
embed = CountingEmbeddingFunc()
|
|
s = _make_storage(embed)
|
|
|
|
await s.delete(["v1"])
|
|
assert await s.get_by_id("v1") is None
|
|
s._client.retrieve.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_uses_build_upsert_batches_for_multiple_batches():
|
|
"""When the points exceed the per-batch point limit, flush calls
|
|
`_client.upsert` multiple times — and a mid-batch failure keeps the
|
|
entire buffer for retry.
|
|
"""
|
|
embed = CountingEmbeddingFunc()
|
|
s = _make_storage(embed)
|
|
s._max_upsert_points_per_batch = 2 # force batching
|
|
|
|
await s.upsert({f"v{i}": {"content": f"c{i}"} for i in range(5)})
|
|
s._client.upsert.side_effect = [None, RuntimeError("batch 2 failed"), None]
|
|
|
|
with pytest.raises(RuntimeError, match="batch 2 failed"):
|
|
await s.index_done_callback()
|
|
|
|
# Stopped at batch 2, total 2 calls so far.
|
|
assert s._client.upsert.call_count == 2
|
|
# Buffer preserved.
|
|
assert len(s._pending_vector_docs) == 5
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_env_workspace_override_shares_flush_lock(patch_namespace_lock):
|
|
cache = patch_namespace_lock
|
|
embed = CountingEmbeddingFunc()
|
|
|
|
with patch.dict(os.environ, {"QDRANT_WORKSPACE": "shared_ws"}, clear=False):
|
|
# Two callers passing different `workspace` would both be redirected
|
|
# by the env override to "shared_ws". Since `_make_storage` skips
|
|
# __post_init__, simulate the override directly:
|
|
a = _make_storage(embed, workspace="shared_ws")
|
|
b = _make_storage(embed, workspace="shared_ws")
|
|
assert a.final_namespace == b.final_namespace
|
|
assert a.effective_workspace == b.effective_workspace == "shared_ws"
|
|
assert a._flush_lock is b._flush_lock
|
|
assert len([k for k in cache if k == (a.final_namespace, "shared_ws")]) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_distinct_workspaces_in_same_collection_get_independent_locks(
|
|
patch_namespace_lock,
|
|
):
|
|
"""Same final_namespace but different workspaces → independent locks."""
|
|
embed = CountingEmbeddingFunc()
|
|
a = _make_storage(embed, workspace="ws_a")
|
|
b = _make_storage(embed, workspace="ws_b")
|
|
# final_namespace depends on namespace only (model suffix is mocked),
|
|
# so the two share it, but workspaces differ → different locks.
|
|
assert a.final_namespace == b.final_namespace
|
|
assert a.effective_workspace != b.effective_workspace
|
|
assert a._flush_lock is not b._flush_lock
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drop_clears_pending_buffers():
|
|
embed = CountingEmbeddingFunc()
|
|
s = _make_storage(embed)
|
|
await s.upsert({"v1": {"content": "hello"}})
|
|
await s.delete(["v2"])
|
|
assert s._pending_vector_docs and s._pending_vector_deletes
|
|
|
|
result = await s.drop()
|
|
assert result["status"] == "success"
|
|
assert s._pending_vector_docs == {}
|
|
assert s._pending_vector_deletes == set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drop_clears_workspace_points_from_workspace_tagged_legacy():
|
|
"""drop() removes only this workspace's points from a workspace-tagged legacy
|
|
collection, so the next startup does not re-migrate the cleared data back."""
|
|
embed = CountingEmbeddingFunc()
|
|
s = _make_storage(embed)
|
|
legacy_collection = f"lightrag_vdb_{s.namespace}"
|
|
s._client.collection_exists = MagicMock(
|
|
side_effect=lambda name: name == legacy_collection
|
|
)
|
|
# Legacy is workspace-tagged: workspace_id present in the payload schema.
|
|
legacy_info = MagicMock()
|
|
legacy_info.payload_schema = {"workspace_id": MagicMock()}
|
|
s._client.get_collection = MagicMock(return_value=legacy_info)
|
|
|
|
result = await s.drop()
|
|
assert result["status"] == "success"
|
|
|
|
deleted_collections = [
|
|
call.kwargs.get("collection_name") for call in s._client.delete.call_args_list
|
|
]
|
|
# Both the active suffixed collection and the legacy collection are cleared
|
|
# via a workspace-filtered delete; the legacy collection itself is kept.
|
|
assert s.final_namespace in deleted_collections
|
|
assert legacy_collection in deleted_collections
|
|
s._client.delete_collection.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drop_drops_untagged_legacy_collection():
|
|
"""For an untagged (pre-isolation) legacy collection, setup_collection would
|
|
migrate ALL of its points back with no workspace filter, so a filtered delete
|
|
misses them. drop() must drop the whole legacy collection instead."""
|
|
embed = CountingEmbeddingFunc()
|
|
s = _make_storage(embed)
|
|
legacy_collection = f"lightrag_vdb_{s.namespace}"
|
|
s._client.collection_exists = MagicMock(
|
|
side_effect=lambda name: name == legacy_collection
|
|
)
|
|
# Legacy is untagged: no workspace_id in schema and none in sampled payloads.
|
|
legacy_info = MagicMock()
|
|
legacy_info.payload_schema = {}
|
|
s._client.get_collection = MagicMock(return_value=legacy_info)
|
|
s._client.scroll = MagicMock(return_value=([], None))
|
|
|
|
result = await s.drop()
|
|
assert result["status"] == "success"
|
|
|
|
# The whole untagged legacy collection is dropped (not a filtered delete).
|
|
s._client.delete_collection.assert_called_once_with(
|
|
collection_name=legacy_collection
|
|
)
|
|
filtered_deletes = [
|
|
call.kwargs.get("collection_name") for call in s._client.delete.call_args_list
|
|
]
|
|
assert legacy_collection not in filtered_deletes
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drop_reports_error_when_legacy_tagging_undetermined():
|
|
"""If the legacy collection's workspace tagging cannot be determined (a
|
|
transient metadata error), drop() must NOT drop the whole collection (that
|
|
could delete other workspaces' migration source) AND must report an error:
|
|
leaving legacy untouched means the clear would not survive a restart, so the
|
|
caller must be able to retry instead of seeing a misleading success."""
|
|
embed = CountingEmbeddingFunc()
|
|
s = _make_storage(embed)
|
|
legacy_collection = f"lightrag_vdb_{s.namespace}"
|
|
s._client.collection_exists = MagicMock(
|
|
side_effect=lambda name: name == legacy_collection
|
|
)
|
|
# Metadata lookup fails -> tagging undetermined.
|
|
s._client.get_collection = MagicMock(side_effect=RuntimeError("qdrant unavailable"))
|
|
|
|
result = await s.drop()
|
|
# The clear is reported as incomplete so it can be retried.
|
|
assert result["status"] == "error"
|
|
|
|
# Legacy is left untouched: neither a filtered delete nor a collection drop.
|
|
legacy_deletes = [
|
|
call.kwargs.get("collection_name") for call in s._client.delete.call_args_list
|
|
]
|
|
assert legacy_collection not in legacy_deletes
|
|
s._client.delete_collection.assert_not_called()
|
|
|
|
|
|
@pytest.mark.offline
|
|
@pytest.mark.asyncio
|
|
async def test_drop_pending_index_ops_clears_buffers():
|
|
"""On an internal-error abort the pipeline calls drop_pending_index_ops to
|
|
discard buffered upserts/deletes without flushing them (PR #3187)."""
|
|
embed = CountingEmbeddingFunc()
|
|
s = _make_storage(embed)
|
|
await s.upsert({"v1": {"content": "x"}, "v2": {"content": "y"}})
|
|
s._pending_vector_deletes.add("old-id")
|
|
assert s._pending_vector_docs
|
|
await s.drop_pending_index_ops()
|
|
assert not s._pending_vector_docs
|
|
assert not s._pending_vector_deletes
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_finalize_closes_qdrant_client():
|
|
"""finalize() must release the Qdrant client transport instead of leaving
|
|
it for GC — mirroring the close-on-release pattern of the other
|
|
server-backed storages."""
|
|
s = _make_storage(MockEmbeddingFunc())
|
|
client = s._client
|
|
assert client is not None
|
|
|
|
await s.finalize()
|
|
|
|
client.close.assert_called_once()
|