Files
deerflow-code/offline-backend-20260512/backend/tests/test_assistant_snapshot_pointer_contract.py

250 lines
8.7 KiB
Python

import asyncio
from collections.abc import AsyncIterator
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from deerflow.persistence.assistant_knowledge import AssistantKnowledgeRepository
from deerflow.persistence.base import Base
class SnapshotEmbeddingClient:
dimensions = 3
fingerprint = "snapshot-test-model"
async def embed_query(self, _query: str) -> list[float]:
return [1.0, 0.0, 0.0]
@pytest_asyncio.fixture
async def repository(tmp_path) -> AsyncIterator[AssistantKnowledgeRepository]:
# A file-backed database gives the atomic-switch test a second connection
# that can read the last committed snapshot while the replacement
# transaction is deliberately paused before commit.
database_path = (tmp_path / "assistant-snapshot.sqlite3").as_posix()
engine = create_async_engine(f"sqlite+aiosqlite:///{database_path}")
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
yield AssistantKnowledgeRepository(async_sessionmaker(engine, expire_on_commit=False))
await engine.dispose()
def _package(*, revision: int, marker: str) -> dict:
return {
"source": {
"provider": "weknora",
"type": "weknora",
"knowledge_base_id": "stable-kb",
"knowledge_base_name": "稳定普通知识库",
"index_revision": revision,
},
"wiki_pages": [
{
"slug": "wiki/stable",
"title": "稳定页面",
"page_type": "article",
"content": f"# 稳定页面\n\n{marker}",
}
],
"vectors": [
{
"wiki_slug": "wiki/stable",
"section_index": 0,
"section_content": marker,
"embedding_model": "snapshot-test-model",
"embedding_fingerprint": SnapshotEmbeddingClient.fingerprint,
"embedding_dimensions": SnapshotEmbeddingClient.dimensions,
"vector": [1.0, 0.0, 0.0],
}
],
}
async def _seed_snapshot(
repository: AssistantKnowledgeRepository,
) -> tuple[dict, dict, dict]:
base = await repository.create_base(created_by="admin", name="助手快照测试库")
job = await repository.import_package(
package=_package(revision=1, marker="OLD-SNAPSHOT-MARKER-991"),
source_type="weknora",
source_key="stable-kb",
source_name="稳定普通知识库",
trigger="initial_import",
created_by="admin",
mapping_id="stable-kb",
base_id=base["id"],
mirror_to_global=False,
)
source = (await repository.list_sources(base_id=base["id"]))[0]
assert source["last_import_job_id"] == job["id"]
return base, source, job
async def _assert_vector_snapshot(
repository: AssistantKnowledgeRepository,
*,
base_id: str,
marker: str,
) -> None:
results = await repository.search_pages(
base_ids=[base_id],
query="完全没有字面重合的向量查询",
embedding_client=SnapshotEmbeddingClient(),
)
assert results
assert results[0]["matched_section"]["content"] == marker
async def _queue_reimport(
repository: AssistantKnowledgeRepository,
*,
base_id: str,
revision: int,
) -> dict:
return await repository.create_queued_import_job(
source_type="weknora",
source_key="stable-kb",
source_name="稳定普通知识库(待发布)",
trigger="source_reimport",
created_by="admin",
metadata={
"provider": "weknora",
"knowledge_base_id": "stable-kb",
"knowledge_base_name": "稳定普通知识库(待发布)",
"index_revision": revision,
},
mapping_id="stable-kb",
base_id=base_id,
)
@pytest.mark.asyncio
async def test_queued_and_running_reimport_keep_last_successful_snapshot(
repository: AssistantKnowledgeRepository,
) -> None:
base, original_source, original_job = await _seed_snapshot(repository)
queued_job = await _queue_reimport(repository, base_id=base["id"], revision=2)
queued_source = await repository.get_source(original_source["id"])
assert queued_job["status"] == "queued"
assert queued_source is not None
assert queued_source["last_import_job_id"] == original_job["id"]
assert queued_source["metadata"]["index_revision"] == 1
assert queued_source["source_name"] == "稳定普通知识库"
await _assert_vector_snapshot(
repository,
base_id=base["id"],
marker="OLD-SNAPSHOT-MARKER-991",
)
await repository.update_import_job(
queued_job["id"],
status="running",
phase="exporting",
)
running_source = await repository.get_source(original_source["id"])
assert running_source is not None
assert running_source["last_import_job_id"] == original_job["id"]
await _assert_vector_snapshot(
repository,
base_id=base["id"],
marker="OLD-SNAPSHOT-MARKER-991",
)
@pytest.mark.asyncio
async def test_failed_reimport_rolls_back_and_keeps_old_vector_snapshot(
repository: AssistantKnowledgeRepository,
) -> None:
base, original_source, original_job = await _seed_snapshot(repository)
queued_job = await _queue_reimport(repository, base_id=base["id"], revision=2)
invalid_package = _package(revision=2, marker="BROKEN-NEW-SNAPSHOT")
invalid_package["vectors"][0]["wiki_slug"] = "wiki/missing"
with pytest.raises(ValueError, match="向量引用不存在"):
await repository.import_package_for_job(
queued_job["id"],
package=invalid_package,
created_by="admin",
mirror_to_global=False,
)
await repository.update_import_job(
queued_job["id"],
status="failed",
phase="failed",
error="测试导入失败",
completed=True,
)
failed_source = await repository.get_source(original_source["id"])
assert failed_source is not None
assert failed_source["last_import_job_id"] == original_job["id"]
assert failed_source["metadata"]["index_revision"] == 1
await _assert_vector_snapshot(
repository,
base_id=base["id"],
marker="OLD-SNAPSHOT-MARKER-991",
)
pages = await repository.list_pages(base_id=base["id"])
assert pages[0]["content_markdown"].endswith("OLD-SNAPSHOT-MARKER-991")
@pytest.mark.asyncio
async def test_successful_reimport_switches_snapshot_only_at_commit(
repository: AssistantKnowledgeRepository,
monkeypatch: pytest.MonkeyPatch,
) -> None:
base, original_source, original_job = await _seed_snapshot(repository)
queued_job = await _queue_reimport(repository, base_id=base["id"], revision=2)
snapshot_written = asyncio.Event()
allow_commit = asyncio.Event()
original_retire_orphans = repository._retire_orphans
async def pause_before_snapshot_switch(session, base_id: str) -> None:
await original_retire_orphans(session, base_id)
snapshot_written.set()
await allow_commit.wait()
monkeypatch.setattr(repository, "_retire_orphans", pause_before_snapshot_switch)
import_task = asyncio.create_task(
repository.import_package_for_job(
queued_job["id"],
package=_package(revision=2, marker="NEW-SNAPSHOT-MARKER-772"),
created_by="admin",
mirror_to_global=False,
)
)
await asyncio.wait_for(snapshot_written.wait(), timeout=5)
try:
# The replacement rows exist only in the writer's uncommitted
# transaction. Other readers must still resolve the old job pointer and
# the old vector snapshot until the transaction commits.
mid_import_source = await repository.get_source(original_source["id"])
assert mid_import_source is not None
assert mid_import_source["last_import_job_id"] == original_job["id"]
assert mid_import_source["metadata"]["index_revision"] == 1
await _assert_vector_snapshot(
repository,
base_id=base["id"],
marker="OLD-SNAPSHOT-MARKER-991",
)
finally:
allow_commit.set()
completed_job = await asyncio.wait_for(import_task, timeout=5)
published_source = await repository.get_source(original_source["id"])
assert completed_job["status"] == "completed"
assert published_source is not None
assert published_source["last_import_job_id"] == queued_job["id"]
assert published_source["metadata"]["index_revision"] == 2
assert published_source["source_name"] == "稳定普通知识库"
await _assert_vector_snapshot(
repository,
base_id=base["id"],
marker="NEW-SNAPSHOT-MARKER-772",
)