946 lines
31 KiB
Python
946 lines
31 KiB
Python
"""Tests for the offline VDB rebuild tool and the fail-loud merge semantics.
|
|
|
|
Covers:
|
|
- rebuild: payload fidelity against the authoritative write points,
|
|
bidirectional edge dedup, AGE quote stripping, dirty-data skipping,
|
|
drop-before-upsert ordering, batching with periodic flushes, and
|
|
error collection on persistently failing batches.
|
|
- check: consistent stores, missing-record detection, legacy reverse
|
|
relation ids not being misreported, and batching.
|
|
- merge: _merge_entities_impl raising VectorStorageConsistencyError on
|
|
persistent VDB failure without deleting source entities.
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import lightrag.tools.rebuild_vdb as rebuild_vdb
|
|
from lightrag.tools.rebuild_vdb import (
|
|
_strip_agtype_quotes,
|
|
check_vdb_consistency,
|
|
rebuild_chunks_vdb,
|
|
rebuild_entities_vdb,
|
|
rebuild_relationships_vdb,
|
|
)
|
|
from lightrag.utils import (
|
|
VectorStorageConsistencyError,
|
|
compute_mdhash_id,
|
|
make_relation_vdb_ids,
|
|
)
|
|
|
|
pytestmark = pytest.mark.offline
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mocks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class MockVDB:
|
|
"""Vector storage mock: records upserts, aligned-None get_by_ids."""
|
|
|
|
def __init__(self):
|
|
self.global_config = {} # no tokenizer -> _truncate_vdb_content is a no-op
|
|
self.records = {}
|
|
self.call_order = []
|
|
|
|
async def _drop():
|
|
self.call_order.append("drop")
|
|
self.records.clear()
|
|
return {"status": "success", "message": "data dropped"}
|
|
|
|
async def _upsert(payload):
|
|
self.call_order.append("upsert")
|
|
self.records.update(payload)
|
|
|
|
async def _get_by_ids(ids):
|
|
return [self.records.get(i) for i in ids]
|
|
|
|
self.drop = AsyncMock(side_effect=_drop)
|
|
self.upsert = AsyncMock(side_effect=_upsert)
|
|
self.get_by_ids = AsyncMock(side_effect=_get_by_ids)
|
|
self.delete = AsyncMock()
|
|
self.index_done_callback = AsyncMock()
|
|
|
|
|
|
def make_graph(nodes=None, edges=None):
|
|
graph = MagicMock()
|
|
graph.get_all_nodes = AsyncMock(return_value=nodes or [])
|
|
graph.get_all_edges = AsyncMock(return_value=edges or [])
|
|
return graph
|
|
|
|
|
|
class _FakeLock:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *args):
|
|
return False
|
|
|
|
|
|
class JsonKVStorage:
|
|
"""Minimal stand-in; the class NAME drives enumerate_kv_keys dispatch."""
|
|
|
|
def __init__(self, data):
|
|
self._data = data
|
|
self._storage_lock = _FakeLock()
|
|
|
|
async def get_by_ids(self, ids):
|
|
return [self._data.get(i) for i in ids]
|
|
|
|
|
|
def node(name, **overrides):
|
|
data = {
|
|
"entity_id": name,
|
|
"description": f"description of {name}",
|
|
"entity_type": "person",
|
|
"source_id": "chunk-abc",
|
|
"file_path": "doc.txt",
|
|
}
|
|
data.update(overrides)
|
|
return data
|
|
|
|
|
|
def edge(src, tgt, **overrides):
|
|
data = {
|
|
"source": src,
|
|
"target": tgt,
|
|
"description": f"{src} knows {tgt}",
|
|
"keywords": "knows",
|
|
"source_id": "chunk-abc",
|
|
"weight": 2.0,
|
|
"file_path": "doc.txt",
|
|
}
|
|
data.update(overrides)
|
|
return data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rebuild: entities
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebuild_entities_payload_matches_authoritative_format():
|
|
graph = make_graph(nodes=[node("Alice")])
|
|
vdb = MockVDB()
|
|
|
|
stats = await rebuild_entities_vdb(graph, vdb, {})
|
|
|
|
entity_id = compute_mdhash_id("Alice", prefix="ent-")
|
|
assert stats["rebuilt"] == 1
|
|
assert stats["skipped"] == 0
|
|
assert vdb.records == {
|
|
entity_id: {
|
|
"entity_name": "Alice",
|
|
"entity_type": "person",
|
|
"content": "Alice\ndescription of Alice",
|
|
"source_id": "chunk-abc",
|
|
"description": "description of Alice",
|
|
"file_path": "doc.txt",
|
|
}
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebuild_entities_handles_missing_optional_fields():
|
|
graph = make_graph(
|
|
nodes=[{"id": "Bob"}] # only "id", no entity_id, no optional fields
|
|
)
|
|
vdb = MockVDB()
|
|
|
|
await rebuild_entities_vdb(graph, vdb, {})
|
|
|
|
record = vdb.records[compute_mdhash_id("Bob", prefix="ent-")]
|
|
assert record["entity_name"] == "Bob"
|
|
assert record["content"] == "Bob\n"
|
|
assert record["entity_type"] == ""
|
|
assert record["source_id"] == ""
|
|
assert record["file_path"] == ""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebuild_entities_skips_dirty_nodes():
|
|
graph = make_graph(nodes=[node("Alice"), {"description": "no id"}, node(" ")])
|
|
vdb = MockVDB()
|
|
|
|
stats = await rebuild_entities_vdb(graph, vdb, {})
|
|
|
|
assert stats["source_total"] == 3
|
|
assert stats["rebuilt"] == 1
|
|
assert stats["skipped"] == 2
|
|
assert list(vdb.records) == [compute_mdhash_id("Alice", prefix="ent-")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebuild_drops_once_before_any_upsert():
|
|
graph = make_graph(nodes=[node("Alice"), node("Bob")])
|
|
vdb = MockVDB()
|
|
|
|
await rebuild_entities_vdb(graph, vdb, {}, batch_size=1)
|
|
|
|
assert vdb.drop.await_count == 1
|
|
assert vdb.call_order[0] == "drop"
|
|
assert vdb.call_order.count("upsert") == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebuild_batches_and_periodic_flush():
|
|
graph = make_graph(nodes=[node(f"E{i:03d}") for i in range(25)])
|
|
vdb = MockVDB()
|
|
|
|
stats = await rebuild_entities_vdb(graph, vdb, {}, batch_size=1)
|
|
|
|
assert stats["batches"] == 25
|
|
assert vdb.upsert.await_count == 25
|
|
# periodic flush at batches 10 and 20, plus the final flush
|
|
assert vdb.index_done_callback.await_count == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebuild_collects_batch_errors_and_continues(monkeypatch):
|
|
# Single-attempt wrapper: no retry delays in tests
|
|
async def _single_attempt(operation, **kwargs):
|
|
await operation()
|
|
|
|
monkeypatch.setattr(
|
|
rebuild_vdb, "safe_vdb_operation_with_exception", _single_attempt
|
|
)
|
|
|
|
graph = make_graph(nodes=[node("Alice"), node("Bob"), node("Carol")])
|
|
vdb = MockVDB()
|
|
poisoned_id = compute_mdhash_id("Bob", prefix="ent-")
|
|
|
|
async def _failing_upsert(payload):
|
|
if poisoned_id in payload:
|
|
raise RuntimeError("embedder down")
|
|
vdb.records.update(payload)
|
|
|
|
vdb.upsert = AsyncMock(side_effect=_failing_upsert)
|
|
|
|
stats = await rebuild_entities_vdb(graph, vdb, {}, batch_size=1)
|
|
|
|
assert stats["rebuilt"] == 2
|
|
assert stats["failed_batches"] == 1
|
|
assert len(stats["errors"]) == 1
|
|
assert stats["errors"][0]["error_msg"] == "embedder down"
|
|
assert poisoned_id not in vdb.records
|
|
assert len(vdb.records) == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rebuild: relationships
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebuild_relationships_payload_normalized():
|
|
# Endpoints arrive in reverse lexicographic order; payload must be sorted
|
|
graph = make_graph(edges=[edge("Bob", "Alice")])
|
|
vdb = MockVDB()
|
|
|
|
stats = await rebuild_relationships_vdb(graph, vdb, {})
|
|
|
|
rel_id = compute_mdhash_id("Alice" + "Bob", prefix="rel-")
|
|
assert stats["rebuilt"] == 1
|
|
assert vdb.records == {
|
|
rel_id: {
|
|
"src_id": "Alice",
|
|
"tgt_id": "Bob",
|
|
"source_id": "chunk-abc",
|
|
"content": "knows\tAlice\nBob\nBob knows Alice",
|
|
"keywords": "knows",
|
|
"description": "Bob knows Alice",
|
|
"weight": 2.0,
|
|
"file_path": "doc.txt",
|
|
}
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebuild_relationships_weight_fallback():
|
|
graph = make_graph(edges=[edge("A", "B", weight="3"), edge("C", "D", weight=None)])
|
|
vdb = MockVDB()
|
|
|
|
await rebuild_relationships_vdb(graph, vdb, {})
|
|
|
|
assert vdb.records[compute_mdhash_id("AB", prefix="rel-")]["weight"] == 3.0
|
|
assert vdb.records[compute_mdhash_id("CD", prefix="rel-")]["weight"] == 1.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebuild_relationships_dedupes_bidirectional_edges():
|
|
# Neo4j/Memgraph return each undirected edge once per direction
|
|
graph = make_graph(edges=[edge("Alice", "Bob"), edge("Bob", "Alice")])
|
|
vdb = MockVDB()
|
|
|
|
stats = await rebuild_relationships_vdb(graph, vdb, {})
|
|
|
|
assert stats["source_total"] == 2
|
|
assert stats["rebuilt"] == 1
|
|
assert stats["duplicates"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebuild_relationships_strips_age_quotes():
|
|
# PG/AGE agtype::text casts wrap endpoint strings in double quotes
|
|
graph = make_graph(edges=[edge('"Alice"', '"Bob"')])
|
|
vdb = MockVDB()
|
|
|
|
await rebuild_relationships_vdb(graph, vdb, {})
|
|
|
|
rel_id = compute_mdhash_id("AliceBob", prefix="rel-")
|
|
assert vdb.records[rel_id]["src_id"] == "Alice"
|
|
assert vdb.records[rel_id]["tgt_id"] == "Bob"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebuild_relationships_skips_edges_without_endpoints():
|
|
graph = make_graph(edges=[edge("Alice", "Bob"), {"description": "no endpoints"}])
|
|
vdb = MockVDB()
|
|
|
|
stats = await rebuild_relationships_vdb(graph, vdb, {})
|
|
|
|
assert stats["rebuilt"] == 1
|
|
assert stats["skipped"] == 1
|
|
|
|
|
|
def test_strip_agtype_quotes():
|
|
assert _strip_agtype_quotes('"Alice"') == "Alice"
|
|
assert _strip_agtype_quotes("Alice") == "Alice"
|
|
assert _strip_agtype_quotes('"') == '"'
|
|
assert _strip_agtype_quotes(None) is None
|
|
assert _strip_agtype_quotes(3) == 3
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rebuild: chunks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebuild_chunks_payload_from_kv_records():
|
|
kv = JsonKVStorage(
|
|
{
|
|
"chunk-1": {
|
|
"content": "chunk text",
|
|
"full_doc_id": "doc-1",
|
|
"file_path": "doc.txt",
|
|
"tokens": 3,
|
|
"chunk_order_index": 0,
|
|
},
|
|
}
|
|
)
|
|
vdb = MockVDB()
|
|
|
|
stats = await rebuild_chunks_vdb(kv, vdb)
|
|
|
|
assert stats["source_total"] == 1
|
|
assert stats["rebuilt"] == 1
|
|
record = vdb.records["chunk-1"]
|
|
assert record["content"] == "chunk text"
|
|
assert record["full_doc_id"] == "doc-1"
|
|
assert record["file_path"] == "doc.txt"
|
|
# extra pipeline fields pass through, as in the authoritative upsert
|
|
assert record["tokens"] == 3
|
|
assert record["chunk_order_index"] == 0
|
|
assert vdb.drop.await_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebuild_chunks_skips_records_without_content():
|
|
kv = JsonKVStorage(
|
|
{
|
|
"chunk-1": {"content": "ok", "full_doc_id": "doc-1"},
|
|
"chunk-2": {"full_doc_id": "doc-1"}, # no content
|
|
}
|
|
)
|
|
vdb = MockVDB()
|
|
|
|
stats = await rebuild_chunks_vdb(kv, vdb)
|
|
|
|
assert stats["rebuilt"] == 1
|
|
assert stats["skipped"] == 1
|
|
assert "chunk-2" not in vdb.records
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebuild_chunks_covers_all_id_schemes():
|
|
# Chunks live under several id schemes that no single prefix matches:
|
|
# custom KG ("chunk-<hash>"), the text pipeline ("{doc_id}-chunk-{order}",
|
|
# which does NOT start with "chunk-"), and multimodal
|
|
# ("{doc_id}-mm-<modality>-{order}"). A prefix-only filter dropped the
|
|
# pipeline/multimodal chunks and rebuilt to zero. All schemes must rebuild.
|
|
kv = JsonKVStorage(
|
|
{
|
|
"chunk-deadbeef": {"content": "custom kg", "full_doc_id": "doc-xyz"},
|
|
"doc-abc123-chunk-000": {"content": "first", "full_doc_id": "doc-abc123"},
|
|
"doc-abc123-chunk-001": {"content": "second", "full_doc_id": "doc-abc123"},
|
|
"doc-abc123-mm-drawing-000": {
|
|
"content": "image caption",
|
|
"full_doc_id": "doc-abc123",
|
|
},
|
|
}
|
|
)
|
|
vdb = MockVDB()
|
|
|
|
stats = await rebuild_chunks_vdb(kv, vdb)
|
|
|
|
assert stats["source_total"] == 4
|
|
assert stats["rebuilt"] == 4
|
|
assert set(vdb.records) == {
|
|
"chunk-deadbeef",
|
|
"doc-abc123-chunk-000",
|
|
"doc-abc123-chunk-001",
|
|
"doc-abc123-mm-drawing-000",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Consistency check
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def seeded_vdbs(nodes, edges):
|
|
"""Build entity/relation VDB mocks already containing all graph records."""
|
|
entities_vdb = MockVDB()
|
|
for n in nodes:
|
|
entities_vdb.records[compute_mdhash_id(n["entity_id"], prefix="ent-")] = {
|
|
"entity_name": n["entity_id"]
|
|
}
|
|
relationships_vdb = MockVDB()
|
|
for e in edges:
|
|
src, tgt = sorted([e["source"], e["target"]])
|
|
relationships_vdb.records[compute_mdhash_id(src + tgt, prefix="rel-")] = {
|
|
"src_id": src,
|
|
"tgt_id": tgt,
|
|
}
|
|
return entities_vdb, relationships_vdb
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_all_consistent():
|
|
nodes = [node("Alice"), node("Bob")]
|
|
edges = [edge("Alice", "Bob")]
|
|
graph = make_graph(nodes=nodes, edges=edges)
|
|
entities_vdb, relationships_vdb = seeded_vdbs(nodes, edges)
|
|
|
|
report = await check_vdb_consistency(graph, entities_vdb, relationships_vdb)
|
|
|
|
assert report["consistent"] is True
|
|
assert report["graph_entities"] == 2
|
|
assert report["graph_relations"] == 1
|
|
assert report["missing_entities"] == 0
|
|
assert report["missing_relations"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_detects_missing_records():
|
|
nodes = [node("Alice"), node("Bob")]
|
|
edges = [edge("Alice", "Bob"), edge("Bob", "Carol")]
|
|
graph = make_graph(nodes=nodes, edges=edges)
|
|
entities_vdb, relationships_vdb = seeded_vdbs(nodes[:1], edges[:1])
|
|
|
|
report = await check_vdb_consistency(graph, entities_vdb, relationships_vdb)
|
|
|
|
assert report["consistent"] is False
|
|
assert report["missing_entities"] == 1
|
|
assert report["missing_entity_names"] == ["Bob"]
|
|
assert report["missing_relations"] == 1
|
|
assert report["missing_relation_pairs"] == ["Bob ~ Carol"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_accepts_legacy_reverse_relation_id():
|
|
# Legacy custom-KG imports hashed the relation in original endpoint
|
|
# order; the VDB holds only the reverse-order id. Not an inconsistency.
|
|
graph = make_graph(edges=[edge("Bob", "Alice")])
|
|
entities_vdb = MockVDB()
|
|
relationships_vdb = MockVDB()
|
|
reverse_id = make_relation_vdb_ids("Bob", "Alice")[1]
|
|
relationships_vdb.records[reverse_id] = {"src_id": "Bob", "tgt_id": "Alice"}
|
|
|
|
report = await check_vdb_consistency(graph, entities_vdb, relationships_vdb)
|
|
|
|
assert report["missing_relations"] == 0
|
|
assert report["consistent"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_batches_requests():
|
|
nodes = [node(f"E{i:03d}") for i in range(5)]
|
|
graph = make_graph(nodes=nodes)
|
|
entities_vdb, relationships_vdb = seeded_vdbs(nodes, [])
|
|
|
|
report = await check_vdb_consistency(
|
|
graph, entities_vdb, relationships_vdb, batch_size=2
|
|
)
|
|
|
|
assert report["consistent"] is True
|
|
assert entities_vdb.get_by_ids.await_count == 3 # ceil(5 / 2)
|
|
assert all(
|
|
len(call.args[0]) <= 2 for call in entities_vdb.get_by_ids.await_args_list
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Merge: fail-loud semantics in _merge_entities_impl
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def make_merge_graph():
|
|
"""Graph mock for merging source 'Bob' into new target 'Alice'.
|
|
|
|
Bob has one edge to Carol; Alice does not exist yet.
|
|
"""
|
|
graph = MagicMock()
|
|
graph.has_node = AsyncMock(side_effect=lambda name: name == "Bob")
|
|
graph.get_node = AsyncMock(
|
|
return_value={
|
|
"entity_id": "Bob",
|
|
"description": "desc",
|
|
"entity_type": "person",
|
|
"source_id": "chunk-abc",
|
|
"file_path": "doc.txt",
|
|
}
|
|
)
|
|
graph.get_node_edges = AsyncMock(return_value=[("Bob", "Carol")])
|
|
graph.get_edge = AsyncMock(
|
|
return_value={
|
|
"description": "knows",
|
|
"keywords": "kw",
|
|
"source_id": "chunk-abc",
|
|
"weight": 1.0,
|
|
"file_path": "doc.txt",
|
|
}
|
|
)
|
|
graph.upsert_node = AsyncMock()
|
|
graph.upsert_edge = AsyncMock()
|
|
graph.delete_node = AsyncMock()
|
|
graph.index_done_callback = AsyncMock()
|
|
return graph
|
|
|
|
|
|
@pytest.fixture
|
|
def single_attempt_vdb_ops(monkeypatch):
|
|
"""Replace the retry wrapper with a single attempt to keep tests fast."""
|
|
import lightrag.utils_graph as utils_graph
|
|
|
|
async def _single_attempt(operation, **kwargs):
|
|
await operation()
|
|
|
|
monkeypatch.setattr(
|
|
utils_graph, "safe_vdb_operation_with_exception", _single_attempt
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_relation_vdb_delete_failure_raises_consistency_error(
|
|
single_attempt_vdb_ops,
|
|
):
|
|
# The step-7 delete of stale relation records runs after the graph is
|
|
# updated; a failure there must fail loud (and not be swallowed upstream).
|
|
from lightrag.utils_graph import _merge_entities_impl
|
|
|
|
graph = make_merge_graph()
|
|
entities_vdb = MockVDB()
|
|
relationships_vdb = MockVDB()
|
|
relationships_vdb.delete = AsyncMock(side_effect=RuntimeError("vdb delete down"))
|
|
|
|
with pytest.raises(VectorStorageConsistencyError) as excinfo:
|
|
await _merge_entities_impl(
|
|
graph, entities_vdb, relationships_vdb, ["Bob"], "Alice"
|
|
)
|
|
|
|
assert "lightrag-rebuild-vdb" in str(excinfo.value)
|
|
relationships_vdb.delete.assert_awaited()
|
|
# Failure is before source-entity deletion (step 10)
|
|
graph.delete_node.assert_not_awaited()
|
|
entities_vdb.delete.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_relation_vdb_failure_raises_consistency_error(
|
|
single_attempt_vdb_ops,
|
|
):
|
|
from lightrag.utils_graph import _merge_entities_impl
|
|
|
|
graph = make_merge_graph()
|
|
entities_vdb = MockVDB()
|
|
relationships_vdb = MockVDB()
|
|
relationships_vdb.upsert = AsyncMock(side_effect=RuntimeError("embedder down"))
|
|
|
|
with pytest.raises(VectorStorageConsistencyError) as excinfo:
|
|
await _merge_entities_impl(
|
|
graph, entities_vdb, relationships_vdb, ["Bob"], "Alice"
|
|
)
|
|
|
|
# Fail-loud guidance in the message
|
|
assert "lightrag-rebuild-vdb" in str(excinfo.value)
|
|
# Graph state was written and is kept (no rollback)
|
|
graph.upsert_edge.assert_awaited()
|
|
# Source entities were NOT deleted (step 10 never reached)
|
|
graph.delete_node.assert_not_awaited()
|
|
entities_vdb.delete.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_entity_vdb_failure_raises_consistency_error(
|
|
single_attempt_vdb_ops,
|
|
):
|
|
from lightrag.utils_graph import _merge_entities_impl
|
|
|
|
graph = make_merge_graph()
|
|
entities_vdb = MockVDB()
|
|
relationships_vdb = MockVDB()
|
|
entities_vdb.upsert = AsyncMock(side_effect=RuntimeError("embedder down"))
|
|
|
|
with pytest.raises(VectorStorageConsistencyError) as excinfo:
|
|
await _merge_entities_impl(
|
|
graph, entities_vdb, relationships_vdb, ["Bob"], "Alice"
|
|
)
|
|
|
|
assert "lightrag-rebuild-vdb" in str(excinfo.value)
|
|
# Relation VDB write succeeded before the entity failure
|
|
relationships_vdb.upsert.assert_awaited()
|
|
graph.delete_node.assert_not_awaited()
|
|
entities_vdb.delete.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_source_entity_delete_failure_raises_consistency_error(
|
|
single_attempt_vdb_ops,
|
|
):
|
|
# Step 10 deletes the source node from the graph first, then its vector
|
|
# record. A delete failure here happens AFTER the graph node is gone, so it
|
|
# must fail loud with a message that does not claim the source still exists.
|
|
from lightrag.utils_graph import _merge_entities_impl
|
|
|
|
graph = make_merge_graph()
|
|
entities_vdb = MockVDB()
|
|
relationships_vdb = MockVDB()
|
|
entities_vdb.delete = AsyncMock(side_effect=RuntimeError("vdb delete down"))
|
|
|
|
with pytest.raises(VectorStorageConsistencyError) as excinfo:
|
|
await _merge_entities_impl(
|
|
graph, entities_vdb, relationships_vdb, ["Bob"], "Alice"
|
|
)
|
|
|
|
message = str(excinfo.value)
|
|
assert "lightrag-rebuild-vdb" in message
|
|
# The source node was already removed; the message must not claim otherwise.
|
|
graph.delete_node.assert_awaited_with("Bob")
|
|
assert "were not deleted" not in message
|
|
entities_vdb.delete.assert_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_final_persist_failure_raises_consistency_error(
|
|
single_attempt_vdb_ops,
|
|
):
|
|
# The step-11 _persist_graph_updates flush runs after source deletion; a
|
|
# failure there must also fail loud (not a raw exception swallowed upstream).
|
|
from lightrag.utils_graph import _merge_entities_impl
|
|
|
|
graph = make_merge_graph()
|
|
entities_vdb = MockVDB()
|
|
relationships_vdb = MockVDB()
|
|
# Succeed at the 8b flush, fail at the final persist flush (step 11).
|
|
entities_vdb.index_done_callback = AsyncMock(
|
|
side_effect=[None, RuntimeError("flush down")]
|
|
)
|
|
|
|
with pytest.raises(VectorStorageConsistencyError) as excinfo:
|
|
await _merge_entities_impl(
|
|
graph, entities_vdb, relationships_vdb, ["Bob"], "Alice"
|
|
)
|
|
|
|
message = str(excinfo.value)
|
|
assert "lightrag-rebuild-vdb" in message
|
|
# Source deletion already happened before the final persist.
|
|
graph.delete_node.assert_awaited_with("Bob")
|
|
assert "were not deleted" not in message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_success_path_unaffected(single_attempt_vdb_ops):
|
|
import lightrag.utils_graph as utils_graph
|
|
from lightrag.utils_graph import _merge_entities_impl
|
|
|
|
graph = make_merge_graph()
|
|
entities_vdb = MockVDB()
|
|
relationships_vdb = MockVDB()
|
|
|
|
async def _noop_persist(**kwargs):
|
|
return None
|
|
|
|
async def _entity_info(*args, **kwargs):
|
|
return {"entity_name": "Alice"}
|
|
|
|
# Avoid index_done_callback/inspection helpers needing full storages
|
|
orig_persist = utils_graph._persist_graph_updates
|
|
orig_info = utils_graph.get_entity_info
|
|
utils_graph._persist_graph_updates = _noop_persist
|
|
utils_graph.get_entity_info = _entity_info
|
|
try:
|
|
result = await _merge_entities_impl(
|
|
graph, entities_vdb, relationships_vdb, ["Bob"], "Alice"
|
|
)
|
|
finally:
|
|
utils_graph._persist_graph_updates = orig_persist
|
|
utils_graph.get_entity_info = orig_info
|
|
|
|
assert result == {"entity_name": "Alice"}
|
|
# Both VDB writes happened and the source entity was deleted
|
|
relationships_vdb.upsert.assert_awaited()
|
|
entities_vdb.upsert.assert_awaited()
|
|
graph.delete_node.assert_awaited_with("Bob")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_deferred_flush_failure_raises_before_delete(
|
|
single_attempt_vdb_ops,
|
|
):
|
|
# Deferred-embedding backends (nano/faiss) buffer in upsert() and only embed
|
|
# in index_done_callback, so an embedder outage surfaces at flush time. The
|
|
# fail-loud guarantee must still hold: raise before deleting source entities.
|
|
from lightrag.utils_graph import _merge_entities_impl
|
|
|
|
graph = make_merge_graph()
|
|
entities_vdb = MockVDB()
|
|
relationships_vdb = MockVDB()
|
|
# upsert succeeds (buffers); the embedding failure happens at flush.
|
|
relationships_vdb.index_done_callback = AsyncMock(
|
|
side_effect=RuntimeError("embedder down")
|
|
)
|
|
|
|
with pytest.raises(VectorStorageConsistencyError) as excinfo:
|
|
await _merge_entities_impl(
|
|
graph, entities_vdb, relationships_vdb, ["Bob"], "Alice"
|
|
)
|
|
|
|
assert "lightrag-rebuild-vdb" in str(excinfo.value)
|
|
# upsert succeeded; the failure is the deferred flush
|
|
relationships_vdb.upsert.assert_awaited()
|
|
relationships_vdb.index_done_callback.assert_awaited()
|
|
# Source entities NOT deleted (step 10 never reached), so the message holds
|
|
graph.delete_node.assert_not_awaited()
|
|
entities_vdb.delete.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_entity_deferred_flush_failure_raises_before_delete(
|
|
single_attempt_vdb_ops,
|
|
):
|
|
from lightrag.utils_graph import _merge_entities_impl
|
|
|
|
graph = make_merge_graph()
|
|
entities_vdb = MockVDB()
|
|
relationships_vdb = MockVDB()
|
|
entities_vdb.index_done_callback = AsyncMock(
|
|
side_effect=RuntimeError("embedder down")
|
|
)
|
|
|
|
with pytest.raises(VectorStorageConsistencyError) as excinfo:
|
|
await _merge_entities_impl(
|
|
graph, entities_vdb, relationships_vdb, ["Bob"], "Alice"
|
|
)
|
|
|
|
assert "lightrag-rebuild-vdb" in str(excinfo.value)
|
|
# Relation flush succeeded before the entity flush failed
|
|
relationships_vdb.index_done_callback.assert_awaited()
|
|
entities_vdb.index_done_callback.assert_awaited()
|
|
graph.delete_node.assert_not_awaited()
|
|
entities_vdb.delete.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Setup: check-only (no api extra) stub embedding function
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_only_stub_carries_embedding_model_name(monkeypatch):
|
|
# When the api extra is unavailable the tool runs check-only with a stub
|
|
# embedding function. Qdrant/PostgreSQL locate the collection/table from
|
|
# model_name + embedding_dim, so the stub must carry the configured
|
|
# EMBEDDING_MODEL — otherwise check-only probes the legacy-named collection
|
|
# and misreports records as missing.
|
|
import lightrag.kg.factory as kg_factory
|
|
from lightrag.tools.rebuild_vdb import RebuildTool
|
|
|
|
monkeypatch.setenv("EMBEDDING_MODEL", "text-embedding-3-large")
|
|
monkeypatch.setenv("EMBEDDING_DIM", "3072")
|
|
|
|
captured_funcs = []
|
|
|
|
class _DummyStorage:
|
|
def __init__(self, *, embedding_func, **kwargs):
|
|
captured_funcs.append(embedding_func)
|
|
|
|
async def initialize(self):
|
|
return None
|
|
|
|
monkeypatch.setattr(kg_factory, "get_storage_class", lambda name: _DummyStorage)
|
|
|
|
tool = RebuildTool()
|
|
# Simulate the api extra being unavailable (check-only mode).
|
|
monkeypatch.setattr(tool, "build_embedding_func", lambda: None)
|
|
|
|
ok = await tool.setup_storages()
|
|
|
|
assert ok is True
|
|
assert tool.embedding_available is False
|
|
assert tool.embedding_func.model_name == "text-embedding-3-large"
|
|
assert tool.embedding_func.embedding_dim == 3072
|
|
# Every storage received the stub carrying the configured model name.
|
|
assert captured_funcs
|
|
assert all(f.model_name == "text-embedding-3-large" for f in captured_funcs)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_only_stub_model_name_none_when_unset(monkeypatch):
|
|
# EMBEDDING_MODEL unset -> model_name None (provider default), matching the
|
|
# server factory; "None" literal is also treated as unset.
|
|
import lightrag.kg.factory as kg_factory
|
|
from lightrag.tools.rebuild_vdb import RebuildTool
|
|
|
|
monkeypatch.delenv("EMBEDDING_MODEL", raising=False)
|
|
|
|
class _DummyStorage:
|
|
def __init__(self, **kwargs):
|
|
pass
|
|
|
|
async def initialize(self):
|
|
return None
|
|
|
|
monkeypatch.setattr(kg_factory, "get_storage_class", lambda name: _DummyStorage)
|
|
|
|
tool = RebuildTool()
|
|
monkeypatch.setattr(tool, "build_embedding_func", lambda: None)
|
|
|
|
await tool.setup_storages()
|
|
|
|
assert tool.embedding_func.model_name is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI exit-code semantics: run() must report success/failure
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _runnable_tool(monkeypatch, inputs):
|
|
"""A RebuildTool with shared-storage init/finalize and prompts stubbed."""
|
|
import lightrag.kg.shared_storage as shared
|
|
|
|
monkeypatch.setattr(shared, "initialize_share_data", lambda workers=1: None)
|
|
monkeypatch.setattr(shared, "finalize_share_data", lambda: None)
|
|
monkeypatch.setattr("builtins.input", lambda *a: next(inputs))
|
|
|
|
tool = rebuild_vdb.RebuildTool()
|
|
monkeypatch.setattr(tool, "print_header", lambda: None)
|
|
monkeypatch.setattr(tool, "confirm_server_stopped", lambda: True)
|
|
return tool
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_returns_true_on_user_cancel(monkeypatch):
|
|
tool = _runnable_tool(monkeypatch, iter([]))
|
|
monkeypatch.setattr(tool, "confirm_server_stopped", lambda: False)
|
|
tool.setup_storages = AsyncMock(return_value=True)
|
|
|
|
assert await tool.run() is True
|
|
tool.setup_storages.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_returns_false_on_setup_failure(monkeypatch):
|
|
tool = _runnable_tool(monkeypatch, iter([]))
|
|
tool.setup_storages = AsyncMock(return_value=False)
|
|
|
|
assert await tool.run() is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_returns_false_on_unhandled_exception(monkeypatch):
|
|
tool = _runnable_tool(monkeypatch, iter([]))
|
|
tool.setup_storages = AsyncMock(side_effect=RuntimeError("db down"))
|
|
|
|
assert await tool.run() is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_returns_false_when_rebuild_has_errors(monkeypatch):
|
|
tool = _runnable_tool(monkeypatch, iter(["2", "0"]))
|
|
tool.setup_storages = AsyncMock(return_value=True)
|
|
tool.embedding_available = True
|
|
tool.print_source_counts = AsyncMock()
|
|
monkeypatch.setattr(tool, "confirm_rebuild", lambda targets: True)
|
|
|
|
failed = rebuild_vdb._new_stats("entities", 3)
|
|
failed["failed_batches"] = 1
|
|
failed["errors"] = [
|
|
{
|
|
"batch": 1,
|
|
"records_lost": 3,
|
|
"error_type": "RuntimeError",
|
|
"error_msg": "embedder down",
|
|
}
|
|
]
|
|
tool.run_rebuild_entities_relations = AsyncMock(return_value=[failed])
|
|
|
|
assert await tool.run() is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_returns_true_on_clean_rebuild(monkeypatch):
|
|
tool = _runnable_tool(monkeypatch, iter(["2", "0"]))
|
|
tool.setup_storages = AsyncMock(return_value=True)
|
|
tool.embedding_available = True
|
|
tool.print_source_counts = AsyncMock()
|
|
monkeypatch.setattr(tool, "confirm_rebuild", lambda targets: True)
|
|
|
|
ok = rebuild_vdb._new_stats("entities", 3)
|
|
ok["rebuilt"] = 3
|
|
tool.run_rebuild_entities_relations = AsyncMock(return_value=[ok])
|
|
|
|
assert await tool.run() is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_check_only_exits_true(monkeypatch):
|
|
tool = _runnable_tool(monkeypatch, iter(["1", "0"]))
|
|
tool.setup_storages = AsyncMock(return_value=True)
|
|
tool.embedding_available = False
|
|
tool.run_check = AsyncMock()
|
|
|
|
assert await tool.run() is True
|
|
tool.run_check.assert_awaited()
|
|
|
|
|
|
def test_main_exits_nonzero_on_failure(monkeypatch):
|
|
monkeypatch.setattr(rebuild_vdb, "load_dotenv", lambda **k: None)
|
|
monkeypatch.setattr(rebuild_vdb, "setup_logger", lambda *a, **k: None)
|
|
|
|
async def _fail():
|
|
return False
|
|
|
|
monkeypatch.setattr(rebuild_vdb, "async_main", _fail)
|
|
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
rebuild_vdb.main()
|
|
assert excinfo.value.code == 1
|
|
|
|
|
|
def test_main_exits_zero_on_success(monkeypatch):
|
|
monkeypatch.setattr(rebuild_vdb, "load_dotenv", lambda **k: None)
|
|
monkeypatch.setattr(rebuild_vdb, "setup_logger", lambda *a, **k: None)
|
|
|
|
async def _ok():
|
|
return True
|
|
|
|
monkeypatch.setattr(rebuild_vdb, "async_main", _ok)
|
|
|
|
# No SystemExit on success.
|
|
rebuild_vdb.main()
|