Files
hkuds--lightrag/tests/pipeline/test_insert_done_error_handling.py
T
2026-07-13 12:08:54 +08:00

508 lines
18 KiB
Python

"""Offline unit tests for the storage-flush error handling around
``index_done_callback`` (PR #3187).
These lock the ``LightRAG._insert_done`` / ``_discard_pending_index_ops`` /
``_insert_done_with_cleanup`` contract that the file pipeline relies on to
fail fast on a shared-buffer flush error instead of cascading every
subsequent document into FAILED.
The tests inject lightweight spy storages via ``_index_storages`` (and, for
the enqueue-owned / LLM-cache special cases, by binding the spy onto the real
``rag.full_docs`` / ``rag.doc_status`` / ``rag.llm_response_cache`` attributes
so the ``is`` identity checks fire). No storage driver is imported and no real
backend flush is exercised — this is pure lightrag.py logic.
"""
from __future__ import annotations
import asyncio
import logging
from uuid import uuid4
import numpy as np
import pytest
from lightrag import LightRAG
from lightrag.exceptions import IndexFlushError
from lightrag.utils import EmbeddingFunc, Tokenizer
pytestmark = pytest.mark.offline
@pytest.fixture(autouse=True)
def _propagate_lightrag_logs():
"""The ``lightrag`` logger sets ``propagate=False``, so caplog's root
handler would miss its records. Re-enable propagation for these tests so
``caplog`` can capture the best-effort error logs we assert on."""
lg = logging.getLogger("lightrag")
old = lg.propagate
lg.propagate = True
try:
yield
finally:
lg.propagate = old
class _SimpleTokenizerImpl:
def encode(self, content: str) -> list[int]:
return [ord(ch) for ch in content]
def decode(self, tokens: list[int]) -> str:
return "".join(chr(t) for t in tokens)
async def _mock_embedding(texts: list[str]) -> np.ndarray:
return np.ones((len(texts), 8), dtype=float)
async def _noop_llm(*args, **kwargs) -> str: # pragma: no cover - never invoked
return ""
async def _make_rag(tmp_path) -> LightRAG:
rag = LightRAG(
working_dir=str(tmp_path / "wd"),
workspace=f"insdone-{uuid4().hex[:8]}",
llm_model_func=_noop_llm,
embedding_func=EmbeddingFunc(
embedding_dim=8, max_token_size=1024, func=_mock_embedding
),
tokenizer=Tokenizer("mock-tokenizer", _SimpleTokenizerImpl()),
)
await rag.initialize_storages()
return rag
_UNSET = object()
class _SpyStorage:
"""Minimal stand-in for a StorageNameSpace with configurable flush/drop.
``flush_error`` / ``drop_error`` are exception *instances* raised by the
respective coroutine. ``recorder`` is a shared list appended with
``(label, "flush"|"drop")`` so call ordering across storages is assertable.
"""
def __init__(
self,
label: str,
*,
namespace: str = "ns",
final_namespace=_UNSET,
flush_error: BaseException | None = None,
drop_error: BaseException | None = None,
recorder: list | None = None,
):
self.label = label
self.namespace = namespace
if final_namespace is not _UNSET:
self.final_namespace = final_namespace
self._flush_error = flush_error
self._drop_error = drop_error
self._recorder = recorder if recorder is not None else []
self.index_done_calls = 0
self.drop_calls = 0
async def index_done_callback(self):
self.index_done_calls += 1
self._recorder.append((self.label, "flush"))
if self._flush_error is not None:
raise self._flush_error
async def drop_pending_index_ops(self):
self.drop_calls += 1
self._recorder.append((self.label, "drop"))
if self._drop_error is not None:
raise self._drop_error
async def finalize(self):
# No-op: keeps finalize_storages() quiet when a spy is bound onto a
# real storage attribute (full_docs / doc_status / llm_response_cache).
return None
# ---------------------------------------------------------------------------
# IndexFlushError class
# ---------------------------------------------------------------------------
def test_index_flush_error_attributes_and_message():
cause = RuntimeError("boom")
err = IndexFlushError("MilvusVectorDBStorage", "entities", cause)
assert err.storage_name == "MilvusVectorDBStorage"
assert err.namespace == "entities"
assert str(err) == "MilvusVectorDBStorage[entities] index flush failed: boom"
# __cause__ is NOT set by the constructor — only by `raise ... from e`
# inside _insert_done. That linkage is asserted in the _insert_done tests.
assert err.__cause__ is None
# ---------------------------------------------------------------------------
# _index_storages (internal contract — weak assertions)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_index_storages_filters_none_and_relative_order(tmp_path):
rag = await _make_rag(tmp_path)
try:
storages = rag._index_storages()
# None entries are filtered out.
assert all(s is not None for s in storages)
# Relative-order contract: enqueue-owned KVs come before the vdbs,
# and the LLM cache precedes every vector store. (No fixed count is
# asserted, so adding a new storage won't break this test.)
idx = {id(s): i for i, s in enumerate(storages)}
assert idx[id(rag.full_docs)] < idx[id(rag.entities_vdb)]
assert idx[id(rag.doc_status)] < idx[id(rag.entities_vdb)]
assert idx[id(rag.llm_response_cache)] < idx[id(rag.entities_vdb)]
assert idx[id(rag.llm_response_cache)] < idx[id(rag.chunks_vdb)]
# Filtering: drop one storage and confirm it disappears from the list.
rag.text_chunks = None
assert all(s is not None for s in rag._index_storages())
finally:
await rag.finalize_storages()
# ---------------------------------------------------------------------------
# _insert_done — success path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_insert_done_success_updates_pipeline_status(tmp_path, monkeypatch):
rag = await _make_rag(tmp_path)
try:
spies = [_SpyStorage("a"), _SpyStorage("b")]
monkeypatch.setattr(rag, "_index_storages", lambda: spies)
status = {"latest_message": "", "history_messages": []}
lock = asyncio.Lock()
await rag._insert_done(pipeline_status=status, pipeline_status_lock=lock)
assert all(s.index_done_calls == 1 for s in spies)
assert status["latest_message"] == "In memory DB persist to disk"
assert "In memory DB persist to disk" in status["history_messages"]
finally:
await rag.finalize_storages()
@pytest.mark.asyncio
async def test_insert_done_success_without_status_no_update(tmp_path, monkeypatch):
rag = await _make_rag(tmp_path)
try:
spies = [_SpyStorage("a")]
monkeypatch.setattr(rag, "_index_storages", lambda: spies)
# Should not raise and not require a status dict.
await rag._insert_done()
await rag._insert_done(pipeline_status={"history_messages": []}) # lock None
assert spies[0].index_done_calls == 2
finally:
await rag.finalize_storages()
# ---------------------------------------------------------------------------
# _insert_done — failure wrapping
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_insert_done_single_failure_wraps_index_flush_error(
tmp_path, monkeypatch
):
rag = await _make_rag(tmp_path)
try:
cause = RuntimeError("flush boom")
spies = [_SpyStorage("ok"), _SpyStorage("bad", flush_error=cause)]
monkeypatch.setattr(rag, "_index_storages", lambda: spies)
with pytest.raises(IndexFlushError) as ei:
await rag._insert_done()
assert ei.value.storage_name == "_SpyStorage"
# __cause__ linkage is established by `raise ... from e`.
assert ei.value.__cause__ is cause
finally:
await rag.finalize_storages()
@pytest.mark.parametrize(
"final_namespace, namespace, expected",
[
("fns", "ns", "fns"), # final_namespace wins
(_UNSET, "ns", "ns"), # falls back to namespace
(_UNSET, "", ""), # neither -> empty string
],
)
@pytest.mark.asyncio
async def test_insert_done_namespace_resolution(
tmp_path, monkeypatch, final_namespace, namespace, expected
):
rag = await _make_rag(tmp_path)
try:
spy = _SpyStorage(
"bad",
namespace=namespace,
final_namespace=final_namespace,
flush_error=RuntimeError("x"),
)
monkeypatch.setattr(rag, "_index_storages", lambda: [spy])
with pytest.raises(IndexFlushError) as ei:
await rag._insert_done()
assert ei.value.namespace == expected
finally:
await rag.finalize_storages()
@pytest.mark.asyncio
async def test_insert_done_multiple_failures_raises_first_logs_rest(
tmp_path, monkeypatch, caplog
):
rag = await _make_rag(tmp_path)
try:
spies = [
_SpyStorage("a", flush_error=RuntimeError("first")),
_SpyStorage("b", flush_error=RuntimeError("second")),
_SpyStorage("c"),
]
monkeypatch.setattr(rag, "_index_storages", lambda: spies)
with caplog.at_level("ERROR", logger="lightrag"):
with pytest.raises(IndexFlushError):
await rag._insert_done()
# gather(return_exceptions=True) runs ALL flushes to completion before
# raising — no detached coroutines.
assert all(s.index_done_calls == 1 for s in spies)
# The non-first failure is logged, not raised.
assert any(
"Additional index flush failure" in rec.message for rec in caplog.records
)
finally:
await rag.finalize_storages()
@pytest.mark.asyncio
async def test_insert_done_cancelled_error_takes_priority(tmp_path, monkeypatch):
rag = await _make_rag(tmp_path)
try:
spies = [
_SpyStorage("normal", flush_error=RuntimeError("normal fail")),
_SpyStorage("cancel", flush_error=asyncio.CancelledError()),
]
monkeypatch.setattr(rag, "_index_storages", lambda: spies)
# CancelledError must propagate as-is, not be wrapped in IndexFlushError.
with pytest.raises(asyncio.CancelledError):
await rag._insert_done()
finally:
await rag.finalize_storages()
# ---------------------------------------------------------------------------
# _discard_pending_index_ops
# ---------------------------------------------------------------------------
def _bind_enqueue_owned_spies(rag, recorder):
"""Bind spies onto the identity-checked attributes and return the list
_index_storages should yield. ``other`` stands in for a regenerable vdb."""
full = _SpyStorage("full_docs", recorder=recorder)
status = _SpyStorage("doc_status", recorder=recorder)
cache = _SpyStorage("llm_cache", recorder=recorder)
other = _SpyStorage("other_vdb", recorder=recorder)
rag.full_docs = full
rag.doc_status = status
rag.llm_response_cache = cache
return full, status, cache, other
@pytest.mark.asyncio
async def test_discard_skip_enqueue_owned_true(tmp_path, monkeypatch):
rag = await _make_rag(tmp_path)
try:
rec: list = []
full, status, cache, other = _bind_enqueue_owned_spies(rag, rec)
monkeypatch.setattr(
rag, "_index_storages", lambda: [full, status, cache, other]
)
await rag._discard_pending_index_ops() # skip_enqueue_owned=True default
# full_docs / doc_status are skipped.
assert full.drop_calls == 0
assert status.drop_calls == 0
# cache + other are dropped.
assert cache.drop_calls == 1
assert other.drop_calls == 1
finally:
await rag.finalize_storages()
@pytest.mark.asyncio
async def test_discard_skip_enqueue_owned_false(tmp_path, monkeypatch):
rag = await _make_rag(tmp_path)
try:
rec: list = []
full, status, cache, other = _bind_enqueue_owned_spies(rag, rec)
monkeypatch.setattr(
rag, "_index_storages", lambda: [full, status, cache, other]
)
await rag._discard_pending_index_ops(skip_enqueue_owned=False)
# Now full_docs / doc_status are ALSO dropped.
assert full.drop_calls == 1
assert status.drop_calls == 1
assert cache.drop_calls == 1
assert other.drop_calls == 1
finally:
await rag.finalize_storages()
@pytest.mark.asyncio
async def test_discard_llm_cache_flush_before_drop(tmp_path, monkeypatch):
rag = await _make_rag(tmp_path)
try:
rec: list = []
full, status, cache, other = _bind_enqueue_owned_spies(rag, rec)
monkeypatch.setattr(
rag, "_index_storages", lambda: [full, status, cache, other]
)
await rag._discard_pending_index_ops()
# The LLM cache is flushed (index_done_callback) BEFORE its buffer is
# dropped — expensive cached results are persisted maximally first.
assert ("llm_cache", "flush") in rec
assert rec.index(("llm_cache", "flush")) < rec.index(("llm_cache", "drop"))
# Non-cache storages are only dropped, never flushed here.
assert ("other_vdb", "flush") not in rec
assert cache.index_done_calls == 1
finally:
await rag.finalize_storages()
@pytest.mark.asyncio
async def test_discard_best_effort_swallows_drop_error(tmp_path, monkeypatch, caplog):
rag = await _make_rag(tmp_path)
try:
rec: list = []
full, status, cache, _ = _bind_enqueue_owned_spies(rag, rec)
boom = _SpyStorage(
"boom_vdb", drop_error=RuntimeError("drop boom"), recorder=rec
)
after = _SpyStorage("after_vdb", recorder=rec)
monkeypatch.setattr(rag, "_index_storages", lambda: [cache, boom, after])
with caplog.at_level("ERROR", logger="lightrag"):
# Must NOT raise — cleanup is best-effort and never masks the
# original abort cause.
await rag._discard_pending_index_ops()
assert boom.drop_calls == 1
# A later storage is still processed despite the earlier drop error.
assert after.drop_calls == 1
assert any(
"Failed to discard pending ops" in rec_.message for rec_ in caplog.records
)
finally:
await rag.finalize_storages()
@pytest.mark.asyncio
async def test_discard_llm_cache_flush_error_swallowed_still_drops(
tmp_path, monkeypatch, caplog
):
rag = await _make_rag(tmp_path)
try:
rec: list = []
cache = _SpyStorage(
"llm_cache", flush_error=RuntimeError("cache flush boom"), recorder=rec
)
rag.llm_response_cache = cache
monkeypatch.setattr(rag, "_index_storages", lambda: [cache])
with caplog.at_level("ERROR", logger="lightrag"):
await rag._discard_pending_index_ops()
# Flush failed (logged), but the drop still ran so a poisoned cache
# item cannot wedge the next batch.
assert cache.index_done_calls == 1
assert cache.drop_calls == 1
assert any(
"Failed to persist LLM cache on abort" in r.message for r in caplog.records
)
finally:
await rag.finalize_storages()
# ---------------------------------------------------------------------------
# _insert_done_with_cleanup
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_insert_done_with_cleanup_success_no_discard(tmp_path, monkeypatch):
rag = await _make_rag(tmp_path)
try:
monkeypatch.setattr(rag, "_index_storages", lambda: [_SpyStorage("a")])
discard_calls = 0
async def spy_discard(*, skip_enqueue_owned=True):
nonlocal discard_calls
discard_calls += 1
monkeypatch.setattr(rag, "_discard_pending_index_ops", spy_discard)
await rag._insert_done_with_cleanup()
assert discard_calls == 0
finally:
await rag.finalize_storages()
@pytest.mark.asyncio
async def test_insert_done_with_cleanup_index_flush_error_discards_and_reraises(
tmp_path, monkeypatch
):
rag = await _make_rag(tmp_path)
try:
spy = _SpyStorage("bad", flush_error=RuntimeError("boom"))
monkeypatch.setattr(rag, "_index_storages", lambda: [spy])
seen_kwargs = {}
async def spy_discard(*, skip_enqueue_owned=True):
seen_kwargs["skip_enqueue_owned"] = skip_enqueue_owned
monkeypatch.setattr(rag, "_discard_pending_index_ops", spy_discard)
with pytest.raises(IndexFlushError):
await rag._insert_done_with_cleanup()
# Direct callers clear full_docs too.
assert seen_kwargs == {"skip_enqueue_owned": False}
finally:
await rag.finalize_storages()
@pytest.mark.asyncio
async def test_insert_done_with_cleanup_cancelled_propagates_no_discard(
tmp_path, monkeypatch
):
rag = await _make_rag(tmp_path)
try:
spy = _SpyStorage("cancel", flush_error=asyncio.CancelledError())
monkeypatch.setattr(rag, "_index_storages", lambda: [spy])
discard_calls = 0
async def spy_discard(*, skip_enqueue_owned=True):
nonlocal discard_calls
discard_calls += 1
monkeypatch.setattr(rag, "_discard_pending_index_ops", spy_discard)
# CancelledError is not an IndexFlushError, so cleanup must NOT run.
with pytest.raises(asyncio.CancelledError):
await rag._insert_done_with_cleanup()
assert discard_calls == 0
finally:
await rag.finalize_storages()