Files
hkuds--lightrag/tests/kg/mongo_impl/test_mongo_storage.py
T
2026-07-13 12:08:54 +08:00

657 lines
26 KiB
Python

import pytest
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
pytest.importorskip(
"pymongo",
reason="pymongo is required for Mongo storage tests",
)
from pymongo.errors import PyMongoError, BulkWriteError, DuplicateKeyError
from lightrag.kg.mongo_impl import (
MongoDocStatusStorage,
MongoGraphStorage,
_canonical_edge_endpoints,
)
pytestmark = pytest.mark.offline
class _AsyncCursor:
def __init__(self, docs):
self._docs = list(docs)
def limit(self, n: int):
self._docs = self._docs[:n]
return self
def __aiter__(self):
self._iter = iter(self._docs)
return self
async def __anext__(self):
try:
return next(self._iter)
except StopIteration:
raise StopAsyncIteration
class TestMongoGraphStorage:
def _make_storage(self):
storage = MongoGraphStorage.__new__(MongoGraphStorage)
storage.workspace = "test"
storage.global_config = {"max_graph_nodes": 1000}
storage._edge_collection_name = "test_edges"
storage.collection = SimpleNamespace()
storage.edge_collection = SimpleNamespace()
return storage
@pytest.mark.asyncio
async def test_get_knowledge_graph_all_backfills_isolated_nodes_when_truncated(
self,
):
storage = self._make_storage()
storage.collection.count_documents = AsyncMock(return_value=5)
storage.edge_collection.aggregate = AsyncMock(
return_value=_AsyncCursor(
[{"_id": "A", "degree": 1}, {"_id": "B", "degree": 1}]
)
)
def collection_find_side_effect(query, projection=None):
if query == {"_id": {"$nin": ["A", "B"]}}:
return _AsyncCursor(
[
{"_id": "C", "entity_type": "person"},
{"_id": "D", "entity_type": "person"},
{"_id": "E", "entity_type": "person"},
]
)
if query == {"_id": {"$in": ["A", "B", "C", "D"]}}:
return _AsyncCursor(
[
{"_id": "B", "entity_type": "person"},
{"_id": "D", "entity_type": "person"},
{"_id": "A", "entity_type": "person"},
{"_id": "C", "entity_type": "person"},
]
)
raise AssertionError(f"Unexpected node query: {query}")
storage.collection.find = Mock(side_effect=collection_find_side_effect)
storage.edge_collection.find = Mock(
return_value=_AsyncCursor(
[
{
"source_node_id": "A",
"target_node_id": "B",
"relationship": "knows",
}
]
)
)
result = await storage.get_knowledge_graph_all_by_degree(
max_depth=2, max_nodes=4
)
assert result.is_truncated is True
assert [node.id for node in result.nodes] == ["A", "B", "C", "D"]
assert len(result.edges) == 1
assert result.edges[0].source == "A"
assert result.edges[0].target == "B"
class TestMongoEdgeKey:
"""Canonical edge-endpoint writes, duplicate-key retries, and the migration."""
def _make_storage(self):
s = MongoGraphStorage.__new__(MongoGraphStorage)
s.workspace = "test"
s.namespace = "chunk_entity_relation"
s.global_config = {}
s._edge_collection_name = "test_edges"
s._max_upsert_payload_bytes = 16 * 1024 * 1024
s._max_upsert_records_per_batch = 128
s.collection = SimpleNamespace(update_one=AsyncMock())
s.edge_collection = SimpleNamespace()
return s
def test_canonical_edge_endpoints_are_direction_independent(self):
assert _canonical_edge_endpoints("B", "A") == _canonical_edge_endpoints(
"A", "B"
)
assert _canonical_edge_endpoints("B", "A") == ("A", "B")
def test_canonical_endpoints_never_collide_across_delimiter(self):
# Distinct pairs that a delimiter-joined key could conflate must stay
# distinct as separate (lo, hi) fields.
assert _canonical_edge_endpoints("A\x1fB", "C") != _canonical_edge_endpoints(
"A", "B\x1fC"
)
@pytest.mark.asyncio
async def test_upsert_edge_filters_and_sets_canonical_endpoints(self):
s = self._make_storage()
s.edge_collection.update_one = AsyncMock()
await s.upsert_edge("B", "A", {"weight": 1.0, "source_id": "c1<SEP>c2"})
args, kwargs = s.edge_collection.update_one.call_args
filt, update = args[0], args[1]
lo, hi = _canonical_edge_endpoints("B", "A")
assert filt == {"edge_lo": lo, "edge_hi": hi}
assert update["$set"]["edge_lo"] == lo
assert update["$set"]["edge_hi"] == hi
assert update["$set"]["source_node_id"] == "B"
assert update["$set"]["target_node_id"] == "A"
assert update["$set"]["source_ids"] == ["c1", "c2"]
assert kwargs.get("upsert") is True
@pytest.mark.asyncio
async def test_upsert_edge_retries_once_on_duplicate_key(self):
s = self._make_storage()
s.edge_collection.update_one = AsyncMock(
side_effect=[DuplicateKeyError("E11000 dup"), None]
)
await s.upsert_edge("A", "B", {"weight": 1.0})
assert s.edge_collection.update_one.await_count == 2
@pytest.mark.asyncio
async def test_upsert_edge_reraises_on_persistent_duplicate(self):
s = self._make_storage()
s.edge_collection.update_one = AsyncMock(
side_effect=DuplicateKeyError("E11000 dup")
)
with pytest.raises(DuplicateKeyError):
await s.upsert_edge("A", "B", {"weight": 1.0})
assert s.edge_collection.update_one.await_count == 2
@pytest.mark.asyncio
async def test_upsert_edges_batch_dedupes_reciprocal_and_sets_endpoints(self):
s = self._make_storage()
calls = []
async def fake_bulk(collection, ops, **kwargs):
calls.append((collection, ops))
with patch(
"lightrag.kg.mongo_impl._run_batched_bulk_write",
new=AsyncMock(side_effect=fake_bulk),
):
await s.upsert_edges_batch(
[("A", "B", {"weight": 1.0}), ("B", "A", {"weight": 2.0})]
)
# Last call is the edge bulk (first is the node-placeholder bulk).
edge_collection, edge_ops = calls[-1]
assert edge_collection is s.edge_collection
assert len(edge_ops) == 1 # reciprocal pair collapsed to one op
op, _bytes, _logid = edge_ops[0]
lo, hi = _canonical_edge_endpoints("A", "B")
assert op._filter == {"edge_lo": lo, "edge_hi": hi}
assert op._doc["$set"]["edge_lo"] == lo
assert op._doc["$set"]["edge_hi"] == hi
assert op._doc["$set"]["weight"] == 2.0 # last-write-wins
@pytest.mark.asyncio
async def test_upsert_edges_batch_retries_on_duplicate_bulk_error(self):
s = self._make_storage()
seq = []
async def fake_bulk(collection, ops, **kwargs):
seq.append(collection)
# Fail the first edge bulk with an all-11000 BulkWriteError.
if collection is s.edge_collection and seq.count(s.edge_collection) == 1:
raise BulkWriteError({"writeErrors": [{"code": 11000}]})
with patch(
"lightrag.kg.mongo_impl._run_batched_bulk_write",
new=AsyncMock(side_effect=fake_bulk),
):
await s.upsert_edges_batch([("A", "B", {"weight": 1.0})])
# node bulk + edge bulk (raises) + edge bulk (retry succeeds)
assert seq.count(s.edge_collection) == 2
@pytest.mark.asyncio
async def test_upsert_edges_batch_reraises_non_duplicate_bulk_error(self):
s = self._make_storage()
async def fake_bulk(collection, ops, **kwargs):
if collection is s.edge_collection:
raise BulkWriteError({"writeErrors": [{"code": 121}]})
with patch(
"lightrag.kg.mongo_impl._run_batched_bulk_write",
new=AsyncMock(side_effect=fake_bulk),
):
with pytest.raises(BulkWriteError):
await s.upsert_edges_batch([("A", "B", {"weight": 1.0})])
@pytest.mark.asyncio
async def test_upsert_edges_batch_surfaces_non_dup_error_hidden_behind_dup(self):
"""Under ordered=True the bulk aborts at the first failing op, so a
non-11000 error sitting *behind* a leading 11000 race is not reported on
the first pass. It must still surface — on the retry, where the leading
dup has resolved to an update and the real error now leads."""
s = self._make_storage()
edge_calls = []
async def fake_bulk(collection, ops, **kwargs):
if collection is s.edge_collection:
edge_calls.append(collection)
if len(edge_calls) == 1:
# First pass: ordered abort reports only the leading dup race.
raise BulkWriteError({"writeErrors": [{"code": 11000}]})
# Retry: dup resolved to an update, the hidden error now leads.
raise BulkWriteError({"writeErrors": [{"code": 121}]})
with patch(
"lightrag.kg.mongo_impl._run_batched_bulk_write",
new=AsyncMock(side_effect=fake_bulk),
):
with pytest.raises(BulkWriteError) as exc:
await s.upsert_edges_batch([("A", "B", {"weight": 1.0})])
# Retried once (dup-only first pass), then the non-dup error re-raised.
assert len(edge_calls) == 2
assert exc.value.details["writeErrors"][0]["code"] == 121
@pytest.mark.asyncio
async def test_upsert_edges_batch_reraises_write_concern_error(self):
"""A writeConcern-only BulkWriteError (empty writeErrors) is a durability
failure — it must surface, not be masked by the duplicate-key retry."""
s = self._make_storage()
edge_calls = []
async def fake_bulk(collection, ops, **kwargs):
if collection is s.edge_collection:
edge_calls.append(collection)
raise BulkWriteError(
{"writeErrors": [], "writeConcernErrors": [{"code": 64}]}
)
with patch(
"lightrag.kg.mongo_impl._run_batched_bulk_write",
new=AsyncMock(side_effect=fake_bulk),
):
with pytest.raises(BulkWriteError):
await s.upsert_edges_batch([("A", "B", {"weight": 1.0})])
assert len(edge_calls) == 1 # not retried
@pytest.mark.asyncio
async def test_edge_migration_skips_when_index_exists(self):
s = self._make_storage()
s.edge_collection.list_indexes = AsyncMock(
return_value=SimpleNamespace(
to_list=AsyncMock(return_value=[{"name": "test_edge_endpoints_unique"}])
)
)
s.edge_collection.aggregate = AsyncMock()
s.edge_collection.create_index = AsyncMock()
await s.create_edge_indexes_and_migrate_if_not_exists()
s.edge_collection.aggregate.assert_not_awaited()
s.edge_collection.create_index.assert_not_awaited()
@pytest.mark.asyncio
async def test_edge_migration_dedupes_backfills_and_builds_unique_index(self):
s = self._make_storage()
s.edge_collection.list_indexes = AsyncMock(
return_value=SimpleNamespace(
to_list=AsyncMock(return_value=[{"name": "_id_"}])
)
)
# One duplicate group for edge {A,B}: two docs with distinct provenance
# AND distinct relation payload (description/keywords/weight).
group = {
"_id": {"lo": "A", "hi": "B"},
"count": 2,
"docs": [
{
"_id": 1,
"source_id": "c1",
"source_ids": ["c1"],
"file_path": "f1",
"description": "d1",
"keywords": "alpha,beta",
"weight": 1.0,
"created_at": 10,
},
{
"_id": 2,
"source_id": "c2",
"source_ids": ["c2"],
"file_path": "f2",
"description": "d2",
"keywords": "beta,gamma",
"weight": 2.0,
"created_at": 20,
},
],
}
s.edge_collection.estimated_document_count = AsyncMock(return_value=4)
s.edge_collection.aggregate = AsyncMock(return_value=_AsyncCursor([group]))
s.edge_collection.update_one = AsyncMock()
s.edge_collection.delete_many = AsyncMock()
s.edge_collection.update_many = AsyncMock(
return_value=SimpleNamespace(modified_count=3)
)
s.edge_collection.create_index = AsyncMock()
with patch("lightrag.kg.mongo_impl.logger") as mock_logger:
await s.create_edge_indexes_and_migrate_if_not_exists()
# OpenSearch-aligned start/complete log wording.
info_lines = [c.args[0] for c in mock_logger.info.call_args_list]
assert any(
line.startswith("[test] Starting canonical edge migration for ")
and "(~4 edges to scan)" in line
for line in info_lines
)
assert any(
"Canonical edge migration complete for" in line
and "scanned 4, deduped 1, backfilled 3" in line
for line in info_lines
)
# Survivor is the newest (created_at 20 → _id 2); the full relation
# payload is merged in before the duplicate is deleted (no evidence lost).
surv_filter, surv_update = s.edge_collection.update_one.call_args[0]
set_fields = surv_update["$set"]
assert surv_filter == {"_id": 2}
assert set_fields["source_ids"] == ["c1", "c2"]
assert set_fields["file_path"] == "f1<SEP>f2"
assert set_fields["description"] == "d1<SEP>d2" # distinct descriptions joined
assert set_fields["keywords"] == "alpha,beta,gamma" # comma set-union, sorted
assert set_fields["weight"] == 3.0 # summed (1.0 + 2.0), idempotent
# The other duplicate is deleted.
assert s.edge_collection.delete_many.call_args[0][0] == {"_id": {"$in": [1]}}
# Compound unique partial index built as the completion flag.
ci_args = s.edge_collection.create_index.call_args.args
ci_kwargs = s.edge_collection.create_index.call_args.kwargs
assert ci_args[0] == [("edge_lo", 1), ("edge_hi", 1)]
assert ci_kwargs["name"] == "test_edge_endpoints_unique"
assert ci_kwargs["unique"] is True
assert set(ci_kwargs["partialFilterExpression"]) == {"edge_lo", "edge_hi"}
async def _run_dedupe(self, s, group):
"""Run _dedupe_legacy_edges over a single group; return the survivor $set."""
s.edge_collection.aggregate = AsyncMock(return_value=_AsyncCursor([group]))
s.edge_collection.update_one = AsyncMock()
s.edge_collection.delete_many = AsyncMock()
await s._dedupe_legacy_edges()
if s.edge_collection.update_one.call_args is None:
return None
return s.edge_collection.update_one.call_args[0][1]["$set"]
@pytest.mark.asyncio
async def test_dedupe_coerces_string_weights(self):
"""Legacy string weights (graph API is dict[str, str]) must not crash the
migration; they coerce to float and sum."""
s = self._make_storage()
group = {
"_id": {"lo": "A", "hi": "B"},
"count": 2,
"docs": [
{"_id": 1, "source_ids": ["c1"], "weight": "1.0", "created_at": 10},
{"_id": 2, "source_ids": ["c2"], "weight": "2.0", "created_at": 20},
],
}
set_fields = await self._run_dedupe(s, group)
assert set_fields["weight"] == 3.0 # coerced floats summed, no TypeError
@pytest.mark.asyncio
async def test_dedupe_merge_is_idempotent_on_retry(self):
"""A fail-fast retry that re-processes an already-merged survivor must
produce the same fields (no double-summed weight, no grown description)."""
s = self._make_storage()
original = [
{
"_id": 1,
"source_id": "c1",
"source_ids": ["c1"],
"file_path": "f1",
"description": "d1",
"keywords": "alpha,beta",
"weight": 1.0,
"created_at": 10,
},
{
"_id": 2,
"source_id": "c2",
"source_ids": ["c2"],
"file_path": "f2",
"description": "d2",
"keywords": "beta,gamma",
"weight": 2.0,
"created_at": 20,
},
]
first = await self._run_dedupe(
s, {"_id": {"lo": "A", "hi": "B"}, "count": 2, "docs": list(original)}
)
# Retry: survivor (_id 2) already carries the merged fields, the other
# duplicate is still present (the previous delete never committed).
survivor_merged = {"_id": 2, "created_at": 20, **first}
second = await self._run_dedupe(
s,
{
"_id": {"lo": "A", "hi": "B"},
"count": 2,
"docs": [original[0], survivor_merged],
},
)
# Summed once (1.0 + 2.0); the retry must NOT re-add the duplicate's
# weight (its source_ids are already folded into the survivor).
assert second["weight"] == first["weight"] == 3.0
assert second["description"] == first["description"] == "d1<SEP>d2"
assert second["source_ids"] == first["source_ids"] == ["c1", "c2"]
assert second["file_path"] == first["file_path"] == "f1<SEP>f2"
assert second["keywords"] == first["keywords"] == "alpha,beta,gamma"
class TestMongoEdgeReadCanonicalFilter:
"""Exact single-edge reads/deletes use the canonical (edge_lo, edge_hi)
filter so they hit the compound unique index instead of the bidirectional
``$or``. Per-node scans (node_degree/get_node_edges/delete_node) intentionally
keep ``$or`` — edge_hi is not a compound-index prefix, so they gain nothing."""
def _make_storage(self, max_delete_records_per_batch=1000):
s = MongoGraphStorage.__new__(MongoGraphStorage)
s.workspace = "test"
s.namespace = "chunk_entity_relation"
s._edge_collection_name = "test_edges"
s._max_delete_records_per_batch = max_delete_records_per_batch
s.edge_collection = SimpleNamespace()
return s
@pytest.mark.asyncio
async def test_has_edge_uses_canonical_filter(self):
s = self._make_storage()
s.edge_collection.find_one = AsyncMock(return_value={"_id": "x"})
assert await s.has_edge("B", "A") is True
filt, projection = s.edge_collection.find_one.call_args[0]
lo, hi = _canonical_edge_endpoints("B", "A")
assert filt == {"edge_lo": lo, "edge_hi": hi}
assert projection == {"_id": 1}
@pytest.mark.asyncio
async def test_has_edge_direction_independent(self):
s = self._make_storage()
s.edge_collection.find_one = AsyncMock(return_value=None)
await s.has_edge("A", "B")
forward = s.edge_collection.find_one.call_args[0][0]
await s.has_edge("B", "A")
backward = s.edge_collection.find_one.call_args[0][0]
assert forward == backward
@pytest.mark.asyncio
async def test_get_edge_uses_canonical_filter(self):
s = self._make_storage()
s.edge_collection.find_one = AsyncMock(return_value={"weight": 1.0})
await s.get_edge("B", "A")
filt = s.edge_collection.find_one.call_args[0][0]
lo, hi = _canonical_edge_endpoints("B", "A")
assert filt == {"edge_lo": lo, "edge_hi": hi}
@pytest.mark.asyncio
async def test_remove_edges_uses_canonical_pairs_and_collapses_reciprocals(self):
s = self._make_storage()
s.edge_collection.delete_many = AsyncMock()
# (A,B) and its reciprocal (B,A) must collapse to a single clause.
await s.remove_edges([("A", "B"), ("B", "A"), ("C", "D")])
query = s.edge_collection.delete_many.call_args[0][0]
lo_ab, hi_ab = _canonical_edge_endpoints("A", "B")
lo_cd, hi_cd = _canonical_edge_endpoints("C", "D")
assert query == {
"$or": [
{"edge_lo": lo_ab, "edge_hi": hi_ab},
{"edge_lo": lo_cd, "edge_hi": hi_cd},
]
}
@pytest.mark.asyncio
async def test_remove_edges_empty_is_noop(self):
s = self._make_storage()
s.edge_collection.delete_many = AsyncMock()
await s.remove_edges([])
s.edge_collection.delete_many.assert_not_awaited()
@pytest.mark.asyncio
async def test_remove_edges_chunks_large_or_by_record_cap(self):
# 5 distinct edges with a cap of 2 → 3 delete_many calls (2 + 2 + 1),
# and every input edge is covered exactly once across the chunks.
s = self._make_storage(max_delete_records_per_batch=2)
s.edge_collection.delete_many = AsyncMock()
edges = [(f"n{i}", f"n{i + 1}") for i in range(5)]
await s.remove_edges(edges)
calls = s.edge_collection.delete_many.call_args_list
assert [len(c[0][0]["$or"]) for c in calls] == [2, 2, 1]
clauses = [clause for c in calls for clause in c[0][0]["$or"]]
expected = [
{"edge_lo": lo, "edge_hi": hi}
for lo, hi in (_canonical_edge_endpoints(src, tgt) for src, tgt in edges)
]
assert clauses == expected
@pytest.mark.asyncio
async def test_remove_edges_non_positive_cap_disables_chunking(self):
# A non-positive cap means "no record-count splitting": one delete_many.
s = self._make_storage(max_delete_records_per_batch=0)
s.edge_collection.delete_many = AsyncMock()
edges = [(f"n{i}", f"n{i + 1}") for i in range(5)]
await s.remove_edges(edges)
assert s.edge_collection.delete_many.await_count == 1
assert len(s.edge_collection.delete_many.call_args[0][0]["$or"]) == 5
class TestMongoDocStatusLookup:
"""Cover the Mongo-native overrides for basename / content_hash lookups."""
def _make_storage(self):
storage = MongoDocStatusStorage.__new__(MongoDocStatusStorage)
storage.workspace = "test"
storage.global_config = {}
storage._collection_name = "test_doc_status"
storage._data = SimpleNamespace()
return storage
@pytest.mark.asyncio
async def test_get_doc_by_file_basename_returns_tuple_on_hit(self):
storage = self._make_storage()
storage._data.find_one = AsyncMock(
return_value={
"_id": "doc-1",
"file_path": "report.pdf",
"status": "processed",
}
)
result = await storage.get_doc_by_file_basename("report.pdf")
assert result is not None
doc_id, doc = result
assert doc_id == "doc-1"
assert doc["file_path"] == "report.pdf"
storage._data.find_one.assert_awaited_once_with({"file_path": "report.pdf"})
@pytest.mark.asyncio
async def test_get_doc_by_file_basename_empty_returns_none_without_query(self):
storage = self._make_storage()
storage._data.find_one = AsyncMock()
assert await storage.get_doc_by_file_basename("") is None
storage._data.find_one.assert_not_called()
@pytest.mark.asyncio
async def test_get_doc_by_file_basename_unknown_source_sentinel(self):
# Lookup for the sentinel must not match real rows that happen to have
# file_path == "unknown_source".
storage = self._make_storage()
storage._data.find_one = AsyncMock()
assert await storage.get_doc_by_file_basename("unknown_source") is None
storage._data.find_one.assert_not_called()
@pytest.mark.asyncio
async def test_get_doc_by_file_basename_miss_returns_none(self):
storage = self._make_storage()
storage._data.find_one = AsyncMock(return_value=None)
assert await storage.get_doc_by_file_basename("missing.pdf") is None
@pytest.mark.asyncio
async def test_get_doc_by_content_hash_returns_tuple_on_hit(self):
storage = self._make_storage()
storage._data.find_one = AsyncMock(
return_value={
"_id": "doc-1",
"file_path": "report.pdf",
"content_hash": "abc123",
"status": "processed",
}
)
result = await storage.get_doc_by_content_hash("abc123")
assert result is not None
doc_id, doc = result
assert doc_id == "doc-1"
assert doc["content_hash"] == "abc123"
storage._data.find_one.assert_awaited_once_with({"content_hash": "abc123"})
@pytest.mark.asyncio
async def test_get_doc_by_content_hash_empty_returns_none_without_query(self):
# Empty hash must short-circuit so it cannot match legacy rows missing
# the field via accidental coercion.
storage = self._make_storage()
storage._data.find_one = AsyncMock()
assert await storage.get_doc_by_content_hash("") is None
storage._data.find_one.assert_not_called()
@pytest.mark.asyncio
async def test_get_doc_by_content_hash_miss_returns_none(self):
storage = self._make_storage()
storage._data.find_one = AsyncMock(return_value=None)
assert await storage.get_doc_by_content_hash("zzz999") is None
@pytest.mark.asyncio
async def test_lookup_swallows_pymongo_error_and_returns_none(self):
# PyMongoError must not propagate to the caller; the dedup path treats
# a storage failure as "no match" and the error is logged instead.
storage = self._make_storage()
storage._data.find_one = AsyncMock(side_effect=PyMongoError("boom"))
assert await storage.get_doc_by_file_basename("report.pdf") is None
assert await storage.get_doc_by_content_hash("abc123") is None