Files
AstrBot/tests/unit/test_kb_upload_atomicity.py
lxfight 296fcfb54a fix: compensate partial knowledge base upload failures (#9007) (#9246)
* fix: roll back document rows when FAISS insert fails

Validate embedding shape/dimension before local writes. If DocumentStorage
commits but FAISS write fails, best-effort delete the partial FAISS ids and
document rows so the two stores do not diverge.

* fix: clean up partial KB upload state before metadata commit

Track metadata_committed right after commit succeeds. On failure before
metadata commit, roll back chunks/vectors first, then residual KB rows and
media files. After metadata is committed, keep the document even if stats
or refresh fails.

* test: cover knowledge base upload atomicity and rollback

Add unit tests for insert_batch FAISS failure rollback, dimension validation
before document writes, upload cleanup order, no-rollback after metadata
commit, and real DocumentStorage+FAISS no-orphan cases.

* fix: log media directory cleanup failures after upload rollback

Best-effort rmdir of the per-document media directory should still emit a
warning when removal fails so incomplete cleanup is diagnosable.
2026-07-15 16:11:02 +08:00

575 lines
19 KiB
Python

"""
Unit tests for knowledge base upload atomicity / compensating rollback.
Covers:
1. insert_batch rolls back document rows when FAISS insert fails
2. upload_document cleans up chunks/vectors/media on storage failure
3. upload_document cleans up after metadata failure (post insert_batch)
4. upload_document does NOT roll back after metadata is committed
5. Real DocumentStorage + EmbeddingStorage leave no orphans on FAISS failure
6. Dimension validation failures never write to DocumentStorage
These tests use lazy imports and a ProviderManager stub to avoid circular
import issues in the astrbot core module chain (same pattern as
test_kb_manager_resilience.py).
"""
import sys
import types
from contextlib import asynccontextmanager
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from astrbot.core.db.vec_db.faiss_impl.vec_db import FaissVecDB
from astrbot.core.exceptions import KnowledgeBaseUploadError
from astrbot.core.knowledge_base.models import KnowledgeBase
@pytest.fixture
def stub_provider_manager_module():
"""Stub provider manager module to avoid circular imports in unit tests."""
original_module = sys.modules.get("astrbot.core.provider.manager")
stub_module = types.ModuleType("astrbot.core.provider.manager")
class ProviderManager: ...
setattr(stub_module, "ProviderManager", ProviderManager)
sys.modules["astrbot.core.provider.manager"] = stub_module
# Drop already-imported modules that transitively need ProviderManager so
# they re-import against the stub.
to_drop = [
name
for name in list(sys.modules)
if name.startswith("astrbot.core.knowledge_base.kb_helper")
or name.startswith("astrbot.core.knowledge_base.kb_mgr")
]
for name in to_drop:
sys.modules.pop(name, None)
try:
yield
finally:
if original_module is not None:
sys.modules["astrbot.core.provider.manager"] = original_module
else:
sys.modules.pop("astrbot.core.provider.manager", None)
def _make_vec_db() -> FaissVecDB:
vec_db = FaissVecDB.__new__(FaissVecDB)
vec_db.embedding_provider = AsyncMock()
vec_db.document_storage = AsyncMock()
vec_db.embedding_storage = AsyncMock()
vec_db.embedding_storage.dimension = 2
return vec_db
def _import_kb_helper():
from astrbot.core.knowledge_base.kb_helper import KBHelper
return KBHelper
def _failing_get_db():
@asynccontextmanager
async def _cm():
raise RuntimeError("kb.db locked")
yield # pragma: no cover
return _cm
def _successful_get_db(session):
@asynccontextmanager
async def _cm():
yield session
return _cm
def _successful_begin():
@asynccontextmanager
async def _cm():
yield None
return _cm
def _session_with_begin(execute_side_effect=None):
"""Session mock that supports ``async with session.begin()``."""
session = MagicMock()
session.begin = _successful_begin()
session.add = MagicMock()
session.commit = AsyncMock()
session.refresh = AsyncMock()
session.execute = AsyncMock(side_effect=execute_side_effect)
return session
async def _make_real_vec_db(tmp_path: Path, dim: int = 4) -> FaissVecDB:
"""Build a FaissVecDB backed by real DocumentStorage + EmbeddingStorage."""
doc_path = str(tmp_path / "doc.db")
index_path = str(tmp_path / "index.faiss")
embedding_provider = MagicMock()
# get_dim is sync in EmbeddingProvider; must return a plain int for FAISS.
embedding_provider.get_dim = MagicMock(return_value=dim)
embedding_provider.get_embeddings_batch = AsyncMock()
embedding_provider.get_embedding = AsyncMock()
vec_db = FaissVecDB(
doc_store_path=doc_path,
index_store_path=index_path,
embedding_provider=embedding_provider,
)
await vec_db.initialize()
return vec_db
@pytest.mark.asyncio
async def test_insert_batch_rolls_back_documents_when_faiss_fails() -> None:
"""FAISS write failure should delete rows already committed to DocumentStorage."""
vec_db = _make_vec_db()
vec_db.embedding_provider.get_embeddings_batch.return_value = [
[0.1, 0.2],
[0.3, 0.4],
]
vec_db.document_storage.insert_documents_batch.return_value = [11, 12]
vec_db.embedding_storage.insert_batch.side_effect = RuntimeError(
"faiss write failed",
)
with pytest.raises(RuntimeError, match="faiss write failed"):
await FaissVecDB.insert_batch(
vec_db,
contents=["chunk-1", "chunk-2"],
metadatas=[{"kb_doc_id": "doc-1"}, {"kb_doc_id": "doc-1"}],
ids=["c1", "c2"],
)
vec_db.embedding_storage.delete.assert_awaited_once_with([11, 12])
assert vec_db.document_storage.delete_document_by_doc_id.await_count == 2
vec_db.document_storage.delete_document_by_doc_id.assert_any_await("c1")
vec_db.document_storage.delete_document_by_doc_id.assert_any_await("c2")
@pytest.mark.asyncio
async def test_insert_batch_rolls_back_on_int_id_count_mismatch() -> None:
"""Mismatched int_id count after document insert should roll back those docs."""
vec_db = _make_vec_db()
vec_db.embedding_provider.get_embeddings_batch.return_value = [
[0.1, 0.2],
[0.3, 0.4],
]
vec_db.document_storage.insert_documents_batch.return_value = [11] # mismatch
with pytest.raises(KnowledgeBaseUploadError) as exc_info:
await FaissVecDB.insert_batch(
vec_db,
contents=["chunk-1", "chunk-2"],
metadatas=[{}, {}],
ids=["c1", "c2"],
)
assert "内部 ID 数量" in str(exc_info.value)
vec_db.embedding_storage.insert_batch.assert_not_awaited()
vec_db.embedding_storage.delete.assert_awaited_once_with([11])
assert vec_db.document_storage.delete_document_by_doc_id.await_count == 2
@pytest.mark.asyncio
async def test_insert_batch_dimension_mismatch_does_not_write_documents() -> None:
"""Dimension validation must fail before DocumentStorage is written."""
vec_db = _make_vec_db()
vec_db.embedding_storage.dimension = 4
vec_db.embedding_provider.get_embeddings_batch.return_value = [
[0.1, 0.2], # wrong dim
[0.3, 0.4],
]
with pytest.raises(KnowledgeBaseUploadError) as exc_info:
await FaissVecDB.insert_batch(
vec_db,
contents=["chunk-1", "chunk-2"],
metadatas=[{}, {}],
ids=["c1", "c2"],
)
assert "维度" in str(exc_info.value)
vec_db.document_storage.insert_documents_batch.assert_not_awaited()
vec_db.embedding_storage.insert_batch.assert_not_awaited()
@pytest.mark.asyncio
async def test_insert_batch_real_storage_rolls_back_on_faiss_failure(
tmp_path: Path,
) -> None:
"""Real DocumentStorage must not keep orphan chunks when FAISS write fails."""
vec_db = await _make_real_vec_db(tmp_path, dim=4)
vec_db.embedding_provider.get_embeddings_batch.return_value = [
[0.1, 0.2, 0.3, 0.4],
[0.5, 0.6, 0.7, 0.8],
]
original_insert = vec_db.embedding_storage.insert_batch
async def boom(vectors, ids):
# Simulate failure after in-memory add would happen: force raise before
# success so DocumentStorage rows must be cleaned up.
raise RuntimeError("simulated faiss disk write failure")
vec_db.embedding_storage.insert_batch = boom # type: ignore[method-assign]
kb_doc_id = "kb-doc-real-1"
with pytest.raises(RuntimeError, match="simulated faiss"):
await vec_db.insert_batch(
contents=["alpha chunk", "beta chunk"],
metadatas=[
{"kb_id": "kb-1", "kb_doc_id": kb_doc_id, "chunk_index": 0},
{"kb_id": "kb-1", "kb_doc_id": kb_doc_id, "chunk_index": 1},
],
ids=["chunk-a", "chunk-b"],
)
remaining = await vec_db.document_storage.get_documents(
metadata_filters={"kb_doc_id": kb_doc_id},
offset=None,
limit=None,
)
assert remaining == []
assert await vec_db.count_documents(metadata_filter={"kb_doc_id": kb_doc_id}) == 0
# Restore for clean close; index should still be empty / consistent.
vec_db.embedding_storage.insert_batch = original_insert # type: ignore[method-assign]
await vec_db.close()
@pytest.mark.asyncio
async def test_insert_batch_real_storage_dimension_mismatch_leaves_no_docs(
tmp_path: Path,
) -> None:
"""Real storage: wrong embedding dim must not leave any document rows."""
vec_db = await _make_real_vec_db(tmp_path, dim=4)
vec_db.embedding_provider.get_embeddings_batch.return_value = [
[0.1, 0.2], # dim 2 != 4
[0.3, 0.4],
]
with pytest.raises(KnowledgeBaseUploadError):
await vec_db.insert_batch(
contents=["alpha", "beta"],
metadatas=[
{"kb_id": "kb-1", "kb_doc_id": "doc-dim", "chunk_index": 0},
{"kb_id": "kb-1", "kb_doc_id": "doc-dim", "chunk_index": 1},
],
ids=["c1", "c2"],
)
remaining = await vec_db.document_storage.get_documents(
metadata_filters={"kb_doc_id": "doc-dim"},
offset=None,
limit=None,
)
assert remaining == []
await vec_db.close()
@pytest.mark.asyncio
async def test_upload_document_cleans_up_on_storage_failure(
tmp_path: Path,
stub_provider_manager_module,
) -> None:
"""Storage failure should clean media and request chunk/vector rollback."""
KBHelper = _import_kb_helper()
helper = KBHelper.__new__(KBHelper)
helper.kb = KnowledgeBase(
kb_name="Test KB",
description="",
embedding_provider_id="emb",
)
helper.kb_db = MagicMock()
helper.vec_db = AsyncMock()
helper.kb_medias_dir = tmp_path / "medias"
helper.kb_medias_dir.mkdir()
helper.chunker = AsyncMock()
helper.chunker.chunk = AsyncMock(return_value=["hello world"])
media_file = helper.kb_medias_dir / "will-be-set" / "img.png"
async def fake_save_media(**kwargs):
nonlocal media_file
doc_id = kwargs["doc_id"]
media_dir = helper.kb_medias_dir / doc_id
media_dir.mkdir(parents=True, exist_ok=True)
media_file = media_dir / "img.png"
media_file.write_bytes(b"fake-image")
media = MagicMock()
media.file_path = str(media_file)
return media
helper._save_media = AsyncMock(side_effect=fake_save_media)
helper.vec_db.insert_batch.side_effect = RuntimeError("embedding provider down")
helper.vec_db.delete_documents = AsyncMock()
helper.kb_db.get_db = _successful_get_db(_session_with_begin())
parse_result = MagicMock()
parse_result.text = "hello world"
parse_result.media = [
MagicMock(
media_type="image",
file_name="img.png",
content=b"fake-image",
mime_type="image/png",
),
]
with (
patch(
"astrbot.core.knowledge_base.kb_helper.select_parser",
new=AsyncMock(
return_value=MagicMock(parse=AsyncMock(return_value=parse_result)),
),
),
patch.object(helper, "_ensure_vec_db", new=AsyncMock()),
pytest.raises(KnowledgeBaseUploadError) as exc_info,
):
await helper.upload_document(
file_name="demo.txt",
file_content=b"hello world",
file_type="txt",
)
assert exc_info.value.stage == "storage"
assert "cause" in exc_info.value.details
helper.vec_db.delete_documents.assert_awaited()
assert not media_file.exists()
@pytest.mark.asyncio
async def test_upload_document_cleans_up_on_metadata_failure(
stub_provider_manager_module,
) -> None:
"""Metadata commit failure after insert_batch should delete written chunks."""
KBHelper = _import_kb_helper()
helper = KBHelper.__new__(KBHelper)
helper.kb = KnowledgeBase(
kb_name="Test KB",
description="",
embedding_provider_id="emb",
)
helper.kb_db = MagicMock()
helper.vec_db = AsyncMock()
helper.kb_medias_dir = Path("/tmp/kb-medias-unused")
helper.chunker = AsyncMock()
helper.vec_db.insert_batch = AsyncMock()
helper.vec_db.delete_documents = AsyncMock()
helper.kb_db.get_db = _failing_get_db()
with (
patch.object(helper, "_ensure_vec_db", new=AsyncMock()),
pytest.raises(KnowledgeBaseUploadError) as exc_info,
):
await helper.upload_document(
file_name="demo.txt",
file_content=None,
file_type="txt",
pre_chunked_text=["chunk a", "chunk b"],
)
assert exc_info.value.stage == "metadata"
helper.vec_db.insert_batch.assert_awaited_once()
helper.vec_db.delete_documents.assert_awaited()
@pytest.mark.asyncio
async def test_upload_document_skips_rollback_after_metadata_commit(
stub_provider_manager_module,
) -> None:
"""Stats refresh failure after metadata commit must not roll back the doc."""
KBHelper = _import_kb_helper()
helper = KBHelper.__new__(KBHelper)
helper.kb = KnowledgeBase(
kb_name="Test KB",
description="",
embedding_provider_id="emb",
)
helper.kb_db = MagicMock()
helper.vec_db = AsyncMock()
helper.kb_medias_dir = Path("/tmp/kb-medias-unused")
helper.chunker = AsyncMock()
helper.vec_db.insert_batch = AsyncMock()
helper.vec_db.delete_documents = AsyncMock()
helper.kb_db.update_kb_stats = AsyncMock(side_effect=RuntimeError("stats fail"))
helper.refresh_kb = AsyncMock()
helper.refresh_document = AsyncMock()
session = _session_with_begin()
helper.kb_db.get_db = _successful_get_db(session)
with (
patch.object(helper, "_ensure_vec_db", new=AsyncMock()),
pytest.raises(KnowledgeBaseUploadError) as exc_info,
):
await helper.upload_document(
file_name="demo.txt",
file_content=None,
file_type="txt",
pre_chunked_text=["chunk a"],
)
assert exc_info.value.stage == "metadata"
assert "统计信息刷新失败" in str(exc_info.value)
helper.vec_db.delete_documents.assert_not_awaited()
@pytest.mark.asyncio
async def test_upload_document_skips_rollback_when_refresh_fails_after_commit(
stub_provider_manager_module,
) -> None:
"""If commit succeeds but session.refresh fails, do not roll back."""
KBHelper = _import_kb_helper()
helper = KBHelper.__new__(KBHelper)
helper.kb = KnowledgeBase(
kb_name="Test KB",
description="",
embedding_provider_id="emb",
)
helper.kb_db = MagicMock()
helper.vec_db = AsyncMock()
helper.kb_medias_dir = Path("/tmp/kb-medias-unused")
helper.chunker = AsyncMock()
helper.vec_db.insert_batch = AsyncMock()
helper.vec_db.delete_documents = AsyncMock()
session = _session_with_begin()
session.refresh = AsyncMock(side_effect=RuntimeError("refresh failed"))
helper.kb_db.get_db = _successful_get_db(session)
with (
patch.object(helper, "_ensure_vec_db", new=AsyncMock()),
pytest.raises(KnowledgeBaseUploadError) as exc_info,
):
await helper.upload_document(
file_name="demo.txt",
file_content=None,
file_type="txt",
pre_chunked_text=["chunk a"],
)
assert exc_info.value.stage == "metadata"
assert "文档记录刷新失败" in str(exc_info.value)
helper.vec_db.delete_documents.assert_not_awaited()
@pytest.mark.asyncio
async def test_cleanup_failed_upload_deletes_vectors_before_metadata(
tmp_path: Path,
stub_provider_manager_module,
) -> None:
"""Rollback should delete vectors first, then metadata rows, then media."""
KBHelper = _import_kb_helper()
helper = KBHelper.__new__(KBHelper)
helper.vec_db = AsyncMock()
helper.kb_db = MagicMock()
helper.kb_medias_dir = tmp_path / "medias"
helper.kb_medias_dir.mkdir()
call_order: list[str] = []
async def track_delete_documents(**kwargs):
call_order.append("vectors")
helper.vec_db.delete_documents = AsyncMock(side_effect=track_delete_documents)
async def track_execute(*args, **kwargs):
if "vectors" in call_order and "metadata" not in call_order:
call_order.append("metadata")
return None
helper.kb_db.get_db = _successful_get_db(
_session_with_begin(execute_side_effect=track_execute),
)
media = helper.kb_medias_dir / "doc-x" / "a.png"
media.parent.mkdir()
media.write_bytes(b"x")
await helper._cleanup_failed_upload(doc_id="doc-x", media_paths=[media])
assert call_order[0] == "vectors"
assert "metadata" in call_order
assert not media.exists()
helper.vec_db.delete_documents.assert_awaited_once_with(
metadata_filters={"kb_doc_id": "doc-x"},
)
@pytest.mark.asyncio
async def test_cleanup_failed_upload_is_best_effort(
tmp_path: Path,
stub_provider_manager_module,
) -> None:
"""Rollback path failures must not raise; media cleanup still runs."""
KBHelper = _import_kb_helper()
helper = KBHelper.__new__(KBHelper)
helper.kb_db = MagicMock()
helper.vec_db = AsyncMock()
helper.vec_db.delete_documents.side_effect = RuntimeError("vec delete failed")
helper.kb_db.get_db = _failing_get_db()
helper.kb_medias_dir = tmp_path / "medias"
helper.kb_medias_dir.mkdir()
media = helper.kb_medias_dir / "doc-x" / "a.png"
media.parent.mkdir()
media.write_bytes(b"x")
# Should not raise
await helper._cleanup_failed_upload(doc_id="doc-x", media_paths=[media])
assert not media.exists()
@pytest.mark.asyncio
async def test_cleanup_failed_upload_real_vec_db_by_kb_doc_id(
tmp_path: Path,
stub_provider_manager_module,
) -> None:
"""Cleanup with a real vec_db removes chunks keyed by kb_doc_id."""
KBHelper = _import_kb_helper()
vec_db = await _make_real_vec_db(tmp_path, dim=4)
vec_db.embedding_provider.get_embeddings_batch.return_value = [
[0.1, 0.2, 0.3, 0.4],
[0.5, 0.6, 0.7, 0.8],
]
kb_doc_id = "upload-doc-cleanup"
await vec_db.insert_batch(
contents=["one", "two"],
metadatas=[
{"kb_id": "kb-1", "kb_doc_id": kb_doc_id, "chunk_index": 0},
{"kb_id": "kb-1", "kb_doc_id": kb_doc_id, "chunk_index": 1},
],
ids=["u1", "u2"],
)
assert await vec_db.count_documents(metadata_filter={"kb_doc_id": kb_doc_id}) == 2
helper = KBHelper.__new__(KBHelper)
helper.vec_db = vec_db
helper.kb_db = MagicMock()
helper.kb_medias_dir = tmp_path / "medias"
helper.kb_medias_dir.mkdir()
helper.kb_db.get_db = _successful_get_db(_session_with_begin())
await helper._cleanup_failed_upload(doc_id=kb_doc_id, media_paths=[])
assert await vec_db.count_documents(metadata_filter={"kb_doc_id": kb_doc_id}) == 0
await vec_db.close()