Initial curated RAGcore source from c16667f0c9397e2bfc8dd0255b114fba23c8d05e
This commit is contained in:
commit
7ca726caf2
659 files changed
+98171
No files matched your search
@@ -0,0 +1,352 @@
|
||||
"""BackupService orchestration against fake ports: atomicity, verification, retention."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.backup.models import (
|
||||
ArtifactChecksum,
|
||||
BackupManifest,
|
||||
BackupStatus,
|
||||
BackupVerificationError,
|
||||
)
|
||||
from ragcore.application.backup.service import MANIFEST_FILENAME, BackupService
|
||||
|
||||
|
||||
def _write_and_checksum(path: Path, content: bytes) -> ArtifactChecksum:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
return ArtifactChecksum(
|
||||
relative_path=path.name, sha256=hashlib.sha256(content).hexdigest(), size_bytes=len(content)
|
||||
)
|
||||
|
||||
|
||||
class _FakePostgresDumper:
|
||||
def __init__(self, *, content: bytes = b"pg-dump-bytes", revision: str = "abc123") -> None:
|
||||
self._content = content
|
||||
self._revision = revision
|
||||
self.dump_calls = 0
|
||||
|
||||
async def dump(self, *, destination: Path) -> ArtifactChecksum:
|
||||
self.dump_calls += 1
|
||||
return _write_and_checksum(destination, self._content)
|
||||
|
||||
async def schema_revision(self) -> str:
|
||||
return self._revision
|
||||
|
||||
|
||||
class _FakeQdrantSnapshotter:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
collections: Mapping[str, bytes] | None = None,
|
||||
aliases: Mapping[str, str] | None = None,
|
||||
) -> None:
|
||||
self._collections = collections if collections is not None else {"docs": b"snapshot-bytes"}
|
||||
self._aliases = aliases if aliases is not None else {"docs-live": "docs"}
|
||||
|
||||
async def snapshot_collections(
|
||||
self, *, collection_names: Sequence[str], destination_dir: Path
|
||||
) -> tuple[ArtifactChecksum, ...]:
|
||||
checksums = []
|
||||
for name in collection_names:
|
||||
content = self._collections[name]
|
||||
checksums.append(_write_and_checksum(destination_dir / f"{name}.snapshot", content))
|
||||
return tuple(checksums)
|
||||
|
||||
async def collection_aliases(self) -> Mapping[str, str]:
|
||||
return self._aliases
|
||||
|
||||
|
||||
class _FakeFileCopier:
|
||||
def __init__(self, *, files: Mapping[str, bytes] | None = None) -> None:
|
||||
self._files = files if files is not None else {"a.bin": b"blob-a", "sub/b.bin": b"blob-b"}
|
||||
|
||||
async def copy_tree(self, *, source: Path, destination: Path) -> tuple[ArtifactChecksum, ...]:
|
||||
checksums = []
|
||||
for relative_path, content in self._files.items():
|
||||
target = destination / relative_path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
checksums.append(
|
||||
ArtifactChecksum(
|
||||
relative_path=relative_path,
|
||||
sha256=hashlib.sha256(content).hexdigest(),
|
||||
size_bytes=len(content),
|
||||
)
|
||||
)
|
||||
return tuple(checksums)
|
||||
|
||||
|
||||
def _service(
|
||||
*,
|
||||
postgres: _FakePostgresDumper | None = None,
|
||||
qdrant: _FakeQdrantSnapshotter | None = None,
|
||||
files: _FakeFileCopier | None = None,
|
||||
clock: object = None,
|
||||
id_factory: object = None,
|
||||
) -> BackupService:
|
||||
kwargs: dict[str, object] = {
|
||||
"postgres": postgres or _FakePostgresDumper(),
|
||||
"qdrant": qdrant or _FakeQdrantSnapshotter(),
|
||||
"files": files or _FakeFileCopier(),
|
||||
}
|
||||
if clock is not None:
|
||||
kwargs["clock"] = clock
|
||||
if id_factory is not None:
|
||||
kwargs["id_factory"] = id_factory
|
||||
return BackupService(**kwargs) # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def test_run_produces_a_complete_manifest_pinning_schema_and_profiles(tmp_path: Path) -> None:
|
||||
service = _service(id_factory=lambda: "backup-1")
|
||||
|
||||
manifest = await service.run(
|
||||
backup_root=tmp_path,
|
||||
blob_root=tmp_path / "blobs-src",
|
||||
parsed_root=tmp_path / "parsed-src",
|
||||
collection_names=["docs"],
|
||||
active_profiles={"embedding": "hybrid-v1:v3", "generation": "default:v1"},
|
||||
)
|
||||
|
||||
assert manifest.status is BackupStatus.COMPLETE
|
||||
assert manifest.backup_id == "backup-1"
|
||||
assert manifest.schema_revision == "abc123"
|
||||
assert manifest.active_profiles == {"embedding": "hybrid-v1:v3", "generation": "default:v1"}
|
||||
assert manifest.postgres_dump is not None
|
||||
assert manifest.postgres_dump.relative_path == "postgres.dump"
|
||||
assert len(manifest.qdrant_snapshots) == 1
|
||||
assert manifest.qdrant_snapshots[0].relative_path == "qdrant/docs.snapshot"
|
||||
assert manifest.collection_aliases == {"docs-live": "docs"}
|
||||
assert {c.relative_path for c in manifest.blob_artifacts} == {"blobs/a.bin", "blobs/sub/b.bin"}
|
||||
assert {c.relative_path for c in manifest.parsed_artifacts} == {
|
||||
"parsed/a.bin",
|
||||
"parsed/sub/b.bin",
|
||||
}
|
||||
assert manifest.counts == {"qdrant_snapshots": 1, "blob_artifacts": 2, "parsed_artifacts": 2}
|
||||
|
||||
|
||||
async def test_run_filters_out_aliases_for_collections_not_in_this_backup(tmp_path: Path) -> None:
|
||||
"""collection_aliases() reports every alias in the whole Qdrant cluster;
|
||||
only the ones for collections this backup actually snapshotted may end
|
||||
up in the manifest, or restore would later look for a snapshot of
|
||||
someone else's unrelated collection."""
|
||||
|
||||
service = _service(
|
||||
qdrant=_FakeQdrantSnapshotter(
|
||||
collections={"docs": b"snapshot-bytes"},
|
||||
aliases={"docs-live": "docs", "unrelated-live": "unrelated-collection"},
|
||||
),
|
||||
id_factory=lambda: "backup-1",
|
||||
)
|
||||
|
||||
manifest = await service.run(
|
||||
backup_root=tmp_path,
|
||||
blob_root=tmp_path / "blobs-src",
|
||||
parsed_root=tmp_path / "parsed-src",
|
||||
collection_names=["docs"],
|
||||
active_profiles={},
|
||||
)
|
||||
|
||||
assert manifest.collection_aliases == {"docs-live": "docs"}
|
||||
|
||||
|
||||
async def test_run_writes_every_artifact_relative_to_backup_dir_on_disk(tmp_path: Path) -> None:
|
||||
service = _service(id_factory=lambda: "backup-1")
|
||||
|
||||
manifest = await service.run(
|
||||
backup_root=tmp_path,
|
||||
blob_root=tmp_path / "blobs-src",
|
||||
parsed_root=tmp_path / "parsed-src",
|
||||
collection_names=["docs"],
|
||||
active_profiles={},
|
||||
)
|
||||
|
||||
backup_dir = tmp_path / "backup-1"
|
||||
assert manifest.postgres_dump is not None
|
||||
for checksum in (
|
||||
manifest.postgres_dump,
|
||||
*manifest.qdrant_snapshots,
|
||||
*manifest.blob_artifacts,
|
||||
*manifest.parsed_artifacts,
|
||||
):
|
||||
assert (backup_dir / checksum.relative_path).is_file()
|
||||
|
||||
|
||||
async def test_manifest_on_disk_is_incomplete_before_run_finishes(tmp_path: Path) -> None:
|
||||
"""The atomic-completion invariant: an in-flight backup must never look done."""
|
||||
|
||||
seen_status: list[str] = []
|
||||
real_dump = _FakePostgresDumper()
|
||||
|
||||
class _ObservingDumper(_FakePostgresDumper):
|
||||
async def dump(self, *, destination: Path) -> ArtifactChecksum:
|
||||
manifest_path = destination.parent / MANIFEST_FILENAME
|
||||
seen_status.append(json.loads(manifest_path.read_text())["status"])
|
||||
return await real_dump.dump(destination=destination)
|
||||
|
||||
async def schema_revision(self) -> str:
|
||||
return await real_dump.schema_revision()
|
||||
|
||||
service = _service(postgres=_ObservingDumper(), id_factory=lambda: "backup-2")
|
||||
|
||||
await service.run(
|
||||
backup_root=tmp_path,
|
||||
blob_root=tmp_path / "blobs-src",
|
||||
parsed_root=tmp_path / "parsed-src",
|
||||
collection_names=["docs"],
|
||||
active_profiles={},
|
||||
)
|
||||
|
||||
assert seen_status == ["incomplete"]
|
||||
final_status = json.loads((tmp_path / "backup-2" / MANIFEST_FILENAME).read_text())["status"]
|
||||
assert final_status == "complete"
|
||||
|
||||
|
||||
async def test_a_partial_backup_is_never_promoted_to_complete(tmp_path: Path) -> None:
|
||||
class _FailingFileCopier(_FakeFileCopier):
|
||||
async def copy_tree(
|
||||
self, *, source: Path, destination: Path
|
||||
) -> tuple[ArtifactChecksum, ...]:
|
||||
raise RuntimeError("disk full")
|
||||
|
||||
service = _service(files=_FailingFileCopier(), id_factory=lambda: "backup-3")
|
||||
|
||||
with pytest.raises(RuntimeError, match="disk full"):
|
||||
await service.run(
|
||||
backup_root=tmp_path,
|
||||
blob_root=tmp_path / "blobs-src",
|
||||
parsed_root=tmp_path / "parsed-src",
|
||||
collection_names=["docs"],
|
||||
active_profiles={},
|
||||
)
|
||||
|
||||
manifest_path = tmp_path / "backup-3" / MANIFEST_FILENAME
|
||||
assert json.loads(manifest_path.read_text())["status"] == "incomplete"
|
||||
|
||||
|
||||
async def test_run_raises_if_a_checksum_is_corrupted_before_verification(tmp_path: Path) -> None:
|
||||
"""Simulates on-disk corruption between write and the re-verify step."""
|
||||
|
||||
class _CorruptingFileCopier(_FakeFileCopier):
|
||||
async def copy_tree(
|
||||
self, *, source: Path, destination: Path
|
||||
) -> tuple[ArtifactChecksum, ...]:
|
||||
checksums = await super().copy_tree(source=source, destination=destination)
|
||||
first = destination / checksums[0].relative_path
|
||||
first.write_bytes(b"corrupted")
|
||||
return checksums
|
||||
|
||||
service = _service(files=_CorruptingFileCopier(), id_factory=lambda: "backup-4")
|
||||
|
||||
with pytest.raises(BackupVerificationError, match="mismatch"):
|
||||
await service.run(
|
||||
backup_root=tmp_path,
|
||||
blob_root=tmp_path / "blobs-src",
|
||||
parsed_root=tmp_path / "parsed-src",
|
||||
collection_names=["docs"],
|
||||
active_profiles={},
|
||||
)
|
||||
|
||||
manifest_path = tmp_path / "backup-4" / MANIFEST_FILENAME
|
||||
assert json.loads(manifest_path.read_text())["status"] == "incomplete"
|
||||
|
||||
|
||||
async def test_run_raises_if_an_artifact_is_missing_before_verification(tmp_path: Path) -> None:
|
||||
class _DeletingFileCopier(_FakeFileCopier):
|
||||
async def copy_tree(
|
||||
self, *, source: Path, destination: Path
|
||||
) -> tuple[ArtifactChecksum, ...]:
|
||||
checksums = await super().copy_tree(source=source, destination=destination)
|
||||
(destination / checksums[0].relative_path).unlink()
|
||||
return checksums
|
||||
|
||||
service = _service(files=_DeletingFileCopier(), id_factory=lambda: "backup-5")
|
||||
|
||||
with pytest.raises(BackupVerificationError, match="missing"):
|
||||
await service.run(
|
||||
backup_root=tmp_path,
|
||||
blob_root=tmp_path / "blobs-src",
|
||||
parsed_root=tmp_path / "parsed-src",
|
||||
collection_names=["docs"],
|
||||
active_profiles={},
|
||||
)
|
||||
|
||||
|
||||
async def test_manifest_never_contains_settings_style_secret_fields(tmp_path: Path) -> None:
|
||||
service = _service(id_factory=lambda: "backup-6")
|
||||
|
||||
manifest = await service.run(
|
||||
backup_root=tmp_path,
|
||||
blob_root=tmp_path / "blobs-src",
|
||||
parsed_root=tmp_path / "parsed-src",
|
||||
collection_names=["docs"],
|
||||
active_profiles={"embedding": "hybrid-v1:v3"},
|
||||
)
|
||||
|
||||
serialized = json.dumps(manifest.to_json_dict())
|
||||
for forbidden in ("database_url", "api_key", "secret", "password", "token"):
|
||||
assert forbidden not in serialized.lower()
|
||||
|
||||
|
||||
def _manifest(backup_id: str, status: BackupStatus, created_at: datetime) -> BackupManifest:
|
||||
return BackupManifest(
|
||||
backup_id=backup_id,
|
||||
status=status,
|
||||
created_at=created_at,
|
||||
software_version="0.0.0",
|
||||
schema_revision="rev",
|
||||
)
|
||||
|
||||
|
||||
def _write_manifest(backup_root: Path, manifest: BackupManifest) -> None:
|
||||
backup_dir = backup_root / manifest.backup_id
|
||||
backup_dir.mkdir(parents=True)
|
||||
(backup_dir / MANIFEST_FILENAME).write_text(json.dumps(manifest.to_json_dict()))
|
||||
|
||||
|
||||
def test_prune_keeps_only_the_newest_complete_backups(tmp_path: Path) -> None:
|
||||
base = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
_write_manifest(tmp_path, _manifest("oldest", BackupStatus.COMPLETE, base))
|
||||
_write_manifest(tmp_path, _manifest("middle", BackupStatus.COMPLETE, base + timedelta(days=1)))
|
||||
_write_manifest(tmp_path, _manifest("newest", BackupStatus.COMPLETE, base + timedelta(days=2)))
|
||||
|
||||
service = _service()
|
||||
removed = service.prune(backup_root=tmp_path, keep_complete=2)
|
||||
|
||||
assert removed == ("oldest",)
|
||||
assert not (tmp_path / "oldest").exists()
|
||||
assert (tmp_path / "middle").exists()
|
||||
assert (tmp_path / "newest").exists()
|
||||
|
||||
|
||||
def test_prune_never_removes_an_incomplete_backup(tmp_path: Path) -> None:
|
||||
base = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
_write_manifest(tmp_path, _manifest("stale-incomplete", BackupStatus.INCOMPLETE, base))
|
||||
_write_manifest(
|
||||
tmp_path, _manifest("complete-one", BackupStatus.COMPLETE, base + timedelta(days=1))
|
||||
)
|
||||
|
||||
service = _service()
|
||||
removed = service.prune(backup_root=tmp_path, keep_complete=0)
|
||||
|
||||
assert removed == ("complete-one",)
|
||||
assert (tmp_path / "stale-incomplete").exists()
|
||||
|
||||
|
||||
def test_prune_rejects_negative_retention(tmp_path: Path) -> None:
|
||||
service = _service()
|
||||
with pytest.raises(ValueError, match="keep_complete"):
|
||||
service.prune(backup_root=tmp_path, keep_complete=-1)
|
||||
|
||||
|
||||
def test_prune_on_nonexistent_backup_root_is_a_noop(tmp_path: Path) -> None:
|
||||
service = _service()
|
||||
removed = service.prune(backup_root=tmp_path / "does-not-exist", keep_complete=5)
|
||||
assert removed == ()
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Unit tests for the document read/retire use cases a sync client drives:
|
||||
look a document up by its own (source_id, external_id) identity, list every
|
||||
version, and soft-retire a document that's disappeared at the source."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.authorization import AuthorizationService
|
||||
from ragcore.application.control.api import ControlPlaneApplication
|
||||
from ragcore.application.documents import (
|
||||
DocumentRecord,
|
||||
DocumentVersionRecord,
|
||||
DocumentVersionState,
|
||||
)
|
||||
from ragcore.domain.authorization import AuthorizationError, Principal, PrincipalType
|
||||
from ragcore.domain.control import Role
|
||||
|
||||
|
||||
class FakeDocumentRepository:
|
||||
def __init__(self) -> None:
|
||||
self.documents: dict[object, DocumentRecord] = {}
|
||||
self.versions: dict[object, list[DocumentVersionRecord]] = {}
|
||||
self.retired: list[object] = []
|
||||
|
||||
def seed(self, document: DocumentRecord, *versions: DocumentVersionRecord) -> None:
|
||||
self.documents[document.id] = document
|
||||
self.versions[document.id] = list(versions)
|
||||
|
||||
async def get_document(self, document_id: object) -> DocumentRecord | None:
|
||||
return self.documents.get(document_id)
|
||||
|
||||
async def get_document_by_source(
|
||||
self, workspace_id: object, source_key: str, external_id: str
|
||||
) -> DocumentRecord | None:
|
||||
return next(
|
||||
(
|
||||
document
|
||||
for document in self.documents.values()
|
||||
if document.workspace_id == workspace_id
|
||||
and document.source_key == source_key
|
||||
and document.external_id == external_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
async def list_versions(self, document_id: object) -> tuple[DocumentVersionRecord, ...]:
|
||||
return tuple(self.versions.get(document_id, ()))
|
||||
|
||||
async def list_searchable_versions(
|
||||
self, document_id: object
|
||||
) -> tuple[DocumentVersionRecord, ...]:
|
||||
document = self.documents[document_id]
|
||||
if document.source_deleted:
|
||||
return ()
|
||||
return tuple(
|
||||
version
|
||||
for version in self.versions.get(document_id, ())
|
||||
if version.state is DocumentVersionState.PUBLISHED
|
||||
)
|
||||
|
||||
async def mark_source_deleted(self, document_id: object) -> None:
|
||||
self.retired.append(document_id)
|
||||
document = self.documents[document_id]
|
||||
self.documents[document_id] = DocumentRecord(
|
||||
id=document.id,
|
||||
workspace_id=document.workspace_id,
|
||||
space_id=document.space_id,
|
||||
source_key=document.source_key,
|
||||
external_id=document.external_id,
|
||||
title=document.title,
|
||||
source_deleted=True,
|
||||
)
|
||||
|
||||
|
||||
def _principal(workspace_id: object, role: Role = Role.EDITOR) -> Principal:
|
||||
return Principal(uuid4(), workspace_id, PrincipalType.SERVICE_ACCOUNT, frozenset({role}))
|
||||
|
||||
|
||||
def _document(workspace_id: object, *, source_deleted: bool = False) -> DocumentRecord:
|
||||
return DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
space_id=uuid4(),
|
||||
source_key=str(uuid4()),
|
||||
external_id="wiki-page-1",
|
||||
title="Wiki page",
|
||||
source_deleted=source_deleted,
|
||||
)
|
||||
|
||||
|
||||
def _version(document_id: object, state: DocumentVersionState) -> DocumentVersionRecord:
|
||||
return DocumentVersionRecord(
|
||||
id=uuid4(),
|
||||
document_id=document_id,
|
||||
source_fingerprint="a" * 64,
|
||||
content_hash="b" * 64,
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version="prose-v1",
|
||||
state=state,
|
||||
)
|
||||
|
||||
|
||||
def _service(repository: FakeDocumentRepository) -> ControlPlaneApplication:
|
||||
return ControlPlaneApplication(
|
||||
repository=None, # type: ignore[arg-type]
|
||||
authorization=AuthorizationService(),
|
||||
document_repository=repository, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_document_by_source_returns_the_active_version() -> None:
|
||||
workspace_id = uuid4()
|
||||
repository = FakeDocumentRepository()
|
||||
document = _document(workspace_id)
|
||||
published = _version(document.id, DocumentVersionState.PUBLISHED)
|
||||
repository.seed(document, published)
|
||||
service = _service(repository)
|
||||
|
||||
found, active = await service.get_document_by_source(
|
||||
_principal(workspace_id),
|
||||
source_id=UUID(document.source_key),
|
||||
external_id=document.external_id,
|
||||
)
|
||||
|
||||
assert found == document
|
||||
assert active == published
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_document_by_source_unknown_pair_raises_lookup_error() -> None:
|
||||
service = _service(FakeDocumentRepository())
|
||||
with pytest.raises(LookupError):
|
||||
await service.get_document_by_source(
|
||||
_principal(uuid4()), source_id=uuid4(), external_id="missing"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_document_by_source_wrong_workspace_raises_lookup_error() -> None:
|
||||
repository = FakeDocumentRepository()
|
||||
document = _document(uuid4())
|
||||
repository.seed(document)
|
||||
service = _service(repository)
|
||||
|
||||
with pytest.raises(LookupError):
|
||||
await service.get_document_by_source(
|
||||
_principal(uuid4()),
|
||||
source_id=UUID(document.source_key),
|
||||
external_id=document.external_id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_document_by_source_insufficient_role_raises_authorization_error() -> None:
|
||||
workspace_id = uuid4()
|
||||
repository = FakeDocumentRepository()
|
||||
document = _document(workspace_id)
|
||||
repository.seed(document)
|
||||
service = _service(repository)
|
||||
|
||||
with pytest.raises(AuthorizationError):
|
||||
await service.get_document_by_source(
|
||||
_principal(workspace_id, role=Role.AUDITOR),
|
||||
source_id=UUID(document.source_key),
|
||||
external_id=document.external_id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_document_versions_returns_every_state() -> None:
|
||||
workspace_id = uuid4()
|
||||
repository = FakeDocumentRepository()
|
||||
document = _document(workspace_id)
|
||||
superseded = _version(document.id, DocumentVersionState.SUPERSEDED)
|
||||
published = _version(document.id, DocumentVersionState.PUBLISHED)
|
||||
repository.seed(document, superseded, published)
|
||||
service = _service(repository)
|
||||
|
||||
versions = await service.list_document_versions(_principal(workspace_id), document.id)
|
||||
|
||||
assert {version.id for version in versions} == {superseded.id, published.id}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_document_versions_wrong_workspace_raises_lookup_error() -> None:
|
||||
repository = FakeDocumentRepository()
|
||||
document = _document(uuid4())
|
||||
repository.seed(document)
|
||||
service = _service(repository)
|
||||
|
||||
with pytest.raises(LookupError):
|
||||
await service.list_document_versions(_principal(uuid4()), document.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retire_document_marks_source_deleted() -> None:
|
||||
workspace_id = uuid4()
|
||||
repository = FakeDocumentRepository()
|
||||
document = _document(workspace_id)
|
||||
repository.seed(document)
|
||||
service = _service(repository)
|
||||
|
||||
retired = await service.retire_document(_principal(workspace_id), document.id)
|
||||
|
||||
assert retired.source_deleted is True
|
||||
assert repository.retired == [document.id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retire_document_is_idempotent() -> None:
|
||||
workspace_id = uuid4()
|
||||
repository = FakeDocumentRepository()
|
||||
document = _document(workspace_id, source_deleted=True)
|
||||
repository.seed(document)
|
||||
service = _service(repository)
|
||||
|
||||
retired = await service.retire_document(_principal(workspace_id), document.id)
|
||||
|
||||
assert retired.source_deleted is True
|
||||
assert repository.retired == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retire_document_wrong_workspace_raises_lookup_error() -> None:
|
||||
repository = FakeDocumentRepository()
|
||||
document = _document(uuid4())
|
||||
repository.seed(document)
|
||||
service = _service(repository)
|
||||
|
||||
with pytest.raises(LookupError):
|
||||
await service.retire_document(_principal(uuid4()), document.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retire_document_requires_source_manage_permission() -> None:
|
||||
workspace_id = uuid4()
|
||||
repository = FakeDocumentRepository()
|
||||
document = _document(workspace_id)
|
||||
repository.seed(document)
|
||||
service = _service(repository)
|
||||
|
||||
with pytest.raises(AuthorizationError):
|
||||
await service.retire_document(_principal(workspace_id, role=Role.AUDITOR), document.id)
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Unit tests for control-plane space and grant use cases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.control import (
|
||||
EffectiveSpaceResolver,
|
||||
GrantService,
|
||||
KnowledgeSpaceLifecycle,
|
||||
)
|
||||
from ragcore.domain.control import (
|
||||
DataPolicy,
|
||||
ProviderKind,
|
||||
Role,
|
||||
SpaceOperation,
|
||||
Workspace,
|
||||
)
|
||||
|
||||
|
||||
def setup_control_plane() -> tuple[Workspace, object, object, object]:
|
||||
workspace = Workspace(uuid4(), "Platform", "platform")
|
||||
application = workspace.create_application(
|
||||
id=uuid4(),
|
||||
name="Client",
|
||||
scopes=(),
|
||||
default_knowledge_space_ids=frozenset(),
|
||||
)
|
||||
private = workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Private", slug="private", data_policy=DataPolicy.LOCAL_ONLY
|
||||
)
|
||||
shared = workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Shared", slug="shared", data_policy=DataPolicy.LOCAL_PREFERRED
|
||||
)
|
||||
return workspace, application, private, shared
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("policy", "provider", "operation", "allowed"),
|
||||
[
|
||||
(DataPolicy.LOCAL_ONLY, ProviderKind.LOCAL, SpaceOperation.ANSWER, True),
|
||||
(DataPolicy.LOCAL_ONLY, ProviderKind.CLOUD, SpaceOperation.SEARCH, False),
|
||||
(DataPolicy.LOCAL_PREFERRED, ProviderKind.CLOUD, SpaceOperation.ANSWER, True),
|
||||
(DataPolicy.RETRIEVAL_ONLY, ProviderKind.LOCAL, SpaceOperation.SEARCH, True),
|
||||
(DataPolicy.NO_GENERATION, ProviderKind.CLOUD, SpaceOperation.CONTEXT, True),
|
||||
(DataPolicy.NO_GENERATION, ProviderKind.LOCAL, SpaceOperation.ANSWER, False),
|
||||
],
|
||||
)
|
||||
def test_data_policy_excludes_incompatible_provider_operations(
|
||||
policy: DataPolicy, provider: ProviderKind, operation: SpaceOperation, allowed: bool
|
||||
) -> None:
|
||||
workspace = Workspace(uuid4(), "Platform", "platform")
|
||||
space = workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Policy", slug="policy", data_policy=policy
|
||||
)
|
||||
|
||||
assert space.allows(operation=operation, provider=provider) is allowed
|
||||
|
||||
|
||||
def test_effective_spaces_are_requested_grants_and_policy_intersection() -> None:
|
||||
workspace, application, private, shared = setup_control_plane()
|
||||
user_id = uuid4()
|
||||
grants = GrantService()
|
||||
app_grant = grants.application_grant(
|
||||
workspace, id=uuid4(), application=application, space=private, role=Role.VIEWER
|
||||
)
|
||||
user_grant = grants.user_grant(
|
||||
workspace, id=uuid4(), user_id=user_id, space=shared, role=Role.VIEWER
|
||||
)
|
||||
|
||||
effective = EffectiveSpaceResolver().resolve(
|
||||
workspace_id=workspace.id,
|
||||
spaces=(private, shared),
|
||||
grants=(app_grant,),
|
||||
user_grants=(user_grant,),
|
||||
application=application,
|
||||
user_id=user_id,
|
||||
requested_space_ids=(private.id, shared.id),
|
||||
)
|
||||
|
||||
assert effective == frozenset({private.id, shared.id})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("role", "operation"),
|
||||
[
|
||||
(Role.VIEWER, SpaceOperation.CONTEXT),
|
||||
(Role.VIEWER, SpaceOperation.ANSWER),
|
||||
(Role.AUDITOR, SpaceOperation.SEARCH),
|
||||
],
|
||||
)
|
||||
def test_grant_role_limits_effective_space_operations(
|
||||
role: Role, operation: SpaceOperation
|
||||
) -> None:
|
||||
workspace, application, private, _ = setup_control_plane()
|
||||
grant = GrantService().application_grant(
|
||||
workspace, id=uuid4(), application=application, space=private, role=role
|
||||
)
|
||||
|
||||
effective = EffectiveSpaceResolver().resolve(
|
||||
workspace_id=workspace.id,
|
||||
spaces=(private,),
|
||||
grants=(grant,),
|
||||
application=application,
|
||||
requested_space_ids=(private.id,),
|
||||
operation=operation,
|
||||
)
|
||||
|
||||
assert effective == frozenset()
|
||||
def test_default_spaces_never_expand_grants() -> None:
|
||||
workspace, application, private, shared = setup_control_plane()
|
||||
application = workspace.create_application(
|
||||
id=application.id,
|
||||
name=application.name,
|
||||
scopes=(),
|
||||
default_knowledge_space_ids=frozenset({private.id, shared.id}),
|
||||
)
|
||||
grant = GrantService().application_grant(
|
||||
workspace, id=uuid4(), application=application, space=private, role=Role.VIEWER
|
||||
)
|
||||
|
||||
effective = EffectiveSpaceResolver().resolve(
|
||||
workspace_id=workspace.id,
|
||||
spaces=(private, shared),
|
||||
grants=(grant,),
|
||||
application=application,
|
||||
)
|
||||
|
||||
assert effective == frozenset({private.id})
|
||||
|
||||
|
||||
def test_inactive_spaces_and_grants_are_excluded() -> None:
|
||||
workspace, application, private, shared = setup_control_plane()
|
||||
inactive_space = workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Inactive", slug="inactive", active=False
|
||||
)
|
||||
grants = GrantService()
|
||||
private_grant = grants.application_grant(
|
||||
workspace,
|
||||
id=uuid4(),
|
||||
application=application,
|
||||
space=private,
|
||||
role=Role.VIEWER,
|
||||
active=False,
|
||||
)
|
||||
inactive_grant = grants.application_grant(
|
||||
workspace, id=uuid4(), application=application, space=inactive_space, role=Role.VIEWER
|
||||
)
|
||||
|
||||
effective = EffectiveSpaceResolver().resolve(
|
||||
workspace_id=workspace.id,
|
||||
spaces=(private, shared, inactive_space),
|
||||
grants=(private_grant, inactive_grant),
|
||||
application=application,
|
||||
requested_space_ids=(private.id, inactive_space.id),
|
||||
)
|
||||
|
||||
assert effective == frozenset()
|
||||
|
||||
|
||||
def test_cross_workspace_spaces_and_grants_are_excluded() -> None:
|
||||
workspace, application, private, _ = setup_control_plane()
|
||||
other = Workspace(uuid4(), "Other", "other")
|
||||
other_space = other.create_knowledge_space(id=uuid4(), name="Other", slug="other")
|
||||
cross_grant = other.grant_user(
|
||||
id=uuid4(), user_id=uuid4(), knowledge_space=other_space, role=Role.VIEWER
|
||||
)
|
||||
|
||||
effective = EffectiveSpaceResolver().resolve(
|
||||
workspace_id=workspace.id,
|
||||
spaces=(private, other_space),
|
||||
user_grants=(cross_grant,),
|
||||
application=application,
|
||||
user_id=cross_grant.user_id,
|
||||
requested_space_ids=(private.id, other_space.id),
|
||||
)
|
||||
|
||||
assert effective == frozenset()
|
||||
|
||||
|
||||
def test_lifecycle_transitions_preserve_identity_without_mutating_source() -> None:
|
||||
_, _, private, _ = setup_control_plane()
|
||||
lifecycle = KnowledgeSpaceLifecycle()
|
||||
|
||||
inactive = lifecycle.deactivate(private)
|
||||
cloud = lifecycle.set_data_policy(inactive, DataPolicy.CLOUD_ALLOWED)
|
||||
active = lifecycle.activate(cloud)
|
||||
|
||||
assert private.active is True
|
||||
assert inactive.active is False
|
||||
assert cloud.data_policy is DataPolicy.CLOUD_ALLOWED
|
||||
assert active.active is True
|
||||
assert active.id == private.id
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Batch byte-quota boundaries: exact limit, one byte over, and fail-closed I/O."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.indexes.models import point_wire_bytes
|
||||
from ragcore.infrastructure.qdrant import adapter as adapter_module
|
||||
from ragcore.infrastructure.qdrant.adapter import QdrantIndexAdapter
|
||||
from ragcore.infrastructure.qdrant.models import (
|
||||
CollectionKind,
|
||||
CollectionProfile,
|
||||
IndexPayload,
|
||||
IndexPoint,
|
||||
)
|
||||
|
||||
|
||||
class _RefusingClient:
|
||||
"""Proves a quota rejection happens before any network mutation is attempted."""
|
||||
|
||||
async def upsert(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise AssertionError("upsert must not run once the batch quota is exceeded")
|
||||
|
||||
async def retrieve(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise AssertionError("retrieve must not run once the batch quota is exceeded")
|
||||
|
||||
async def set_payload(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise AssertionError("set_payload must not run once the batch quota is exceeded")
|
||||
|
||||
|
||||
class _RecordingClient:
|
||||
"""A minimal working double so an at-quota batch can complete normally."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.upserted_ids: list[str] = []
|
||||
|
||||
async def upsert(self, *, points: Any, **kwargs: Any) -> None:
|
||||
self.upserted_ids = [point.id for point in points]
|
||||
|
||||
async def retrieve(self, *, ids: list[str], **kwargs: Any) -> list[SimpleNamespace]:
|
||||
return [
|
||||
SimpleNamespace(
|
||||
id=point_id,
|
||||
vector={
|
||||
"dense": [0.1, 0.2, 0.3, 0.4],
|
||||
"sparse": SimpleNamespace(indices=[1], values=[0.5]),
|
||||
},
|
||||
)
|
||||
for point_id in ids
|
||||
]
|
||||
|
||||
async def set_payload(self, **kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _profile() -> CollectionProfile:
|
||||
return CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile="quota",
|
||||
version="v1",
|
||||
dimensions=4,
|
||||
model_digest="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
def _point(profile: CollectionProfile, *, text: str = "x" * 1000) -> IndexPoint:
|
||||
chunk_id = uuid4()
|
||||
return IndexPoint(
|
||||
point_id=chunk_id,
|
||||
dense_vector=(0.1, 0.2, 0.3, 0.4),
|
||||
sparse_indices=(1, 2),
|
||||
sparse_values=(0.5, 0.5),
|
||||
payload=IndexPayload(
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
document_id=uuid4(),
|
||||
document_version_id=uuid4(),
|
||||
chunk_id=chunk_id,
|
||||
parent_chunk_id=None,
|
||||
source_id=uuid4(),
|
||||
status="published",
|
||||
content_type="text/markdown",
|
||||
language="en",
|
||||
trust_level="source",
|
||||
location={"page": 1},
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version=profile.version,
|
||||
embedding_model_digest=profile.model_digest,
|
||||
text=text,
|
||||
excerpt="short excerpt",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_batch_exactly_at_the_byte_limit_is_accepted(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
profile = _profile()
|
||||
point = _point(profile)
|
||||
monkeypatch.setattr(adapter_module, "MAX_BATCH_BYTES", point_wire_bytes(point))
|
||||
adapter = QdrantIndexAdapter(_RecordingClient()) # type: ignore[arg-type]
|
||||
|
||||
await adapter.upsert_points(profile, (point,))
|
||||
|
||||
|
||||
async def test_batch_one_byte_over_the_limit_is_rejected_with_no_partial_mutation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
profile = _profile()
|
||||
point = _point(profile)
|
||||
monkeypatch.setattr(adapter_module, "MAX_BATCH_BYTES", point_wire_bytes(point) - 1)
|
||||
adapter = QdrantIndexAdapter(_RefusingClient()) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(ValueError, match="serialized batch limit"):
|
||||
await adapter.upsert_points(profile, (point,))
|
||||
|
||||
|
||||
async def test_batch_quota_counts_every_metadata_field_not_only_text(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
profile = _profile()
|
||||
small = _point(profile, text="short")
|
||||
large_location = replace(
|
||||
small.payload, location={f"k{i}": f"v{i}" for i in range(500)}
|
||||
)
|
||||
large_metadata_point = replace(small, payload=large_location)
|
||||
|
||||
# Set the limit strictly between the two sizes: only the metadata-heavy point
|
||||
# must be rejected, proving the estimate is not text/excerpt-only.
|
||||
threshold = (point_wire_bytes(small) + point_wire_bytes(large_metadata_point)) // 2
|
||||
monkeypatch.setattr(adapter_module, "MAX_BATCH_BYTES", threshold)
|
||||
|
||||
accepting_adapter = QdrantIndexAdapter(_RecordingClient()) # type: ignore[arg-type]
|
||||
await accepting_adapter.upsert_points(profile, (small,))
|
||||
|
||||
refusing_adapter = QdrantIndexAdapter(_RefusingClient()) # type: ignore[arg-type]
|
||||
with pytest.raises(ValueError, match="serialized batch limit"):
|
||||
await refusing_adapter.upsert_points(profile, (large_metadata_point,))
|
||||
|
||||
|
||||
async def test_batch_quota_counts_multibyte_unicode_by_real_utf8_bytes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
profile = _profile()
|
||||
ascii_point = _point(profile, text="a" * 300)
|
||||
multibyte_point = _point(profile, text="漢" * 300)
|
||||
|
||||
assert point_wire_bytes(multibyte_point) > point_wire_bytes(ascii_point)
|
||||
|
||||
monkeypatch.setattr(adapter_module, "MAX_BATCH_BYTES", point_wire_bytes(ascii_point))
|
||||
refusing_adapter = QdrantIndexAdapter(_RefusingClient()) # type: ignore[arg-type]
|
||||
with pytest.raises(ValueError, match="serialized batch limit"):
|
||||
await refusing_adapter.upsert_points(profile, (multibyte_point,))
|
||||
|
||||
|
||||
async def test_batch_quota_is_cumulative_across_many_small_points(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
profile = _profile()
|
||||
points = tuple(_point(profile, text=f"passage {i}") for i in range(20))
|
||||
per_point = point_wire_bytes(points[0])
|
||||
# Allow 5 points' worth of bytes; the 6th must trip the cumulative check even
|
||||
# though no single point is anywhere near the limit on its own.
|
||||
monkeypatch.setattr(adapter_module, "MAX_BATCH_BYTES", per_point * 5 + per_point // 2)
|
||||
|
||||
refusing_adapter = QdrantIndexAdapter(_RefusingClient()) # type: ignore[arg-type]
|
||||
with pytest.raises(ValueError, match="serialized batch limit"):
|
||||
await refusing_adapter.upsert_points(profile, points)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Cumulative per-build byte quota: many small points must not evade it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
import ragcore.application.indexes.service as service_module
|
||||
from ragcore.application.indexes import models as models_module
|
||||
from ragcore.application.indexes.models import IndexBuildError
|
||||
from ragcore.application.indexes.service import _stream_build
|
||||
from ragcore.infrastructure.qdrant.models import (
|
||||
CollectionKind,
|
||||
CollectionProfile,
|
||||
IndexPayload,
|
||||
IndexPoint,
|
||||
)
|
||||
|
||||
|
||||
def _profile() -> CollectionProfile:
|
||||
return CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile="build-quota",
|
||||
version="v1",
|
||||
dimensions=4,
|
||||
model_digest="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
def _point(profile: CollectionProfile, index: int) -> IndexPoint:
|
||||
chunk_id = uuid4()
|
||||
return IndexPoint(
|
||||
point_id=chunk_id,
|
||||
dense_vector=(0.1, 0.2, 0.3, 0.4),
|
||||
sparse_indices=(),
|
||||
sparse_values=(),
|
||||
payload=IndexPayload(
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
document_id=uuid4(),
|
||||
document_version_id=uuid4(),
|
||||
chunk_id=chunk_id,
|
||||
parent_chunk_id=None,
|
||||
source_id=uuid4(),
|
||||
status="published",
|
||||
content_type="text/markdown",
|
||||
language="en",
|
||||
trust_level="source",
|
||||
location={"page": index},
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version=profile.version,
|
||||
embedding_model_digest=profile.model_digest,
|
||||
text=f"passage {index}",
|
||||
excerpt=f"passage {index}",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _RecordingStore:
|
||||
def __init__(self) -> None:
|
||||
self.upserted: list[IndexPoint] = []
|
||||
|
||||
async def upsert_points(self, profile: CollectionProfile, points: object) -> None:
|
||||
self.upserted.extend(points) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class _ListSource:
|
||||
def __init__(self, points: tuple[IndexPoint, ...]) -> None:
|
||||
self._points = points
|
||||
|
||||
async def iter_points(self, profile: CollectionProfile) -> AsyncIterator[IndexPoint]:
|
||||
for point in self._points:
|
||||
yield point
|
||||
|
||||
|
||||
async def test_many_small_points_are_bounded_by_the_cumulative_build_quota(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
profile = _profile()
|
||||
points = tuple(_point(profile, index) for index in range(10))
|
||||
per_point_bytes = models_module.point_wire_bytes(points[0])
|
||||
# _stream_build resolves MAX_BUILD_BYTES against service.py's own module
|
||||
# globals (bound at import time), so it must be patched there, not on models.
|
||||
monkeypatch.setattr(
|
||||
service_module, "MAX_BUILD_BYTES", per_point_bytes * 5 + per_point_bytes // 2
|
||||
)
|
||||
|
||||
store = _RecordingStore()
|
||||
with pytest.raises(IndexBuildError, match="byte quota"):
|
||||
await _stream_build(store, profile, _ListSource(points))
|
||||
|
||||
# Fewer than all ten points may have been accepted; none beyond the quota.
|
||||
assert len(store.upserted) < len(points)
|
||||
|
||||
|
||||
async def test_build_quota_is_not_evaded_by_many_tiny_points(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
profile = _profile()
|
||||
points = tuple(_point(profile, index) for index in range(50))
|
||||
per_point_bytes = models_module.point_wire_bytes(points[0])
|
||||
|
||||
monkeypatch.setattr(service_module, "MAX_BUILD_BYTES", per_point_bytes * 10)
|
||||
|
||||
store = _RecordingStore()
|
||||
with pytest.raises(IndexBuildError, match="byte quota"):
|
||||
await _stream_build(store, profile, _ListSource(points))
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Pure unit coverage for the point_wire_bytes byte-quoting estimator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from uuid import uuid4
|
||||
|
||||
from ragcore.application.indexes.models import point_wire_bytes
|
||||
from ragcore.infrastructure.qdrant.models import (
|
||||
CollectionKind,
|
||||
CollectionProfile,
|
||||
IndexPayload,
|
||||
IndexPoint,
|
||||
)
|
||||
|
||||
|
||||
def _profile() -> CollectionProfile:
|
||||
return CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile="quota-test",
|
||||
version="v1",
|
||||
dimensions=4,
|
||||
model_digest="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
def _point(profile: CollectionProfile, *, text: str = "safe passage") -> IndexPoint:
|
||||
chunk_id = uuid4()
|
||||
return IndexPoint(
|
||||
point_id=chunk_id,
|
||||
dense_vector=(0.1, 0.2, 0.3, 0.4),
|
||||
sparse_indices=(1, 2),
|
||||
sparse_values=(0.5, 0.5),
|
||||
payload=IndexPayload(
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
document_id=uuid4(),
|
||||
document_version_id=uuid4(),
|
||||
chunk_id=chunk_id,
|
||||
parent_chunk_id=None,
|
||||
source_id=uuid4(),
|
||||
status="published",
|
||||
content_type="text/markdown",
|
||||
language="en",
|
||||
trust_level="source",
|
||||
location={"page": 1, "block": "heading"},
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version=profile.version,
|
||||
embedding_model_digest=profile.model_digest,
|
||||
text=text,
|
||||
excerpt=text,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_wire_bytes_grows_with_non_text_metadata_fields() -> None:
|
||||
profile = _profile()
|
||||
small = _point(profile)
|
||||
large_location = replace(
|
||||
small.payload, location={f"key_{index}": f"value_{index}" for index in range(200)}
|
||||
)
|
||||
large = replace(small, payload=large_location)
|
||||
|
||||
assert point_wire_bytes(large) > point_wire_bytes(small)
|
||||
|
||||
|
||||
def test_wire_bytes_grows_with_sparse_vector_size() -> None:
|
||||
profile = _profile()
|
||||
small = _point(profile)
|
||||
large = replace(
|
||||
small,
|
||||
sparse_indices=tuple(range(500)),
|
||||
sparse_values=tuple(0.1 for _ in range(500)),
|
||||
)
|
||||
|
||||
assert point_wire_bytes(large) > point_wire_bytes(small)
|
||||
|
||||
|
||||
def test_wire_bytes_counts_real_utf8_bytes_for_multibyte_text() -> None:
|
||||
profile = _profile()
|
||||
ascii_point = _point(profile, text="a" * 100)
|
||||
multibyte_point = _point(profile, text="漢" * 100) # each character is 3 UTF-8 bytes
|
||||
|
||||
assert point_wire_bytes(multibyte_point) > point_wire_bytes(ascii_point) + 150
|
||||
|
||||
|
||||
def test_wire_bytes_includes_fingerprint_field_overhead() -> None:
|
||||
import json
|
||||
|
||||
profile = _profile()
|
||||
point = _point(profile)
|
||||
raw_payload_bytes = len(
|
||||
json.dumps(dict(point.payload.as_dict()), sort_keys=True, separators=(",", ":")).encode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
|
||||
# The adapter attaches three 64-character fingerprint fields after the payload is
|
||||
# built; the estimate must already include their size so quota cannot be evaded
|
||||
# by omitting them.
|
||||
assert point_wire_bytes(point) > raw_payload_bytes + 3 * 64
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Incremental ingest and authoritative rebuild must agree on every logical
|
||||
payload field -- the one canonical ``IndexableChunk`` mapping, exercised from
|
||||
both ends.
|
||||
|
||||
The handler indexes a document (fake Qdrant store records the points, fake
|
||||
repository records the chunk rows exactly as PostgreSQL would store them).
|
||||
The rebuild source then reads those *stored* chunk rows back as
|
||||
``PublishedChunkRecord``s -- the same shape ``iter_published_content`` yields --
|
||||
and re-embeds them. Apart from the embedding vector (a different profile
|
||||
would produce a different one) every payload field must be identical: source
|
||||
identity, document/version/chunk identity, language, content type, trust
|
||||
level, section, page/line ranges, structure path, parser version.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from ragcore.application.documents import (
|
||||
ChunkRecord,
|
||||
DocumentRecord,
|
||||
DocumentVersionRecord,
|
||||
DocumentVersionState,
|
||||
PublishedChunkRecord,
|
||||
)
|
||||
from ragcore.application.jobs.models import Job, JobContext
|
||||
from ragcore.application.sources import SourceRecord
|
||||
from ragcore.config import Settings
|
||||
from ragcore.domain.control import DataPolicy
|
||||
from ragcore.domain.models import EmbeddingProfile, EmbeddingProvider
|
||||
from ragcore.infrastructure.blob.filesystem import FilesystemBlobStorage
|
||||
from ragcore.infrastructure.indexes import ProductionAuthoritativeIndexSource
|
||||
from ragcore.infrastructure.ingestion.handler import IngestUploadHandler
|
||||
from ragcore.infrastructure.models.embedding_factory import create_embedding_adapter_factory
|
||||
from ragcore.infrastructure.models.persistence import JsonEmbeddingProfileStore
|
||||
from ragcore.infrastructure.parsers.publisher import FilesystemArtifactPublisher
|
||||
from ragcore.infrastructure.qdrant.models import CollectionProfile, IndexPoint
|
||||
from ragcore.infrastructure.security import (
|
||||
ContentSecurityGateService,
|
||||
production_content_security_policy,
|
||||
)
|
||||
|
||||
WORKSPACE_ID = uuid4()
|
||||
SPACE_ID = uuid4()
|
||||
SOURCE_ID = uuid4()
|
||||
DIMENSIONS = 4
|
||||
|
||||
|
||||
class RecordingRepository:
|
||||
"""Stores documents, versions and chunk rows; replays them as published content."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.documents: list[DocumentRecord] = []
|
||||
self.versions: dict[UUID, DocumentVersionRecord] = {}
|
||||
self.chunks: dict[UUID, tuple[ChunkRecord, ...]] = {}
|
||||
|
||||
async def create_document(self, document: DocumentRecord) -> DocumentRecord:
|
||||
self.documents.append(document)
|
||||
return document
|
||||
|
||||
async def create_version(self, version: DocumentVersionRecord) -> DocumentVersionRecord:
|
||||
self.versions[version.id] = version
|
||||
return version
|
||||
|
||||
async def get_version(self, version_id: UUID) -> DocumentVersionRecord | None:
|
||||
return self.versions.get(version_id)
|
||||
|
||||
async def get_document_by_source(
|
||||
self, workspace_id: UUID, source_key: str, external_id: str
|
||||
) -> DocumentRecord | None:
|
||||
return None
|
||||
|
||||
async def publish_validated(self, version_id, artifacts, chunks): # type: ignore[no-untyped-def]
|
||||
self.chunks[version_id] = tuple(chunks)
|
||||
version = self.versions[version_id]
|
||||
self.versions[version_id] = DocumentVersionRecord(
|
||||
id=version.id,
|
||||
document_id=version.document_id,
|
||||
source_fingerprint=version.source_fingerprint,
|
||||
content_hash=version.content_hash,
|
||||
parser_version=version.parser_version,
|
||||
chunk_profile_version=version.chunk_profile_version,
|
||||
state=DocumentVersionState.PUBLISHED,
|
||||
job_id=version.job_id,
|
||||
)
|
||||
return self.versions[version_id]
|
||||
|
||||
async def iter_published_content(self) -> AsyncIterator[PublishedChunkRecord]:
|
||||
for version_id, chunk_rows in self.chunks.items():
|
||||
version = self.versions[version_id]
|
||||
document = next(d for d in self.documents if d.id == version.document_id)
|
||||
for chunk in chunk_rows:
|
||||
yield PublishedChunkRecord(
|
||||
workspace_id=document.workspace_id,
|
||||
space_id=document.space_id,
|
||||
source_id=document.source_id,
|
||||
document_id=document.id,
|
||||
document_version_id=version.id,
|
||||
chunk_id=chunk.id,
|
||||
parent_id=chunk.parent_id,
|
||||
structure_path=chunk.structure_path,
|
||||
content=chunk.content,
|
||||
parser_version=version.parser_version,
|
||||
metadata=dict(chunk.metadata),
|
||||
)
|
||||
|
||||
|
||||
class RecordingQdrant:
|
||||
def __init__(self) -> None:
|
||||
self.points: list[IndexPoint] = []
|
||||
self.alias_target: str | None = None
|
||||
|
||||
async def current_alias_target(self, alias_name: str) -> str | None:
|
||||
return self.alias_target
|
||||
|
||||
async def create_shadow(self, profile: CollectionProfile) -> None:
|
||||
return None
|
||||
|
||||
async def activate(self, profile: CollectionProfile) -> None:
|
||||
self.alias_target = profile.collection_name
|
||||
|
||||
async def upsert_points(self, profile: CollectionProfile, points: list[IndexPoint]) -> None:
|
||||
self.points.extend(points)
|
||||
|
||||
|
||||
class NullRegistry:
|
||||
async def ensure_source(self, record: SourceRecord) -> SourceRecord:
|
||||
return record
|
||||
|
||||
|
||||
class NullAudit:
|
||||
async def record(self, **event: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _embed(request: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(request.content)
|
||||
return httpx.Response(200, json={"embeddings": [[0.5, 0.5, 0.5, 0.5] for _ in body["input"]]})
|
||||
|
||||
|
||||
async def _profile_store(tmp_path: Path) -> tuple[JsonEmbeddingProfileStore, EmbeddingProfile]:
|
||||
store = JsonEmbeddingProfileStore(tmp_path / "profiles.json")
|
||||
profile = EmbeddingProfile(
|
||||
id=uuid4(),
|
||||
profile="parity-embed",
|
||||
version="v1",
|
||||
provider=EmbeddingProvider.OLLAMA,
|
||||
model_identifier="parity:v1",
|
||||
model_digest="d" * 64,
|
||||
dimensions=DIMENSIONS,
|
||||
context_limit=2048,
|
||||
tokenizer="test",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
)
|
||||
await store.add(profile)
|
||||
await store.activate(profile.id)
|
||||
return store, profile
|
||||
|
||||
|
||||
async def _bytes(data: bytes) -> AsyncIterator[bytes]:
|
||||
yield data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incremental_ingest_and_authoritative_rebuild_produce_identical_payloads(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store, profile = await _profile_store(tmp_path)
|
||||
repository = RecordingRepository()
|
||||
qdrant = RecordingQdrant()
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(_embed), follow_redirects=False)
|
||||
adapter_factory = create_embedding_adapter_factory(
|
||||
settings=Settings(env="test", ollama_url="http://ollama:11434"), http_client=client
|
||||
)
|
||||
handler = IngestUploadHandler(
|
||||
document_repository=repository, # type: ignore[arg-type]
|
||||
blob_storage=FilesystemBlobStorage(tmp_path / "blobs"),
|
||||
artifact_publisher=FilesystemArtifactPublisher(tmp_path / "parsed"),
|
||||
embedding_profile_store=store,
|
||||
embedding_adapter_factory=adapter_factory,
|
||||
qdrant_store=qdrant, # type: ignore[arg-type]
|
||||
source_registry=NullRegistry(),
|
||||
security_gate=ContentSecurityGateService(
|
||||
NullAudit(), policy=production_content_security_policy()
|
||||
),
|
||||
)
|
||||
blob = FilesystemBlobStorage(tmp_path / "blobs")
|
||||
reference = await blob.put(
|
||||
_bytes(
|
||||
b"# Backup Runbook\n\n"
|
||||
b"## Nightly snapshot\n\n"
|
||||
b"Run the snapshot job SNAP-22 before midnight and verify the checksum.\n\n"
|
||||
b"## Restore drill\n\n"
|
||||
b"Restore into the disposable database and compare row counts with production.\n"
|
||||
),
|
||||
media_type="text/markdown",
|
||||
original_name="backup.md",
|
||||
)
|
||||
job = Job(
|
||||
id=uuid4(),
|
||||
job_key="parity",
|
||||
operation="ingest_upload",
|
||||
target_key=reference.digest,
|
||||
profile_version="upload-v1",
|
||||
payload={
|
||||
"blob_digest": reference.digest,
|
||||
"media_type": "text/markdown",
|
||||
"original_name": "backup.md",
|
||||
"space_id": str(SPACE_ID),
|
||||
"workspace_id": str(WORKSPACE_ID),
|
||||
"source_id": str(SOURCE_ID),
|
||||
"external_id": "runbooks/backup.md",
|
||||
"locale": "en",
|
||||
"trust_level": "trusted_internal",
|
||||
},
|
||||
available_at=datetime.now(UTC),
|
||||
)
|
||||
await handler(
|
||||
JobContext(job=job, attempt_number=1, idempotency_key="k", cancellation=asyncio.Event())
|
||||
)
|
||||
ingested = {point.point_id: point for point in qdrant.points}
|
||||
assert ingested
|
||||
|
||||
rebuilt_source = ProductionAuthoritativeIndexSource(
|
||||
document_repository=repository, # type: ignore[arg-type]
|
||||
embedding_profile=profile,
|
||||
embedding_http_client=client,
|
||||
embedding_base_url="http://ollama:11434",
|
||||
)
|
||||
collection = CollectionProfile(
|
||||
kind="dense", # type: ignore[arg-type]
|
||||
profile=profile.profile,
|
||||
version=profile.version,
|
||||
dimensions=profile.dimensions,
|
||||
model_digest=profile.model_digest,
|
||||
)
|
||||
rebuilt = {point.point_id: point async for point in rebuilt_source.iter_points(collection)}
|
||||
|
||||
assert set(rebuilt) == set(ingested), "rebuild must cover exactly the ingested child chunks"
|
||||
for point_id, original in ingested.items():
|
||||
again = rebuilt[point_id]
|
||||
assert isinstance(again, IndexPoint)
|
||||
assert again.payload.as_dict() == original.payload.as_dict(), point_id
|
||||
assert again.sparse_indices == original.sparse_indices
|
||||
assert again.sparse_values == original.sparse_values
|
||||
# And the provenance that matters is really there, not vacuously equal.
|
||||
payload = again.payload
|
||||
assert payload.source_id == SOURCE_ID
|
||||
assert payload.trust_level == "trusted_internal"
|
||||
assert payload.language == "en"
|
||||
assert payload.content_type == "text/markdown"
|
||||
assert payload.location["structure_path"]
|
||||
assert isinstance(payload.location.get("line_start"), int)
|
||||
assert isinstance(payload.location.get("line_end"), int)
|
||||
sections = {point.payload.location.get("section") for point in rebuilt.values()}
|
||||
assert sections & {"Nightly snapshot", "Restore drill"}, sections
|
||||
await client.aclose()
|
||||
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.documents import (
|
||||
DocumentRecord,
|
||||
DocumentVersionRecord,
|
||||
DocumentVersionState,
|
||||
)
|
||||
from ragcore.application.indexes.space_reconcile import (
|
||||
MAX_DELETE_BATCH,
|
||||
SpaceIndexReconciler,
|
||||
SpacePoint,
|
||||
)
|
||||
|
||||
|
||||
class _Profile:
|
||||
collection_name = "rag_dense_test_v1"
|
||||
alias_name = "rag_dense_test_active"
|
||||
version = "v1"
|
||||
model_digest = "a" * 64
|
||||
|
||||
|
||||
class FakeDocuments:
|
||||
def __init__(self) -> None:
|
||||
self.documents: list[DocumentRecord] = []
|
||||
self.versions: dict[UUID, list[DocumentVersionRecord]] = {}
|
||||
|
||||
def add(self, document: DocumentRecord, *versions: DocumentVersionRecord) -> None:
|
||||
self.documents.append(document)
|
||||
self.versions[document.id] = list(versions)
|
||||
|
||||
async def list_documents_by_space(self, space_id: UUID) -> tuple[DocumentRecord, ...]:
|
||||
return tuple(d for d in self.documents if d.space_id == space_id)
|
||||
|
||||
async def list_searchable_versions(
|
||||
self, document_id: UUID
|
||||
) -> tuple[DocumentVersionRecord, ...]:
|
||||
document = next(d for d in self.documents if d.id == document_id)
|
||||
if document.source_deleted:
|
||||
return ()
|
||||
return tuple(
|
||||
v
|
||||
for v in self.versions.get(document_id, ())
|
||||
if v.state is DocumentVersionState.PUBLISHED
|
||||
)
|
||||
|
||||
|
||||
class FakeStore:
|
||||
def __init__(self, points: dict[UUID, list[SpacePoint]]) -> None:
|
||||
self.points = points
|
||||
self.deleted: list[list[UUID]] = []
|
||||
|
||||
async def list_space_points(self, profile: object, space_id: UUID) -> tuple[SpacePoint, ...]:
|
||||
return tuple(self.points.get(space_id, ()))
|
||||
|
||||
async def delete_points(self, profile: object, point_ids: Sequence[UUID]) -> None:
|
||||
self.deleted.append(list(point_ids))
|
||||
|
||||
|
||||
def _document(space_id: UUID, *, retired: bool = False) -> DocumentRecord:
|
||||
return DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=uuid4(),
|
||||
space_id=space_id,
|
||||
source_key=str(uuid4()),
|
||||
external_id=f"record-{uuid4()}",
|
||||
title="record",
|
||||
source_deleted=retired,
|
||||
)
|
||||
|
||||
|
||||
def _version(document_id: UUID, state: DocumentVersionState) -> DocumentVersionRecord:
|
||||
return DocumentVersionRecord(
|
||||
id=uuid4(),
|
||||
document_id=document_id,
|
||||
source_fingerprint="a" * 64,
|
||||
content_hash="b" * 64,
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version="prose-v1",
|
||||
state=state,
|
||||
)
|
||||
|
||||
|
||||
def _points(document: DocumentRecord, version: DocumentVersionRecord, n: int) -> list[SpacePoint]:
|
||||
return [
|
||||
SpacePoint(point_id=uuid4(), document_id=document.id, document_version_id=version.id)
|
||||
for _ in range(n)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_drops_superseded_and_retired_points_only_in_the_space() -> None:
|
||||
space, other_space = uuid4(), uuid4()
|
||||
documents = FakeDocuments()
|
||||
current = _document(space)
|
||||
published = _version(current.id, DocumentVersionState.PUBLISHED)
|
||||
superseded = _version(current.id, DocumentVersionState.SUPERSEDED)
|
||||
documents.add(current, published, superseded)
|
||||
retired = _document(space, retired=True)
|
||||
retired_version = _version(retired.id, DocumentVersionState.PUBLISHED)
|
||||
documents.add(retired, retired_version)
|
||||
foreign = _document(other_space)
|
||||
foreign_version = _version(foreign.id, DocumentVersionState.PUBLISHED)
|
||||
documents.add(foreign, foreign_version)
|
||||
|
||||
keep = _points(current, published, 3)
|
||||
stale = _points(current, superseded, 2)
|
||||
gone = _points(retired, retired_version, 4)
|
||||
store = FakeStore(
|
||||
{space: keep + stale + gone, other_space: _points(foreign, foreign_version, 5)}
|
||||
)
|
||||
|
||||
report = await SpaceIndexReconciler(store, _Profile()).reconcile(space, documents) # type: ignore[arg-type]
|
||||
|
||||
deleted = {point_id for batch in store.deleted for point_id in batch}
|
||||
assert deleted == {p.point_id for p in stale + gone}
|
||||
assert report.points_before == 9
|
||||
assert report.deleted_points == 6
|
||||
assert report.points_after == 3
|
||||
assert report.searchable_versions == 1
|
||||
assert report.documents_total == 2
|
||||
assert report.documents_retired == 1
|
||||
assert report.orphaned_documents == 2
|
||||
# The other space was never touched.
|
||||
assert store.points[other_space] and all(
|
||||
p.point_id not in deleted for p in store.points[other_space]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_is_idempotent_and_batches_deletes() -> None:
|
||||
space = uuid4()
|
||||
documents = FakeDocuments()
|
||||
document = _document(space)
|
||||
old = _version(document.id, DocumentVersionState.SUPERSEDED)
|
||||
new = _version(document.id, DocumentVersionState.PUBLISHED)
|
||||
documents.add(document, old, new)
|
||||
stale = _points(document, old, MAX_DELETE_BATCH + 10)
|
||||
store = FakeStore({space: stale + _points(document, new, 1)})
|
||||
|
||||
first = await SpaceIndexReconciler(store, _Profile()).reconcile(space, documents) # type: ignore[arg-type]
|
||||
assert first.deleted_points == MAX_DELETE_BATCH + 10
|
||||
assert [len(batch) for batch in store.deleted] == [MAX_DELETE_BATCH, 10]
|
||||
|
||||
store.points[space] = [p for p in store.points[space] if p.document_version_id == new.id]
|
||||
second = await SpaceIndexReconciler(store, _Profile()).reconcile(space, documents) # type: ignore[arg-type]
|
||||
assert second.deleted_points == 0
|
||||
assert second.points_after == 1
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,161 @@
|
||||
"""JobWorker: ingestion job outcomes and claim age are really recorded."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
|
||||
from ragcore.application.jobs.models import (
|
||||
Job,
|
||||
JobClaim,
|
||||
JobContext,
|
||||
JobError,
|
||||
RetryableJobError,
|
||||
TerminalJobError,
|
||||
)
|
||||
from ragcore.application.jobs.worker import JobWorker
|
||||
from ragcore.platform.telemetry import TelemetryMetrics
|
||||
|
||||
|
||||
def _telemetry() -> tuple[TelemetryMetrics, InMemoryMetricReader]:
|
||||
reader = InMemoryMetricReader()
|
||||
provider = MeterProvider(
|
||||
resource=Resource.create({"service.name": "test"}), metric_readers=[reader]
|
||||
)
|
||||
return TelemetryMetrics(meter_provider=provider), reader
|
||||
|
||||
|
||||
def _metric_points(reader: InMemoryMetricReader, name: str) -> list[object]:
|
||||
data = reader.get_metrics_data()
|
||||
if data is None:
|
||||
return []
|
||||
for resource_metric in data.resource_metrics:
|
||||
for scope_metric in resource_metric.scope_metrics:
|
||||
for metric in scope_metric.metrics:
|
||||
if metric.name == name:
|
||||
return list(metric.data.data_points)
|
||||
return []
|
||||
|
||||
|
||||
class _FakeJobRepository:
|
||||
"""Enough of ``JobRepository`` for one claim/complete or claim/fail cycle."""
|
||||
|
||||
def __init__(self, job: Job) -> None:
|
||||
self._job = job
|
||||
self._claimed = False
|
||||
self.completed: list[JobClaim] = []
|
||||
self.failed: list[tuple[JobClaim, JobError]] = []
|
||||
|
||||
async def enqueue(self, job: Job) -> Job:
|
||||
raise NotImplementedError
|
||||
|
||||
async def claim_next(
|
||||
self, worker_id: str, *, now: datetime, lease_duration: timedelta
|
||||
) -> JobClaim | None:
|
||||
if self._claimed:
|
||||
return None
|
||||
self._claimed = True
|
||||
return JobClaim(
|
||||
job=self._job, worker_id=worker_id, attempt_number=1, lease_until=now + lease_duration
|
||||
)
|
||||
|
||||
async def heartbeat(
|
||||
self, claim: JobClaim, *, now: datetime, lease_duration: timedelta
|
||||
) -> bool:
|
||||
return True
|
||||
|
||||
async def complete(self, claim: JobClaim, *, now: datetime) -> None:
|
||||
self.completed.append(claim)
|
||||
|
||||
async def fail(
|
||||
self,
|
||||
claim: JobClaim,
|
||||
error: JobError,
|
||||
*,
|
||||
now: datetime,
|
||||
retry_after: timedelta = timedelta(0),
|
||||
) -> None:
|
||||
self.failed.append((claim, error))
|
||||
|
||||
|
||||
def _job(*, available_at: datetime) -> Job:
|
||||
return Job(
|
||||
id=uuid4(),
|
||||
job_key=f"job-{uuid4()}",
|
||||
operation="ingest_upload",
|
||||
target_key="target",
|
||||
profile_version="v1",
|
||||
available_at=available_at,
|
||||
)
|
||||
|
||||
|
||||
async def test_successful_job_records_claimed_age_and_succeeded_result() -> None:
|
||||
telemetry, reader = _telemetry()
|
||||
available_at = datetime.now(UTC) - timedelta(seconds=5)
|
||||
repository = _FakeJobRepository(_job(available_at=available_at))
|
||||
|
||||
async def handler(ctx: JobContext) -> None:
|
||||
return None
|
||||
|
||||
worker = JobWorker(
|
||||
repository, {"ingest_upload": handler}, worker_id="worker-1", metrics=telemetry
|
||||
)
|
||||
assert await worker.run_once() is True
|
||||
|
||||
age_points = _metric_points(reader, "ragcore_job_age_seconds")
|
||||
assert len(age_points) == 1
|
||||
assert age_points[0].sum == pytest.approx(5.0, abs=1.0)
|
||||
|
||||
result_points = _metric_points(reader, "ragcore_ingestion_job_result_total")
|
||||
assert len(result_points) == 1
|
||||
assert dict(result_points[0].attributes) == {
|
||||
"operation": "ingest_upload",
|
||||
"result": "succeeded",
|
||||
}
|
||||
assert repository.completed
|
||||
|
||||
|
||||
async def test_retryable_failure_records_retryable_failed_result() -> None:
|
||||
telemetry, reader = _telemetry()
|
||||
repository = _FakeJobRepository(_job(available_at=datetime.now(UTC)))
|
||||
|
||||
async def handler(ctx: JobContext) -> None:
|
||||
raise RetryableJobError("transient", retry_after_seconds=1.0)
|
||||
|
||||
worker = JobWorker(
|
||||
repository, {"ingest_upload": handler}, worker_id="worker-1", metrics=telemetry
|
||||
)
|
||||
assert await worker.run_once() is True
|
||||
|
||||
result_points = _metric_points(reader, "ragcore_ingestion_job_result_total")
|
||||
assert len(result_points) == 1
|
||||
assert dict(result_points[0].attributes) == {
|
||||
"operation": "ingest_upload",
|
||||
"result": "retryable_failed",
|
||||
}
|
||||
assert repository.failed
|
||||
|
||||
|
||||
async def test_terminal_failure_records_terminal_failed_result() -> None:
|
||||
telemetry, reader = _telemetry()
|
||||
repository = _FakeJobRepository(_job(available_at=datetime.now(UTC)))
|
||||
|
||||
async def handler(ctx: JobContext) -> None:
|
||||
raise TerminalJobError("unrecoverable")
|
||||
|
||||
worker = JobWorker(
|
||||
repository, {"ingest_upload": handler}, worker_id="worker-1", metrics=telemetry
|
||||
)
|
||||
assert await worker.run_once() is True
|
||||
|
||||
result_points = _metric_points(reader, "ragcore_ingestion_job_result_total")
|
||||
assert len(result_points) == 1
|
||||
assert dict(result_points[0].attributes) == {
|
||||
"operation": "ingest_upload",
|
||||
"result": "terminal_failed",
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"""A worker process runs more than one job at a time, or it does not scale.
|
||||
|
||||
``claim_next`` has always used ``FOR UPDATE SKIP LOCKED``, so several claim
|
||||
loops can safely draw from the same queue -- but nothing ever ran more than
|
||||
one. A folder source with a few thousand files was therefore ingested strictly
|
||||
one document after another.
|
||||
|
||||
The test below cannot pass on a serial pool: each handler waits until every
|
||||
other one has started before it returns, so if the loops take turns the whole
|
||||
thing deadlocks instead of finishing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.jobs.models import Job, JobClaim, JobContext, JobError
|
||||
from ragcore.application.jobs.worker import run_worker_pool
|
||||
|
||||
CONCURRENCY = 3
|
||||
|
||||
|
||||
def _job(index: int) -> Job:
|
||||
return Job(
|
||||
id=uuid4(),
|
||||
workspace_id=uuid4(),
|
||||
job_key=f"job-{index}",
|
||||
operation="ingest_upload",
|
||||
target_key="blob:abc",
|
||||
profile_version="upload-v1",
|
||||
available_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
class _QueueRepository:
|
||||
"""Hands every job out exactly once, to whoever asks first."""
|
||||
|
||||
def __init__(self, jobs: list[Job]) -> None:
|
||||
self._pending = list(jobs)
|
||||
self._expected = len(jobs)
|
||||
self.completed: list[Job] = []
|
||||
self.claimed_by: list[str] = []
|
||||
self.drained = asyncio.Event()
|
||||
|
||||
async def claim_next(
|
||||
self, worker_id: str, *, now: datetime, lease_duration: timedelta
|
||||
) -> JobClaim | None:
|
||||
if not self._pending:
|
||||
return None
|
||||
job = self._pending.pop(0)
|
||||
self.claimed_by.append(worker_id)
|
||||
return JobClaim(job=job, worker_id=worker_id, attempt_number=1, lease_until=now)
|
||||
|
||||
async def heartbeat(self, claim: JobClaim, **kwargs: object) -> bool:
|
||||
return True
|
||||
|
||||
async def complete(self, claim: JobClaim, *, now: datetime) -> None:
|
||||
self.completed.append(claim.job)
|
||||
if len(self.completed) == self._expected:
|
||||
self.drained.set()
|
||||
|
||||
async def fail(self, claim: JobClaim, error: JobError, **kwargs: object) -> None:
|
||||
raise AssertionError(f"no job should fail here: {error}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_worker_pool_runs_jobs_at_the_same_time() -> None:
|
||||
repository = _QueueRepository([_job(index) for index in range(CONCURRENCY)])
|
||||
started = asyncio.Barrier(CONCURRENCY)
|
||||
stop = asyncio.Event()
|
||||
|
||||
async def handler(context: JobContext) -> None:
|
||||
# Returns only once every other handler has also started. A pool that
|
||||
# runs one job at a time can never get past this.
|
||||
await started.wait()
|
||||
|
||||
async def drain() -> None:
|
||||
await repository.drained.wait()
|
||||
stop.set()
|
||||
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(
|
||||
run_worker_pool(
|
||||
repository, # type: ignore[arg-type]
|
||||
{"ingest_upload": handler},
|
||||
stop=stop,
|
||||
concurrency=CONCURRENCY,
|
||||
worker_id_prefix="pool-test",
|
||||
poll_interval=0.01,
|
||||
),
|
||||
drain(),
|
||||
),
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
assert len(repository.completed) == CONCURRENCY
|
||||
# Every loop identifies itself: "which worker holds this lease" is the
|
||||
# first question an operator asks about a stuck job.
|
||||
assert len(set(repository.claimed_by)) == CONCURRENCY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_pool_of_one_is_the_previous_behaviour() -> None:
|
||||
repository = _QueueRepository([_job(0)])
|
||||
stop = asyncio.Event()
|
||||
|
||||
async def handler(context: JobContext) -> None:
|
||||
stop.set()
|
||||
|
||||
await asyncio.wait_for(
|
||||
run_worker_pool(
|
||||
repository, # type: ignore[arg-type]
|
||||
{"ingest_upload": handler},
|
||||
stop=stop,
|
||||
concurrency=1,
|
||||
worker_id_prefix="pool-test",
|
||||
poll_interval=0.01,
|
||||
),
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
assert len(repository.completed) == 1
|
||||
assert repository.claimed_by == ["pool-test-0"]
|
||||
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from ragcore.application.models.bootstrap import (
|
||||
EmbeddingBootstrapError,
|
||||
EmbeddingBootstrapRequest,
|
||||
ensure_embedding_profile,
|
||||
)
|
||||
from ragcore.application.models.registry import (
|
||||
EmbeddingProfileNotFoundError,
|
||||
EmbeddingProfileRegistry,
|
||||
)
|
||||
from ragcore.domain.control import DataPolicy
|
||||
from ragcore.domain.models import EmbeddingProfile, EmbeddingProvider, NormalizationMode
|
||||
from ragcore.infrastructure.models.memory import InMemoryEmbeddingProfileStore
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, responses: list[httpx.Response]) -> None:
|
||||
self.responses = responses
|
||||
self.requests: list[str] = []
|
||||
|
||||
async def post(self, url: str, **kwargs: object) -> httpx.Response:
|
||||
self.requests.append(url)
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
def _response(status: int, body: object) -> httpx.Response:
|
||||
return httpx.Response(status, json=body, request=httpx.Request("POST", "http://test"))
|
||||
|
||||
|
||||
def _request() -> EmbeddingBootstrapRequest:
|
||||
return EmbeddingBootstrapRequest(
|
||||
provider=EmbeddingProvider.OLLAMA,
|
||||
model_identifier="nomic-embed-text:1.5",
|
||||
base_url="http://ollama",
|
||||
)
|
||||
|
||||
|
||||
def _existing_profile() -> EmbeddingProfile:
|
||||
return EmbeddingProfile(
|
||||
id=uuid4(),
|
||||
profile="hand-registered",
|
||||
version="v1",
|
||||
provider=EmbeddingProvider.OLLAMA,
|
||||
model_identifier="mxbai-embed-large:1",
|
||||
model_digest="a" * 64,
|
||||
dimensions=3,
|
||||
context_limit=4096,
|
||||
tokenizer="sentencepiece-v1",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
normalization=NormalizationMode.NONE,
|
||||
)
|
||||
|
||||
|
||||
async def test_noop_when_a_profile_is_already_active() -> None:
|
||||
store = InMemoryEmbeddingProfileStore()
|
||||
registry = EmbeddingProfileRegistry(store)
|
||||
existing = _existing_profile()
|
||||
await registry.register(existing)
|
||||
await registry.activate(existing.id)
|
||||
|
||||
client = FakeClient([])
|
||||
await ensure_embedding_profile(registry, _request(), http_client=client) # type: ignore[arg-type]
|
||||
|
||||
assert client.requests == []
|
||||
assert (await registry.active()).id == existing.id
|
||||
|
||||
|
||||
async def test_noop_and_no_raise_when_no_request_and_no_active_profile() -> None:
|
||||
registry = EmbeddingProfileRegistry(InMemoryEmbeddingProfileStore())
|
||||
client = FakeClient([])
|
||||
|
||||
await ensure_embedding_profile(registry, None, http_client=client) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(EmbeddingProfileNotFoundError):
|
||||
await registry.active()
|
||||
|
||||
|
||||
async def test_bootstraps_a_real_profile_from_provider_and_model_probes() -> None:
|
||||
registry = EmbeddingProfileRegistry(InMemoryEmbeddingProfileStore())
|
||||
client = FakeClient(
|
||||
[
|
||||
_response(200, {"digest": "sha256:" + "b" * 64}),
|
||||
_response(200, {"embeddings": [[0.1, 0.2, 0.3, 0.4]]}),
|
||||
]
|
||||
)
|
||||
|
||||
await ensure_embedding_profile(registry, _request(), http_client=client) # type: ignore[arg-type]
|
||||
|
||||
active = await registry.active()
|
||||
assert active.model_identifier == "nomic-embed-text:1.5"
|
||||
assert active.model_digest == "b" * 64
|
||||
assert active.dimensions == 4
|
||||
assert client.requests == ["http://ollama/api/show", "http://ollama/api/embed"]
|
||||
|
||||
|
||||
async def test_bootstrap_raises_when_the_model_is_unreachable() -> None:
|
||||
registry = EmbeddingProfileRegistry(InMemoryEmbeddingProfileStore())
|
||||
client = FakeClient([_response(500, {"error": "down"})])
|
||||
|
||||
with pytest.raises(EmbeddingBootstrapError):
|
||||
await ensure_embedding_profile(registry, _request(), http_client=client) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(EmbeddingProfileNotFoundError):
|
||||
await registry.active()
|
||||
|
||||
|
||||
async def test_bootstrap_raises_on_malformed_digest() -> None:
|
||||
registry = EmbeddingProfileRegistry(InMemoryEmbeddingProfileStore())
|
||||
client = FakeClient([_response(200, {"digest": "not-a-digest"})])
|
||||
|
||||
with pytest.raises(EmbeddingBootstrapError):
|
||||
await ensure_embedding_profile(registry, _request(), http_client=client) # type: ignore[arg-type]
|
||||
@@ -0,0 +1,593 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import replace
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.models.selection import (
|
||||
EmbeddingCutoverEvidence,
|
||||
EmbeddingCutoverRejected,
|
||||
EmbeddingRollbackEvidence,
|
||||
EmbeddingSelectionConflict,
|
||||
EmbeddingSelectionIntegrityError,
|
||||
EmbeddingSelectionNotInitialized,
|
||||
EmbeddingSelectionOperationResult,
|
||||
EmbeddingSelectionService,
|
||||
EmbeddingSelectionState,
|
||||
EmbeddingSelectionUnavailable,
|
||||
EmbeddingSelectionVerificationResult,
|
||||
)
|
||||
from ragcore.domain.control import DataPolicy
|
||||
from ragcore.domain.models import (
|
||||
EmbeddingProfile,
|
||||
EmbeddingProvider,
|
||||
EmbeddingSelection,
|
||||
NormalizationMode,
|
||||
)
|
||||
from ragcore.infrastructure.models.memory import InMemoryEmbeddingProfileStore
|
||||
|
||||
|
||||
def _profile(
|
||||
*,
|
||||
profile: str,
|
||||
version: str,
|
||||
provider: EmbeddingProvider = EmbeddingProvider.MODELFORGE,
|
||||
digest: str = "a" * 64,
|
||||
space_id: UUID | None = None,
|
||||
) -> EmbeddingProfile:
|
||||
return EmbeddingProfile(
|
||||
id=uuid4(),
|
||||
profile=profile,
|
||||
version=version,
|
||||
provider=provider,
|
||||
model_identifier=(
|
||||
"rag.embedding@1"
|
||||
if provider is EmbeddingProvider.MODELFORGE
|
||||
else "nomic-embed-text:1.5"
|
||||
),
|
||||
model_digest=digest,
|
||||
dimensions=4,
|
||||
context_limit=4096,
|
||||
tokenizer="tokenizer-v1",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
normalization=NormalizationMode.L2,
|
||||
embedding_space_id=space_id,
|
||||
)
|
||||
|
||||
|
||||
class MemorySelectionStore:
|
||||
"""Small lock-backed fake with the same sentinel, CAS, and retry semantics."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.state: EmbeddingSelectionState | None = None
|
||||
self.initialized = False
|
||||
self.operations: dict[UUID, tuple[str, EmbeddingSelectionOperationResult]] = {}
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
async def get(self) -> EmbeddingSelectionState:
|
||||
if not self.initialized or self.state is None:
|
||||
raise EmbeddingSelectionNotInitialized("not initialized")
|
||||
return self.state
|
||||
|
||||
async def initialize(self, selection: EmbeddingSelection) -> EmbeddingSelectionState:
|
||||
async with self.lock:
|
||||
if not self.initialized:
|
||||
self.state = EmbeddingSelectionState(0, selection, None)
|
||||
self.initialized = True
|
||||
assert self.state is not None
|
||||
return self.state
|
||||
|
||||
async def find_operation(
|
||||
self,
|
||||
operation_id: UUID,
|
||||
*,
|
||||
request_fingerprint: str,
|
||||
) -> EmbeddingSelectionOperationResult | None:
|
||||
entry = self.operations.get(operation_id)
|
||||
if entry is None:
|
||||
return None
|
||||
old_fingerprint, result = entry
|
||||
if old_fingerprint != request_fingerprint:
|
||||
raise EmbeddingSelectionConflict("operation id conflict")
|
||||
return EmbeddingSelectionOperationResult(
|
||||
result.operation_id,
|
||||
result.generation,
|
||||
result.selection,
|
||||
replayed=True,
|
||||
evidence=result.evidence,
|
||||
)
|
||||
|
||||
async def compare_and_swap(
|
||||
self,
|
||||
*,
|
||||
expected_generation: int,
|
||||
target: EmbeddingSelection,
|
||||
operation_id: UUID,
|
||||
action: str,
|
||||
request_fingerprint: str,
|
||||
operation_evidence: Mapping[str, object] | None = None,
|
||||
) -> EmbeddingSelectionOperationResult:
|
||||
async with self.lock:
|
||||
replay = await self.find_operation(
|
||||
operation_id,
|
||||
request_fingerprint=request_fingerprint,
|
||||
)
|
||||
if replay is not None:
|
||||
return replay
|
||||
if action == "initialize":
|
||||
if self.initialized:
|
||||
raise EmbeddingSelectionConflict("already initialized")
|
||||
if expected_generation != 0:
|
||||
raise EmbeddingSelectionConflict("stale")
|
||||
self.state = EmbeddingSelectionState(expected_generation, target, None)
|
||||
self.initialized = True
|
||||
result = EmbeddingSelectionOperationResult(
|
||||
operation_id,
|
||||
expected_generation,
|
||||
target,
|
||||
evidence=operation_evidence or {},
|
||||
)
|
||||
self.operations[operation_id] = (request_fingerprint, result)
|
||||
return result
|
||||
state = await self.get()
|
||||
if state.generation != expected_generation:
|
||||
raise EmbeddingSelectionConflict("stale")
|
||||
if action == "rollback" and state.previous != target:
|
||||
raise EmbeddingSelectionConflict("not persisted prior")
|
||||
self.state = EmbeddingSelectionState(state.generation + 1, target, state.active)
|
||||
result = EmbeddingSelectionOperationResult(
|
||||
operation_id,
|
||||
self.state.generation,
|
||||
target,
|
||||
evidence=operation_evidence or {},
|
||||
)
|
||||
self.operations[operation_id] = (request_fingerprint, result)
|
||||
return result
|
||||
|
||||
|
||||
class UnavailableSelectionStore(MemorySelectionStore):
|
||||
async def get(self) -> EmbeddingSelectionState:
|
||||
raise EmbeddingSelectionUnavailable("database is unavailable")
|
||||
|
||||
|
||||
class RecordingVerifier:
|
||||
def __init__(
|
||||
self, *, verified_point_count: int = 7, include_gateway_request_id: bool = False
|
||||
) -> None:
|
||||
self.verified: list[EmbeddingSelection] = []
|
||||
self.verified_point_count = verified_point_count
|
||||
self.gateway_request_ids = (uuid4(),) if include_gateway_request_id else ()
|
||||
|
||||
async def verify(
|
||||
self, selection: EmbeddingSelection, profile: EmbeddingProfile
|
||||
) -> EmbeddingSelectionVerificationResult:
|
||||
assert selection.matches_profile(profile)
|
||||
self.verified.append(selection)
|
||||
return EmbeddingSelectionVerificationResult(
|
||||
gateway_request_ids=self.gateway_request_ids,
|
||||
verified_point_count=self.verified_point_count,
|
||||
)
|
||||
|
||||
|
||||
def _evidence(selection: EmbeddingSelection) -> EmbeddingCutoverEvidence:
|
||||
return EmbeddingCutoverEvidence(
|
||||
comparison_id=uuid4(),
|
||||
comparison_reviewed=True,
|
||||
comparison_passed=True,
|
||||
operator_id=uuid4(),
|
||||
approval_id=uuid4(),
|
||||
operator_approved_impact_and_reindex=True,
|
||||
index_operation_id=uuid4(),
|
||||
authoritative_reindex_completed=True,
|
||||
collection_identity_pins_verified=True,
|
||||
consumer_smoke_passed=True,
|
||||
candidate=selection,
|
||||
gateway_route_key=selection.experiment_route_key,
|
||||
gateway_embedding_space_id=selection.embedding_space_id,
|
||||
gateway_dimensions=selection.dimensions,
|
||||
)
|
||||
|
||||
|
||||
async def _service(
|
||||
*profiles: EmbeddingProfile,
|
||||
) -> tuple[EmbeddingSelectionService, MemorySelectionStore, RecordingVerifier]:
|
||||
profile_store = InMemoryEmbeddingProfileStore()
|
||||
for profile in profiles:
|
||||
await profile_store.add(profile)
|
||||
await profile_store.activate(profiles[0].id)
|
||||
selection_store = MemorySelectionStore()
|
||||
verifier = RecordingVerifier()
|
||||
return (
|
||||
EmbeddingSelectionService(
|
||||
profiles=profile_store, selections=selection_store, verifier=verifier
|
||||
),
|
||||
selection_store,
|
||||
verifier,
|
||||
)
|
||||
|
||||
|
||||
async def test_bootstrap_pins_initial_route_and_later_config_cannot_remap_it() -> None:
|
||||
current = _profile(profile="embed", version="v1", space_id=uuid4())
|
||||
service, _, _ = await _service(current)
|
||||
|
||||
initial = await service.bootstrap(initial_experiment_route_key="lab-current-route")
|
||||
restarted = await service.bootstrap(initial_experiment_route_key="different-lab-route")
|
||||
|
||||
assert initial.generation == restarted.generation == 0
|
||||
assert restarted.active.experiment_route_key == "lab-current-route"
|
||||
assert restarted.active.collection_name == "rag_dense_embed_v1"
|
||||
|
||||
|
||||
async def test_explicit_modelforge_initialization_registers_and_cas_pins_exact_rc_identity() -> (
|
||||
None
|
||||
):
|
||||
profile = _profile(
|
||||
profile="modelforge-rc-con01",
|
||||
version="rc1",
|
||||
digest="d" * 64,
|
||||
space_id=uuid4(),
|
||||
)
|
||||
profile_store = InMemoryEmbeddingProfileStore()
|
||||
selection_store = MemorySelectionStore()
|
||||
verifier = RecordingVerifier(verified_point_count=12, include_gateway_request_id=True)
|
||||
service = EmbeddingSelectionService(
|
||||
profiles=profile_store,
|
||||
selections=selection_store,
|
||||
verifier=verifier,
|
||||
)
|
||||
operation_id, operator_id = uuid4(), uuid4()
|
||||
|
||||
result = await service.initialize_modelforge(
|
||||
expected_generation=0,
|
||||
operation_id=operation_id,
|
||||
operator_id=operator_id,
|
||||
profile=profile,
|
||||
experiment_route_key="rc-canary-2026-09",
|
||||
)
|
||||
|
||||
assert result.generation == 0
|
||||
assert result.selection.profile_id == profile.id
|
||||
assert result.selection.model_digest == profile.model_digest
|
||||
assert result.selection.dimensions == profile.dimensions
|
||||
assert result.selection.embedding_space_id == profile.embedding_space_id
|
||||
assert result.selection.experiment_route_key == "rc-canary-2026-09"
|
||||
assert result.selection.collection_name == "rag_dense_modelforge-rc-con01_rc1"
|
||||
verification = result.evidence["verification"]
|
||||
assert isinstance(verification, Mapping)
|
||||
assert len(verification["gateway_request_ids"]) == 1
|
||||
UUID(verification["gateway_request_ids"][0])
|
||||
assert verification["verified_point_count"] == 12
|
||||
assert await profile_store.get(profile.id) == profile
|
||||
assert await profile_store.get_active() is None
|
||||
assert selection_store.state == EmbeddingSelectionState(0, result.selection, None)
|
||||
|
||||
replay = await service.initialize_modelforge(
|
||||
expected_generation=0,
|
||||
operation_id=operation_id,
|
||||
operator_id=operator_id,
|
||||
profile=profile,
|
||||
experiment_route_key="rc-canary-2026-09",
|
||||
)
|
||||
assert replay.replayed is True
|
||||
assert replay.selection == result.selection
|
||||
assert len(verifier.verified) == 1
|
||||
|
||||
with pytest.raises(EmbeddingSelectionConflict, match="operation id conflict"):
|
||||
await service.initialize_modelforge(
|
||||
expected_generation=0,
|
||||
operation_id=operation_id,
|
||||
operator_id=operator_id,
|
||||
profile=profile,
|
||||
experiment_route_key="rc-canary-next",
|
||||
)
|
||||
|
||||
|
||||
async def test_modelforge_initialization_rejects_existing_ollama() -> None:
|
||||
current = _profile(
|
||||
profile="ollama-current",
|
||||
version="v1",
|
||||
provider=EmbeddingProvider.OLLAMA,
|
||||
)
|
||||
candidate = _profile(
|
||||
profile="modelforge-rc-con01",
|
||||
version="rc1",
|
||||
space_id=uuid4(),
|
||||
)
|
||||
profile_store = InMemoryEmbeddingProfileStore()
|
||||
selection_store = MemorySelectionStore()
|
||||
await selection_store.initialize(EmbeddingSelection.from_profile(current))
|
||||
service = EmbeddingSelectionService(
|
||||
profiles=profile_store,
|
||||
selections=selection_store,
|
||||
verifier=RecordingVerifier(),
|
||||
)
|
||||
|
||||
with pytest.raises(EmbeddingSelectionConflict, match="already initialized"):
|
||||
await service.initialize_modelforge(
|
||||
expected_generation=0,
|
||||
operation_id=uuid4(),
|
||||
operator_id=uuid4(),
|
||||
profile=candidate,
|
||||
experiment_route_key="rc-canary-2026-09",
|
||||
)
|
||||
|
||||
assert await profile_store.get(candidate.id) is None
|
||||
assert selection_store.state == EmbeddingSelectionState(
|
||||
0, EmbeddingSelection.from_profile(current), None
|
||||
)
|
||||
|
||||
|
||||
async def test_explicit_modelforge_initialization_requires_verified_route_and_nonempty_index() -> (
|
||||
None
|
||||
):
|
||||
profile = _profile(
|
||||
profile="modelforge-rc-con01",
|
||||
version="rc1",
|
||||
space_id=uuid4(),
|
||||
)
|
||||
profile_store = InMemoryEmbeddingProfileStore()
|
||||
selection_store = MemorySelectionStore()
|
||||
verifier = RecordingVerifier(verified_point_count=0, include_gateway_request_id=True)
|
||||
service = EmbeddingSelectionService(
|
||||
profiles=profile_store,
|
||||
selections=selection_store,
|
||||
verifier=verifier,
|
||||
)
|
||||
|
||||
with pytest.raises(EmbeddingCutoverRejected, match="collection is empty"):
|
||||
await service.initialize_modelforge(
|
||||
expected_generation=0,
|
||||
operation_id=uuid4(),
|
||||
operator_id=uuid4(),
|
||||
profile=profile,
|
||||
experiment_route_key="rc-canary-2026-09",
|
||||
)
|
||||
assert await profile_store.get(profile.id) is None
|
||||
|
||||
with pytest.raises(EmbeddingCutoverRejected, match="must use ModelForge"):
|
||||
await service.initialize_modelforge(
|
||||
expected_generation=0,
|
||||
operation_id=uuid4(),
|
||||
operator_id=uuid4(),
|
||||
profile=_profile(profile="ollama", version="v1", provider=EmbeddingProvider.OLLAMA),
|
||||
experiment_route_key="rc-canary-2026-09",
|
||||
)
|
||||
assert selection_store.state is None
|
||||
|
||||
|
||||
async def test_changed_space_cutover_and_rollback_restore_full_prior_selection() -> None:
|
||||
old_space, new_space = uuid4(), uuid4()
|
||||
current = _profile(profile="embed", version="v1", space_id=old_space)
|
||||
candidate = _profile(
|
||||
profile="embed",
|
||||
version="v2",
|
||||
digest="b" * 64,
|
||||
space_id=new_space,
|
||||
)
|
||||
service, store, verifier = await _service(current, candidate)
|
||||
initial = await service.bootstrap(initial_experiment_route_key="lab-current-route")
|
||||
prior = initial.active
|
||||
target = EmbeddingSelection.from_profile(candidate, experiment_route_key="lab-candidate-route")
|
||||
operation_id = uuid4()
|
||||
evidence = _evidence(target)
|
||||
|
||||
promoted = await service.promote(
|
||||
expected_generation=0,
|
||||
operation_id=operation_id,
|
||||
target=target,
|
||||
evidence=evidence,
|
||||
)
|
||||
assert promoted.generation == 1
|
||||
assert promoted.selection == target
|
||||
assert promoted.evidence["verification"] == {
|
||||
"gateway_request_ids": [],
|
||||
"verified_point_count": 7,
|
||||
}
|
||||
assert store.state is not None and store.state.previous == prior
|
||||
assert (await service.resolve_active()).selection == target
|
||||
|
||||
replay = await service.promote(
|
||||
expected_generation=0,
|
||||
operation_id=operation_id,
|
||||
target=target,
|
||||
evidence=evidence,
|
||||
)
|
||||
assert replay.replayed is True and replay.generation == 1
|
||||
with pytest.raises(EmbeddingSelectionConflict, match="operation id conflict"):
|
||||
await service.promote(
|
||||
expected_generation=0,
|
||||
operation_id=operation_id,
|
||||
target=target,
|
||||
evidence=replace(evidence, approval_id=uuid4()),
|
||||
)
|
||||
|
||||
rolled_back = await service.rollback(
|
||||
expected_generation=1,
|
||||
operation_id=uuid4(),
|
||||
evidence=EmbeddingRollbackEvidence(
|
||||
operator_id=uuid4(), approval_id=uuid4(), reason="validated candidate regression"
|
||||
),
|
||||
)
|
||||
assert rolled_back.generation == 2
|
||||
assert rolled_back.selection == prior
|
||||
assert prior.embedding_space_id == old_space
|
||||
assert prior.experiment_route_key == "lab-current-route"
|
||||
assert prior.collection_name == "rag_dense_embed_v1"
|
||||
assert (await service.resolve_active()).selection == prior
|
||||
assert verifier.verified == [target, prior]
|
||||
|
||||
|
||||
async def test_concurrent_promotions_use_generation_cas() -> None:
|
||||
current = _profile(profile="embed", version="v1", space_id=uuid4())
|
||||
candidate_a = _profile(profile="embed", version="v2", digest="b" * 64, space_id=uuid4())
|
||||
candidate_b = _profile(profile="embed", version="v3", digest="c" * 64, space_id=uuid4())
|
||||
service, store, _ = await _service(current, candidate_a, candidate_b)
|
||||
await service.bootstrap(initial_experiment_route_key="lab-current-route")
|
||||
target_a = EmbeddingSelection.from_profile(candidate_a, experiment_route_key="lab-route-a")
|
||||
target_b = EmbeddingSelection.from_profile(candidate_b, experiment_route_key="lab-route-b")
|
||||
|
||||
outcomes = await asyncio.gather(
|
||||
service.promote(
|
||||
expected_generation=0,
|
||||
operation_id=uuid4(),
|
||||
target=target_a,
|
||||
evidence=_evidence(target_a),
|
||||
),
|
||||
service.promote(
|
||||
expected_generation=0,
|
||||
operation_id=uuid4(),
|
||||
target=target_b,
|
||||
evidence=_evidence(target_b),
|
||||
),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
assert sum(not isinstance(result, BaseException) for result in outcomes) == 1
|
||||
failures = [result for result in outcomes if isinstance(result, BaseException)]
|
||||
assert len(failures) == 1 and isinstance(failures[0], EmbeddingSelectionConflict)
|
||||
assert store.state is not None and store.state.generation == 1
|
||||
|
||||
|
||||
async def test_stale_route_evidence_and_stale_collection_are_rejected() -> None:
|
||||
current = _profile(profile="embed", version="v1", space_id=uuid4())
|
||||
candidate = _profile(profile="embed", version="v2", digest="b" * 64, space_id=uuid4())
|
||||
service, _, verifier = await _service(current, candidate)
|
||||
await service.bootstrap(initial_experiment_route_key="lab-current-route")
|
||||
target = EmbeddingSelection.from_profile(candidate, experiment_route_key="lab-candidate-route")
|
||||
evidence = _evidence(target)
|
||||
stale_route = replace(evidence, gateway_route_key="lab-old-route")
|
||||
|
||||
with pytest.raises(EmbeddingCutoverRejected, match="gateway route"):
|
||||
await service.promote(
|
||||
expected_generation=0,
|
||||
operation_id=uuid4(),
|
||||
target=target,
|
||||
evidence=stale_route,
|
||||
)
|
||||
assert verifier.verified == []
|
||||
|
||||
with pytest.raises(ValueError, match="collection does not match"):
|
||||
EmbeddingSelection(
|
||||
profile_id=candidate.id,
|
||||
profile=candidate.profile,
|
||||
version=candidate.version,
|
||||
provider=candidate.provider,
|
||||
model_identifier=candidate.model_identifier,
|
||||
model_digest=candidate.model_digest,
|
||||
dimensions=candidate.dimensions,
|
||||
embedding_space_id=candidate.embedding_space_id,
|
||||
experiment_route_key="lab-candidate-route",
|
||||
collection_name="rag_dense_embed_v1",
|
||||
)
|
||||
|
||||
|
||||
async def test_promotion_requires_a_distinct_candidate_modelforge_route() -> None:
|
||||
current = _profile(profile="embed", version="v1", space_id=uuid4())
|
||||
candidate = _profile(profile="embed", version="v2", digest="b" * 64, space_id=uuid4())
|
||||
service, store, verifier = await _service(current, candidate)
|
||||
await service.bootstrap(initial_experiment_route_key="lab-current-route")
|
||||
target = EmbeddingSelection.from_profile(candidate, experiment_route_key="lab-current-route")
|
||||
|
||||
with pytest.raises(EmbeddingCutoverRejected, match="distinct pinned current and candidate"):
|
||||
await service.promote(
|
||||
expected_generation=0,
|
||||
operation_id=uuid4(),
|
||||
target=target,
|
||||
evidence=_evidence(target),
|
||||
)
|
||||
|
||||
assert store.state is not None and store.state.generation == 0
|
||||
assert verifier.verified == []
|
||||
|
||||
|
||||
async def test_promotion_refuses_to_drop_an_unresolvable_prior_profile() -> None:
|
||||
current = _profile(profile="embed", version="v1", space_id=uuid4())
|
||||
candidate = _profile(profile="embed", version="v2", digest="b" * 64, space_id=uuid4())
|
||||
profile_store = InMemoryEmbeddingProfileStore()
|
||||
await profile_store.add(candidate)
|
||||
await profile_store.activate(candidate.id)
|
||||
selection_store = MemorySelectionStore()
|
||||
prior = EmbeddingSelection.from_profile(current, experiment_route_key="lab-current-route")
|
||||
await selection_store.initialize(prior)
|
||||
verifier = RecordingVerifier()
|
||||
service = EmbeddingSelectionService(
|
||||
profiles=profile_store,
|
||||
selections=selection_store,
|
||||
verifier=verifier,
|
||||
)
|
||||
target = EmbeddingSelection.from_profile(candidate, experiment_route_key="lab-next-route")
|
||||
|
||||
with pytest.raises(EmbeddingSelectionIntegrityError, match="unregistered"):
|
||||
await service.promote(
|
||||
expected_generation=0,
|
||||
operation_id=uuid4(),
|
||||
target=target,
|
||||
evidence=_evidence(target),
|
||||
)
|
||||
|
||||
assert selection_store.state == EmbeddingSelectionState(0, prior, None)
|
||||
assert verifier.verified == []
|
||||
|
||||
|
||||
async def test_promotion_rejects_an_empty_candidate_collection() -> None:
|
||||
current = _profile(profile="embed", version="v1", space_id=uuid4())
|
||||
candidate = _profile(profile="embed", version="v2", digest="b" * 64, space_id=uuid4())
|
||||
service, store, verifier = await _service(current, candidate)
|
||||
verifier.verified_point_count = 0
|
||||
await service.bootstrap(initial_experiment_route_key="lab-current-route")
|
||||
target = EmbeddingSelection.from_profile(candidate, experiment_route_key="lab-candidate-route")
|
||||
|
||||
with pytest.raises(EmbeddingCutoverRejected, match="collection is empty"):
|
||||
await service.promote(
|
||||
expected_generation=0,
|
||||
operation_id=uuid4(),
|
||||
target=target,
|
||||
evidence=_evidence(target),
|
||||
)
|
||||
|
||||
assert store.state is not None and store.state.generation == 0
|
||||
assert verifier.verified == [target]
|
||||
|
||||
|
||||
async def test_cutover_evidence_requires_real_boolean_attestations() -> None:
|
||||
current = _profile(profile="embed", version="v1", space_id=uuid4())
|
||||
candidate = _profile(profile="embed", version="v2", digest="b" * 64, space_id=uuid4())
|
||||
service, _, verifier = await _service(current, candidate)
|
||||
await service.bootstrap(initial_experiment_route_key="lab-current-route")
|
||||
target = EmbeddingSelection.from_profile(candidate, experiment_route_key="lab-candidate-route")
|
||||
|
||||
with pytest.raises(EmbeddingCutoverRejected, match="incomplete or failed"):
|
||||
await service.promote(
|
||||
expected_generation=0,
|
||||
operation_id=uuid4(),
|
||||
target=target,
|
||||
evidence=replace(_evidence(target), comparison_passed=1), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert verifier.verified == []
|
||||
|
||||
|
||||
async def test_failed_durable_read_does_not_fall_back_to_json_active_profile() -> None:
|
||||
current = _profile(profile="embed", version="v1", space_id=uuid4())
|
||||
service, _, _ = await _service(current)
|
||||
|
||||
with pytest.raises(EmbeddingSelectionNotInitialized):
|
||||
await service.resolve_active()
|
||||
|
||||
|
||||
async def test_database_unavailability_does_not_fall_back_to_json_active_profile() -> None:
|
||||
profile = _profile(profile="embed", version="v1", space_id=uuid4())
|
||||
profile_store = InMemoryEmbeddingProfileStore()
|
||||
await profile_store.add(profile)
|
||||
await profile_store.activate(profile.id)
|
||||
service = EmbeddingSelectionService(
|
||||
profiles=profile_store,
|
||||
selections=UnavailableSelectionStore(),
|
||||
verifier=RecordingVerifier(),
|
||||
)
|
||||
|
||||
with pytest.raises(EmbeddingSelectionUnavailable, match="database is unavailable"):
|
||||
await service.resolve_active()
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.profiles import (
|
||||
ActivationDecision,
|
||||
AlreadyRolledBackError,
|
||||
CandidateAliasTarget,
|
||||
IncompleteEvaluationError,
|
||||
NotActivatedError,
|
||||
ProfileActivationError,
|
||||
require_complete_run,
|
||||
)
|
||||
from ragcore.evaluation import EvaluationCase, EvaluationDataset, EvaluationRun, RetrievalRunProfile
|
||||
from ragcore.evaluation.metrics.models import GateResult, RegressionFinding, RegressionMetric
|
||||
from ragcore.evaluation.models import AnswerabilityStatus, CaseResult
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _run_profile(index_version: str = "v1") -> RetrievalRunProfile:
|
||||
return RetrievalRunProfile(
|
||||
embedding_profile_digest="a" * 64,
|
||||
sparse_profile_version="technical-v1",
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
index_collection_version=index_version,
|
||||
)
|
||||
|
||||
|
||||
def _passing_gate() -> GateResult:
|
||||
return GateResult(thresholds_version="v1", passed=True, regressions=())
|
||||
|
||||
|
||||
def _target() -> CandidateAliasTarget:
|
||||
return CandidateAliasTarget(
|
||||
alias_name="rag_dense_hybrid_active",
|
||||
collection_name="rag_dense_hybrid_v2",
|
||||
version="v2",
|
||||
model_digest="b" * 64,
|
||||
)
|
||||
|
||||
|
||||
def _decision(**overrides: object) -> ActivationDecision:
|
||||
values: dict[str, object] = {
|
||||
"id": uuid4(),
|
||||
"created_at": _now(),
|
||||
"dataset_id": "sample",
|
||||
"dataset_version": "v1",
|
||||
"baseline_run_id": uuid4(),
|
||||
"candidate_run_id": uuid4(),
|
||||
"baseline_profile": _run_profile("v1"),
|
||||
"candidate_profile": _run_profile("v2"),
|
||||
"gate": _passing_gate(),
|
||||
"activated": True,
|
||||
"candidate_target": _target(),
|
||||
"index_operation_id": uuid4(),
|
||||
"previous_collection": "rag_dense_hybrid_v1",
|
||||
}
|
||||
values.update(overrides)
|
||||
return ActivationDecision(**values)
|
||||
|
||||
|
||||
def test_candidate_alias_target_rejects_unsafe_identifiers() -> None:
|
||||
with pytest.raises(ProfileActivationError):
|
||||
CandidateAliasTarget(
|
||||
alias_name="not a safe id!", collection_name="c", version="v1", model_digest="d"
|
||||
)
|
||||
|
||||
|
||||
def test_activated_decision_requires_candidate_target_and_operation_id() -> None:
|
||||
with pytest.raises(ProfileActivationError):
|
||||
_decision(candidate_target=None)
|
||||
with pytest.raises(ProfileActivationError):
|
||||
_decision(index_operation_id=None)
|
||||
|
||||
|
||||
def test_rejected_decision_must_not_carry_activation_fields() -> None:
|
||||
with pytest.raises(ProfileActivationError):
|
||||
_decision(activated=False, gate=_failing_gate())
|
||||
|
||||
|
||||
def _failing_gate() -> GateResult:
|
||||
return GateResult(
|
||||
thresholds_version="v1",
|
||||
passed=False,
|
||||
regressions=(
|
||||
RegressionFinding(
|
||||
metric=RegressionMetric.RECALL_AT_5,
|
||||
tag=None,
|
||||
baseline_value=0.9,
|
||||
candidate_value=0.5,
|
||||
delta=-0.4,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_failed_gate_can_never_be_recorded_as_activated() -> None:
|
||||
with pytest.raises(ProfileActivationError):
|
||||
_decision(gate=_failing_gate(), activated=True)
|
||||
|
||||
|
||||
def test_rollback_requires_an_activated_decision() -> None:
|
||||
rejected = _decision(
|
||||
activated=False, gate=_failing_gate(), candidate_target=None, index_operation_id=None
|
||||
)
|
||||
with pytest.raises(NotActivatedError):
|
||||
rejected.with_rollback(rolled_back_at=_now())
|
||||
|
||||
|
||||
def test_rollback_cannot_happen_twice() -> None:
|
||||
decision = _decision()
|
||||
rolled_back = decision.with_rollback(rolled_back_at=_now())
|
||||
assert rolled_back.rolled_back is True
|
||||
with pytest.raises(AlreadyRolledBackError):
|
||||
rolled_back.with_rollback(rolled_back_at=_now())
|
||||
|
||||
|
||||
def _dataset() -> EvaluationDataset:
|
||||
case = EvaluationCase(
|
||||
id="case-1",
|
||||
question="What restarts the payment service?",
|
||||
language="en",
|
||||
expected_answerability=AnswerabilityStatus.ANSWERABLE,
|
||||
expected_document_ids=(uuid4(),),
|
||||
expected_chunk_ids=(uuid4(),),
|
||||
required_facts=("restart via kubectl rollout restart",),
|
||||
)
|
||||
return EvaluationDataset(id="sample", version="v1", cases=(case,))
|
||||
|
||||
|
||||
def _run(dataset: EvaluationDataset, *, cover_all: bool = True) -> EvaluationRun:
|
||||
case_results = tuple(
|
||||
CaseResult(
|
||||
case_id=case.id,
|
||||
retrieved_chunk_ids=case.expected_chunk_ids,
|
||||
matched_expected_ids=case.expected_chunk_ids,
|
||||
missing_expected_ids=(),
|
||||
unexpected_ids=(),
|
||||
)
|
||||
for case in dataset.cases
|
||||
)
|
||||
if not cover_all:
|
||||
case_results = case_results[:0]
|
||||
return EvaluationRun(
|
||||
id=uuid4(),
|
||||
dataset_id=dataset.id,
|
||||
dataset_version=dataset.version,
|
||||
run_profile=_run_profile(),
|
||||
started_at=_now(),
|
||||
case_results=case_results or (
|
||||
CaseResult(
|
||||
case_id="unrelated-case",
|
||||
retrieved_chunk_ids=(),
|
||||
matched_expected_ids=(),
|
||||
missing_expected_ids=(),
|
||||
unexpected_ids=(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_require_complete_run_accepts_full_coverage() -> None:
|
||||
dataset = _dataset()
|
||||
require_complete_run(dataset, _run(dataset))
|
||||
|
||||
|
||||
def test_require_complete_run_rejects_partial_coverage() -> None:
|
||||
dataset = _dataset()
|
||||
with pytest.raises(IncompleteEvaluationError):
|
||||
require_complete_run(dataset, _run(dataset, cover_all=False))
|
||||
|
||||
|
||||
def test_require_complete_run_rejects_wrong_dataset_version() -> None:
|
||||
dataset = _dataset()
|
||||
run = _run(dataset)
|
||||
other_dataset = EvaluationDataset(id="sample", version="v2", cases=dataset.cases)
|
||||
with pytest.raises(IncompleteEvaluationError):
|
||||
require_complete_run(other_dataset, run)
|
||||
@@ -0,0 +1,371 @@
|
||||
"""Fast unit coverage for ProfileActivationService using in-memory index stores.
|
||||
|
||||
Reuses the real ``IndexBuildService`` (never reimplemented), backed by
|
||||
in-memory fakes for ``DerivedIndexStore``/``IndexOperationStore`` instead of
|
||||
real Qdrant/Postgres -- this proves ProfileActivationService's own gating,
|
||||
decision-persistence and rollback-reconstruction logic quickly and
|
||||
deterministically. Slower, real-infrastructure end-to-end coverage lives in
|
||||
``tests/integration/profiles``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.indexes import (
|
||||
IndexBuildService,
|
||||
IndexOperationStatus,
|
||||
MalformedIndexPoint,
|
||||
StoredIndexPoint,
|
||||
)
|
||||
from ragcore.application.profiles import (
|
||||
ActivationDecisionNotFoundError,
|
||||
ActivationRejectedError,
|
||||
AlreadyRolledBackError,
|
||||
CandidateAliasTarget,
|
||||
IncompleteEvaluationError,
|
||||
ProfileActivationService,
|
||||
)
|
||||
from ragcore.evaluation import EvaluationCase, EvaluationDataset, EvaluationRun, RetrievalRunProfile
|
||||
from ragcore.evaluation.metrics.models import RegressionThresholds
|
||||
from ragcore.evaluation.models import AnswerabilityStatus, CaseResult
|
||||
|
||||
|
||||
class InMemoryDerivedIndexStore:
|
||||
def __init__(self) -> None:
|
||||
self.aliases: dict[str, str] = {}
|
||||
self.shadows: set[str] = set()
|
||||
self.build_calls = 0
|
||||
self.rollback_calls: list[tuple[str, str | None]] = []
|
||||
|
||||
async def create_shadow(self, profile: object) -> None:
|
||||
self.shadows.add(profile.collection_name) # type: ignore[attr-defined]
|
||||
self.build_calls += 1
|
||||
|
||||
async def delete_shadow(self, profile: object) -> None:
|
||||
self.shadows.discard(profile.collection_name) # type: ignore[attr-defined]
|
||||
|
||||
async def upsert_points(self, profile: object, points: Sequence[object]) -> None:
|
||||
return None
|
||||
|
||||
async def delete_points(self, profile: object, point_ids: Sequence[UUID]) -> None:
|
||||
return None
|
||||
|
||||
async def delete_raw_points(self, profile: object, raw_ids: Sequence[str]) -> None:
|
||||
return None
|
||||
|
||||
async def count_points(self, profile: object) -> int:
|
||||
return 0
|
||||
|
||||
async def list_points(
|
||||
self, profile: object
|
||||
) -> tuple[tuple[StoredIndexPoint, ...], tuple[MalformedIndexPoint, ...]]:
|
||||
return (), ()
|
||||
|
||||
async def current_alias_target(self, alias_name: str) -> str | None:
|
||||
return self.aliases.get(alias_name)
|
||||
|
||||
async def activate(self, profile: object) -> None:
|
||||
self.aliases[profile.alias_name] = profile.collection_name # type: ignore[attr-defined]
|
||||
|
||||
async def rollback(self, alias_name: str, previous_collection: str | None) -> None:
|
||||
self.rollback_calls.append((alias_name, previous_collection))
|
||||
if previous_collection is None:
|
||||
self.aliases.pop(alias_name, None)
|
||||
else:
|
||||
self.aliases[alias_name] = previous_collection
|
||||
|
||||
|
||||
class InMemoryIndexOperationStore:
|
||||
def __init__(self) -> None:
|
||||
self._rows: dict[UUID, dict[str, object]] = {}
|
||||
|
||||
async def begin(
|
||||
self,
|
||||
*,
|
||||
alias_name: str,
|
||||
operation: str,
|
||||
collection_name: str,
|
||||
owner_token: str,
|
||||
now: datetime,
|
||||
lease_duration: timedelta,
|
||||
) -> UUID | None:
|
||||
for row in self._rows.values():
|
||||
in_progress = row["status"] == IndexOperationStatus.IN_PROGRESS
|
||||
if row["alias_name"] == alias_name and in_progress:
|
||||
return None
|
||||
operation_id = uuid4()
|
||||
self._rows[operation_id] = {
|
||||
"alias_name": alias_name,
|
||||
"operation": operation,
|
||||
"collection_name": collection_name,
|
||||
"owner_token": owner_token,
|
||||
"status": IndexOperationStatus.IN_PROGRESS,
|
||||
"previous_collection": None,
|
||||
}
|
||||
return operation_id
|
||||
|
||||
async def set_previous_collection(
|
||||
self,
|
||||
operation_id: UUID,
|
||||
owner_token: str,
|
||||
previous_collection: str | None,
|
||||
*,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
self._rows[operation_id]["previous_collection"] = previous_collection
|
||||
|
||||
async def record_step(
|
||||
self,
|
||||
operation_id: UUID,
|
||||
owner_token: str,
|
||||
step: str,
|
||||
*,
|
||||
now: datetime,
|
||||
details: Mapping[str, object] | None = None,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
async def finish(
|
||||
self,
|
||||
operation_id: UUID,
|
||||
owner_token: str,
|
||||
status: IndexOperationStatus,
|
||||
*,
|
||||
now: datetime,
|
||||
error_code: str | None = None,
|
||||
error_details: Mapping[str, object] | None = None,
|
||||
) -> bool:
|
||||
self._rows[operation_id]["status"] = status
|
||||
return True
|
||||
|
||||
|
||||
class FixtureSource:
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
async def iter_points(self, profile: object) -> AsyncIterator[object]:
|
||||
return
|
||||
yield # pragma: no cover - makes this an async generator with zero points
|
||||
|
||||
|
||||
class PassingSmoke:
|
||||
async def run(self, profile: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class InMemoryActivationDecisionStore:
|
||||
def __init__(self) -> None:
|
||||
self._decisions: dict[UUID, object] = {}
|
||||
|
||||
async def save(self, decision: object) -> None:
|
||||
self._decisions[decision.id] = decision # type: ignore[attr-defined]
|
||||
|
||||
async def get(self, decision_id: UUID) -> object | None:
|
||||
return self._decisions.get(decision_id)
|
||||
|
||||
async def list_recent(self, limit: int = 20) -> tuple[object, ...]:
|
||||
return tuple(self._decisions.values())[:limit]
|
||||
|
||||
async def mark_rolled_back(self, decision_id: UUID, *, rolled_back_at: datetime) -> None:
|
||||
decision = self._decisions[decision_id]
|
||||
self._decisions[decision_id] = decision.with_rollback(rolled_back_at=rolled_back_at) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def _dataset() -> EvaluationDataset:
|
||||
case = EvaluationCase(
|
||||
id="case-1",
|
||||
question="How do I restart the payment service?",
|
||||
language="en",
|
||||
expected_answerability=AnswerabilityStatus.ANSWERABLE,
|
||||
expected_document_ids=(uuid4(),),
|
||||
expected_chunk_ids=(uuid4(),),
|
||||
required_facts=("restart via kubectl rollout restart",),
|
||||
)
|
||||
return EvaluationDataset(id="sample", version="v1", cases=(case,))
|
||||
|
||||
|
||||
def _run_profile(version: str) -> RetrievalRunProfile:
|
||||
return RetrievalRunProfile(
|
||||
embedding_profile_digest="a" * 64,
|
||||
sparse_profile_version="technical-v1",
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
index_collection_version=version,
|
||||
)
|
||||
|
||||
|
||||
def _complete_run(dataset: EvaluationDataset, *, version: str, hit: bool = True) -> EvaluationRun:
|
||||
case = dataset.cases[0]
|
||||
retrieved = case.expected_chunk_ids if hit else ()
|
||||
return EvaluationRun(
|
||||
id=uuid4(),
|
||||
dataset_id=dataset.id,
|
||||
dataset_version=dataset.version,
|
||||
run_profile=_run_profile(version),
|
||||
started_at=datetime.now(UTC),
|
||||
case_results=(
|
||||
CaseResult(
|
||||
case_id=case.id,
|
||||
retrieved_chunk_ids=retrieved,
|
||||
matched_expected_ids=retrieved,
|
||||
missing_expected_ids=() if hit else case.expected_chunk_ids,
|
||||
unexpected_ids=(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _incomplete_run(dataset: EvaluationDataset) -> EvaluationRun:
|
||||
return EvaluationRun(
|
||||
id=uuid4(),
|
||||
dataset_id=dataset.id,
|
||||
dataset_version=dataset.version,
|
||||
run_profile=_run_profile("v2"),
|
||||
started_at=datetime.now(UTC),
|
||||
case_results=(
|
||||
CaseResult(
|
||||
case_id="a-different-case",
|
||||
retrieved_chunk_ids=(),
|
||||
matched_expected_ids=(),
|
||||
missing_expected_ids=(),
|
||||
unexpected_ids=(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _target() -> CandidateAliasTarget:
|
||||
return CandidateAliasTarget(
|
||||
alias_name="rag_dense_hybrid_active",
|
||||
collection_name="rag_dense_hybrid_v2",
|
||||
version="v2",
|
||||
model_digest="b" * 64,
|
||||
)
|
||||
|
||||
|
||||
_ServiceFixture = tuple[
|
||||
ProfileActivationService, InMemoryDerivedIndexStore, InMemoryActivationDecisionStore
|
||||
]
|
||||
|
||||
|
||||
def _service() -> _ServiceFixture:
|
||||
store = InMemoryDerivedIndexStore()
|
||||
builder = IndexBuildService(store, InMemoryIndexOperationStore())
|
||||
decisions = InMemoryActivationDecisionStore()
|
||||
return ProfileActivationService(index_builder=builder, decisions=decisions), store, decisions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incomplete_candidate_run_is_rejected_before_any_qdrant_call() -> None:
|
||||
service, store, decisions = _service()
|
||||
dataset = _dataset()
|
||||
baseline = _complete_run(dataset, version="v1")
|
||||
with pytest.raises(IncompleteEvaluationError):
|
||||
await service.activate(
|
||||
dataset=dataset,
|
||||
baseline_run=baseline,
|
||||
candidate_run=_incomplete_run(dataset),
|
||||
thresholds=RegressionThresholds(version="v1"),
|
||||
candidate_profile=_target(),
|
||||
source=FixtureSource(),
|
||||
smoke=PassingSmoke(),
|
||||
)
|
||||
assert store.build_calls == 0
|
||||
assert await decisions.list_recent() == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_regressed_candidate_is_rejected_and_recorded_without_activating() -> None:
|
||||
service, store, decisions = _service()
|
||||
dataset = _dataset()
|
||||
baseline = _complete_run(dataset, version="v1", hit=True)
|
||||
candidate = _complete_run(dataset, version="v2", hit=False)
|
||||
with pytest.raises(ActivationRejectedError) as excinfo:
|
||||
await service.activate(
|
||||
dataset=dataset,
|
||||
baseline_run=baseline,
|
||||
candidate_run=candidate,
|
||||
thresholds=RegressionThresholds(version="v1"),
|
||||
candidate_profile=_target(),
|
||||
source=FixtureSource(),
|
||||
smoke=PassingSmoke(),
|
||||
)
|
||||
assert excinfo.value.gate.passed is False
|
||||
assert store.build_calls == 0
|
||||
recorded = (await decisions.list_recent())[0]
|
||||
assert recorded.activated is False
|
||||
assert recorded.candidate_target is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_passing_candidate_is_activated_and_recorded() -> None:
|
||||
service, store, decisions = _service()
|
||||
dataset = _dataset()
|
||||
baseline = _complete_run(dataset, version="v1", hit=True)
|
||||
candidate = _complete_run(dataset, version="v2", hit=True)
|
||||
decision = await service.activate(
|
||||
dataset=dataset,
|
||||
baseline_run=baseline,
|
||||
candidate_run=candidate,
|
||||
thresholds=RegressionThresholds(version="v1"),
|
||||
candidate_profile=_target(),
|
||||
source=FixtureSource(),
|
||||
smoke=PassingSmoke(),
|
||||
)
|
||||
assert decision.activated is True
|
||||
assert store.aliases["rag_dense_hybrid_active"] == "rag_dense_hybrid_v2"
|
||||
stored = await decisions.get(decision.id)
|
||||
assert stored is not None
|
||||
assert stored.index_operation_id == decision.index_operation_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rollback_restores_the_previous_alias_and_marks_the_decision() -> None:
|
||||
service, store, _decisions = _service()
|
||||
dataset = _dataset()
|
||||
baseline = _complete_run(dataset, version="v1", hit=True)
|
||||
candidate = _complete_run(dataset, version="v2", hit=True)
|
||||
decision = await service.activate(
|
||||
dataset=dataset,
|
||||
baseline_run=baseline,
|
||||
candidate_run=candidate,
|
||||
thresholds=RegressionThresholds(version="v1"),
|
||||
candidate_profile=_target(),
|
||||
source=FixtureSource(),
|
||||
smoke=PassingSmoke(),
|
||||
)
|
||||
assert decision.previous_collection is None # nothing was active before this test
|
||||
|
||||
rolled_back = await service.rollback(decision.id)
|
||||
assert rolled_back.rolled_back is True
|
||||
assert store.rollback_calls == [("rag_dense_hybrid_active", None)]
|
||||
assert "rag_dense_hybrid_active" not in store.aliases
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rollback_twice_is_rejected() -> None:
|
||||
service, _, _ = _service()
|
||||
dataset = _dataset()
|
||||
decision = await service.activate(
|
||||
dataset=dataset,
|
||||
baseline_run=_complete_run(dataset, version="v1", hit=True),
|
||||
candidate_run=_complete_run(dataset, version="v2", hit=True),
|
||||
thresholds=RegressionThresholds(version="v1"),
|
||||
candidate_profile=_target(),
|
||||
source=FixtureSource(),
|
||||
smoke=PassingSmoke(),
|
||||
)
|
||||
await service.rollback(decision.id)
|
||||
with pytest.raises(AlreadyRolledBackError):
|
||||
await service.rollback(decision.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rollback_of_unknown_decision_is_rejected() -> None:
|
||||
service, _, _ = _service()
|
||||
with pytest.raises(ActivationDecisionNotFoundError):
|
||||
await service.rollback(uuid4())
|
||||
@@ -0,0 +1,500 @@
|
||||
"""RestoreGateService against fake ports: manifest verification, reconciliation,
|
||||
golden queries, cross-space denial, and the never-touch-the-source guarantee."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.backup.models import BackupStatus
|
||||
from ragcore.application.restore.models import GoldenQueryProbe, RestoreReport, SecurityDenialProbe
|
||||
from ragcore.application.restore.service import RestoreGateService, read_verified_manifest
|
||||
from ragcore.domain.citations import Citation
|
||||
from ragcore.domain.control import DataPolicy, Grant, KnowledgeSpace, Role, UserGrant
|
||||
|
||||
SOURCE_DB_URL = "postgresql+psycopg://ragcore:ragcore@postgres:5432/source"
|
||||
|
||||
|
||||
def _write_backup(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
collection_name: str = "docs_v1",
|
||||
alias: str = "docs-live",
|
||||
point_ids: Sequence[UUID] = (),
|
||||
status: BackupStatus = BackupStatus.COMPLETE,
|
||||
) -> tuple[Path, dict[Path, dict[UUID, Mapping[str, object]]]]:
|
||||
backup_dir = tmp_path / "backup"
|
||||
backup_dir.mkdir()
|
||||
|
||||
dump_bytes = b"pg-dump-content"
|
||||
dump_path = backup_dir / "postgres.dump"
|
||||
dump_path.write_bytes(dump_bytes)
|
||||
|
||||
snapshot_bytes = b"qdrant-snapshot-content"
|
||||
snapshot_name = f"{collection_name}-1234-2026-01-01.snapshot"
|
||||
snapshot_path = backup_dir / "qdrant" / snapshot_name
|
||||
snapshot_path.parent.mkdir(parents=True)
|
||||
snapshot_path.write_bytes(snapshot_bytes)
|
||||
|
||||
def _checksum(path: Path, content: bytes) -> dict[str, object]:
|
||||
return {
|
||||
"relative_path": str(path.relative_to(backup_dir)),
|
||||
"sha256": hashlib.sha256(content).hexdigest(),
|
||||
"size_bytes": len(content),
|
||||
}
|
||||
|
||||
manifest = {
|
||||
"backup_id": "backup-1",
|
||||
"status": status.value,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
"software_version": "0.0.0",
|
||||
"schema_revision": "abc123",
|
||||
"active_profiles": {},
|
||||
"postgres_dump": _checksum(dump_path, dump_bytes),
|
||||
"qdrant_snapshots": [_checksum(snapshot_path, snapshot_bytes)],
|
||||
"collection_aliases": {alias: collection_name},
|
||||
"blob_artifacts": [],
|
||||
"parsed_artifacts": [],
|
||||
"counts": {},
|
||||
}
|
||||
(backup_dir / "manifest.json").write_text(json.dumps(manifest))
|
||||
|
||||
snapshot_contents = {
|
||||
snapshot_path: {
|
||||
point_id: _point_payload(point_id) for point_id in point_ids
|
||||
}
|
||||
}
|
||||
return backup_dir, snapshot_contents
|
||||
|
||||
|
||||
def _point_payload(point_id: UUID) -> Mapping[str, object]:
|
||||
return {
|
||||
"workspace_id": str(uuid4()),
|
||||
"space_id": str(uuid4()),
|
||||
"document_id": str(uuid4()),
|
||||
"document_version_id": str(uuid4()),
|
||||
"chunk_id": str(point_id),
|
||||
"location": {"page": 1},
|
||||
"text": f"content for {point_id}",
|
||||
}
|
||||
|
||||
|
||||
class _FakePostgresRestorer:
|
||||
def __init__(self, *, revision: str = "abc123") -> None:
|
||||
self._revision = revision
|
||||
self.created_databases: list[str] = []
|
||||
self.restored_dumps: list[Path] = []
|
||||
|
||||
async def create_empty_database(self, *, database_name: str) -> str:
|
||||
self.created_databases.append(database_name)
|
||||
return f"postgresql+psycopg://ragcore:ragcore@postgres:5432/{database_name}"
|
||||
|
||||
async def restore_dump(self, *, dump_path: Path, target_database_url: str) -> None:
|
||||
self.restored_dumps.append(dump_path)
|
||||
|
||||
async def upgrade_to_head(self, *, target_database_url: str) -> str:
|
||||
return self._revision
|
||||
|
||||
async def drop_database(self, *, database_name: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _FakeQdrantRestorer:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
snapshot_contents: Mapping[Path, Mapping[UUID, Mapping[str, object]]],
|
||||
source_collections: Mapping[str, Mapping[UUID, Mapping[str, object]]],
|
||||
nearest_by_vector: Mapping[tuple[float, ...], UUID] | None = None,
|
||||
) -> None:
|
||||
self._snapshot_contents = snapshot_contents
|
||||
self._collections: dict[str, dict[UUID, Mapping[str, object]]] = {
|
||||
name: dict(points) for name, points in source_collections.items()
|
||||
}
|
||||
self._nearest_by_vector = nearest_by_vector or {}
|
||||
self.restore_calls: list[tuple[Path, str]] = []
|
||||
self.alias_calls: list[dict[str, str]] = []
|
||||
|
||||
async def restore_collection(self, *, snapshot_path: Path, collection_name: str) -> None:
|
||||
self.restore_calls.append((snapshot_path, collection_name))
|
||||
self._collections[collection_name] = dict(self._snapshot_contents[snapshot_path])
|
||||
|
||||
async def restore_aliases(self, *, alias_to_collection: Mapping[str, str]) -> None:
|
||||
self.alias_calls.append(dict(alias_to_collection))
|
||||
|
||||
async def scroll_point_ids(self, *, collection_name: str) -> frozenset[UUID]:
|
||||
return frozenset(self._collections.get(collection_name, {}).keys())
|
||||
|
||||
async def fetch_point_payload(
|
||||
self, *, collection_name: str, point_id: UUID
|
||||
) -> Mapping[str, object] | None:
|
||||
return self._collections.get(collection_name, {}).get(point_id)
|
||||
|
||||
async def search_nearest(
|
||||
self, *, collection_name: str, query_vector: Sequence[float], limit: int = 1
|
||||
) -> tuple[UUID, ...]:
|
||||
match = self._nearest_by_vector.get(tuple(query_vector))
|
||||
if match is None or match not in self._collections.get(collection_name, {}):
|
||||
return ()
|
||||
return (match,)
|
||||
|
||||
async def drop_collection(self, *, collection_name: str) -> None:
|
||||
self._collections.pop(collection_name, None)
|
||||
|
||||
|
||||
class _FakeFileCopier:
|
||||
async def copy_tree(self, *, source: Path, destination: Path) -> tuple:
|
||||
return ()
|
||||
|
||||
|
||||
class _FakeControlPlaneReader:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
spaces_by_url: Mapping[str, tuple[KnowledgeSpace, ...]],
|
||||
grants_by_url: Mapping[str, tuple[Grant, ...]] | None = None,
|
||||
user_grants_by_url: Mapping[str, tuple[UserGrant, ...]] | None = None,
|
||||
) -> None:
|
||||
self._spaces_by_url = spaces_by_url
|
||||
self._grants_by_url = grants_by_url or {}
|
||||
self._user_grants_by_url = user_grants_by_url or {}
|
||||
|
||||
async def list_spaces(
|
||||
self, *, database_url: str, workspace_id: UUID
|
||||
) -> tuple[KnowledgeSpace, ...]:
|
||||
return self._spaces_by_url.get(database_url, ())
|
||||
|
||||
async def list_grants(self, *, database_url: str, workspace_id: UUID) -> tuple[Grant, ...]:
|
||||
return self._grants_by_url.get(database_url, ())
|
||||
|
||||
async def list_user_grants(
|
||||
self, *, database_url: str, workspace_id: UUID
|
||||
) -> tuple[UserGrant, ...]:
|
||||
return self._user_grants_by_url.get(database_url, ())
|
||||
|
||||
|
||||
def _space(workspace_id: UUID, space_id: UUID) -> KnowledgeSpace:
|
||||
return KnowledgeSpace(
|
||||
id=space_id,
|
||||
workspace_id=workspace_id,
|
||||
name=f"space-{space_id.hex[:8]}",
|
||||
slug=f"space-{space_id.hex[:8]}",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
)
|
||||
|
||||
|
||||
async def test_full_run_passes_when_restore_faithfully_reproduces_the_source(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
point_id = uuid4()
|
||||
backup_dir, snapshot_contents = _write_backup(tmp_path, point_ids=(point_id,))
|
||||
query_vector = (0.1, 0.2, 0.3, 0.4)
|
||||
payload = next(iter(next(iter(snapshot_contents.values())).values()))
|
||||
|
||||
workspace_id = uuid4()
|
||||
principal_id = uuid4()
|
||||
granted_space_id = uuid4()
|
||||
denied_space_id = uuid4()
|
||||
disposable_db_url = "postgresql+psycopg://ragcore:ragcore@postgres:5432/restore-1"
|
||||
|
||||
both_spaces = (_space(workspace_id, granted_space_id), _space(workspace_id, denied_space_id))
|
||||
control_plane = _FakeControlPlaneReader(
|
||||
spaces_by_url={
|
||||
SOURCE_DB_URL: both_spaces,
|
||||
disposable_db_url: both_spaces,
|
||||
},
|
||||
user_grants_by_url={
|
||||
disposable_db_url: (
|
||||
UserGrant(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
user_id=principal_id,
|
||||
knowledge_space_id=granted_space_id,
|
||||
role=Role.VIEWER,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
qdrant = _FakeQdrantRestorer(
|
||||
snapshot_contents=snapshot_contents,
|
||||
source_collections={"docs_v1": {point_id: payload}},
|
||||
nearest_by_vector={query_vector: point_id},
|
||||
)
|
||||
service = RestoreGateService(
|
||||
postgres=_FakePostgresRestorer(),
|
||||
qdrant=qdrant,
|
||||
control_plane=control_plane,
|
||||
files=_FakeFileCopier(),
|
||||
)
|
||||
expected_citation = Citation.create(
|
||||
workspace_id=UUID(str(payload["workspace_id"])),
|
||||
space_id=UUID(str(payload["space_id"])),
|
||||
document_id=UUID(str(payload["document_id"])),
|
||||
document_version_id=UUID(str(payload["document_version_id"])),
|
||||
chunk_id=UUID(str(payload["chunk_id"])),
|
||||
parent_chunk_id=None,
|
||||
location=dict(payload["location"]), # type: ignore[arg-type]
|
||||
excerpt=str(payload["text"]),
|
||||
source_uri="ragcore://source/restore-gate",
|
||||
acquired_at=datetime.now(UTC),
|
||||
retrieval_run_id=uuid4(),
|
||||
index_alias="docs-live",
|
||||
profile_version="restore-gate",
|
||||
)
|
||||
|
||||
report = await service.run(
|
||||
backup_dir=backup_dir,
|
||||
source_database_url=SOURCE_DB_URL,
|
||||
disposable_database_name="restore-1",
|
||||
disposable_collection_prefix="disposable",
|
||||
disposable_blob_root=tmp_path / "blobs",
|
||||
disposable_parsed_root=tmp_path / "parsed",
|
||||
golden_queries=(
|
||||
GoldenQueryProbe(
|
||||
collection_alias="docs-live",
|
||||
point_id=point_id,
|
||||
query_vector=query_vector,
|
||||
expected_excerpt_checksum=expected_citation.excerpt_checksum,
|
||||
),
|
||||
),
|
||||
security_probe=SecurityDenialProbe(
|
||||
principal_id=principal_id,
|
||||
workspace_id=workspace_id,
|
||||
granted_space_id=granted_space_id,
|
||||
denied_space_id=denied_space_id,
|
||||
),
|
||||
)
|
||||
|
||||
assert isinstance(report, RestoreReport)
|
||||
assert report.passed
|
||||
assert report.reconciliation[0].matches
|
||||
assert report.golden_queries[0].passed
|
||||
assert report.security_denial is not None
|
||||
assert report.security_denial.passed
|
||||
assert qdrant.restore_calls == [
|
||||
(backup_dir / "qdrant" / "docs_v1-1234-2026-01-01.snapshot", "disposable-docs_v1")
|
||||
]
|
||||
assert qdrant.alias_calls == [{"docs-live": "disposable-docs_v1"}]
|
||||
|
||||
|
||||
async def test_incomplete_manifest_is_rejected_before_touching_any_infra(tmp_path: Path) -> None:
|
||||
backup_dir, _ = _write_backup(tmp_path, status=BackupStatus.INCOMPLETE)
|
||||
postgres = _FakePostgresRestorer()
|
||||
service = RestoreGateService(
|
||||
postgres=postgres,
|
||||
qdrant=_FakeQdrantRestorer(snapshot_contents={}, source_collections={}),
|
||||
control_plane=_FakeControlPlaneReader(spaces_by_url={}),
|
||||
files=_FakeFileCopier(),
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="not 'complete'"):
|
||||
await service.run(
|
||||
backup_dir=backup_dir,
|
||||
source_database_url=SOURCE_DB_URL,
|
||||
disposable_database_name="restore-2",
|
||||
disposable_collection_prefix="disposable",
|
||||
disposable_blob_root=tmp_path / "blobs",
|
||||
disposable_parsed_root=tmp_path / "parsed",
|
||||
golden_queries=(),
|
||||
security_probe=SecurityDenialProbe(
|
||||
principal_id=uuid4(),
|
||||
workspace_id=uuid4(),
|
||||
granted_space_id=uuid4(),
|
||||
denied_space_id=uuid4(),
|
||||
),
|
||||
)
|
||||
|
||||
assert postgres.created_databases == []
|
||||
|
||||
|
||||
async def test_corrupted_artifact_checksum_is_rejected_before_touching_any_infra(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
backup_dir, _ = _write_backup(tmp_path)
|
||||
(backup_dir / "postgres.dump").write_bytes(b"corrupted-bytes")
|
||||
postgres = _FakePostgresRestorer()
|
||||
service = RestoreGateService(
|
||||
postgres=postgres,
|
||||
qdrant=_FakeQdrantRestorer(snapshot_contents={}, source_collections={}),
|
||||
control_plane=_FakeControlPlaneReader(spaces_by_url={}),
|
||||
files=_FakeFileCopier(),
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="checksum mismatch"):
|
||||
await service.run(
|
||||
backup_dir=backup_dir,
|
||||
source_database_url=SOURCE_DB_URL,
|
||||
disposable_database_name="restore-3",
|
||||
disposable_collection_prefix="disposable",
|
||||
disposable_blob_root=tmp_path / "blobs",
|
||||
disposable_parsed_root=tmp_path / "parsed",
|
||||
golden_queries=(),
|
||||
security_probe=SecurityDenialProbe(
|
||||
principal_id=uuid4(),
|
||||
workspace_id=uuid4(),
|
||||
granted_space_id=uuid4(),
|
||||
denied_space_id=uuid4(),
|
||||
),
|
||||
)
|
||||
|
||||
assert postgres.created_databases == []
|
||||
|
||||
|
||||
async def test_reconciliation_reports_a_point_missing_from_the_restored_snapshot(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
present_id = uuid4()
|
||||
missing_id = uuid4()
|
||||
backup_dir, snapshot_contents = _write_backup(tmp_path, point_ids=(present_id,))
|
||||
workspace_id = uuid4()
|
||||
|
||||
qdrant = _FakeQdrantRestorer(
|
||||
snapshot_contents=snapshot_contents,
|
||||
# The live source has one extra point the snapshot never captured.
|
||||
source_collections={
|
||||
"docs_v1": {
|
||||
present_id: _point_payload(present_id),
|
||||
missing_id: _point_payload(missing_id),
|
||||
}
|
||||
},
|
||||
)
|
||||
control_plane = _FakeControlPlaneReader(spaces_by_url={})
|
||||
service = RestoreGateService(
|
||||
postgres=_FakePostgresRestorer(),
|
||||
qdrant=qdrant,
|
||||
control_plane=control_plane,
|
||||
files=_FakeFileCopier(),
|
||||
)
|
||||
|
||||
report = await service.run(
|
||||
backup_dir=backup_dir,
|
||||
source_database_url=SOURCE_DB_URL,
|
||||
disposable_database_name="restore-4",
|
||||
disposable_collection_prefix="disposable",
|
||||
disposable_blob_root=tmp_path / "blobs",
|
||||
disposable_parsed_root=tmp_path / "parsed",
|
||||
golden_queries=(),
|
||||
security_probe=SecurityDenialProbe(
|
||||
principal_id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
granted_space_id=uuid4(),
|
||||
denied_space_id=uuid4(),
|
||||
),
|
||||
)
|
||||
|
||||
assert not report.passed
|
||||
assert report.reconciliation[0].missing_point_ids == frozenset({missing_id})
|
||||
assert report.reconciliation[0].orphan_point_ids == frozenset()
|
||||
|
||||
|
||||
async def test_golden_query_fails_when_the_expected_chunk_is_not_returned(tmp_path: Path) -> None:
|
||||
point_id = uuid4()
|
||||
backup_dir, snapshot_contents = _write_backup(tmp_path, point_ids=(point_id,))
|
||||
qdrant = _FakeQdrantRestorer(
|
||||
snapshot_contents=snapshot_contents,
|
||||
source_collections={"docs_v1": {point_id: _point_payload(point_id)}},
|
||||
nearest_by_vector={}, # no vector configured -> search_nearest returns nothing
|
||||
)
|
||||
service = RestoreGateService(
|
||||
postgres=_FakePostgresRestorer(),
|
||||
qdrant=qdrant,
|
||||
control_plane=_FakeControlPlaneReader(spaces_by_url={}),
|
||||
files=_FakeFileCopier(),
|
||||
)
|
||||
|
||||
report = await service.run(
|
||||
backup_dir=backup_dir,
|
||||
source_database_url=SOURCE_DB_URL,
|
||||
disposable_database_name="restore-5",
|
||||
disposable_collection_prefix="disposable",
|
||||
disposable_blob_root=tmp_path / "blobs",
|
||||
disposable_parsed_root=tmp_path / "parsed",
|
||||
golden_queries=(
|
||||
GoldenQueryProbe(
|
||||
collection_alias="docs-live",
|
||||
point_id=point_id,
|
||||
query_vector=(0.1, 0.2, 0.3, 0.4),
|
||||
expected_excerpt_checksum="0" * 64,
|
||||
),
|
||||
),
|
||||
security_probe=SecurityDenialProbe(
|
||||
principal_id=uuid4(),
|
||||
workspace_id=uuid4(),
|
||||
granted_space_id=uuid4(),
|
||||
denied_space_id=uuid4(),
|
||||
),
|
||||
)
|
||||
|
||||
assert not report.passed
|
||||
assert not report.golden_queries[0].passed
|
||||
assert report.golden_queries[0].returned_chunk_id is None
|
||||
|
||||
|
||||
async def test_security_denial_fails_when_the_denied_space_is_still_reachable(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
backup_dir, snapshot_contents = _write_backup(tmp_path)
|
||||
workspace_id = uuid4()
|
||||
principal_id = uuid4()
|
||||
granted_space_id = uuid4()
|
||||
denied_space_id = uuid4()
|
||||
disposable_db_url = "postgresql+psycopg://ragcore:ragcore@postgres:5432/restore-6"
|
||||
|
||||
control_plane = _FakeControlPlaneReader(
|
||||
spaces_by_url={
|
||||
disposable_db_url: (
|
||||
_space(workspace_id, granted_space_id),
|
||||
_space(workspace_id, denied_space_id),
|
||||
)
|
||||
},
|
||||
user_grants_by_url={
|
||||
disposable_db_url: (
|
||||
# Misconfigured fixture: a grant to the space that should be denied.
|
||||
UserGrant(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
user_id=principal_id,
|
||||
knowledge_space_id=denied_space_id,
|
||||
role=Role.VIEWER,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
service = RestoreGateService(
|
||||
postgres=_FakePostgresRestorer(),
|
||||
qdrant=_FakeQdrantRestorer(snapshot_contents=snapshot_contents, source_collections={}),
|
||||
control_plane=control_plane,
|
||||
files=_FakeFileCopier(),
|
||||
)
|
||||
|
||||
report = await service.run(
|
||||
backup_dir=backup_dir,
|
||||
source_database_url=SOURCE_DB_URL,
|
||||
disposable_database_name="restore-6",
|
||||
disposable_collection_prefix="disposable",
|
||||
disposable_blob_root=tmp_path / "blobs",
|
||||
disposable_parsed_root=tmp_path / "parsed",
|
||||
golden_queries=(),
|
||||
security_probe=SecurityDenialProbe(
|
||||
principal_id=principal_id,
|
||||
workspace_id=workspace_id,
|
||||
granted_space_id=granted_space_id,
|
||||
denied_space_id=denied_space_id,
|
||||
),
|
||||
)
|
||||
|
||||
assert not report.passed
|
||||
assert report.security_denial is not None
|
||||
assert not report.security_denial.passed
|
||||
assert denied_space_id in report.security_denial.effective_space_ids
|
||||
|
||||
|
||||
def test_read_verified_manifest_rejects_a_missing_manifest_file(tmp_path: Path) -> None:
|
||||
with pytest.raises(Exception, match=r"no manifest\.json"):
|
||||
read_verified_manifest(tmp_path)
|
||||
@@ -0,0 +1,526 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from qdrant_client.http import models
|
||||
|
||||
from ragcore.api.v1.context.service import ContextExecutionRequest, ContextQuery
|
||||
from ragcore.api.v1.search.service import SearchExecutionRequest, SearchFilterData, SearchQuery
|
||||
from ragcore.application.models.selection import ResolvedEmbeddingSelection
|
||||
from ragcore.application.retrieval.authorization import AuthorizedRetrievalScope
|
||||
from ragcore.config import Settings
|
||||
from ragcore.domain.authorization import Principal, PrincipalType
|
||||
from ragcore.domain.control import DataPolicy
|
||||
from ragcore.domain.models import EmbeddingProfile, EmbeddingProvider, EmbeddingSelection
|
||||
from ragcore.infrastructure.models.embedding_factory import create_embedding_adapter_factory
|
||||
from ragcore.infrastructure.models.persistence import JsonEmbeddingProfileStore
|
||||
from ragcore.infrastructure.retrieval.production_executor import (
|
||||
PostgresDocumentTitleLookup,
|
||||
ProductionContextExecutor,
|
||||
ProductionSearchExecutor,
|
||||
RetrievalPipeline,
|
||||
)
|
||||
from ragcore.platform.telemetry import TelemetryMetrics
|
||||
|
||||
WORKSPACE_ID = uuid4()
|
||||
SPACE_ID = uuid4()
|
||||
DOCUMENT_ID = uuid4()
|
||||
DOCUMENT_VERSION_ID = uuid4()
|
||||
CHUNK_ID = uuid4()
|
||||
SOURCE_ID = uuid4()
|
||||
|
||||
EMBEDDING_PROFILE = EmbeddingProfile(
|
||||
id=uuid4(),
|
||||
profile="hybrid",
|
||||
version="v1",
|
||||
provider=EmbeddingProvider.OLLAMA,
|
||||
model_identifier="nomic-embed-text:v1",
|
||||
model_digest="0" * 64,
|
||||
dimensions=2,
|
||||
context_limit=2048,
|
||||
tokenizer="cl100k",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
)
|
||||
|
||||
_PAYLOAD = {
|
||||
"workspace_id": str(WORKSPACE_ID),
|
||||
"space_id": str(SPACE_ID),
|
||||
"document_id": str(DOCUMENT_ID),
|
||||
"document_version_id": str(DOCUMENT_VERSION_ID),
|
||||
"chunk_id": str(CHUNK_ID),
|
||||
"source_id": str(SOURCE_ID),
|
||||
"parent_chunk_id": None,
|
||||
"status": "published",
|
||||
"content_type": "text/plain",
|
||||
"language": "en",
|
||||
"trust_level": "source",
|
||||
"embedding_model_digest": EMBEDDING_PROFILE.model_digest,
|
||||
"chunk_profile_version": EMBEDDING_PROFILE.version,
|
||||
"text": "The runbook says to restart the payment service after a failover.",
|
||||
"location": {"page": 1},
|
||||
}
|
||||
|
||||
|
||||
class FakeQdrantClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
alias_target: str = "rag_dense_hybrid_v1",
|
||||
payload: dict[str, object] = _PAYLOAD,
|
||||
) -> None:
|
||||
self.alias_target = alias_target
|
||||
self.payload = payload
|
||||
self.query_collections: list[str] = []
|
||||
|
||||
async def get_aliases(self, **kwargs: object) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
aliases=[
|
||||
SimpleNamespace(
|
||||
alias_name="rag_dense_hybrid_active", collection_name=self.alias_target
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
async def get_collection(self, collection_name: str, **kwargs: object) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
config=SimpleNamespace(
|
||||
params=SimpleNamespace(
|
||||
vectors={"dense": models.VectorParams(size=2, distance=models.Distance.COSINE)},
|
||||
sparse_vectors={"sparse": models.SparseVectorParams()},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async def query_points(self, **kwargs: object) -> models.QueryResponse:
|
||||
self.query_collections.append(str(kwargs.get("collection_name")))
|
||||
return models.QueryResponse(
|
||||
points=[
|
||||
models.ScoredPoint(id=str(CHUNK_ID), version=0, score=0.9, payload=self.payload)
|
||||
]
|
||||
)
|
||||
|
||||
async def retrieve(self, **kwargs: object) -> list[SimpleNamespace]:
|
||||
ids = kwargs.get("ids", [])
|
||||
return [SimpleNamespace(id=raw_id, payload=self.payload) for raw_id in ids]
|
||||
|
||||
|
||||
class PayloadlessQdrantClient(FakeQdrantClient):
|
||||
"""Ranks a point and then does not return its payload.
|
||||
|
||||
Qdrant answered the search with this point, so it is in the index -- but
|
||||
the follow-up ``retrieve`` gives nothing back for it. The pipeline cannot
|
||||
build a citation without the text, so the hit is dropped; the point of
|
||||
this double is that the caller must be *told* that happened.
|
||||
"""
|
||||
|
||||
async def retrieve(self, **kwargs: object) -> list[SimpleNamespace]:
|
||||
return []
|
||||
|
||||
|
||||
class FakeTitleLookup:
|
||||
def __init__(self, titles: dict | None = None) -> None: # type: ignore[type-arg]
|
||||
self._titles = {DOCUMENT_ID: "Restart Runbook"} if titles is None else titles
|
||||
|
||||
async def titles(self, document_ids: set) -> dict: # type: ignore[type-arg]
|
||||
return {doc_id: self._titles[doc_id] for doc_id in document_ids if doc_id in self._titles}
|
||||
|
||||
|
||||
def _embed_transport(vector: tuple = (0.1, 0.2)) -> httpx.MockTransport: # type: ignore[type-arg]
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"embeddings": [list(vector)]})
|
||||
|
||||
return httpx.MockTransport(handler)
|
||||
|
||||
|
||||
def _failing_transport(status_code: int = 503) -> httpx.MockTransport:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(status_code, json={"error": "unavailable"})
|
||||
|
||||
return httpx.MockTransport(handler)
|
||||
|
||||
|
||||
def _empty_store() -> JsonEmbeddingProfileStore:
|
||||
path = Path(tempfile.mkdtemp()) / "profiles.json"
|
||||
return JsonEmbeddingProfileStore(path)
|
||||
|
||||
|
||||
async def _activated_store() -> JsonEmbeddingProfileStore:
|
||||
store = _empty_store()
|
||||
await store.add(EMBEDDING_PROFILE)
|
||||
await store.activate(EMBEDDING_PROFILE.id)
|
||||
return store
|
||||
|
||||
|
||||
async def _activated_store_for(profile: EmbeddingProfile) -> JsonEmbeddingProfileStore:
|
||||
store = _empty_store()
|
||||
await store.add(profile)
|
||||
await store.activate(profile.id)
|
||||
return store
|
||||
|
||||
|
||||
class FixedSelectionResolver:
|
||||
def __init__(self, profile: EmbeddingProfile, *, route: str) -> None:
|
||||
self.selected = ResolvedEmbeddingSelection(
|
||||
generation=8,
|
||||
profile=profile,
|
||||
selection=EmbeddingSelection.from_profile(profile, experiment_route_key=route),
|
||||
)
|
||||
|
||||
async def resolve_active(self) -> ResolvedEmbeddingSelection:
|
||||
return self.selected
|
||||
|
||||
|
||||
def _pipeline(
|
||||
*,
|
||||
embedding_transport: httpx.MockTransport,
|
||||
store,
|
||||
metrics: TelemetryMetrics | None = None,
|
||||
) -> RetrievalPipeline:
|
||||
client = httpx.AsyncClient(transport=embedding_transport, follow_redirects=False)
|
||||
return RetrievalPipeline(
|
||||
qdrant_client=FakeQdrantClient(), # type: ignore[arg-type]
|
||||
embedding_profile_store=store,
|
||||
embedding_adapter_factory=create_embedding_adapter_factory(
|
||||
settings=Settings(env="test", ollama_url="http://localhost:11434"),
|
||||
http_client=client,
|
||||
),
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
|
||||
def _scope() -> AuthorizedRetrievalScope:
|
||||
return AuthorizedRetrievalScope(WORKSPACE_ID, frozenset({SPACE_ID}))
|
||||
|
||||
|
||||
def _search_request(query: str = "how do I restart the payment service") -> SearchExecutionRequest:
|
||||
return SearchExecutionRequest(
|
||||
principal=Principal(uuid4(), WORKSPACE_ID, PrincipalType.HUMAN, frozenset()),
|
||||
query=SearchQuery(query=query, max_results=5, filters=SearchFilterData()),
|
||||
scope=_scope(),
|
||||
retrieval_run_id=uuid4(),
|
||||
)
|
||||
|
||||
|
||||
def _context_request(
|
||||
query: str = "how do I restart the payment service",
|
||||
) -> ContextExecutionRequest:
|
||||
return ContextExecutionRequest(
|
||||
principal=Principal(uuid4(), WORKSPACE_ID, PrincipalType.HUMAN, frozenset()),
|
||||
query=ContextQuery(
|
||||
query=SearchQuery(query=query, max_results=5, filters=SearchFilterData()),
|
||||
max_context_tokens=4000,
|
||||
),
|
||||
scope=_scope(),
|
||||
retrieval_run_id=uuid4(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_degrades_when_no_active_embedding_profile() -> None:
|
||||
pipeline = _pipeline(embedding_transport=_embed_transport(), store=_empty_store())
|
||||
result = await pipeline.run(
|
||||
query_text="restart payment",
|
||||
content_types=(),
|
||||
languages=(),
|
||||
scope=_scope(),
|
||||
max_results=5,
|
||||
profile="hybrid-v1",
|
||||
retrieval_run_id=uuid4(),
|
||||
)
|
||||
assert result.hits == ()
|
||||
assert "no_active_embedding_profile" in result.degradation_codes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_records_retrieval_duration_and_zero_result() -> None:
|
||||
reader = InMemoryMetricReader()
|
||||
provider = MeterProvider(
|
||||
resource=Resource.create({"service.name": "test"}), metric_readers=[reader]
|
||||
)
|
||||
metrics = TelemetryMetrics(meter_provider=provider)
|
||||
pipeline = _pipeline(
|
||||
embedding_transport=_embed_transport(), store=_empty_store(), metrics=metrics
|
||||
)
|
||||
|
||||
await pipeline.run(
|
||||
query_text="restart payment",
|
||||
content_types=(),
|
||||
languages=(),
|
||||
scope=_scope(),
|
||||
max_results=5,
|
||||
profile="hybrid-v1",
|
||||
retrieval_run_id=uuid4(),
|
||||
)
|
||||
|
||||
data = reader.get_metrics_data()
|
||||
assert data is not None
|
||||
names = {
|
||||
metric.name
|
||||
for resource_metric in data.resource_metrics
|
||||
for scope_metric in resource_metric.scope_metrics
|
||||
for metric in scope_metric.metrics
|
||||
}
|
||||
assert "ragcore_retrieval_duration_seconds" in names
|
||||
assert "ragcore_retrieval_zero_result_total" in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_degrades_when_embedding_provider_unavailable() -> None:
|
||||
pipeline = _pipeline(embedding_transport=_failing_transport(), store=await _activated_store())
|
||||
result = await pipeline.run(
|
||||
query_text="restart payment",
|
||||
content_types=(),
|
||||
languages=(),
|
||||
scope=_scope(),
|
||||
max_results=5,
|
||||
profile="hybrid-v1",
|
||||
retrieval_run_id=uuid4(),
|
||||
)
|
||||
assert result.hits == ()
|
||||
assert "embedding_provider_unavailable" in result.degradation_codes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_executor_happy_path_returns_titled_hit_with_fused_candidate() -> None:
|
||||
"""M8-01: no reranking -- the fused RRF ranking is the ranking, not a
|
||||
degraded fallback, so this must not report degraded and rerank_score is
|
||||
always None (kept as a field for response-shape compatibility)."""
|
||||
|
||||
pipeline = _pipeline(embedding_transport=_embed_transport(), store=await _activated_store())
|
||||
executor = ProductionSearchExecutor(pipeline, FakeTitleLookup()) # type: ignore[arg-type]
|
||||
result = await executor.execute(_search_request())
|
||||
|
||||
assert result.degraded is False
|
||||
assert len(result.hits) == 1
|
||||
hit = result.hits[0]
|
||||
assert hit.title == "Restart Runbook"
|
||||
assert "restart the payment service" in hit.text
|
||||
assert hit.citation.document_id == DOCUMENT_ID
|
||||
assert hit.citation.workspace_id == WORKSPACE_ID
|
||||
assert hit.fused.candidate.provenance.chunk_id == CHUNK_ID
|
||||
assert hit.rerank_score is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_executor_falls_back_to_untitled_when_title_missing() -> None:
|
||||
pipeline = _pipeline(embedding_transport=_embed_transport(), store=await _activated_store())
|
||||
executor = ProductionSearchExecutor(pipeline, FakeTitleLookup(titles={})) # type: ignore[arg-type]
|
||||
result = await executor.execute(_search_request())
|
||||
assert result.hits[0].title == "Untitled document"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_executor_happy_path_returns_context_candidate() -> None:
|
||||
pipeline = _pipeline(embedding_transport=_embed_transport(), store=await _activated_store())
|
||||
executor = ProductionContextExecutor(pipeline, FakeQdrantClient()) # type: ignore[arg-type]
|
||||
result = await executor.execute(_context_request())
|
||||
|
||||
assert result.degraded is False
|
||||
assert len(result.candidates) == 1
|
||||
assert "restart the payment service" in result.candidates[0].text
|
||||
assert result.candidates[0].citation.document_id == DOCUMENT_ID
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_executor_returns_empty_tuple_when_degraded() -> None:
|
||||
pipeline = _pipeline(embedding_transport=_embed_transport(), store=_empty_store())
|
||||
executor = ProductionContextExecutor(pipeline, FakeQdrantClient()) # type: ignore[arg-type]
|
||||
result = await executor.execute(_context_request())
|
||||
assert result.candidates == ()
|
||||
assert result.degraded is True
|
||||
assert "no_active_embedding_profile" in result.degradation_codes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rest_search_uses_durable_route_and_collection_selection(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
profile = EmbeddingProfile(
|
||||
id=uuid4(),
|
||||
profile="hybrid",
|
||||
version="v1",
|
||||
provider=EmbeddingProvider.MODELFORGE,
|
||||
model_identifier="rag.embedding@1",
|
||||
model_digest="c" * 64,
|
||||
dimensions=2,
|
||||
context_limit=8192,
|
||||
tokenizer="capability-managed",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
embedding_space_id=uuid4(),
|
||||
)
|
||||
credential_file = tmp_path / "credential"
|
||||
credential_file.write_text("mf_" + "x" * 48, encoding="utf-8")
|
||||
credential_file.chmod(0o600)
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"capability": "rag.embedding@1",
|
||||
"dimension": profile.dimensions,
|
||||
"normalized": True,
|
||||
"embedding_space_id": str(profile.embedding_space_id),
|
||||
"data": [[1.0, 0.0]],
|
||||
"request_id": str(uuid4()),
|
||||
},
|
||||
)
|
||||
|
||||
settings = Settings(
|
||||
env="test",
|
||||
ollama_url="http://localhost:11434",
|
||||
modelforge_base_url="http://modelforge:8000",
|
||||
modelforge_credential_file=credential_file,
|
||||
modelforge_embedding_experiment_route="stale-global-route",
|
||||
)
|
||||
payload = {
|
||||
**_PAYLOAD,
|
||||
"embedding_model_digest": profile.model_digest,
|
||||
"embedding_space_id": str(profile.embedding_space_id),
|
||||
}
|
||||
qdrant = FakeQdrantClient(alias_target="rag_dense_hybrid_stale", payload=payload)
|
||||
pipeline = RetrievalPipeline(
|
||||
qdrant_client=qdrant, # type: ignore[arg-type]
|
||||
embedding_profile_store=await _activated_store_for(profile),
|
||||
embedding_adapter_factory=create_embedding_adapter_factory(
|
||||
settings=settings,
|
||||
http_client=httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(respond), follow_redirects=False
|
||||
),
|
||||
),
|
||||
embedding_selection_resolver=FixedSelectionResolver(profile, route="rc-canary-2026-09"),
|
||||
)
|
||||
executor = ProductionSearchExecutor(pipeline, FakeTitleLookup()) # type: ignore[arg-type]
|
||||
|
||||
result = await executor.execute(_search_request())
|
||||
|
||||
assert result.degraded is False
|
||||
assert len(result.hits) == 1
|
||||
assert qdrant.query_collections
|
||||
assert set(qdrant.query_collections) == {"rag_dense_hybrid_v1"}
|
||||
assert len(seen) == 1
|
||||
assert seen[0].url.path == "/api/v1/capability-experiments/rc-canary-2026-09/invoke"
|
||||
assert json.loads(seen[0].content) == {
|
||||
"input": ["how do I restart the payment service"],
|
||||
"input_type": "query",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_title_lookup_returns_empty_dict_for_empty_input() -> None:
|
||||
lookup = PostgresDocumentTitleLookup(session_factory=None) # type: ignore[arg-type]
|
||||
assert await lookup.titles(set()) == {}
|
||||
|
||||
|
||||
class RecordingQdrantClient(FakeQdrantClient):
|
||||
"""Also records every query_points call so filter compilation is inspectable."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.query_calls: list[dict] = [] # type: ignore[type-arg]
|
||||
|
||||
async def query_points(self, **kwargs: object) -> models.QueryResponse:
|
||||
self.query_calls.append(dict(kwargs))
|
||||
return await super().query_points(**kwargs)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_executor_compiles_every_accepted_filter_into_the_qdrant_query() -> None:
|
||||
"""Regression: source_ids/document_ids/trust_levels used to be accepted by
|
||||
the API and silently dropped before ever reaching Qdrant."""
|
||||
|
||||
client = RecordingQdrantClient()
|
||||
embedding_client = httpx.AsyncClient(transport=_embed_transport(), follow_redirects=False)
|
||||
pipeline = RetrievalPipeline(
|
||||
qdrant_client=client, # type: ignore[arg-type]
|
||||
embedding_profile_store=await _activated_store(),
|
||||
embedding_adapter_factory=create_embedding_adapter_factory(
|
||||
settings=Settings(env="test", ollama_url="http://localhost:11434"),
|
||||
http_client=embedding_client,
|
||||
),
|
||||
)
|
||||
executor = ProductionSearchExecutor(pipeline, FakeTitleLookup()) # type: ignore[arg-type]
|
||||
request = SearchExecutionRequest(
|
||||
principal=Principal(uuid4(), WORKSPACE_ID, PrincipalType.HUMAN, frozenset()),
|
||||
query=SearchQuery(
|
||||
query="restart",
|
||||
max_results=5,
|
||||
filters=SearchFilterData(
|
||||
source_ids=(SOURCE_ID,),
|
||||
document_ids=(DOCUMENT_ID,),
|
||||
trust_levels=("source",),
|
||||
),
|
||||
),
|
||||
scope=_scope(),
|
||||
retrieval_run_id=uuid4(),
|
||||
)
|
||||
await executor.execute(request)
|
||||
|
||||
assert client.query_calls
|
||||
for call in client.query_calls:
|
||||
compiled = str(call["query_filter"])
|
||||
assert str(SOURCE_ID) in compiled
|
||||
assert str(DOCUMENT_ID) in compiled
|
||||
assert "trust_level" in compiled
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_executor_reports_per_stage_timings() -> None:
|
||||
pipeline = _pipeline(embedding_transport=_embed_transport(), store=await _activated_store())
|
||||
executor = ProductionSearchExecutor(pipeline, FakeTitleLookup()) # type: ignore[arg-type]
|
||||
result = await executor.execute(_search_request())
|
||||
|
||||
timings = dict(result.timings_ms or {})
|
||||
for stage in (
|
||||
"query_embedding",
|
||||
"sparse_encoding",
|
||||
"dense_search",
|
||||
"sparse_search",
|
||||
"fusion",
|
||||
"payload_fetch",
|
||||
):
|
||||
assert stage in timings
|
||||
assert timings[stage] >= 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_reports_when_a_ranked_hit_has_no_payload() -> None:
|
||||
"""A dropped hit is a degradation, not a quietly shorter answer.
|
||||
|
||||
Returning four results where the ranking found five, with nothing in the
|
||||
response to say so, is indistinguishable from "there were only four" --
|
||||
and the caller acts on it.
|
||||
"""
|
||||
|
||||
embedding_client = httpx.AsyncClient(transport=_embed_transport(), follow_redirects=False)
|
||||
pipeline = RetrievalPipeline(
|
||||
qdrant_client=PayloadlessQdrantClient(), # type: ignore[arg-type]
|
||||
embedding_profile_store=await _activated_store(),
|
||||
embedding_adapter_factory=create_embedding_adapter_factory(
|
||||
settings=Settings(env="test", ollama_url="http://localhost:11434"),
|
||||
http_client=embedding_client,
|
||||
),
|
||||
)
|
||||
|
||||
result = await pipeline.run(
|
||||
query_text="restart payment",
|
||||
content_types=(),
|
||||
languages=(),
|
||||
scope=_scope(),
|
||||
max_results=5,
|
||||
profile="hybrid-v1",
|
||||
retrieval_run_id=uuid4(),
|
||||
)
|
||||
|
||||
assert result.hits == ()
|
||||
assert "payload_unavailable" in result.degradation_codes
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Sources that carry an interval get synced without anyone asking.
|
||||
|
||||
Until now a sync ran only when an operator pressed the button. For a platform
|
||||
whose promise is that an index keeps step with a folder or a repository, that
|
||||
made staleness the default and freshness a chore someone had to remember.
|
||||
|
||||
The scheduler does not sync anything. It enqueues the same ``sync_source`` job
|
||||
the Control Plane button enqueues, so every ownership, convergence and
|
||||
authorization rule downstream is the one already in place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.sources.models import SourceRecord, SourceStatus
|
||||
from ragcore.application.sources.scheduler import SourceSyncScheduler
|
||||
from ragcore.domain.sources import ConnectorKind, SourceSyncState
|
||||
|
||||
NOW = datetime(2026, 8, 23, 12, 0, tzinfo=UTC)
|
||||
WORKSPACE = uuid4()
|
||||
SPACE = uuid4()
|
||||
|
||||
|
||||
def _source(
|
||||
*,
|
||||
interval: int | None = 3600,
|
||||
last_sync_at: datetime | None = None,
|
||||
status: SourceStatus = SourceStatus.ACTIVE,
|
||||
sync_state: SourceSyncState = SourceSyncState.COMPLETED,
|
||||
kind: ConnectorKind = ConnectorKind.FOLDER,
|
||||
) -> SourceRecord:
|
||||
return SourceRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=WORKSPACE,
|
||||
space_id=SPACE,
|
||||
name="Runbooks",
|
||||
kind=kind,
|
||||
status=status,
|
||||
sync_state=sync_state,
|
||||
last_sync_at=last_sync_at,
|
||||
sync_interval_seconds=interval,
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, sources: list[SourceRecord]) -> None:
|
||||
self._sources = sources
|
||||
self.asked_at: list[datetime] = []
|
||||
|
||||
async def list_due_for_sync(self, *, now: datetime, limit: int) -> tuple[SourceRecord, ...]:
|
||||
self.asked_at.append(now)
|
||||
return tuple(self._sources[:limit])
|
||||
|
||||
|
||||
class _Jobs:
|
||||
def __init__(self) -> None:
|
||||
self.enqueued: list[tuple[UUID, str]] = []
|
||||
self.any_enqueued = asyncio.Event()
|
||||
|
||||
async def enqueue(self, **kwargs: object) -> object:
|
||||
self.enqueued.append((kwargs["workspace_id"], str(kwargs["idempotency_key"]))) # type: ignore[arg-type]
|
||||
self.any_enqueued.set()
|
||||
return object()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_due_source_is_enqueued_as_an_ordinary_sync_job() -> None:
|
||||
due = _source(interval=3600, last_sync_at=NOW - timedelta(hours=2))
|
||||
jobs = _Jobs()
|
||||
scheduler = SourceSyncScheduler(_Registry([due]), jobs) # type: ignore[arg-type]
|
||||
|
||||
enqueued = await scheduler.tick(now=NOW)
|
||||
|
||||
assert enqueued == 1
|
||||
assert [workspace for workspace, _ in jobs.enqueued] == [WORKSPACE]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_schedulers_ticking_together_produce_one_job() -> None:
|
||||
"""Several worker replicas each run a scheduler. They must not each start
|
||||
the same sync -- and they do not need a lock to avoid it: the key is
|
||||
derived from when the sync came due, so both compute the same one and the
|
||||
queue's own uniqueness collapses them."""
|
||||
|
||||
due = _source(interval=3600, last_sync_at=NOW - timedelta(hours=2))
|
||||
first, second = _Jobs(), _Jobs()
|
||||
|
||||
await SourceSyncScheduler(_Registry([due]), first).tick(now=NOW) # type: ignore[arg-type]
|
||||
await SourceSyncScheduler(_Registry([due]), second).tick( # type: ignore[arg-type]
|
||||
now=NOW + timedelta(seconds=17)
|
||||
)
|
||||
|
||||
assert first.enqueued and second.enqueued
|
||||
assert first.enqueued[0][1] == second.enqueued[0][1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_source_that_has_never_synced_is_due_immediately() -> None:
|
||||
jobs = _Jobs()
|
||||
scheduler = SourceSyncScheduler(_Registry([_source(last_sync_at=None)]), jobs) # type: ignore[arg-type]
|
||||
|
||||
assert await scheduler.tick(now=NOW) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"source",
|
||||
[
|
||||
pytest.param(_source(interval=None), id="no interval configured"),
|
||||
pytest.param(
|
||||
_source(last_sync_at=NOW - timedelta(minutes=5)), id="synced within the interval"
|
||||
),
|
||||
pytest.param(_source(status=SourceStatus.PAUSED), id="paused"),
|
||||
pytest.param(_source(status=SourceStatus.DELETED), id="deleted"),
|
||||
pytest.param(
|
||||
_source(sync_state=SourceSyncState.SCANNING), id="a run is already in flight"
|
||||
),
|
||||
pytest.param(
|
||||
_source(sync_state=SourceSyncState.AWAITING_INGEST),
|
||||
id="waiting on its own child ingests",
|
||||
),
|
||||
pytest.param(_source(kind=ConnectorKind.UPLOAD), id="fed by its callers, not synced"),
|
||||
],
|
||||
)
|
||||
async def test_sources_that_must_not_be_scheduled(source: SourceRecord) -> None:
|
||||
"""The registry query is the first filter, but it is not the only one: the
|
||||
scheduler refuses these itself, so a widened query cannot quietly start
|
||||
syncing something that should be left alone."""
|
||||
|
||||
jobs = _Jobs()
|
||||
scheduler = SourceSyncScheduler(_Registry([source]), jobs) # type: ignore[arg-type]
|
||||
|
||||
assert await scheduler.tick(now=NOW) == 0
|
||||
assert jobs.enqueued == []
|
||||
|
||||
|
||||
class _FailingRegistry(_Registry):
|
||||
def __init__(self, sources: list[SourceRecord]) -> None:
|
||||
super().__init__(sources)
|
||||
self.calls = 0
|
||||
|
||||
async def list_due_for_sync(self, *, now: datetime, limit: int) -> tuple[SourceRecord, ...]:
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
raise RuntimeError("the database blinked")
|
||||
return await super().list_due_for_sync(now=now, limit=limit)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_failing_tick_does_not_end_the_loop() -> None:
|
||||
"""The scheduler shares a process with the claim loops. A transient
|
||||
database error while looking for due sources must cost one tick, not the
|
||||
worker -- the next tick finds the same sources still due."""
|
||||
|
||||
registry = _FailingRegistry([_source(last_sync_at=None)])
|
||||
jobs = _Jobs()
|
||||
stop = asyncio.Event()
|
||||
scheduler = SourceSyncScheduler(registry, jobs) # type: ignore[arg-type]
|
||||
|
||||
async def until_enqueued() -> None:
|
||||
await jobs.any_enqueued.wait()
|
||||
stop.set()
|
||||
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(
|
||||
scheduler.run(stop, interval_seconds=0.01),
|
||||
until_enqueued(),
|
||||
),
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
assert registry.calls >= 2
|
||||
assert len(jobs.enqueued) >= 1
|
||||
@@ -0,0 +1,666 @@
|
||||
"""Source synchronisation semantics: discover -> compare -> ingest new/changed
|
||||
-> preserve unchanged -> retire disappeared -> persist the outcome."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections.abc import AsyncIterable, AsyncIterator
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.documents import DocumentRecord
|
||||
from ragcore.application.jobs import Job, JobService
|
||||
from ragcore.application.sources import (
|
||||
SourceItemRecord,
|
||||
SourceItemState,
|
||||
SourceItemSummary,
|
||||
SourceRecord,
|
||||
SourceStatus,
|
||||
SourceSyncOutcome,
|
||||
SourceSyncRunRecord,
|
||||
SourceSyncService,
|
||||
)
|
||||
from ragcore.application.storage import BlobReference
|
||||
from ragcore.domain.sources import (
|
||||
Acquisition,
|
||||
ConnectorKind,
|
||||
ScanResult,
|
||||
SourceCursor,
|
||||
SourceFingerprint,
|
||||
SourceItem,
|
||||
SourceSyncState,
|
||||
)
|
||||
|
||||
WORKSPACE_ID = uuid4()
|
||||
SPACE_ID = uuid4()
|
||||
|
||||
|
||||
class FakeConnector:
|
||||
"""Serves a mutable set of path -> content; pages when asked to."""
|
||||
|
||||
def __init__(self, source_id: UUID, files: dict[str, bytes], *, page_size: int = 100) -> None:
|
||||
self.source_id = source_id
|
||||
self.files = files
|
||||
self.page_size = page_size
|
||||
self.acquired: list[str] = []
|
||||
self.fail_scan = False
|
||||
|
||||
def _items(self) -> list[SourceItem]:
|
||||
return [
|
||||
SourceItem(
|
||||
source_id=self.source_id,
|
||||
external_id=path,
|
||||
locator=path,
|
||||
name=path.rsplit("/", 1)[-1],
|
||||
media_type="text/plain",
|
||||
metadata={"sha256": hashlib.sha256(content).hexdigest(), "commit": "abc1234def"},
|
||||
)
|
||||
for path, content in sorted(self.files.items())
|
||||
]
|
||||
|
||||
async def scan(self, cursor: SourceCursor | None = None) -> ScanResult:
|
||||
if self.fail_scan:
|
||||
raise RuntimeError("mount gone")
|
||||
items = self._items()
|
||||
start = 0
|
||||
if cursor is not None and cursor.value:
|
||||
start = next(i for i, item in enumerate(items) if item.external_id == cursor.value) + 1
|
||||
page = items[start : start + self.page_size]
|
||||
has_more = start + self.page_size < len(items)
|
||||
return ScanResult(
|
||||
tuple(page),
|
||||
SourceCursor(page[-1].external_id) if has_more and page else None,
|
||||
complete=not has_more and cursor is None,
|
||||
)
|
||||
|
||||
async def acquire(self, item: SourceItem) -> Acquisition:
|
||||
self.acquired.append(item.locator)
|
||||
content = self.files[item.locator]
|
||||
|
||||
async def stream() -> AsyncIterator[bytes]:
|
||||
yield content
|
||||
|
||||
return Acquisition(
|
||||
stream=stream(),
|
||||
source_item=item,
|
||||
fingerprint=SourceFingerprint(hashlib.sha256(content).hexdigest()),
|
||||
media_type="text/plain",
|
||||
original_name=item.name,
|
||||
)
|
||||
|
||||
def fingerprint(self, item: SourceItem) -> SourceFingerprint:
|
||||
return SourceFingerprint(str(item.metadata["sha256"]))
|
||||
|
||||
|
||||
_CONVERGEABLE = (SourceSyncState.AWAITING_INGEST, SourceSyncState.COMPLETED_WITH_FAILURES)
|
||||
|
||||
|
||||
class FakeRegistry:
|
||||
"""Mirrors PostgresSourceRegistry's convergence semantics, which is the
|
||||
point of these tests: the counters and the settled state must come out of
|
||||
remembered items, not out of what discovery happened to see."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.items: dict[tuple[UUID, str], SourceItemRecord] = {}
|
||||
self.runs: dict[UUID, SourceSyncRunRecord] = {}
|
||||
self.states: list[tuple[SourceSyncState, bool]] = []
|
||||
self.sync_state = SourceSyncState.IDLE
|
||||
|
||||
async def record_sync_run(self, run: SourceSyncRunRecord) -> SourceSyncRunRecord:
|
||||
self.runs[run.id] = run
|
||||
return run
|
||||
|
||||
async def set_sync_state(
|
||||
self, source_id, state, *, now, error=None, revision=None, successful=False
|
||||
):
|
||||
self.states.append((state, successful))
|
||||
self.sync_state = state
|
||||
return None
|
||||
|
||||
async def list_items(self, source_id: UUID) -> tuple[SourceItemRecord, ...]:
|
||||
return tuple(record for (sid, _), record in self.items.items() if sid == source_id)
|
||||
|
||||
async def upsert_items(self, items: tuple[SourceItemRecord, ...]) -> None:
|
||||
for item in items:
|
||||
self.items[(item.source_id, item.external_id)] = item
|
||||
|
||||
async def mark_items_removed(self, source_id, external_ids, *, now) -> None:
|
||||
for external_id in external_ids:
|
||||
current = self.items[(source_id, external_id)]
|
||||
self.items[(source_id, external_id)] = replace(
|
||||
current,
|
||||
removed=True,
|
||||
last_seen_at=now,
|
||||
# Retirement *is* the applied state: the desired outcome (no
|
||||
# searchable content) has been reached.
|
||||
applied_fingerprint=current.fingerprint,
|
||||
applied_at=now,
|
||||
pending_job_id=None,
|
||||
last_failure_code=None,
|
||||
last_failure_retryable=None,
|
||||
)
|
||||
|
||||
async def mark_item_ingested(
|
||||
self, source_id, external_id, *, fingerprint, document_id, now
|
||||
) -> bool:
|
||||
current = self.items.get((source_id, external_id))
|
||||
if current is None or current.fingerprint != fingerprint:
|
||||
return False
|
||||
self.items[(source_id, external_id)] = replace(
|
||||
current,
|
||||
applied_fingerprint=fingerprint,
|
||||
applied_at=now,
|
||||
pending_job_id=None,
|
||||
attempts=0,
|
||||
last_failure_code=None,
|
||||
last_failure_retryable=None,
|
||||
last_failure_job_id=None,
|
||||
document_id=document_id if document_id is not None else current.document_id,
|
||||
)
|
||||
return True
|
||||
|
||||
async def mark_item_failed(
|
||||
self, source_id, external_id, *, fingerprint, failure_code, retryable, now, job_id=None
|
||||
) -> bool:
|
||||
current = self.items.get((source_id, external_id))
|
||||
if current is None or current.fingerprint != fingerprint:
|
||||
return False
|
||||
self.items[(source_id, external_id)] = replace(
|
||||
current,
|
||||
pending_job_id=None,
|
||||
last_seen_at=now,
|
||||
last_failure_code=failure_code[:64],
|
||||
last_failure_retryable=retryable,
|
||||
last_failure_job_id=job_id,
|
||||
)
|
||||
return True
|
||||
|
||||
async def summarize_items(self, source_id: UUID) -> SourceItemSummary:
|
||||
records = [record for (sid, _), record in self.items.items() if sid == source_id]
|
||||
live = [record for record in records if not record.removed]
|
||||
return SourceItemSummary(
|
||||
total=len(records),
|
||||
removed=sum(1 for record in records if record.removed),
|
||||
applied=sum(1 for record in live if record.applied),
|
||||
failed=sum(1 for record in live if record.state is SourceItemState.FAILED),
|
||||
pending=sum(1 for record in live if record.state is SourceItemState.PENDING),
|
||||
)
|
||||
|
||||
async def converge_sync_state(self, source_id, state, *, now, successful=False):
|
||||
if self.sync_state not in _CONVERGEABLE:
|
||||
return None
|
||||
self.states.append((state, successful))
|
||||
self.sync_state = state
|
||||
return object()
|
||||
|
||||
|
||||
class FakeDocuments:
|
||||
def __init__(self) -> None:
|
||||
self.documents: dict[tuple[str, str], DocumentRecord] = {}
|
||||
self.retired: list[UUID] = []
|
||||
|
||||
def seed(self, source_id: UUID, external_id: str) -> DocumentRecord:
|
||||
document = DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=WORKSPACE_ID,
|
||||
space_id=SPACE_ID,
|
||||
source_key=str(source_id),
|
||||
external_id=external_id,
|
||||
title=external_id,
|
||||
source_id=source_id,
|
||||
)
|
||||
self.documents[(str(source_id), external_id)] = document
|
||||
return document
|
||||
|
||||
async def get_document_by_source(self, workspace_id, source_key, external_id):
|
||||
return self.documents.get((source_key, external_id))
|
||||
|
||||
async def mark_source_deleted(self, document_id: UUID) -> None:
|
||||
self.retired.append(document_id)
|
||||
|
||||
|
||||
class FakeBlobs:
|
||||
def __init__(self) -> None:
|
||||
self.stored: list[tuple[str, str]] = []
|
||||
|
||||
async def put(
|
||||
self,
|
||||
chunks: AsyncIterable[bytes],
|
||||
*,
|
||||
media_type=None,
|
||||
original_name=None,
|
||||
expected_sha256=None,
|
||||
):
|
||||
body = b"".join([chunk async for chunk in chunks])
|
||||
digest = hashlib.sha256(body).hexdigest()
|
||||
assert expected_sha256 == digest
|
||||
self.stored.append((original_name or "", digest))
|
||||
return BlobReference(
|
||||
digest=digest,
|
||||
size=len(body),
|
||||
media_type=media_type,
|
||||
original_name=original_name,
|
||||
storage_key=f"blobs/{digest}",
|
||||
)
|
||||
|
||||
|
||||
class RecordingJobs:
|
||||
def __init__(self) -> None:
|
||||
self.jobs: list[Job] = []
|
||||
|
||||
async def enqueue(self, job: Job) -> Job:
|
||||
# Mirrors the real repository's idempotency on job_key.
|
||||
existing = next((j for j in self.jobs if j.job_key == job.job_key), None)
|
||||
if existing is not None:
|
||||
return existing
|
||||
self.jobs.append(job)
|
||||
return job
|
||||
|
||||
|
||||
def _source(source_id: UUID, kind: ConnectorKind = ConnectorKind.FOLDER) -> SourceRecord:
|
||||
return SourceRecord(
|
||||
id=source_id,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
space_id=SPACE_ID,
|
||||
name="Runbooks",
|
||||
kind=kind,
|
||||
status=SourceStatus.ACTIVE,
|
||||
config={"root": "/mnt/runbooks", "trust_level": "trusted_internal"},
|
||||
)
|
||||
|
||||
|
||||
def _service(
|
||||
registry: FakeRegistry, documents: FakeDocuments, blobs: FakeBlobs, jobs: RecordingJobs
|
||||
) -> SourceSyncService:
|
||||
return SourceSyncService(
|
||||
registry=registry, # type: ignore[arg-type]
|
||||
documents=documents, # type: ignore[arg-type]
|
||||
blob_storage=blobs, # type: ignore[arg-type]
|
||||
jobs=JobService(jobs), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_sync_ingests_every_item_as_an_ingest_upload_job() -> None:
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha", "dir/b.md": b"bravo"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
|
||||
report = await _service(registry, documents, blobs, jobs).run(
|
||||
_source(source_id), connector, job_id=uuid4()
|
||||
)
|
||||
|
||||
# Enqueued is not ingested: the run cannot honestly say "completed" while
|
||||
# the child jobs that make the content searchable are still in flight.
|
||||
assert report.run.outcome is SourceSyncOutcome.AWAITING_INGEST
|
||||
assert (report.run.discovered, report.run.new, report.run.changed, report.run.unchanged) == (
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
assert report.run.removed == 0 and report.run.failed == 0
|
||||
assert report.run.pending == 2
|
||||
assert report.run.revision == "abc1234def"
|
||||
assert len(jobs.jobs) == 2
|
||||
for job in jobs.jobs:
|
||||
assert job.operation == "ingest_upload"
|
||||
assert job.payload["source_id"] == str(source_id)
|
||||
assert job.payload["space_id"] == str(SPACE_ID)
|
||||
assert job.payload["workspace_id"] == str(WORKSPACE_ID)
|
||||
assert job.payload["trust_level"] == "trusted_internal"
|
||||
assert job.payload["revision"] == "abc1234def"
|
||||
assert job.payload["external_id"] in {"a.md", "dir/b.md"}
|
||||
assert registry.states[-1] == (SourceSyncState.AWAITING_INGEST, False)
|
||||
assert {record.external_id for record in registry.items.values()} == {"a.md", "dir/b.md"}
|
||||
assert all(record.state is SourceItemState.PENDING for record in registry.items.values())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resync_skips_unchanged_ingests_changed_and_retires_removed() -> None:
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha", "b.md": b"bravo", "c.md": b"charlie"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
await service.run(_source(source_id), connector)
|
||||
first_jobs = len(jobs.jobs)
|
||||
retired_doc = documents.seed(source_id, "c.md")
|
||||
|
||||
connector.files["b.md"] = b"bravo v2"
|
||||
del connector.files["c.md"]
|
||||
connector.acquired.clear()
|
||||
report = await service.run(_source(source_id), connector)
|
||||
|
||||
assert (
|
||||
report.run.discovered,
|
||||
report.run.new,
|
||||
report.run.changed,
|
||||
report.run.unchanged,
|
||||
report.run.removed,
|
||||
) == (2, 0, 1, 1, 1)
|
||||
assert connector.acquired == ["b.md"], "unchanged content is never re-acquired"
|
||||
assert len(jobs.jobs) == first_jobs + 1
|
||||
assert documents.retired == [retired_doc.id]
|
||||
assert report.retired_document_ids == (retired_doc.id,)
|
||||
assert registry.items[(source_id, "c.md")].removed is True
|
||||
assert registry.items[(source_id, "a.md")].removed is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_page_discovery_is_still_complete_and_reconciles_removals() -> None:
|
||||
source_id = uuid4()
|
||||
files = {f"doc{i:02d}.md": f"body {i}".encode() for i in range(7)}
|
||||
connector = FakeConnector(source_id, files, page_size=3)
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
first = await service.run(_source(source_id), connector)
|
||||
assert first.run.discovered == 7
|
||||
|
||||
removed = documents.seed(source_id, "doc03.md")
|
||||
del connector.files["doc03.md"]
|
||||
second = await service.run(_source(source_id), connector)
|
||||
assert second.run.removed == 1
|
||||
assert documents.retired == [removed.id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovery_failure_records_a_failed_run_and_retires_nothing() -> None:
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
await service.run(_source(source_id), connector)
|
||||
documents.seed(source_id, "a.md")
|
||||
|
||||
connector.fail_scan = True
|
||||
with pytest.raises(Exception, match="discovery failed"):
|
||||
await service.run(_source(source_id), connector)
|
||||
|
||||
failed_runs = [run for run in registry.runs.values() if run.outcome is SourceSyncOutcome.FAILED]
|
||||
assert len(failed_runs) == 1
|
||||
assert failed_runs[0].error_code == "discovery_failed"
|
||||
assert documents.retired == [], "an incomplete discovery never retires documents"
|
||||
assert registry.states[-1] == (SourceSyncState.FAILED, False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_sources_are_not_syncable() -> None:
|
||||
source_id = uuid4()
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
with pytest.raises(Exception, match="only connector-backed"):
|
||||
await service.run(_source(source_id, ConnectorKind.UPLOAD), FakeConnector(source_id, {}))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_re_enqueueing_the_same_content_collapses_onto_one_job() -> None:
|
||||
"""Same source item + same fingerprint -> same idempotency key: a sync
|
||||
that re-sees content whose earlier job is still queued does not duplicate it."""
|
||||
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
await service.run(_source(source_id), connector)
|
||||
# Forget the registry memory (as if the first run's bookkeeping were lost)
|
||||
# and sync again: the job key still dedups.
|
||||
registry.items.clear()
|
||||
await service.run(_source(source_id), connector)
|
||||
assert len(jobs.jobs) == 1
|
||||
assert jobs.jobs[0].available_at <= datetime.now(UTC)
|
||||
|
||||
|
||||
async def _apply_all(
|
||||
registry: FakeRegistry, source_id: UUID, *, only: set[str] | None = None
|
||||
) -> None:
|
||||
"""Stand in for the child ingest jobs reporting success."""
|
||||
|
||||
now = datetime.now(UTC)
|
||||
for (sid, external_id), record in list(registry.items.items()):
|
||||
if sid == source_id and not record.removed and (only is None or external_id in only):
|
||||
await registry.mark_item_ingested(
|
||||
source_id,
|
||||
external_id,
|
||||
fingerprint=record.fingerprint,
|
||||
document_id=uuid4(),
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_source_only_completes_once_every_child_ingestion_applied() -> None:
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha", "b.md": b"bravo"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
|
||||
first = await service.run(_source(source_id), connector)
|
||||
assert first.run.outcome is SourceSyncOutcome.AWAITING_INGEST
|
||||
|
||||
await _apply_all(registry, source_id)
|
||||
second = await service.run(_source(source_id), connector)
|
||||
|
||||
assert second.run.outcome is SourceSyncOutcome.COMPLETED
|
||||
assert (second.run.unchanged, second.run.pending, second.run.failed) == (2, 0, 0)
|
||||
assert registry.states[-1] == (SourceSyncState.COMPLETED, True)
|
||||
assert len(jobs.jobs) == 2, "nothing is re-enqueued once the content is applied"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unchanged_item_keeps_the_fingerprint_its_ingestion_applied() -> None:
|
||||
"""The regression that makes convergence meaningless: a re-sync that
|
||||
rebuilds the remembered record loses ``applied_fingerprint`` and every
|
||||
already-searchable item silently reverts to pending."""
|
||||
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
await service.run(_source(source_id), connector)
|
||||
await _apply_all(registry, source_id)
|
||||
applied_at = registry.items[(source_id, "a.md")].applied_at
|
||||
|
||||
await service.run(_source(source_id), connector)
|
||||
|
||||
record = registry.items[(source_id, "a.md")]
|
||||
assert record.applied is True
|
||||
assert record.state is SourceItemState.APPLIED
|
||||
assert record.applied_at == applied_at, "a no-op re-sync must not restamp convergence"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_failed_child_ingestion_settles_the_source_with_failures() -> None:
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha", "b.md": b"bravo"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
await service.run(_source(source_id), connector)
|
||||
|
||||
# "a.md" became searchable; "b.md"'s ingestion failed for good.
|
||||
await _apply_all(registry, source_id, only={"a.md"})
|
||||
await registry.mark_item_failed(
|
||||
source_id,
|
||||
"b.md",
|
||||
fingerprint=registry.items[(source_id, "b.md")].fingerprint,
|
||||
failure_code="security_quarantined",
|
||||
retryable=False,
|
||||
now=datetime.now(UTC),
|
||||
)
|
||||
|
||||
report = await service.run(_source(source_id), connector)
|
||||
|
||||
assert report.run.outcome is SourceSyncOutcome.COMPLETED_WITH_FAILURES
|
||||
assert (report.run.failed, report.run.pending) == (1, 0)
|
||||
assert report.run.error_code == "partial_failures"
|
||||
assert registry.states[-1] == (SourceSyncState.COMPLETED_WITH_FAILURES, False)
|
||||
assert registry.items[(source_id, "b.md")].state is SourceItemState.FAILED
|
||||
assert len(jobs.jobs) == 2, (
|
||||
"a terminally failed fingerprint is not re-enqueued: the child job for "
|
||||
"that exact content already exists and will never report again"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recovery_from_a_terminal_failure_runs_through_the_child_job_not_a_resync() -> None:
|
||||
"""The full recovery loop for content that has not changed.
|
||||
|
||||
The chosen semantics are that a terminally failed item whose *discovered*
|
||||
fingerprint is unchanged is never re-enqueued by a later sync -- the child
|
||||
job for that exact content already exists, so re-enqueueing would either
|
||||
collapse onto it or spin. That is a deliberate choice and it only holds up
|
||||
if the failure is recoverable some other way, so this test walks the whole
|
||||
path: fail -> the failure names its job -> sync again changes nothing ->
|
||||
that job is retried -> the item converges.
|
||||
|
||||
Without the last two steps the "no re-enqueue" rule would be a permanent
|
||||
dead end dressed up as a policy.
|
||||
"""
|
||||
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
|
||||
first = await service.run(_source(source_id), connector)
|
||||
ingest_job_id = first.enqueued_job_ids[0]
|
||||
fingerprint = registry.items[(source_id, "a.md")].fingerprint
|
||||
|
||||
# The child ingestion fails terminally, naming the job that failed.
|
||||
await registry.mark_item_failed(
|
||||
source_id,
|
||||
"a.md",
|
||||
fingerprint=fingerprint,
|
||||
failure_code="embedding_failed",
|
||||
retryable=True,
|
||||
now=datetime.now(UTC),
|
||||
)
|
||||
failed = registry.items[(source_id, "a.md")]
|
||||
assert failed.state is SourceItemState.FAILED
|
||||
assert failed.applied is False
|
||||
|
||||
# Syncing again is honest about it: nothing new is enqueued, the item is
|
||||
# still failed, and the source does not claim to have completed cleanly.
|
||||
again = await service.run(_source(source_id), connector)
|
||||
assert len(jobs.jobs) == 1, "the same terminal fingerprint is never re-enqueued"
|
||||
assert again.run.outcome is SourceSyncOutcome.COMPLETED_WITH_FAILURES
|
||||
assert again.run.failed == 1
|
||||
assert registry.items[(source_id, "a.md")].state is SourceItemState.FAILED
|
||||
|
||||
# Recovery: an authorized operator retries that child ingest job, and this
|
||||
# time it reports success for the same fingerprint.
|
||||
await registry.mark_item_ingested(
|
||||
source_id,
|
||||
"a.md",
|
||||
fingerprint=fingerprint,
|
||||
document_id=uuid4(),
|
||||
now=datetime.now(UTC),
|
||||
)
|
||||
|
||||
recovered = registry.items[(source_id, "a.md")]
|
||||
assert recovered.applied_fingerprint == fingerprint
|
||||
assert recovered.applied is True
|
||||
assert recovered.state is SourceItemState.APPLIED
|
||||
assert recovered.last_failure_code is None
|
||||
summary = await registry.summarize_items(source_id)
|
||||
assert (summary.failed, summary.pending) == (0, 0)
|
||||
assert summary.converged is True
|
||||
assert ingest_job_id is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_terminal_failure_remembers_the_job_an_operator_must_retry() -> None:
|
||||
""""Retry the failed ingest job" needs an actual job to point at."""
|
||||
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
report = await service.run(_source(source_id), connector)
|
||||
job_id = report.enqueued_job_ids[0]
|
||||
|
||||
await registry.mark_item_failed(
|
||||
source_id,
|
||||
"a.md",
|
||||
fingerprint=registry.items[(source_id, "a.md")].fingerprint,
|
||||
failure_code="embedding_failed",
|
||||
retryable=True,
|
||||
now=datetime.now(UTC),
|
||||
job_id=job_id,
|
||||
)
|
||||
|
||||
assert registry.items[(source_id, "a.md")].last_failure_job_id == job_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_changed_content_clears_a_previous_failure_and_keeps_what_was_applied() -> None:
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
await service.run(_source(source_id), connector)
|
||||
await _apply_all(registry, source_id)
|
||||
applied = registry.items[(source_id, "a.md")].applied_fingerprint
|
||||
|
||||
connector.files["a.md"] = b"alpha v2"
|
||||
report = await service.run(_source(source_id), connector)
|
||||
|
||||
record = registry.items[(source_id, "a.md")]
|
||||
assert report.run.outcome is SourceSyncOutcome.AWAITING_INGEST
|
||||
assert record.state is SourceItemState.PENDING
|
||||
assert record.applied is False
|
||||
assert record.applied_fingerprint == applied, (
|
||||
"the old content stays searchable until the new content is really applied"
|
||||
)
|
||||
assert record.pending_job_id == report.enqueued_job_ids[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_retired_item_counts_as_converged_not_as_outstanding_work() -> None:
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha", "gone.md": b"gone"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
await service.run(_source(source_id), connector)
|
||||
await _apply_all(registry, source_id)
|
||||
|
||||
del connector.files["gone.md"]
|
||||
report = await service.run(_source(source_id), connector)
|
||||
|
||||
assert report.run.outcome is SourceSyncOutcome.COMPLETED
|
||||
assert report.run.removed == 1
|
||||
summary = await registry.summarize_items(source_id)
|
||||
assert (summary.removed, summary.applied, summary.pending, summary.failed) == (1, 1, 0, 0)
|
||||
assert summary.converged is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_retired_item_that_reappears_unchanged_is_pending_again() -> None:
|
||||
"""Retirement records "no searchable content" as the *applied* state. When
|
||||
the file comes back -- even byte-identical -- that applied state is stale:
|
||||
the document is still retired until the re-enqueued ingest reactivates it,
|
||||
so carrying the retirement's fingerprint forward would report the source
|
||||
converged while the reactivation is still in flight."""
|
||||
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha", "back.md": b"returns"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
await service.run(_source(source_id), connector)
|
||||
await _apply_all(registry, source_id)
|
||||
|
||||
returning = dict(connector.files)
|
||||
del connector.files["back.md"]
|
||||
await service.run(_source(source_id), connector)
|
||||
assert registry.items[(source_id, "back.md")].state is SourceItemState.REMOVED
|
||||
|
||||
connector.files = returning
|
||||
report = await service.run(_source(source_id), connector)
|
||||
|
||||
record = registry.items[(source_id, "back.md")]
|
||||
assert record.removed is False
|
||||
assert record.state is SourceItemState.PENDING
|
||||
assert record.applied is False
|
||||
assert report.run.outcome is SourceSyncOutcome.AWAITING_INGEST
|
||||
Reference in new issue
Block a user