Initial curated RAGcore source from c16667f0c9397e2bfc8dd0255b114fba23c8d05e
This commit is contained in:
commit
7ca726caf2
659 files changed
+98171
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,143 @@
|
||||
"""PostgreSQL integration coverage for append-only audit events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import cast
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import DBAPIError
|
||||
|
||||
from ragcore.application.audit import AuditEventService
|
||||
from ragcore.config import Settings
|
||||
from ragcore.domain.audit import AuditEvent, AuditEventType
|
||||
from ragcore.domain.control import Workspace
|
||||
from ragcore.infrastructure.database import (
|
||||
create_database_engine,
|
||||
create_session_factory,
|
||||
session_scope,
|
||||
)
|
||||
from ragcore.infrastructure.database.audit import AuditEventRepository
|
||||
from ragcore.infrastructure.database.control import ControlPlaneRepository
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def audit_context() -> tuple[AuditEventRepository, Workspace, Workspace]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
factory = create_session_factory(engine)
|
||||
async with session_scope(factory) as session:
|
||||
suffix = uuid4().hex[:8]
|
||||
first = Workspace(uuid4(), "First", f"first-{suffix}")
|
||||
second = Workspace(uuid4(), "Second", f"second-{suffix}")
|
||||
control = ControlPlaneRepository(session)
|
||||
await control.create_workspace(first)
|
||||
await control.create_workspace(second)
|
||||
await session.commit()
|
||||
yield AuditEventRepository(session), first, second
|
||||
await session.rollback()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_audit_events_redact_content_and_preserve_trace_correlation(
|
||||
audit_context: tuple[AuditEventRepository, Workspace, Workspace],
|
||||
) -> None:
|
||||
repository, workspace, _ = audit_context
|
||||
service = AuditEventService(repository, clock=lambda: datetime(2026, 8, 1, tzinfo=UTC))
|
||||
event = await service.record(
|
||||
workspace_id=workspace.id,
|
||||
event_type=AuditEventType.SECURITY_FAILURE,
|
||||
trace_id="trace-123",
|
||||
correlation_id="request-456",
|
||||
details={
|
||||
"reason": "denied",
|
||||
"token": "rc_sa_secret",
|
||||
"query": "private text",
|
||||
"nested": {"password": "secret", "safe": "kept"},
|
||||
},
|
||||
)
|
||||
await repository._session.commit()
|
||||
|
||||
stored = await service.list_events(workspace.id)
|
||||
assert event.trace_id == "trace-123"
|
||||
assert stored[0].correlation_id == "request-456"
|
||||
assert stored[0].details == {"reason": "denied", "nested": {"safe": "kept"}}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_repository_resanitizes_mutated_event_details(
|
||||
audit_context: tuple[AuditEventRepository, Workspace, Workspace],
|
||||
) -> None:
|
||||
repository, workspace, _ = audit_context
|
||||
event = AuditEvent(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace.id,
|
||||
event_type=AuditEventType.ADMIN_ACTION,
|
||||
occurred_at=datetime.now(UTC),
|
||||
trace_id="mutable-details",
|
||||
details={"nested": {}},
|
||||
)
|
||||
nested = cast(dict[str, object], event.details["nested"])
|
||||
nested["token"] = "must-not-persist"
|
||||
|
||||
await repository.append(event)
|
||||
await repository._session.commit()
|
||||
|
||||
stored = await repository.list_events(workspace.id)
|
||||
assert stored[0].details == {"nested": {}}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_audit_reads_are_workspace_scoped(
|
||||
audit_context: tuple[AuditEventRepository, Workspace, Workspace],
|
||||
) -> None:
|
||||
repository, first, second = audit_context
|
||||
service = AuditEventService(repository)
|
||||
await service.record(
|
||||
workspace_id=first.id,
|
||||
event_type=AuditEventType.ADMIN_ACTION,
|
||||
trace_id="first-trace",
|
||||
)
|
||||
await service.record(
|
||||
workspace_id=second.id,
|
||||
event_type=AuditEventType.ADMIN_ACTION,
|
||||
trace_id="second-trace",
|
||||
)
|
||||
await repository._session.commit()
|
||||
|
||||
assert [event.trace_id for event in await service.list_events(first.id)] == ["first-trace"]
|
||||
assert [event.trace_id for event in await service.list_events(second.id)] == ["second-trace"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_database_rejects_audit_update_delete_and_truncate(
|
||||
audit_context: tuple[AuditEventRepository, Workspace, Workspace],
|
||||
) -> None:
|
||||
repository, workspace, _ = audit_context
|
||||
service = AuditEventService(repository)
|
||||
event = await service.record(
|
||||
workspace_id=workspace.id,
|
||||
event_type=AuditEventType.ADMIN_ACTION,
|
||||
trace_id="immutable-trace",
|
||||
)
|
||||
await repository._session.commit()
|
||||
|
||||
with pytest.raises(DBAPIError, match="append-only"):
|
||||
await repository._session.execute(
|
||||
text("UPDATE audit.audit_events SET trace_id = 'changed' WHERE id = :id"),
|
||||
{"id": event.id},
|
||||
)
|
||||
await repository._session.rollback()
|
||||
with pytest.raises(DBAPIError, match="append-only"):
|
||||
await repository._session.execute(
|
||||
text("DELETE FROM audit.audit_events WHERE id = :id"), {"id": event.id}
|
||||
)
|
||||
await repository._session.rollback()
|
||||
with pytest.raises(DBAPIError, match="append-only"):
|
||||
await repository._session.execute(text("TRUNCATE audit.audit_events"))
|
||||
await repository._session.rollback()
|
||||
|
||||
assert (await service.list_events(workspace.id))[0].trace_id == "immutable-trace"
|
||||
@@ -0,0 +1,236 @@
|
||||
"""End-to-end backup pipeline against real PostgreSQL and Qdrant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from qdrant_client.models import Distance, VectorParams
|
||||
|
||||
from ragcore.application.backup.models import BackupStatus
|
||||
from ragcore.application.backup.service import MANIFEST_FILENAME, BackupService
|
||||
from ragcore.config import Settings
|
||||
from ragcore.infrastructure.backup import (
|
||||
LocalFileCopier,
|
||||
PgDumpPostgresDumper,
|
||||
QdrantHttpSnapshotter,
|
||||
)
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def event_loop_policy():
|
||||
if sys.platform == "win32":
|
||||
return asyncio.WindowsSelectorEventLoopPolicy()
|
||||
return asyncio.get_event_loop_policy()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings() -> Settings:
|
||||
return Settings(env="test")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def session_factory(settings: Settings):
|
||||
engine = create_database_engine(settings)
|
||||
try:
|
||||
yield create_session_factory(engine)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def qdrant_client(settings: Settings) -> AsyncIterator[AsyncQdrantClient]:
|
||||
client = AsyncQdrantClient(
|
||||
url=settings.qdrant_url, api_key=settings.qdrant_api_key.get_secret_value(), timeout=300
|
||||
)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def temp_collection(qdrant_client: AsyncQdrantClient) -> AsyncIterator[str]:
|
||||
name = f"backup-test-{uuid4().hex[:12]}"
|
||||
await qdrant_client.create_collection(
|
||||
collection_name=name, vectors_config=VectorParams(size=4, distance=Distance.COSINE)
|
||||
)
|
||||
await qdrant_client.upsert(
|
||||
collection_name=name,
|
||||
points=[{"id": str(uuid4()), "vector": [0.1, 0.2, 0.3, 0.4], "payload": {"k": "v"}}],
|
||||
)
|
||||
alias = f"{name}-live"
|
||||
await qdrant_client.update_collection_aliases(
|
||||
change_aliases_operations=[
|
||||
{"create_alias": {"collection_name": name, "alias_name": alias}}
|
||||
]
|
||||
)
|
||||
try:
|
||||
yield name
|
||||
finally:
|
||||
await qdrant_client.delete_collection(name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def postgres_dumper(settings: Settings, session_factory) -> PgDumpPostgresDumper:
|
||||
return PgDumpPostgresDumper(database_url=settings.database_url, session_factory=session_factory)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def qdrant_snapshotter(
|
||||
settings: Settings, qdrant_client: AsyncQdrantClient
|
||||
) -> AsyncIterator[QdrantHttpSnapshotter]:
|
||||
async with httpx.AsyncClient(timeout=300) as http_client:
|
||||
yield QdrantHttpSnapshotter(
|
||||
client=qdrant_client,
|
||||
http_client=http_client,
|
||||
base_url=settings.qdrant_url,
|
||||
api_key=settings.qdrant_api_key.get_secret_value(),
|
||||
)
|
||||
|
||||
|
||||
async def test_pg_dump_produces_a_real_custom_format_dump_and_schema_revision(
|
||||
postgres_dumper: PgDumpPostgresDumper, tmp_path: Path
|
||||
) -> None:
|
||||
revision = await postgres_dumper.schema_revision()
|
||||
assert revision
|
||||
|
||||
checksum = await postgres_dumper.dump(destination=tmp_path / "postgres.dump")
|
||||
dump_path = tmp_path / "postgres.dump"
|
||||
assert dump_path.is_file()
|
||||
assert dump_path.stat().st_size > 0
|
||||
# pg_dump custom-format files start with the "PGDMP" magic header.
|
||||
assert dump_path.read_bytes()[:5] == b"PGDMP"
|
||||
assert checksum.size_bytes == dump_path.stat().st_size
|
||||
|
||||
|
||||
async def test_qdrant_snapshotter_creates_downloads_and_reports_real_aliases(
|
||||
qdrant_snapshotter: QdrantHttpSnapshotter, temp_collection: str, tmp_path: Path
|
||||
) -> None:
|
||||
checksums = await qdrant_snapshotter.snapshot_collections(
|
||||
collection_names=[temp_collection], destination_dir=tmp_path
|
||||
)
|
||||
|
||||
assert len(checksums) == 1
|
||||
snapshot_path = tmp_path / checksums[0].relative_path
|
||||
assert snapshot_path.is_file()
|
||||
assert snapshot_path.stat().st_size == checksums[0].size_bytes
|
||||
assert snapshot_path.stat().st_size > 0
|
||||
|
||||
aliases = await qdrant_snapshotter.collection_aliases()
|
||||
assert aliases.get(f"{temp_collection}-live") == temp_collection
|
||||
|
||||
|
||||
async def test_local_file_copier_copies_and_checksums_a_real_tree(tmp_path: Path) -> None:
|
||||
source = tmp_path / "src"
|
||||
(source / "nested").mkdir(parents=True)
|
||||
(source / "a.txt").write_bytes(b"alpha")
|
||||
(source / "nested" / "b.txt").write_bytes(b"beta")
|
||||
|
||||
copier = LocalFileCopier()
|
||||
checksums = await copier.copy_tree(source=source, destination=tmp_path / "dst")
|
||||
|
||||
assert {c.relative_path for c in checksums} == {"a.txt", "nested/b.txt"}
|
||||
assert (tmp_path / "dst" / "a.txt").read_bytes() == b"alpha"
|
||||
assert (tmp_path / "dst" / "nested" / "b.txt").read_bytes() == b"beta"
|
||||
|
||||
|
||||
async def test_full_backup_service_run_against_real_infra_produces_complete_manifest(
|
||||
postgres_dumper: PgDumpPostgresDumper,
|
||||
qdrant_snapshotter: QdrantHttpSnapshotter,
|
||||
temp_collection: str,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = BackupService(
|
||||
postgres=postgres_dumper, qdrant=qdrant_snapshotter, files=LocalFileCopier()
|
||||
)
|
||||
blob_root = tmp_path / "blobs-src"
|
||||
blob_root.mkdir()
|
||||
(blob_root / "doc.bin").write_bytes(b"blob-content")
|
||||
parsed_root = tmp_path / "parsed-src"
|
||||
parsed_root.mkdir()
|
||||
(parsed_root / "doc.json").write_bytes(b"{}")
|
||||
|
||||
manifest = await service.run(
|
||||
backup_root=tmp_path / "backups",
|
||||
blob_root=blob_root,
|
||||
parsed_root=parsed_root,
|
||||
collection_names=[temp_collection],
|
||||
active_profiles={"embedding": "hybrid-v1:v3"},
|
||||
)
|
||||
|
||||
assert manifest.status is BackupStatus.COMPLETE
|
||||
assert manifest.schema_revision
|
||||
assert manifest.active_profiles == {"embedding": "hybrid-v1:v3"}
|
||||
assert manifest.collection_aliases.get(f"{temp_collection}-live") == temp_collection
|
||||
assert len(manifest.qdrant_snapshots) == 1
|
||||
assert len(manifest.blob_artifacts) == 1
|
||||
assert len(manifest.parsed_artifacts) == 1
|
||||
|
||||
on_disk = json.loads(
|
||||
(tmp_path / "backups" / manifest.backup_id / MANIFEST_FILENAME).read_text()
|
||||
)
|
||||
assert on_disk["status"] == "complete"
|
||||
|
||||
|
||||
async def test_backup_service_run_leaves_incomplete_manifest_when_a_collection_is_missing(
|
||||
postgres_dumper: PgDumpPostgresDumper,
|
||||
qdrant_snapshotter: QdrantHttpSnapshotter,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = BackupService(
|
||||
postgres=postgres_dumper, qdrant=qdrant_snapshotter, files=LocalFileCopier()
|
||||
)
|
||||
|
||||
# Either BackupError or the qdrant_client's own not-found error is acceptable.
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
await service.run(
|
||||
backup_root=tmp_path / "backups",
|
||||
blob_root=tmp_path / "blobs-src",
|
||||
parsed_root=tmp_path / "parsed-src",
|
||||
collection_names=["does-not-exist-" + uuid4().hex[:8]],
|
||||
active_profiles={},
|
||||
)
|
||||
|
||||
backup_dirs = list((tmp_path / "backups").iterdir())
|
||||
assert len(backup_dirs) == 1
|
||||
manifest = json.loads((backup_dirs[0] / MANIFEST_FILENAME).read_text())
|
||||
assert manifest["status"] == "incomplete"
|
||||
|
||||
|
||||
async def test_prune_removes_old_completed_backups_on_the_real_filesystem(
|
||||
postgres_dumper: PgDumpPostgresDumper,
|
||||
qdrant_snapshotter: QdrantHttpSnapshotter,
|
||||
temp_collection: str,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = BackupService(
|
||||
postgres=postgres_dumper, qdrant=qdrant_snapshotter, files=LocalFileCopier()
|
||||
)
|
||||
backup_root = tmp_path / "backups"
|
||||
|
||||
for _ in range(3):
|
||||
await service.run(
|
||||
backup_root=backup_root,
|
||||
blob_root=tmp_path / "blobs-src",
|
||||
parsed_root=tmp_path / "parsed-src",
|
||||
collection_names=[temp_collection],
|
||||
active_profiles={},
|
||||
)
|
||||
|
||||
removed = service.prune(backup_root=backup_root, keep_complete=1)
|
||||
|
||||
assert len(removed) == 2
|
||||
remaining = list(backup_root.iterdir())
|
||||
assert len(remaining) == 1
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.storage import (
|
||||
BlobIntegrityError,
|
||||
HashMismatchError,
|
||||
InvalidBlobDigestError,
|
||||
UnsafeBlobPathError,
|
||||
)
|
||||
from ragcore.infrastructure.blob import FilesystemBlobStorage
|
||||
|
||||
|
||||
async def _chunks(*values: bytes):
|
||||
for value in values:
|
||||
yield value
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_identical_bytes_are_deduplicated(tmp_path: Path) -> None:
|
||||
storage = FilesystemBlobStorage(tmp_path)
|
||||
payload = b"same bytes, one authoritative blob"
|
||||
expected = hashlib.sha256(payload).hexdigest()
|
||||
|
||||
first = await storage.put(_chunks(payload[:9], payload[9:]), original_name="one.txt")
|
||||
second = await storage.put(_chunks(payload), original_name="retry.txt")
|
||||
|
||||
assert first.digest == second.digest == expected
|
||||
assert first.size == second.size == len(payload)
|
||||
assert first.storage_key == second.storage_key
|
||||
assert storage.path_for(expected).is_file()
|
||||
assert list((tmp_path / "blobs").rglob("*.blob")) == [storage.path_for(expected)]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_partial_stream_never_becomes_authoritative(tmp_path: Path) -> None:
|
||||
storage = FilesystemBlobStorage(tmp_path)
|
||||
|
||||
async def interrupted():
|
||||
yield b"partial"
|
||||
raise OSError("source interrupted")
|
||||
|
||||
with pytest.raises(OSError, match="source interrupted"):
|
||||
await storage.put(interrupted())
|
||||
|
||||
assert list((tmp_path / "blobs").rglob("*.blob")) == []
|
||||
assert list((tmp_path / "tmp").glob("*.part")) == []
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_hash_mismatch_fails_closed_without_blob(tmp_path: Path) -> None:
|
||||
storage = FilesystemBlobStorage(tmp_path)
|
||||
|
||||
with pytest.raises(HashMismatchError):
|
||||
await storage.put(_chunks(b"content"), expected_sha256="0" * 64)
|
||||
|
||||
assert list((tmp_path / "blobs").rglob("*.blob")) == []
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_invalid_digest_and_symlink_destination_fail_closed(tmp_path: Path) -> None:
|
||||
storage = FilesystemBlobStorage(tmp_path)
|
||||
with pytest.raises(InvalidBlobDigestError):
|
||||
storage.path_for("../outside")
|
||||
|
||||
payload = b"symlink target must never be replaced"
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
destination = storage.path_for(digest)
|
||||
destination.parent.mkdir(parents=True)
|
||||
outside = tmp_path / "outside.blob"
|
||||
outside.write_bytes(b"unrelated")
|
||||
os.symlink(outside, destination)
|
||||
|
||||
with pytest.raises(UnsafeBlobPathError):
|
||||
await storage.put(_chunks(payload))
|
||||
assert outside.read_bytes() == b"unrelated"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_existing_corrupt_content_address_is_rejected(tmp_path: Path) -> None:
|
||||
storage = FilesystemBlobStorage(tmp_path)
|
||||
payload = b"authoritative content"
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
destination = storage.path_for(digest)
|
||||
destination.parent.mkdir(parents=True)
|
||||
destination.write_bytes(b"corrupt")
|
||||
|
||||
with pytest.raises(BlobIntegrityError):
|
||||
await storage.put(_chunks(payload))
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Integration tests against real local Git object databases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.domain.sources import GitSourceConfig
|
||||
from ragcore.infrastructure.connectors.git import GitConnector
|
||||
|
||||
|
||||
def git(root: Path, *arguments: str) -> str:
|
||||
git_executable = shutil.which("git")
|
||||
if git_executable is None:
|
||||
raise RuntimeError("git is required for this integration test")
|
||||
result = subprocess.run( # noqa: S603 - test arguments are passed without a shell
|
||||
[git_executable, *arguments],
|
||||
cwd=root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def repository(tmp_path: Path) -> tuple[Path, str]:
|
||||
root = tmp_path / "repository"
|
||||
root.mkdir()
|
||||
git(root, "init", "--quiet")
|
||||
git(root, "config", "user.email", "tests@example.invalid")
|
||||
git(root, "config", "user.name", "RAGCore Tests")
|
||||
(root / "README.md").write_text("read me", encoding="utf-8")
|
||||
(root / "src").mkdir()
|
||||
(root / "src" / "main.py").write_text("print('safe')\n", encoding="utf-8")
|
||||
git(root, "add", "README.md", "src/main.py")
|
||||
git(root, "commit", "--quiet", "-m", "initial")
|
||||
return root, git(root, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
def make_connector(root: Path, commit: str, *, max_items: int = 256) -> GitConnector:
|
||||
return GitConnector(
|
||||
uuid4(),
|
||||
GitSourceConfig("local-checkout", commit, root.resolve()),
|
||||
allowed_roots=(root.resolve(),),
|
||||
max_items=max_items,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.anyio
|
||||
async def test_scan_and_acquire_store_commit_path_and_language_provenance(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root, commit = repository(tmp_path)
|
||||
current = make_connector(root, commit)
|
||||
|
||||
result = await current.scan()
|
||||
item = next(item for item in result.items if item.locator == "src/main.py")
|
||||
acquisition = await current.acquire(item)
|
||||
|
||||
assert result.complete is True
|
||||
assert item.external_id == f"{commit}:src/main.py"
|
||||
assert item.metadata["commit"] == commit
|
||||
assert item.metadata["path"] == "src/main.py"
|
||||
assert item.metadata["language"] == "python"
|
||||
assert b"".join([chunk async for chunk in acquisition.stream]) == b"print('safe')\n"
|
||||
assert acquisition.fingerprint == current.fingerprint(item)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.anyio
|
||||
async def test_same_pinned_commit_scan_is_idempotent_and_cursorable(tmp_path: Path) -> None:
|
||||
root, commit = repository(tmp_path)
|
||||
current = make_connector(root, commit, max_items=1)
|
||||
|
||||
first = await current.scan()
|
||||
second = await current.scan()
|
||||
assert first == second
|
||||
assert first.complete is False
|
||||
assert first.next_cursor is not None
|
||||
|
||||
remainder = await current.scan(first.next_cursor)
|
||||
assert remainder.complete is False
|
||||
assert remainder.next_cursor is None
|
||||
assert all(item.external_id.startswith(f"{commit}:") for item in remainder.items)
|
||||
|
||||
(root / "src" / "second.py").write_text("value = 2\n", encoding="utf-8")
|
||||
git(root, "add", "src/second.py")
|
||||
git(root, "commit", "--quiet", "-m", "second")
|
||||
latest = git(root, "rev-parse", "HEAD")
|
||||
old_scan = await current.scan()
|
||||
latest_scan = await make_connector(root, latest).scan()
|
||||
assert all(item.metadata["commit"] == commit for item in old_scan.items)
|
||||
assert all(item.metadata["commit"] == latest for item in latest_scan.items)
|
||||
assert any(item.locator == "src/second.py" for item in latest_scan.items)
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Real Postgres coverage for the ``DocumentRepository`` methods a source-keyed
|
||||
sync client needs: look a document up by ``(workspace_id, source_key, external_id)``
|
||||
without knowing its id, and list every version of a document regardless of state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from ragcore.application.documents import (
|
||||
DocumentRecord,
|
||||
DocumentVersionRecord,
|
||||
DocumentVersionState,
|
||||
)
|
||||
from ragcore.application.sources import implicit_source_record
|
||||
from ragcore.config import Settings
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.content import PostgresDocumentRepository
|
||||
from ragcore.infrastructure.database.sources import PostgresSourceRegistry
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop_policy() -> asyncio.AbstractEventLoopPolicy:
|
||||
if sys.platform == "win32":
|
||||
return asyncio.WindowsSelectorEventLoopPolicy()
|
||||
return asyncio.get_event_loop_policy()
|
||||
|
||||
|
||||
async def _register_source(factory, document) -> None: # type: ignore[no-untyped-def]
|
||||
"""The content schema requires a registry row for every document's source."""
|
||||
|
||||
await PostgresSourceRegistry(factory).ensure_source(
|
||||
implicit_source_record(
|
||||
source_id=document.source_id,
|
||||
workspace_id=document.workspace_id,
|
||||
space_id=document.space_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine() -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_get_document_by_source_matches_the_workspace_source_key_external_id_triple(
|
||||
db_engine: AsyncEngine,
|
||||
) -> None:
|
||||
factory = create_session_factory(db_engine)
|
||||
documents = PostgresDocumentRepository(factory)
|
||||
workspace_id = uuid4()
|
||||
source_key = str(uuid4())
|
||||
external_id = f"page-{uuid4()}"
|
||||
document = DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
space_id=uuid4(),
|
||||
source_key=source_key,
|
||||
external_id=external_id,
|
||||
title="Wiki page",
|
||||
)
|
||||
await _register_source(factory, document)
|
||||
await documents.create_document(document)
|
||||
|
||||
found = await documents.get_document_by_source(workspace_id, source_key, external_id)
|
||||
assert found == document
|
||||
|
||||
assert await documents.get_document_by_source(workspace_id, source_key, "other-id") is None
|
||||
assert await documents.get_document_by_source(uuid4(), source_key, external_id) is None
|
||||
|
||||
|
||||
async def test_list_versions_returns_every_state_newest_first(db_engine: AsyncEngine) -> None:
|
||||
factory = create_session_factory(db_engine)
|
||||
documents = PostgresDocumentRepository(factory)
|
||||
document = DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
source_key=str(uuid4()),
|
||||
external_id=f"page-{uuid4()}",
|
||||
title="Wiki page",
|
||||
)
|
||||
await _register_source(factory, document)
|
||||
await documents.create_document(document)
|
||||
|
||||
first = 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=DocumentVersionState.SUPERSEDED,
|
||||
)
|
||||
second = DocumentVersionRecord(
|
||||
id=uuid4(),
|
||||
document_id=document.id,
|
||||
source_fingerprint="c" * 64,
|
||||
content_hash="d" * 64,
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version="prose-v1",
|
||||
state=DocumentVersionState.PUBLISHED,
|
||||
)
|
||||
await documents.create_version(first)
|
||||
await documents.create_version(second)
|
||||
|
||||
versions = await documents.list_versions(document.id)
|
||||
assert {version.id for version in versions} == {first.id, second.id}
|
||||
assert versions[0].id == second.id
|
||||
|
||||
assert await documents.list_versions(uuid4()) == ()
|
||||
@@ -0,0 +1,316 @@
|
||||
"""PostgreSQL integration coverage for the control-plane persistence boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from ragcore.application.authorization import AuthorizationService
|
||||
from ragcore.application.control.api import ControlPlaneApplication
|
||||
from ragcore.config import Settings
|
||||
from ragcore.domain.authorization import Principal, PrincipalType
|
||||
from ragcore.domain.control import DataPolicy, Grant, KnowledgeSpace, Policy, Role, Scope, Workspace
|
||||
from ragcore.infrastructure.database import (
|
||||
create_database_engine,
|
||||
create_session_factory,
|
||||
session_scope,
|
||||
)
|
||||
from ragcore.infrastructure.database.control import ControlPlaneRepository
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop_policy() -> asyncio.AbstractEventLoopPolicy:
|
||||
if sys.platform == "win32":
|
||||
return asyncio.WindowsSelectorEventLoopPolicy()
|
||||
return asyncio.get_event_loop_policy()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repository() -> ControlPlaneRepository:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
factory = create_session_factory(engine)
|
||||
async with session_scope(factory) as session:
|
||||
# Deliberately does not truncate control.workspaces: audit.audit_events
|
||||
# has a foreign key to workspace_id, and TRUNCATE ... CASCADE follows
|
||||
# that FK regardless of its ON DELETE action, tripping audit_events'
|
||||
# own unconditional append-only trigger (BEFORE TRUNCATE, migration
|
||||
# 0005) and erroring out this whole fixture. Every test using this
|
||||
# fixture already creates its own uniquely-id'd (uuid4()) workspace
|
||||
# and only reads back rows scoped to that id, so leaving prior test
|
||||
# runs' workspace rows in place is harmless and does not affect
|
||||
# correctness.
|
||||
await session.execute(
|
||||
text(
|
||||
"TRUNCATE control.grants, control.policies, control.applications, "
|
||||
"control.spaces CASCADE"
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
yield ControlPlaneRepository(session)
|
||||
await session.rollback()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_workspace_slug_and_space_slug_are_unique(repository: ControlPlaneRepository) -> None:
|
||||
first = Workspace(uuid4(), "First", "shared")
|
||||
second = Workspace(uuid4(), "Second", "shared")
|
||||
await repository.create_workspace(first)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
await repository.create_workspace(second)
|
||||
await repository._session.rollback()
|
||||
|
||||
workspace = Workspace(uuid4(), "Spaces", "spaces")
|
||||
await repository.create_workspace(workspace)
|
||||
await repository.create_space(
|
||||
workspace.create_knowledge_space(id=uuid4(), name="One", slug="docs")
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
await repository.create_space(
|
||||
workspace.create_knowledge_space(id=uuid4(), name="Two", slug="docs")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_repository_reads_are_workspace_scoped(repository: ControlPlaneRepository) -> None:
|
||||
first = Workspace(uuid4(), "First", "first")
|
||||
second = Workspace(uuid4(), "Second", "second")
|
||||
await repository.create_workspace(first)
|
||||
await repository.create_workspace(second)
|
||||
await repository.create_application(
|
||||
first.create_application(id=uuid4(), name="Client", scopes={Scope.SEARCH})
|
||||
)
|
||||
await repository.create_application(
|
||||
second.create_application(id=uuid4(), name="Client", scopes={Scope.SEARCH})
|
||||
)
|
||||
|
||||
assert len(await repository.list_applications(first.id)) == 1
|
||||
assert len(await repository.list_applications(second.id)) == 1
|
||||
assert (await repository.list_applications(first.id))[0].workspace_id == first.id
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_composite_foreign_keys_reject_cross_workspace_grants(
|
||||
repository: ControlPlaneRepository,
|
||||
) -> None:
|
||||
first = Workspace(uuid4(), "First", "first")
|
||||
second = Workspace(uuid4(), "Second", "second")
|
||||
await repository.create_workspace(first)
|
||||
await repository.create_workspace(second)
|
||||
application = first.create_application(id=uuid4(), name="Client", scopes={Scope.SEARCH})
|
||||
space = second.create_knowledge_space(id=uuid4(), name="Private", slug="private")
|
||||
await repository.create_application(application)
|
||||
await repository.create_space(space)
|
||||
|
||||
cross_workspace_grant = Grant(
|
||||
id=uuid4(),
|
||||
workspace_id=second.id,
|
||||
application_id=application.id,
|
||||
knowledge_space_id=space.id,
|
||||
role=Role.VIEWER,
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
await repository.create_grant(cross_workspace_grant)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_application_and_workspace_policy_uniqueness(
|
||||
repository: ControlPlaneRepository,
|
||||
) -> None:
|
||||
workspace = Workspace(uuid4(), "Control", "control")
|
||||
await repository.create_workspace(workspace)
|
||||
await repository.create_application(
|
||||
workspace.create_application(id=uuid4(), name="One", slug="client", scopes={Scope.SEARCH})
|
||||
)
|
||||
await repository._session.commit()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
await repository.create_application(
|
||||
workspace.create_application(
|
||||
id=uuid4(), name="Two", slug="client", scopes={Scope.SEARCH}
|
||||
)
|
||||
)
|
||||
await repository._session.rollback()
|
||||
|
||||
await repository.create_policy(workspace.create_policy(id=uuid4(), operation="search"))
|
||||
await repository._session.commit()
|
||||
with pytest.raises(IntegrityError):
|
||||
await repository.create_policy(workspace.create_policy(id=uuid4(), operation="search"))
|
||||
await repository._session.rollback()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_space_and_policy_reads_are_workspace_scoped(
|
||||
repository: ControlPlaneRepository,
|
||||
) -> None:
|
||||
first = Workspace(uuid4(), "First", "first")
|
||||
second = Workspace(uuid4(), "Second", "second")
|
||||
await repository.create_workspace(first)
|
||||
await repository.create_workspace(second)
|
||||
await repository.create_space(
|
||||
first.create_knowledge_space(id=uuid4(), name="First space", slug="first-space")
|
||||
)
|
||||
await repository.create_space(
|
||||
second.create_knowledge_space(id=uuid4(), name="Second space", slug="second-space")
|
||||
)
|
||||
await repository.create_policy(first.create_policy(id=uuid4(), operation="search"))
|
||||
await repository.create_policy(second.create_policy(id=uuid4(), operation="search"))
|
||||
|
||||
assert len(await repository.list_spaces(first.id)) == 1
|
||||
assert len(await repository.list_spaces(second.id)) == 1
|
||||
assert len(await repository.list_policies(first.id)) == 1
|
||||
assert len(await repository.list_policies(second.id)) == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_composite_foreign_keys_reject_cross_workspace_spaces_and_policies(
|
||||
repository: ControlPlaneRepository,
|
||||
) -> None:
|
||||
first = Workspace(uuid4(), "First", "first")
|
||||
second = Workspace(uuid4(), "Second", "second")
|
||||
await repository.create_workspace(first)
|
||||
await repository.create_workspace(second)
|
||||
await repository._session.commit()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
await repository.create_space(
|
||||
KnowledgeSpace(id=uuid4(), workspace_id=uuid4(), name="Orphan", slug="orphan")
|
||||
)
|
||||
await repository._session.rollback()
|
||||
|
||||
second_space = second.create_knowledge_space(id=uuid4(), name="Private", slug="private")
|
||||
await repository.create_space(second_space)
|
||||
with pytest.raises(IntegrityError):
|
||||
await repository.create_policy(
|
||||
Policy(
|
||||
id=uuid4(),
|
||||
workspace_id=first.id,
|
||||
knowledge_space_id=second_space.id,
|
||||
operation="search",
|
||||
)
|
||||
)
|
||||
await repository._session.rollback()
|
||||
|
||||
|
||||
def test_empty_database_migration_round_trip() -> None:
|
||||
config = Config(str(REPOSITORY_ROOT / "alembic.ini"))
|
||||
command.downgrade(config, "base")
|
||||
command.upgrade(config, "head")
|
||||
|
||||
async def check_schema() -> None:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
async with engine.connect() as connection:
|
||||
result = await connection.execute(
|
||||
text(
|
||||
"SELECT table_name FROM information_schema.tables "
|
||||
"WHERE table_schema = 'control'"
|
||||
)
|
||||
)
|
||||
assert {row[0] for row in result} == {
|
||||
"workspaces",
|
||||
"applications",
|
||||
"application_default_spaces",
|
||||
"spaces",
|
||||
"grants",
|
||||
"policies",
|
||||
"service_accounts",
|
||||
"user_grants",
|
||||
}
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(check_schema())
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize("data_policy", list(DataPolicy))
|
||||
async def test_space_data_policy_round_trips_through_repository(
|
||||
repository: ControlPlaneRepository, data_policy: DataPolicy
|
||||
) -> None:
|
||||
workspace = Workspace(uuid4(), "Policies", "policies")
|
||||
await repository.create_workspace(workspace)
|
||||
space = workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Policy space", slug="policy-space", data_policy=data_policy
|
||||
)
|
||||
|
||||
await repository.create_space(space)
|
||||
|
||||
stored = (await repository.list_spaces(workspace.id))[0]
|
||||
assert stored.data_policy is data_policy
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_default_knowledge_space_ids_round_trip_through_a_new_session(
|
||||
repository: ControlPlaneRepository,
|
||||
) -> None:
|
||||
workspace = Workspace(uuid4(), "Defaults", "defaults")
|
||||
await repository.create_workspace(workspace)
|
||||
space_one = workspace.create_knowledge_space(id=uuid4(), name="Space One", slug="space-one")
|
||||
space_two = workspace.create_knowledge_space(id=uuid4(), name="Space Two", slug="space-two")
|
||||
await repository.create_space(space_one)
|
||||
await repository.create_space(space_two)
|
||||
|
||||
application = workspace.create_application(
|
||||
id=uuid4(),
|
||||
name="Defaults Client",
|
||||
slug="defaults-client",
|
||||
default_knowledge_space_ids=frozenset({space_one.id, space_two.id}),
|
||||
)
|
||||
created = await repository.create_application(application)
|
||||
assert created.default_knowledge_space_ids == frozenset({space_one.id, space_two.id})
|
||||
# This repository is Core (not ORM): every select() below is a real
|
||||
# round trip with no identity-map caching, so re-reading through the
|
||||
# same session already proves the value is genuinely in the database,
|
||||
# not merely held on the Python-side entity that created() returned.
|
||||
# The shared fixture session never commits mid-test on its own (see its
|
||||
# own docstring) -- commit explicitly so this row would also survive
|
||||
# into a genuinely separate connection, not just this transaction.
|
||||
await repository._session.commit()
|
||||
|
||||
reloaded = await repository.get_application(application.id, workspace.id)
|
||||
assert reloaded is not None
|
||||
assert reloaded.default_knowledge_space_ids == frozenset({space_one.id, space_two.id})
|
||||
|
||||
listed = await repository.list_applications(workspace.id)
|
||||
(listed_application,) = [app for app in listed if app.id == application.id]
|
||||
assert listed_application.default_knowledge_space_ids == frozenset(
|
||||
{space_one.id, space_two.id}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_application_use_case_rejects_an_unauthorized_default_space(
|
||||
repository: ControlPlaneRepository,
|
||||
) -> None:
|
||||
workspace = Workspace(uuid4(), "Defaults Reject", "defaults-reject")
|
||||
await repository.create_workspace(workspace)
|
||||
allowed_space = workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Allowed", slug="allowed-space"
|
||||
)
|
||||
await repository.create_space(allowed_space)
|
||||
foreign_space_id = uuid4() # belongs to no workspace at all
|
||||
|
||||
control_application = ControlPlaneApplication(repository, AuthorizationService())
|
||||
principal = Principal(
|
||||
uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.WORKSPACE_ADMIN})
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="default knowledge spaces must belong"):
|
||||
await control_application.create_application(
|
||||
principal,
|
||||
slug="rejected-client",
|
||||
display_name="Rejected Client",
|
||||
default_space_ids=frozenset({allowed_space.id, foreign_space_id}),
|
||||
)
|
||||
|
||||
assert await repository.list_applications(workspace.id) == ()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""PostgreSQL and session-lifecycle integration coverage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
|
||||
from ragcore.config import Settings
|
||||
from ragcore.infrastructure.database import (
|
||||
create_database_engine,
|
||||
create_session_factory,
|
||||
session_scope,
|
||||
)
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop_policy():
|
||||
if sys.platform == "win32":
|
||||
return asyncio.WindowsSelectorEventLoopPolicy()
|
||||
return asyncio.get_event_loop_policy()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def alembic_config() -> Config:
|
||||
return Config(str(REPOSITORY_ROOT / "alembic.ini"))
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def session_factory():
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
try:
|
||||
yield create_session_factory(engine)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def test_empty_database_can_upgrade_downgrade_and_upgrade(alembic_config: Config) -> None:
|
||||
command.upgrade(alembic_config, "head")
|
||||
command.downgrade(alembic_config, "base")
|
||||
command.upgrade(alembic_config, "head")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_postgresql_session_executes_query(session_factory) -> None:
|
||||
async with session_scope(session_factory) as session:
|
||||
result = await session.execute(text("SELECT 1"))
|
||||
|
||||
assert result.scalar_one() == 1
|
||||
|
||||
|
||||
class _RecordingSession:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
self.rolled_back = False
|
||||
|
||||
async def __aenter__(self) -> _RecordingSession:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
self.closed = True
|
||||
|
||||
async def rollback(self) -> None:
|
||||
self.rolled_back = True
|
||||
|
||||
|
||||
class _RecordingFactory:
|
||||
def __init__(self, session: _RecordingSession) -> None:
|
||||
self.session = session
|
||||
|
||||
def __call__(self) -> _RecordingSession:
|
||||
return self.session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_scope_rolls_back_and_closes_on_failure() -> None:
|
||||
recording_session = _RecordingSession()
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
async with session_scope(_RecordingFactory(recording_session)):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
assert recording_session.rolled_back
|
||||
assert recording_session.closed
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Dependency-aware health integration coverage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ragcore.infrastructure.health import ReadinessChecker
|
||||
from ragcore.main import app, create_app
|
||||
|
||||
|
||||
async def _ok() -> None:
|
||||
return None
|
||||
|
||||
|
||||
async def _fail() -> None:
|
||||
raise OSError("database password should never escape readiness details")
|
||||
|
||||
|
||||
async def _slow() -> None:
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
|
||||
def _client(checker: ReadinessChecker) -> TestClient:
|
||||
return TestClient(create_app(readiness_checker=checker), raise_server_exceptions=False)
|
||||
|
||||
|
||||
def test_liveness_does_not_call_downstreams() -> None:
|
||||
async def must_not_run() -> None:
|
||||
raise AssertionError("liveness must not probe dependencies")
|
||||
|
||||
with _client(
|
||||
ReadinessChecker(
|
||||
{"postgres": must_not_run, "qdrant": must_not_run, "storage": must_not_run}
|
||||
)
|
||||
) as client:
|
||||
response = client.get("/health/live")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok", "service": "ragcore"}
|
||||
|
||||
|
||||
def test_readiness_returns_all_ok_components() -> None:
|
||||
checker = ReadinessChecker({"postgres": _ok, "qdrant": _ok, "storage": _ok})
|
||||
|
||||
with _client(checker) as client:
|
||||
response = client.get("/health/ready")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["status"] == "ok"
|
||||
assert set(body["components"]) == {"postgres", "qdrant", "storage"}
|
||||
assert all(component["status"] == "ok" for component in body["components"].values())
|
||||
assert all(component["code"] is None for component in body["components"].values())
|
||||
|
||||
|
||||
def test_readiness_returns_stable_problem_details_for_dependency_failure() -> None:
|
||||
checker = ReadinessChecker({"postgres": _fail, "qdrant": _ok, "storage": _ok})
|
||||
|
||||
with _client(checker) as client:
|
||||
response = client.get("/health/ready", headers={"X-Request-ID": "health-failure-123"})
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["content-type"].startswith("application/problem+json")
|
||||
body = response.json()
|
||||
assert body["status"] == 503
|
||||
assert body["code"] == "DEPENDENCY_UNAVAILABLE"
|
||||
assert body["trace_id"] == "health-failure-123"
|
||||
assert body["errors"] == [{"component": "postgres", "code": "POSTGRES_UNAVAILABLE"}]
|
||||
assert "database password" not in response.text
|
||||
|
||||
|
||||
def test_readiness_applies_strict_probe_timeout() -> None:
|
||||
checker = ReadinessChecker(
|
||||
{"postgres": _slow, "qdrant": _ok, "storage": _ok}, timeout_seconds=0.02
|
||||
)
|
||||
|
||||
started = time.perf_counter()
|
||||
with _client(checker) as client:
|
||||
response = client.get("/health/ready")
|
||||
elapsed = time.perf_counter() - started
|
||||
|
||||
assert response.status_code == 503
|
||||
# The timeout actually firing (not the probe merely finishing fast on
|
||||
# its own) is what the error code below proves -- it can only appear if
|
||||
# the checker's own 20ms deadline cut the 200ms `_slow()` probe off
|
||||
# early. `elapsed` therefore only needs to rule out an indefinite hang,
|
||||
# not stand in for a precise timing proof: a tight millisecond-scale
|
||||
# margin here (this used to assert `elapsed < 0.15`) flakes under real
|
||||
# system load from TestClient/ASGI scheduling jitter alone, with the
|
||||
# timeout logic itself never having failed at all.
|
||||
assert response.json()["errors"] == [{"component": "postgres", "code": "POSTGRES_TIMEOUT"}]
|
||||
assert elapsed < 2.0
|
||||
def test_production_readiness_checks_compose_dependencies() -> None:
|
||||
with TestClient(app, raise_server_exceptions=False) as client:
|
||||
response = client.get("/health/ready")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["status"] == "ok"
|
||||
assert set(body["components"]) == {"postgres", "qdrant", "storage"}
|
||||
assert all(component["status"] == "ok" for component in body["components"].values())
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Proves a bearer-authenticated service account can actually resolve real
|
||||
effective spaces end to end against real Postgres.
|
||||
|
||||
Before this fix, `ControlPlaneAuthorizationInputsProvider.load()` always
|
||||
returned `application=None` and `grants=()` for every principal, and
|
||||
`Principal` had no way to even carry which `Application` a service
|
||||
account's bearer credential belongs to. `EffectiveSpaceResolver` only
|
||||
ever honors workspace-level `Grant`s alongside a real `Application`, so
|
||||
every bearer-token search/context/answers call was silently resolving to
|
||||
zero effective spaces, regardless of what it requested -- a real,
|
||||
previously-undetected bug found while building M7-03 (MCP), which relies
|
||||
entirely on this same bearer-token path actually working.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import text
|
||||
|
||||
from ragcore.application.control.spaces import EffectiveSpaceResolver
|
||||
from ragcore.application.identity.credentials import ServiceAccountCredentialService
|
||||
from ragcore.config import Settings
|
||||
from ragcore.domain.authorization import Principal, PrincipalType
|
||||
from ragcore.domain.control import (
|
||||
DataPolicy,
|
||||
Role,
|
||||
Scope,
|
||||
ServiceAccount,
|
||||
SpaceOperation,
|
||||
Workspace,
|
||||
)
|
||||
from ragcore.infrastructure.database import (
|
||||
create_database_engine,
|
||||
create_session_factory,
|
||||
session_scope,
|
||||
)
|
||||
from ragcore.infrastructure.database.control import ControlPlaneRepository
|
||||
from ragcore.infrastructure.database.identity import PostgresCredentialRepository
|
||||
from ragcore.web.query_lab.dependencies import ControlPlaneAuthorizationInputsProvider
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def session_factory():
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
factory = create_session_factory(engine)
|
||||
async with session_scope(factory) as session:
|
||||
# See tests/integration/control/test_control_plane.py's own repository
|
||||
# fixture for why control.workspaces is deliberately not truncated.
|
||||
await session.execute(
|
||||
text(
|
||||
"TRUNCATE control.grants, control.policies, control.applications, "
|
||||
"control.spaces, identity.service_account_credentials CASCADE"
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
yield factory
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_bearer_service_account_resolves_real_granted_space(session_factory) -> None:
|
||||
workspace_id = uuid4()
|
||||
workspace = Workspace(workspace_id, "MCP workspace", f"mcp-workspace-{workspace_id.hex[:8]}")
|
||||
space = workspace.create_knowledge_space(
|
||||
id=uuid4(), name="MCP space", slug="mcp-space", data_policy=DataPolicy.LOCAL_ONLY
|
||||
)
|
||||
application = workspace.create_application(
|
||||
id=uuid4(), name="MCP server", slug="mcp-server", scopes={Scope.SEARCH}
|
||||
)
|
||||
service_account = ServiceAccount(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace.id,
|
||||
application_id=application.id,
|
||||
name="MCP service account",
|
||||
scopes=frozenset({Scope.SEARCH}),
|
||||
)
|
||||
|
||||
async with session_scope(session_factory) as session:
|
||||
repository = ControlPlaneRepository(session)
|
||||
await repository.create_workspace(workspace)
|
||||
await repository.create_space(space)
|
||||
await repository.create_application(application)
|
||||
await repository.create_service_account(service_account)
|
||||
grant = workspace.grant(
|
||||
id=uuid4(), application=application, knowledge_space=space, role=Role.VIEWER
|
||||
)
|
||||
await repository.create_grant(grant)
|
||||
await session.commit()
|
||||
|
||||
async def load_service_account(account_id):
|
||||
async with session_scope(session_factory) as session:
|
||||
return await ControlPlaneRepository(session).get_service_account_by_id(account_id)
|
||||
|
||||
async with session_scope(session_factory) as session:
|
||||
credential_service = ServiceAccountCredentialService(
|
||||
PostgresCredentialRepository(session),
|
||||
"test-credential-pepper-that-is-long-enough",
|
||||
service_account_loader=load_service_account,
|
||||
)
|
||||
issued = await credential_service.issue(service_account, scopes={Scope.SEARCH})
|
||||
await session.commit()
|
||||
|
||||
async with session_scope(session_factory) as session:
|
||||
credential_service = ServiceAccountCredentialService(
|
||||
PostgresCredentialRepository(session),
|
||||
"test-credential-pepper-that-is-long-enough",
|
||||
service_account_loader=load_service_account,
|
||||
)
|
||||
authenticated = await credential_service.authenticate(
|
||||
issued.token, required_scopes=[Scope.SEARCH]
|
||||
)
|
||||
|
||||
assert authenticated is not None
|
||||
assert authenticated.application_id == application.id
|
||||
|
||||
principal = Principal(
|
||||
id=service_account.id,
|
||||
workspace_id=workspace.id,
|
||||
principal_type=PrincipalType.SERVICE_ACCOUNT,
|
||||
roles=frozenset({Role.EDITOR}),
|
||||
application_id=authenticated.application_id,
|
||||
)
|
||||
|
||||
provider = ControlPlaneAuthorizationInputsProvider(session_factory)
|
||||
inputs = await provider.load(principal)
|
||||
|
||||
assert inputs.application is not None
|
||||
assert inputs.application.id == application.id
|
||||
assert any(g.knowledge_space_id == space.id for g in inputs.grants)
|
||||
|
||||
effective_space_ids = EffectiveSpaceResolver().resolve(
|
||||
workspace_id=workspace.id,
|
||||
spaces=inputs.spaces,
|
||||
grants=inputs.grants,
|
||||
user_grants=inputs.user_grants,
|
||||
application=inputs.application,
|
||||
requested_space_ids=(space.id,),
|
||||
operation=SpaceOperation.SEARCH,
|
||||
)
|
||||
|
||||
assert effective_space_ids == frozenset({space.id})
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Distributed alias-lock primitive: concurrency, staleness and release."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from ragcore.application.indexes.models import IndexOperationStatus
|
||||
from ragcore.config import Settings
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.indexes import PostgresIndexOperationStore
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def engine() -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(engine: AsyncEngine) -> PostgresIndexOperationStore:
|
||||
return PostgresIndexOperationStore(create_session_factory(engine))
|
||||
|
||||
|
||||
def alias() -> str:
|
||||
return f"rag_dense_lock-test-{uuid4().hex}_active"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_begin_grants_lock_when_alias_is_free(store: PostgresIndexOperationStore) -> None:
|
||||
now = datetime.now(UTC)
|
||||
operation_id = await store.begin(
|
||||
alias_name=alias(),
|
||||
operation="build",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token=uuid4().hex,
|
||||
now=now,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert operation_id is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_begin_only_one_succeeds(store: PostgresIndexOperationStore) -> None:
|
||||
target = alias()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
async def attempt(owner: str) -> object:
|
||||
return await store.begin(
|
||||
alias_name=target,
|
||||
operation="build",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token=owner,
|
||||
now=now,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
|
||||
results = await asyncio.gather(attempt(uuid4().hex), attempt(uuid4().hex))
|
||||
granted = [result for result in results if result is not None]
|
||||
assert len(granted) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_second_promotion_is_rejected_until_first_finishes(
|
||||
store: PostgresIndexOperationStore,
|
||||
) -> None:
|
||||
target = alias()
|
||||
now = datetime.now(UTC)
|
||||
first = await store.begin(
|
||||
alias_name=target,
|
||||
operation="build",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token="owner-a",
|
||||
now=now,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert first is not None
|
||||
|
||||
blocked = await store.begin(
|
||||
alias_name=target,
|
||||
operation="rollback",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token="owner-b",
|
||||
now=now,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert blocked is None
|
||||
|
||||
released = await store.finish(
|
||||
first, "owner-a", IndexOperationStatus.SUCCEEDED, now=now
|
||||
)
|
||||
assert released is True
|
||||
|
||||
second = await store.begin(
|
||||
alias_name=target,
|
||||
operation="rollback",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token="owner-b",
|
||||
now=now,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert second is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_lease_is_reclaimed_as_needs_repair(
|
||||
store: PostgresIndexOperationStore,
|
||||
) -> None:
|
||||
target = alias()
|
||||
started = datetime.now(UTC)
|
||||
crashed = await store.begin(
|
||||
alias_name=target,
|
||||
operation="build",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token="owner-crashed",
|
||||
now=started,
|
||||
lease_duration=timedelta(seconds=1),
|
||||
)
|
||||
assert crashed is not None
|
||||
|
||||
later = started + timedelta(seconds=5)
|
||||
resumed = await store.begin(
|
||||
alias_name=target,
|
||||
operation="build",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token="owner-resumed",
|
||||
now=later,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert resumed is not None
|
||||
assert resumed != crashed
|
||||
|
||||
# The crashed operation must stay detectable, not silently vanish.
|
||||
still_blocked_third_party = await store.begin(
|
||||
alias_name=target,
|
||||
operation="build",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token="owner-third",
|
||||
now=later,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert still_blocked_third_party is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finish_is_idempotent_and_requires_terminal_status(
|
||||
store: PostgresIndexOperationStore,
|
||||
) -> None:
|
||||
target = alias()
|
||||
now = datetime.now(UTC)
|
||||
operation_id = await store.begin(
|
||||
alias_name=target,
|
||||
operation="build",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token="owner-a",
|
||||
now=now,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert operation_id is not None
|
||||
|
||||
with pytest.raises(ValueError, match="terminal"):
|
||||
await store.finish(operation_id, "owner-a", IndexOperationStatus.IN_PROGRESS, now=now)
|
||||
|
||||
first_finish = await store.finish(
|
||||
operation_id, "owner-a", IndexOperationStatus.FAILED_COMPENSATED, now=now
|
||||
)
|
||||
assert first_finish is True
|
||||
|
||||
second_finish = await store.finish(
|
||||
operation_id, "owner-a", IndexOperationStatus.FAILED_COMPENSATED, now=now
|
||||
)
|
||||
assert second_finish is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lock_scope_is_specific_to_one_alias(store: PostgresIndexOperationStore) -> None:
|
||||
now = datetime.now(UTC)
|
||||
first_alias, second_alias = alias(), alias()
|
||||
first = await store.begin(
|
||||
alias_name=first_alias,
|
||||
operation="build",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token="owner-a",
|
||||
now=now,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
second = await store.begin(
|
||||
alias_name=second_alias,
|
||||
operation="build",
|
||||
collection_name="rag_dense_y_v1",
|
||||
owner_token="owner-b",
|
||||
now=now,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert first is not None
|
||||
assert second is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_step_appends_ordered_entries(store: PostgresIndexOperationStore) -> None:
|
||||
target = alias()
|
||||
now = datetime.now(UTC)
|
||||
operation_id = await store.begin(
|
||||
alias_name=target,
|
||||
operation="build",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token="owner-a",
|
||||
now=now,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert operation_id is not None
|
||||
|
||||
await store.record_step(operation_id, "owner-a", "created_shadow", now=now)
|
||||
await store.record_step(
|
||||
operation_id,
|
||||
"owner-a",
|
||||
"activated",
|
||||
now=now,
|
||||
details={"previous_collection": "rag_dense_x_v0"},
|
||||
)
|
||||
|
||||
# A step recorded by a non-owner must not be applied.
|
||||
await store.record_step(operation_id, "owner-mismatched", "hijacked", now=now)
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Real Postgres + real Qdrant coverage for the production ``AuthoritativeIndexSource``.
|
||||
|
||||
Mirrors ``test_rebuild.py``/``test_profile_activation.py``'s fixture style, but --
|
||||
unlike those, which both stay on a fixture source deliberately -- seeds real
|
||||
published content through ``PostgresDocumentRepository``/``DocumentPublicationService``
|
||||
and proves ``ProductionAuthoritativeIndexSource`` reads it back, re-embeds it, and
|
||||
carries it through a real shadow build, validation and alias activation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from ragcore.application.documents import (
|
||||
ChunkRecord,
|
||||
DocumentPublicationService,
|
||||
DocumentRecord,
|
||||
DocumentVersionRecord,
|
||||
DocumentVersionState,
|
||||
)
|
||||
from ragcore.application.indexes import IndexBuildService, IndexBuildState
|
||||
from ragcore.application.sources import implicit_source_record
|
||||
from ragcore.config import Settings
|
||||
from ragcore.domain.control import DataPolicy
|
||||
from ragcore.domain.models import EmbeddingProfile, EmbeddingProvider, NormalizationMode
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.content import PostgresDocumentRepository
|
||||
from ragcore.infrastructure.database.content.tables import documents as documents_table
|
||||
from ragcore.infrastructure.database.indexes import PostgresIndexOperationStore
|
||||
from ragcore.infrastructure.database.sources import PostgresSourceRegistry
|
||||
from ragcore.infrastructure.indexes import ProductionAuthoritativeIndexSource
|
||||
from ragcore.infrastructure.qdrant import CollectionKind, CollectionProfile
|
||||
from ragcore.infrastructure.qdrant.rebuild import QdrantRebuildStore
|
||||
from ragcore.infrastructure.qdrant.smoke import QdrantGoldenSmoke
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop_policy() -> asyncio.AbstractEventLoopPolicy:
|
||||
if sys.platform == "win32":
|
||||
return asyncio.WindowsSelectorEventLoopPolicy()
|
||||
return asyncio.get_event_loop_policy()
|
||||
|
||||
|
||||
class FakeEmbeddingClient:
|
||||
"""Duck-typed ``httpx.AsyncClient`` stand-in: echoes one dummy vector per
|
||||
requested input, no network. ``iter_published_content`` scans every
|
||||
published chunk in this shared test database, not just the one this test
|
||||
seeds, so the batch size here is whatever else happens to be published --
|
||||
echoing rather than pre-queuing a fixed-size response keeps this correct
|
||||
regardless of that ambient state."""
|
||||
|
||||
async def post(self, url: str, **kwargs: object) -> httpx.Response:
|
||||
body = kwargs["json"]
|
||||
assert isinstance(body, dict)
|
||||
input_count = len(body["input"])
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"embeddings": [[0.1, 0.2, 0.3, 0.4] for _ in range(input_count)]},
|
||||
request=httpx.Request("POST", "http://test"),
|
||||
)
|
||||
|
||||
|
||||
def _embedding_profile() -> EmbeddingProfile:
|
||||
return EmbeddingProfile(
|
||||
id=uuid4(),
|
||||
profile="reindex-test",
|
||||
version=uuid4().hex[:12],
|
||||
provider=EmbeddingProvider.OLLAMA,
|
||||
model_identifier="nomic-embed-text:1.5",
|
||||
model_digest="b" * 64,
|
||||
dimensions=4,
|
||||
context_limit=4096,
|
||||
tokenizer="sentencepiece-v1",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
max_batch_size=8,
|
||||
normalization=NormalizationMode.NONE,
|
||||
)
|
||||
|
||||
|
||||
def _collection_profile(embedding_profile: EmbeddingProfile) -> CollectionProfile:
|
||||
return CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile=embedding_profile.profile,
|
||||
version=embedding_profile.version,
|
||||
dimensions=embedding_profile.dimensions,
|
||||
model_digest=embedding_profile.model_digest,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def qdrant() -> AsyncIterator[AsyncQdrantClient]:
|
||||
client = AsyncQdrantClient(
|
||||
url=os.getenv("RAGCORE_QDRANT_URL", "http://qdrant:6333"),
|
||||
api_key=os.getenv("RAGCORE_QDRANT_API_KEY", "replace-me"),
|
||||
timeout=60,
|
||||
)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine() -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _register_source(factory, document) -> None: # type: ignore[no-untyped-def]
|
||||
"""The content schema requires a registry row for every document's source."""
|
||||
|
||||
await PostgresSourceRegistry(factory).ensure_source(
|
||||
implicit_source_record(
|
||||
source_id=document.source_id,
|
||||
workspace_id=document.workspace_id,
|
||||
space_id=document.space_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_published_chunks_flow_through_shadow_build_validation_and_activation(
|
||||
qdrant: AsyncQdrantClient,
|
||||
db_engine: AsyncEngine,
|
||||
) -> None:
|
||||
session_factory = create_session_factory(db_engine)
|
||||
documents = PostgresDocumentRepository(session_factory)
|
||||
publication = DocumentPublicationService(documents)
|
||||
|
||||
embedding_profile = _embedding_profile()
|
||||
profile = _collection_profile(embedding_profile)
|
||||
|
||||
document = DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
source_key="authoritative-source-test",
|
||||
external_id=f"doc-{uuid4()}",
|
||||
title="Authoritative Source Runbook",
|
||||
)
|
||||
await _register_source(session_factory, document)
|
||||
await documents.create_document(document)
|
||||
version = DocumentVersionRecord(
|
||||
id=uuid4(),
|
||||
document_id=document.id,
|
||||
source_fingerprint="f" * 64,
|
||||
content_hash="c" * 64,
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version="prose-v1",
|
||||
state=DocumentVersionState.VALIDATED,
|
||||
)
|
||||
await documents.create_version(version)
|
||||
chunk = ChunkRecord(
|
||||
id=uuid4(),
|
||||
document_version_id=version.id,
|
||||
structure_path="/blocks/0000",
|
||||
content_hash="d" * 64,
|
||||
chunk_profile_version="prose-v1",
|
||||
content="Restart the payment service before escalating to on-call.",
|
||||
token_count=10,
|
||||
metadata={
|
||||
"role": "child",
|
||||
"content_type": "text/plain",
|
||||
"language": "en",
|
||||
"trust_level": "user_uploaded",
|
||||
},
|
||||
)
|
||||
await publication.publish_validated(version.id, artifacts=(), chunks=(chunk,))
|
||||
|
||||
source = ProductionAuthoritativeIndexSource(
|
||||
document_repository=documents,
|
||||
embedding_profile=embedding_profile,
|
||||
embedding_http_client=FakeEmbeddingClient(), # type: ignore[arg-type]
|
||||
embedding_base_url="http://ollama",
|
||||
)
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
operations = PostgresIndexOperationStore(session_factory)
|
||||
builder = IndexBuildService(store, operations)
|
||||
|
||||
try:
|
||||
report = await builder.build(profile, source, QdrantGoldenSmoke(qdrant))
|
||||
|
||||
assert report.state is IndexBuildState.ACTIVE
|
||||
assert report.validation.passed is True
|
||||
assert report.validation.expected_count >= 1
|
||||
assert await store.current_alias_target(profile.alias_name) == profile.collection_name
|
||||
|
||||
stored_points, malformed = await store.list_points(profile)
|
||||
assert malformed == ()
|
||||
# This shared test database accumulates published content across
|
||||
# runs (``iter_published_content`` is deliberately unscoped), so
|
||||
# assert *our* chunk made it through rather than an exact total.
|
||||
assert chunk.id in {point.point_id for point in stored_points}
|
||||
finally:
|
||||
await store.rollback(profile.alias_name, None)
|
||||
await qdrant.delete_collection(profile.collection_name)
|
||||
async with session_factory() as session:
|
||||
await session.execute(
|
||||
documents_table.delete().where(documents_table.c.id == document.id)
|
||||
)
|
||||
await session.commit()
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Fault injection at multiple build stages: verify compensation and status."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from qdrant_client.http.exceptions import UnexpectedResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from ragcore.application.indexes import (
|
||||
IndexBuildError,
|
||||
IndexBuildService,
|
||||
IndexRepairError,
|
||||
)
|
||||
from ragcore.config import Settings
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.indexes import PostgresIndexOperationStore
|
||||
from ragcore.infrastructure.qdrant import (
|
||||
CollectionKind,
|
||||
CollectionProfile,
|
||||
IndexPayload,
|
||||
IndexPoint,
|
||||
)
|
||||
from ragcore.infrastructure.qdrant.rebuild import QdrantRebuildStore
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
class FixtureSource:
|
||||
def __init__(self, points: Iterable[IndexPoint]) -> None:
|
||||
self.points = tuple(points)
|
||||
|
||||
async def iter_points(self, profile: CollectionProfile) -> AsyncIterator[IndexPoint]:
|
||||
for point in self.points:
|
||||
yield point
|
||||
|
||||
|
||||
class FailingAfterNSource:
|
||||
"""Yields real points, then raises to simulate an authoritative-source failure."""
|
||||
|
||||
def __init__(self, points: Iterable[IndexPoint], *, fail_after: int) -> None:
|
||||
self.points = tuple(points)
|
||||
self.fail_after = fail_after
|
||||
|
||||
async def iter_points(self, profile: CollectionProfile) -> AsyncIterator[IndexPoint]:
|
||||
for index, point in enumerate(self.points):
|
||||
if index >= self.fail_after:
|
||||
raise RuntimeError("simulated authoritative source outage")
|
||||
yield point
|
||||
|
||||
|
||||
class FailingSmoke:
|
||||
async def run(self, profile: CollectionProfile) -> None:
|
||||
raise AssertionError("simulated golden smoke failure")
|
||||
|
||||
|
||||
class PassingSmoke:
|
||||
async def run(self, profile: CollectionProfile) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class HookableStore:
|
||||
"""Delegates to a real QdrantRebuildStore; lets tests hook activate/rollback."""
|
||||
|
||||
def __init__(self, inner: QdrantRebuildStore) -> None:
|
||||
self._inner = inner
|
||||
self.activate_hook: Callable[[], None] | None = None
|
||||
self.rollback_hook: Callable[[], None] | None = None
|
||||
|
||||
async def create_shadow(self, profile: CollectionProfile) -> None:
|
||||
await self._inner.create_shadow(profile)
|
||||
|
||||
async def delete_shadow(self, profile: CollectionProfile) -> None:
|
||||
await self._inner.delete_shadow(profile)
|
||||
|
||||
async def upsert_points(self, profile: CollectionProfile, points: Sequence[IndexPoint]) -> None:
|
||||
await self._inner.upsert_points(profile, points)
|
||||
|
||||
async def delete_points(self, profile: CollectionProfile, point_ids: Sequence[UUID]) -> None:
|
||||
await self._inner.delete_points(profile, point_ids)
|
||||
|
||||
async def delete_raw_points(self, profile: CollectionProfile, raw_ids: Sequence[str]) -> None:
|
||||
await self._inner.delete_raw_points(profile, raw_ids)
|
||||
|
||||
async def count_points(self, profile: CollectionProfile) -> int:
|
||||
return await self._inner.count_points(profile)
|
||||
|
||||
async def list_points(self, profile: CollectionProfile) -> Any:
|
||||
return await self._inner.list_points(profile)
|
||||
|
||||
async def current_alias_target(self, alias_name: str) -> str | None:
|
||||
return await self._inner.current_alias_target(alias_name)
|
||||
|
||||
async def activate(self, profile: CollectionProfile) -> None:
|
||||
await self._inner.activate(profile)
|
||||
if self.activate_hook is not None:
|
||||
self.activate_hook()
|
||||
|
||||
async def rollback(self, alias_name: str, previous_collection: str | None) -> None:
|
||||
if self.rollback_hook is not None:
|
||||
self.rollback_hook()
|
||||
await self._inner.rollback(alias_name, previous_collection)
|
||||
|
||||
|
||||
def _raise(message: str) -> None:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def make_profile() -> CollectionProfile:
|
||||
return CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile="compensate-test",
|
||||
version=uuid4().hex[:12],
|
||||
dimensions=4,
|
||||
model_digest="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
def make_point(profile: CollectionProfile, index: int) -> IndexPoint:
|
||||
point_id = uuid4()
|
||||
return IndexPoint(
|
||||
point_id=point_id,
|
||||
dense_vector=(0.1 + index, 0.2, 0.3, 0.4),
|
||||
sparse_indices=(index + 1, index + 9),
|
||||
sparse_values=(0.8, 0.3),
|
||||
payload=IndexPayload(
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
document_id=uuid4(),
|
||||
document_version_id=uuid4(),
|
||||
chunk_id=point_id,
|
||||
parent_chunk_id=None,
|
||||
source_id=uuid4(),
|
||||
status="published",
|
||||
content_type="text/markdown",
|
||||
language="en",
|
||||
trust_level="source",
|
||||
location={"page": index + 1},
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version=profile.version,
|
||||
embedding_model_digest=profile.model_digest,
|
||||
text=f"A safe indexed passage {index}.",
|
||||
excerpt=f"A safe indexed passage {index}.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def qdrant() -> AsyncIterator[AsyncQdrantClient]:
|
||||
client = AsyncQdrantClient(
|
||||
url=os.getenv("RAGCORE_QDRANT_URL", "http://qdrant:6333"),
|
||||
api_key=os.getenv("RAGCORE_QDRANT_API_KEY", "replace-me"),
|
||||
timeout=60,
|
||||
)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine() -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def operations(db_engine: AsyncEngine) -> PostgresIndexOperationStore:
|
||||
return PostgresIndexOperationStore(create_session_factory(db_engine))
|
||||
|
||||
|
||||
async def _delete_collection_if_exists(client: AsyncQdrantClient, name: str) -> None:
|
||||
try:
|
||||
await client.delete_collection(collection_name=name, timeout=30)
|
||||
except UnexpectedResponse as error:
|
||||
if error.status_code != 404:
|
||||
raise
|
||||
|
||||
|
||||
async def _collection_exists(client: AsyncQdrantClient, name: str) -> bool:
|
||||
try:
|
||||
await client.get_collection(name)
|
||||
except UnexpectedResponse as error:
|
||||
if error.status_code == 404:
|
||||
return False
|
||||
raise
|
||||
return True
|
||||
|
||||
|
||||
async def test_failure_before_validation_deletes_orphan_shadow_and_leaves_alias_untouched(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
points = (make_point(profile, 0), make_point(profile, 1), make_point(profile, 2))
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
with pytest.raises(IndexBuildError, match="index build failed"):
|
||||
await service.build(
|
||||
profile, FailingAfterNSource(points, fail_after=1), PassingSmoke()
|
||||
)
|
||||
|
||||
assert await store.current_alias_target(profile.alias_name) is None
|
||||
assert await _collection_exists(qdrant, profile.collection_name) is False
|
||||
finally:
|
||||
await _delete_collection_if_exists(qdrant, profile.collection_name)
|
||||
|
||||
|
||||
async def test_smoke_failure_deletes_shadow_and_preserves_prior_active_collection(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
good = make_profile()
|
||||
bad = CollectionProfile(
|
||||
kind=good.kind,
|
||||
profile=good.profile,
|
||||
version=uuid4().hex[:12],
|
||||
dimensions=good.dimensions,
|
||||
model_digest=good.model_digest,
|
||||
)
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
good_report = await service.build(
|
||||
good, FixtureSource((make_point(good, 0),)), PassingSmoke()
|
||||
)
|
||||
assert good_report.state.value == "active"
|
||||
|
||||
with pytest.raises(IndexBuildError, match="golden smoke"):
|
||||
await service.build(bad, FixtureSource((make_point(bad, 1),)), FailingSmoke())
|
||||
|
||||
# The previously active collection must still be the one serving the alias.
|
||||
assert await store.current_alias_target(good.alias_name) == good.collection_name
|
||||
assert await _collection_exists(qdrant, bad.collection_name) is False
|
||||
finally:
|
||||
await _delete_collection_if_exists(qdrant, good.collection_name)
|
||||
await _delete_collection_if_exists(qdrant, bad.collection_name)
|
||||
|
||||
|
||||
async def test_activation_verification_failure_restores_previous_alias(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
good = make_profile()
|
||||
bad = CollectionProfile(
|
||||
kind=good.kind,
|
||||
profile=good.profile,
|
||||
version=uuid4().hex[:12],
|
||||
dimensions=good.dimensions,
|
||||
model_digest=good.model_digest,
|
||||
)
|
||||
inner = QdrantRebuildStore(qdrant)
|
||||
hookable = HookableStore(inner)
|
||||
service = IndexBuildService(hookable, operations)
|
||||
try:
|
||||
good_report = await service.build(
|
||||
good, FixtureSource((make_point(good, 0),)), PassingSmoke()
|
||||
)
|
||||
assert good_report.state.value == "active"
|
||||
|
||||
hookable.activate_hook = lambda: _raise("simulated activation verification failure")
|
||||
with pytest.raises(IndexBuildError, match="index build failed"):
|
||||
await service.build(bad, FixtureSource((make_point(bad, 1),)), PassingSmoke())
|
||||
|
||||
# Even though the low-level alias flip landed, compensation must force it
|
||||
# back to the last known-good collection and confirm that before returning.
|
||||
assert await inner.current_alias_target(good.alias_name) == good.collection_name
|
||||
finally:
|
||||
await _delete_collection_if_exists(qdrant, good.collection_name)
|
||||
await _delete_collection_if_exists(qdrant, bad.collection_name)
|
||||
|
||||
|
||||
async def test_activation_rollback_that_also_fails_raises_repair_error(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
good = make_profile()
|
||||
bad = CollectionProfile(
|
||||
kind=good.kind,
|
||||
profile=good.profile,
|
||||
version=uuid4().hex[:12],
|
||||
dimensions=good.dimensions,
|
||||
model_digest=good.model_digest,
|
||||
)
|
||||
inner = QdrantRebuildStore(qdrant)
|
||||
hookable = HookableStore(inner)
|
||||
service = IndexBuildService(hookable, operations)
|
||||
try:
|
||||
await service.build(good, FixtureSource((make_point(good, 0),)), PassingSmoke())
|
||||
|
||||
hookable.activate_hook = lambda: _raise("simulated activation verification failure")
|
||||
hookable.rollback_hook = lambda: _raise("simulated rollback failure")
|
||||
with pytest.raises(IndexRepairError, match="manual repair required"):
|
||||
await service.build(bad, FixtureSource((make_point(bad, 1),)), PassingSmoke())
|
||||
|
||||
# A rollback that itself fails must not be silently reported as a clean
|
||||
# failure: the alias state after this is unconfirmed and must be fixed
|
||||
# out of band, which is exactly what IndexRepairError signals.
|
||||
finally:
|
||||
hookable.activate_hook = None
|
||||
hookable.rollback_hook = None
|
||||
await inner.rollback(good.alias_name, good.collection_name)
|
||||
await _delete_collection_if_exists(qdrant, good.collection_name)
|
||||
await _delete_collection_if_exists(qdrant, bad.collection_name)
|
||||
|
||||
|
||||
async def test_repeating_a_failed_build_after_cleanup_succeeds(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
points = (make_point(profile, 0), make_point(profile, 1))
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
with pytest.raises(IndexBuildError):
|
||||
await service.build(profile, FailingAfterNSource(points, fail_after=1), PassingSmoke())
|
||||
|
||||
# Retrying the exact same alias/collection after compensation must work,
|
||||
# proving the lock and the shadow collection were both fully released.
|
||||
report = await service.build(profile, FixtureSource(points), PassingSmoke())
|
||||
assert report.state.value == "active"
|
||||
assert await store.current_alias_target(profile.alias_name) == profile.collection_name
|
||||
finally:
|
||||
await _delete_collection_if_exists(qdrant, profile.collection_name)
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Service-level concurrency: racing promotions, promote-vs-rollback, timeouts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Iterable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from ragcore.application.indexes import (
|
||||
IndexBuildError,
|
||||
IndexBuildService,
|
||||
IndexLockTimeoutError,
|
||||
IndexOperationStatus,
|
||||
)
|
||||
from ragcore.config import Settings
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.indexes import PostgresIndexOperationStore
|
||||
from ragcore.infrastructure.qdrant import (
|
||||
CollectionKind,
|
||||
CollectionProfile,
|
||||
IndexPayload,
|
||||
IndexPoint,
|
||||
)
|
||||
from ragcore.infrastructure.qdrant.rebuild import QdrantRebuildStore
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
# Real build latency in this environment (collection create + 8 payload indexes +
|
||||
# per-point upsert/retrieve/set_payload round trips) can run into the tens of
|
||||
# seconds, so timeouts here are generous. What is asserted is boundedness and
|
||||
# correctness of the outcome, not a tight wall-clock bound.
|
||||
_GENEROUS_TIMEOUT = timedelta(seconds=60)
|
||||
|
||||
|
||||
class FixtureSource:
|
||||
def __init__(self, points: Iterable[IndexPoint]) -> None:
|
||||
self.points = tuple(points)
|
||||
|
||||
async def iter_points(self, profile: CollectionProfile) -> AsyncIterator[IndexPoint]:
|
||||
for point in self.points:
|
||||
yield point
|
||||
|
||||
|
||||
class PassingSmoke:
|
||||
async def run(self, profile: CollectionProfile) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def make_profile() -> CollectionProfile:
|
||||
# A fresh profile name (not just version) per call keeps each test's alias
|
||||
# namespace isolated, so leftover/slow state from one test can never block
|
||||
# another test's lock acquisition.
|
||||
return CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile=f"concurrency-{uuid4().hex[:8]}",
|
||||
version=uuid4().hex[:12],
|
||||
dimensions=4,
|
||||
model_digest="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
def make_point(profile: CollectionProfile, index: int) -> IndexPoint:
|
||||
point_id = uuid4()
|
||||
return IndexPoint(
|
||||
point_id=point_id,
|
||||
dense_vector=(0.1 + index, 0.2, 0.3, 0.4),
|
||||
sparse_indices=(index + 1,),
|
||||
sparse_values=(0.8,),
|
||||
payload=IndexPayload(
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
document_id=uuid4(),
|
||||
document_version_id=uuid4(),
|
||||
chunk_id=point_id,
|
||||
parent_chunk_id=None,
|
||||
source_id=uuid4(),
|
||||
status="published",
|
||||
content_type="text/markdown",
|
||||
language="en",
|
||||
trust_level="source",
|
||||
location={"page": index + 1},
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version=profile.version,
|
||||
embedding_model_digest=profile.model_digest,
|
||||
text=f"A safe indexed passage {index}.",
|
||||
excerpt=f"A safe indexed passage {index}.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def qdrant() -> AsyncIterator[AsyncQdrantClient]:
|
||||
client = AsyncQdrantClient(
|
||||
url=os.getenv("RAGCORE_QDRANT_URL", "http://qdrant:6333"),
|
||||
api_key=os.getenv("RAGCORE_QDRANT_API_KEY", "replace-me"),
|
||||
timeout=60,
|
||||
)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine() -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def operations(db_engine: AsyncEngine) -> PostgresIndexOperationStore:
|
||||
return PostgresIndexOperationStore(create_session_factory(db_engine))
|
||||
|
||||
|
||||
async def test_two_concurrent_promotions_only_one_activates(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
points = (make_point(profile, 0), make_point(profile, 1))
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations, lock_acquire_timeout=_GENEROUS_TIMEOUT)
|
||||
try:
|
||||
results = await asyncio.gather(
|
||||
service.build(profile, FixtureSource(points), PassingSmoke()),
|
||||
service.build(profile, FixtureSource(points), PassingSmoke()),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
succeeded = [result for result in results if not isinstance(result, BaseException)]
|
||||
rejected = [result for result in results if isinstance(result, BaseException)]
|
||||
assert len(succeeded) == 1
|
||||
assert len(rejected) == 1
|
||||
# Whichever caller loses the race is safely turned away: either it waits
|
||||
# its turn and then sees the alias already active, or it times out first.
|
||||
# Both prove mutual exclusion held; neither corrupts state.
|
||||
assert isinstance(rejected[0], (IndexBuildError, IndexLockTimeoutError))
|
||||
assert await store.current_alias_target(profile.alias_name) == profile.collection_name
|
||||
finally:
|
||||
await store.rollback(profile.alias_name, None)
|
||||
await qdrant.delete_collection(profile.collection_name)
|
||||
|
||||
|
||||
async def test_promotion_and_rollback_starting_together_are_serialized_not_interleaved(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
"""A rollback racing a promotion on the same alias must wait, not interleave.
|
||||
|
||||
The lock is held directly through the primitive for a short, fully controlled
|
||||
window rather than by racing a real build's variable duration, so the outcome
|
||||
is deterministic without depending on Qdrant/container timing.
|
||||
"""
|
||||
|
||||
profile = make_profile()
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations, lock_acquire_timeout=_GENEROUS_TIMEOUT)
|
||||
try:
|
||||
report = await service.build(
|
||||
profile, FixtureSource((make_point(profile, 0),)), PassingSmoke()
|
||||
)
|
||||
|
||||
holder_token = uuid4().hex
|
||||
hold_started = datetime.now(UTC)
|
||||
held_operation_id = await operations.begin(
|
||||
alias_name=profile.alias_name,
|
||||
operation="reconcile",
|
||||
collection_name=profile.collection_name,
|
||||
owner_token=holder_token,
|
||||
now=hold_started,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert held_operation_id is not None
|
||||
|
||||
async def release_after_delay() -> None:
|
||||
await asyncio.sleep(1.0)
|
||||
await operations.finish(
|
||||
held_operation_id,
|
||||
holder_token,
|
||||
IndexOperationStatus.SUCCEEDED,
|
||||
now=datetime.now(UTC),
|
||||
)
|
||||
|
||||
release_task = asyncio.create_task(release_after_delay())
|
||||
try:
|
||||
started = time.monotonic()
|
||||
await service.rollback(report)
|
||||
elapsed = time.monotonic() - started
|
||||
finally:
|
||||
await release_task # always await: never leak a background task
|
||||
|
||||
# The rollback could only have completed after the manual hold released.
|
||||
assert elapsed >= 0.9
|
||||
assert await store.current_alias_target(profile.alias_name) is None
|
||||
finally:
|
||||
await store.rollback(profile.alias_name, None)
|
||||
await qdrant.delete_collection(profile.collection_name)
|
||||
|
||||
|
||||
async def test_lock_acquire_timeout_is_bounded_and_raises(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
|
||||
holder_token = uuid4().hex
|
||||
operation_id = await operations.begin(
|
||||
alias_name=profile.alias_name,
|
||||
operation="build",
|
||||
collection_name=profile.collection_name,
|
||||
owner_token=holder_token,
|
||||
now=datetime.now(UTC),
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert operation_id is not None
|
||||
|
||||
service = IndexBuildService(
|
||||
store,
|
||||
operations,
|
||||
lock_acquire_timeout=timedelta(seconds=1),
|
||||
lock_poll_interval_seconds=0.1,
|
||||
)
|
||||
try:
|
||||
started = time.monotonic()
|
||||
with pytest.raises(IndexLockTimeoutError):
|
||||
await service.build(
|
||||
profile, FixtureSource((make_point(profile, 0),)), PassingSmoke()
|
||||
)
|
||||
elapsed = time.monotonic() - started
|
||||
# Bounded, not an indefinite hang -- not a tight bound on the nominal
|
||||
# 1s `lock_acquire_timeout` itself. Each of the ~10 poll iterations
|
||||
# this loop takes still does a real Postgres round trip
|
||||
# (`operations.begin()`), and this suite's own real build latency in
|
||||
# this environment can run into the tens of seconds (see
|
||||
# `_GENEROUS_TIMEOUT` above), so a narrow millisecond-scale margin
|
||||
# here flakes under real system load without the timeout logic
|
||||
# itself having failed at all.
|
||||
assert elapsed < _GENEROUS_TIMEOUT.total_seconds()
|
||||
finally:
|
||||
await operations.finish(
|
||||
operation_id,
|
||||
holder_token,
|
||||
IndexOperationStatus.SUCCEEDED,
|
||||
now=datetime.now(UTC),
|
||||
)
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Malformed/legacy Qdrant points must be detected, excluded, repaired or quarantined."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Iterable
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from qdrant_client.http import models
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from ragcore.application.indexes import IndexBuildService
|
||||
from ragcore.config import Settings
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.indexes import PostgresIndexOperationStore
|
||||
from ragcore.infrastructure.qdrant import (
|
||||
CollectionKind,
|
||||
CollectionProfile,
|
||||
IndexPayload,
|
||||
IndexPoint,
|
||||
)
|
||||
from ragcore.infrastructure.qdrant.rebuild import QdrantRebuildStore
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
class FixtureSource:
|
||||
def __init__(self, points: Iterable[IndexPoint]) -> None:
|
||||
self.points = tuple(points)
|
||||
|
||||
async def iter_points(self, profile: CollectionProfile) -> AsyncIterator[IndexPoint]:
|
||||
for point in self.points:
|
||||
yield point
|
||||
|
||||
|
||||
class PassingSmoke:
|
||||
async def run(self, profile: CollectionProfile) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def make_profile() -> CollectionProfile:
|
||||
return CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile="malformed-test",
|
||||
version=uuid4().hex[:12],
|
||||
dimensions=4,
|
||||
model_digest="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
def make_point(profile: CollectionProfile, index: int) -> IndexPoint:
|
||||
point_id = uuid4()
|
||||
return IndexPoint(
|
||||
point_id=point_id,
|
||||
dense_vector=(0.1 + index, 0.2, 0.3, 0.4),
|
||||
sparse_indices=(index + 1, index + 9),
|
||||
sparse_values=(0.8, 0.3),
|
||||
payload=IndexPayload(
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
document_id=uuid4(),
|
||||
document_version_id=uuid4(),
|
||||
chunk_id=point_id,
|
||||
parent_chunk_id=None,
|
||||
source_id=uuid4(),
|
||||
status="published",
|
||||
content_type="text/markdown",
|
||||
language="en",
|
||||
trust_level="source",
|
||||
location={"page": index + 1},
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version=profile.version,
|
||||
embedding_model_digest=profile.model_digest,
|
||||
text=f"A safe indexed passage {index}.",
|
||||
excerpt=f"A safe indexed passage {index}.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def qdrant() -> AsyncIterator[AsyncQdrantClient]:
|
||||
client = AsyncQdrantClient(
|
||||
url=os.getenv("RAGCORE_QDRANT_URL", "http://qdrant:6333"),
|
||||
api_key=os.getenv("RAGCORE_QDRANT_API_KEY", "replace-me"),
|
||||
timeout=60,
|
||||
)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine() -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def operations(db_engine: AsyncEngine) -> PostgresIndexOperationStore:
|
||||
return PostgresIndexOperationStore(create_session_factory(db_engine))
|
||||
|
||||
|
||||
def _legacy_point_struct(point_id: object, profile: CollectionProfile) -> models.PointStruct:
|
||||
"""A point written without the fingerprint fields our adapter now requires."""
|
||||
|
||||
return models.PointStruct(
|
||||
id=point_id,
|
||||
vector={
|
||||
"dense": [0.5, 0.5, 0.5, 0.5],
|
||||
"sparse": models.SparseVector(indices=[1], values=[1.0]),
|
||||
},
|
||||
payload={
|
||||
"workspace_id": str(uuid4()),
|
||||
"space_id": str(uuid4()),
|
||||
"document_id": str(uuid4()),
|
||||
"status": "published",
|
||||
"text": "a legacy point written before fingerprinting existed",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def test_malformed_point_matching_expected_id_is_repaired_not_served(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
keep, repairable = make_point(profile, 0), make_point(profile, 1)
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
await service.build(profile, FixtureSource((keep, repairable)), PassingSmoke())
|
||||
|
||||
# Simulate a corrupted/legacy point sharing the deterministic id of a real,
|
||||
# currently-published chunk: the fingerprint fields fail the wire-integrity
|
||||
# shape check.
|
||||
await qdrant.set_payload(
|
||||
collection_name=profile.collection_name,
|
||||
payload={"index_fingerprint": "not-a-valid-fingerprint"},
|
||||
points=[str(repairable.point_id)],
|
||||
wait=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
valid_before, malformed_before = await store.list_points(profile)
|
||||
assert {item.point_id for item in valid_before} == {keep.point_id}
|
||||
assert {item.point_id for item in malformed_before} == {repairable.point_id}
|
||||
|
||||
report = await service.reconcile(profile, FixtureSource((keep, repairable)))
|
||||
|
||||
assert report.repairable_malformed_point_ids == (repairable.point_id,)
|
||||
assert report.quarantined_raw_ids == ()
|
||||
|
||||
valid_after, malformed_after = await store.list_points(profile)
|
||||
assert malformed_after == ()
|
||||
assert {item.point_id for item in valid_after} == {keep.point_id, repairable.point_id}
|
||||
finally:
|
||||
await store.rollback(profile.alias_name, None)
|
||||
await qdrant.delete_collection(profile.collection_name)
|
||||
|
||||
|
||||
async def test_malformed_point_with_no_matching_expected_id_is_quarantined(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
keep = make_point(profile, 0)
|
||||
orphan_id = uuid4()
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
await service.build(profile, FixtureSource((keep,)), PassingSmoke())
|
||||
await qdrant.upsert(
|
||||
collection_name=profile.collection_name,
|
||||
points=[_legacy_point_struct(str(orphan_id), profile)],
|
||||
wait=True,
|
||||
)
|
||||
|
||||
report = await service.reconcile(profile, FixtureSource((keep,)))
|
||||
|
||||
assert report.repairable_malformed_point_ids == ()
|
||||
assert report.quarantined_raw_ids == (str(orphan_id),)
|
||||
|
||||
valid_after, malformed_after = await store.list_points(profile)
|
||||
assert malformed_after == ()
|
||||
assert {item.point_id for item in valid_after} == {keep.point_id}
|
||||
assert await store.count_points(profile) == 1
|
||||
finally:
|
||||
await store.rollback(profile.alias_name, None)
|
||||
await qdrant.delete_collection(profile.collection_name)
|
||||
|
||||
|
||||
async def test_point_with_non_deterministic_id_is_quarantined_by_raw_id(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
keep = make_point(profile, 0)
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
await service.build(profile, FixtureSource((keep,)), PassingSmoke())
|
||||
# Qdrant accepts unsigned-integer ids too; our contract only issues UUIDv5
|
||||
# chunk ids, so an integer id can only be a foreign/legacy point.
|
||||
await qdrant.upsert(
|
||||
collection_name=profile.collection_name,
|
||||
points=[_legacy_point_struct(424242, profile)],
|
||||
wait=True,
|
||||
)
|
||||
|
||||
_valid_before, malformed_before = await store.list_points(profile)
|
||||
assert len(malformed_before) == 1
|
||||
assert malformed_before[0].point_id is None
|
||||
assert malformed_before[0].raw_id == "424242"
|
||||
|
||||
report = await service.reconcile(profile, FixtureSource((keep,)))
|
||||
|
||||
assert report.quarantined_raw_ids == ("424242",)
|
||||
assert await store.count_points(profile) == 1
|
||||
finally:
|
||||
await store.rollback(profile.alias_name, None)
|
||||
await qdrant.delete_collection(profile.collection_name)
|
||||
|
||||
|
||||
async def test_repeated_reconcile_after_repair_is_idempotent(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
keep, repairable = make_point(profile, 0), make_point(profile, 1)
|
||||
orphan_id = uuid4()
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
await service.build(profile, FixtureSource((keep, repairable)), PassingSmoke())
|
||||
await qdrant.set_payload(
|
||||
collection_name=profile.collection_name,
|
||||
payload={"index_fingerprint": "not-a-valid-fingerprint"},
|
||||
points=[str(repairable.point_id)],
|
||||
wait=True,
|
||||
timeout=30,
|
||||
)
|
||||
await qdrant.upsert(
|
||||
collection_name=profile.collection_name,
|
||||
points=[_legacy_point_struct(str(orphan_id), profile)],
|
||||
wait=True,
|
||||
)
|
||||
|
||||
first = await service.reconcile(profile, FixtureSource((keep, repairable)))
|
||||
assert first.repairable_malformed_point_ids == (repairable.point_id,)
|
||||
assert first.quarantined_raw_ids == (str(orphan_id),)
|
||||
|
||||
second = await service.reconcile(profile, FixtureSource((keep, repairable)))
|
||||
assert second.missing_point_ids == ()
|
||||
assert second.orphan_point_ids == ()
|
||||
assert second.changed_point_ids == ()
|
||||
assert second.repairable_malformed_point_ids == ()
|
||||
assert second.quarantined_raw_ids == ()
|
||||
assert await store.count_points(profile) == 2
|
||||
finally:
|
||||
await store.rollback(profile.alias_name, None)
|
||||
await qdrant.delete_collection(profile.collection_name)
|
||||
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Iterable
|
||||
from dataclasses import replace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from ragcore.application.indexes import IndexBuildService, IndexBuildState
|
||||
from ragcore.config import Settings
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.indexes import PostgresIndexOperationStore
|
||||
from ragcore.infrastructure.qdrant import (
|
||||
CollectionKind,
|
||||
CollectionProfile,
|
||||
IndexPayload,
|
||||
IndexPoint,
|
||||
QdrantIndexAdapter,
|
||||
)
|
||||
from ragcore.infrastructure.qdrant.rebuild import QdrantRebuildStore
|
||||
|
||||
|
||||
class FixtureSource:
|
||||
def __init__(self, points: Iterable[IndexPoint]) -> None:
|
||||
self.points = tuple(points)
|
||||
|
||||
async def iter_points(self, profile: CollectionProfile) -> AsyncIterator[IndexPoint]:
|
||||
for point in self.points:
|
||||
yield point
|
||||
|
||||
|
||||
class QdrantGoldenSmoke:
|
||||
def __init__(self, client: AsyncQdrantClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def run(self, profile: CollectionProfile) -> None:
|
||||
response = await self.client.query_points(
|
||||
collection_name=profile.collection_name,
|
||||
query=[0.1, 0.2, 0.3, 0.4],
|
||||
using="dense",
|
||||
limit=1,
|
||||
with_payload=True,
|
||||
timeout=30,
|
||||
)
|
||||
if not response.points:
|
||||
raise AssertionError("golden smoke query returned no points")
|
||||
|
||||
|
||||
def make_profile() -> CollectionProfile:
|
||||
return CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile="rebuild-test",
|
||||
version=uuid4().hex[:12],
|
||||
dimensions=4,
|
||||
model_digest="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
def make_point(profile: CollectionProfile, index: int) -> IndexPoint:
|
||||
point_id = uuid4()
|
||||
return IndexPoint(
|
||||
point_id=point_id,
|
||||
dense_vector=(0.1 + index, 0.2, 0.3, 0.4),
|
||||
sparse_indices=(index + 1, index + 9),
|
||||
sparse_values=(0.8, 0.3),
|
||||
payload=IndexPayload(
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
document_id=uuid4(),
|
||||
document_version_id=uuid4(),
|
||||
chunk_id=point_id,
|
||||
parent_chunk_id=None,
|
||||
source_id=uuid4(),
|
||||
status="published",
|
||||
content_type="text/markdown",
|
||||
language="en",
|
||||
trust_level="source",
|
||||
location={"page": index + 1},
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version=profile.version,
|
||||
embedding_model_digest=profile.model_digest,
|
||||
text=f"A safe indexed passage {index}.",
|
||||
excerpt=f"A safe indexed passage {index}.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def qdrant() -> AsyncIterator[AsyncQdrantClient]:
|
||||
client = AsyncQdrantClient(
|
||||
url=os.getenv("RAGCORE_QDRANT_URL", "http://qdrant:6333"),
|
||||
api_key=os.getenv("RAGCORE_QDRANT_API_KEY", "replace-me"),
|
||||
timeout=60,
|
||||
)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine() -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def operations(db_engine: AsyncEngine) -> PostgresIndexOperationStore:
|
||||
return PostgresIndexOperationStore(create_session_factory(db_engine))
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_rebuild_from_empty_validates_and_activates(
|
||||
qdrant: AsyncQdrantClient,
|
||||
operations: PostgresIndexOperationStore,
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
points = (make_point(profile, 0), make_point(profile, 1))
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
report = await service.build(profile, FixtureSource(points), QdrantGoldenSmoke(qdrant))
|
||||
|
||||
assert report.state is IndexBuildState.ACTIVE
|
||||
assert report.previous_collection is None
|
||||
assert report.validation.expected_count == 2
|
||||
assert report.validation.actual_count == 2
|
||||
assert report.validation.passed
|
||||
assert await store.current_alias_target(profile.alias_name) == profile.collection_name
|
||||
finally:
|
||||
await store.rollback(profile.alias_name, None)
|
||||
await qdrant.delete_collection(profile.collection_name)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_alias_rollback_restores_previous_active_collection(
|
||||
qdrant: AsyncQdrantClient,
|
||||
operations: PostgresIndexOperationStore,
|
||||
) -> None:
|
||||
first = make_profile()
|
||||
second = CollectionProfile(
|
||||
kind=first.kind,
|
||||
profile=first.profile,
|
||||
version=uuid4().hex[:12],
|
||||
dimensions=first.dimensions,
|
||||
model_digest=first.model_digest,
|
||||
)
|
||||
first_points = (make_point(first, 0),)
|
||||
second_points = (make_point(second, 1),)
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
await service.build(first, FixtureSource(first_points), QdrantGoldenSmoke(qdrant))
|
||||
second_report = await service.build(
|
||||
second, FixtureSource(second_points), QdrantGoldenSmoke(qdrant)
|
||||
)
|
||||
assert second_report.previous_collection == first.collection_name
|
||||
|
||||
await service.rollback(second_report)
|
||||
|
||||
assert await store.current_alias_target(first.alias_name) == first.collection_name
|
||||
finally:
|
||||
await store.rollback(first.alias_name, None)
|
||||
await qdrant.delete_collection(first.collection_name)
|
||||
await qdrant.delete_collection(second.collection_name)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_rebuild_rejects_direct_payload_tampering(
|
||||
qdrant: AsyncQdrantClient,
|
||||
operations: PostgresIndexOperationStore,
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
point = make_point(profile, 0)
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
await service.build(profile, FixtureSource((point,)), QdrantGoldenSmoke(qdrant))
|
||||
await qdrant.set_payload(
|
||||
collection_name=profile.collection_name,
|
||||
payload={"text": "tampered indexed content"},
|
||||
points=[str(point.point_id)],
|
||||
wait=True,
|
||||
timeout=30,
|
||||
)
|
||||
valid, malformed = await store.list_points(profile)
|
||||
assert valid == ()
|
||||
assert len(malformed) == 1
|
||||
assert malformed[0].point_id == point.point_id
|
||||
assert "wire integrity" in malformed[0].reason
|
||||
finally:
|
||||
await store.rollback(profile.alias_name, None)
|
||||
await qdrant.delete_collection(profile.collection_name)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_reconciliation_repairs_missing_changed_and_orphan_points(
|
||||
qdrant: AsyncQdrantClient,
|
||||
operations: PostgresIndexOperationStore,
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
points = (make_point(profile, 0), make_point(profile, 1))
|
||||
rogue = make_point(profile, 99)
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
adapter = QdrantIndexAdapter(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
await service.build(profile, FixtureSource(points), QdrantGoldenSmoke(qdrant))
|
||||
await adapter.delete_points(profile, (points[0].point_id,))
|
||||
await adapter.upsert_points(profile, (rogue,))
|
||||
changed = replace(
|
||||
points[1],
|
||||
payload=replace(
|
||||
points[1].payload,
|
||||
text="A changed indexed passage.",
|
||||
excerpt="A changed indexed passage.",
|
||||
),
|
||||
)
|
||||
await adapter.upsert_points(profile, (changed,))
|
||||
|
||||
report = await service.reconcile(profile, FixtureSource(points))
|
||||
|
||||
assert report.missing_point_ids == (points[0].point_id,)
|
||||
assert report.orphan_point_ids == (rogue.point_id,)
|
||||
assert report.changed_point_ids == (points[1].point_id,)
|
||||
# repaired_point_ids is deterministically sorted by str(uuid), not by
|
||||
# fixture insertion order, since both ids are independently random.
|
||||
assert set(report.repaired_point_ids) == {points[0].point_id, points[1].point_id}
|
||||
assert report.repaired_point_ids == tuple(
|
||||
sorted((points[0].point_id, points[1].point_id), key=str)
|
||||
)
|
||||
assert await store.count_points(profile) == 2
|
||||
finally:
|
||||
await store.rollback(profile.alias_name, None)
|
||||
await qdrant.delete_collection(profile.collection_name)
|
||||
@@ -0,0 +1,632 @@
|
||||
"""Integration gate: the real ``IngestUploadHandler`` against a real Qdrant
|
||||
server, searched through the real ``RetrievalPipeline``/``ProductionSearchExecutor``.
|
||||
|
||||
(This is the *handler-level* integration gate. The system-level gate that
|
||||
goes through HTTP upload -> PostgreSQL job -> worker lease -> this handler ->
|
||||
HTTP search lives in ``tests/system/``.)
|
||||
|
||||
upload payload -> security gate -> parser -> chunker -> dense embedding
|
||||
-> sparse encoding -> Qdrant upsert -> /v1/search execution path
|
||||
|
||||
Proves, against the live index, that:
|
||||
|
||||
1. the document is processed and indexed;
|
||||
2. every stored point carries a dense vector;
|
||||
3. every stored point carries a sparse vector;
|
||||
4. the document is retrievable via dense search (exact-vector query);
|
||||
5. the document is retrievable via sparse search (identifier-token query);
|
||||
6. RRF fuses both backends (both ranks present, no degradation);
|
||||
7. language / document_id filters constrain the results;
|
||||
8. **source identity**: the source UUID the *caller* supplied at upload time
|
||||
-- never a value read back from the index -- is what the ``source_ids``
|
||||
filter matches and what every candidate's provenance reports: source A's
|
||||
two documents come back for ``source_ids=[A]``, source B's one document
|
||||
for ``[B]``, neither leaks into the other, and an ad hoc upload without a
|
||||
source id belongs to the space's built-in Uploads source.
|
||||
|
||||
Embeddings use a deterministic hash-based mock transport: the point of this
|
||||
gate is the indexing/search contract, not embedding quality, and determinism
|
||||
is what makes the dense exact-match assertion (cosine similarity 1.0 with an
|
||||
identical query vector) a real proof that dense retrieval works end to end.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
|
||||
from ragcore.api.v1.search.service import (
|
||||
SearchExecutionRequest,
|
||||
SearchFilterData,
|
||||
SearchQuery,
|
||||
)
|
||||
from ragcore.application.documents import (
|
||||
DocumentRecord,
|
||||
DocumentVersionRecord,
|
||||
DocumentVersionState,
|
||||
)
|
||||
from ragcore.application.jobs.models import Job, JobContext
|
||||
from ragcore.application.retrieval.authorization import AuthorizedRetrievalScope
|
||||
from ragcore.application.sources import SourceRecord
|
||||
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
|
||||
from ragcore.domain.sources import upload_source_id
|
||||
from ragcore.infrastructure.blob.filesystem import FilesystemBlobStorage
|
||||
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.rebuild import QdrantRebuildStore
|
||||
from ragcore.infrastructure.retrieval.production_executor import (
|
||||
ProductionSearchExecutor,
|
||||
RetrievalPipeline,
|
||||
)
|
||||
from ragcore.infrastructure.security import (
|
||||
ContentSecurityGateService,
|
||||
production_content_security_policy,
|
||||
)
|
||||
|
||||
WORKSPACE_ID = uuid4()
|
||||
SPACE_ID = uuid4()
|
||||
SOURCE_A = uuid4()
|
||||
SOURCE_B = uuid4()
|
||||
DIMENSIONS = 8
|
||||
|
||||
ENGLISH_DOCUMENT = (
|
||||
"Gateway Failover Runbook\n\n"
|
||||
"When the payment gateway loses power, cycle breaker BRK-4417 in rack two.\n"
|
||||
"Wait ten seconds before restoring the feed and confirm the status light.\n"
|
||||
)
|
||||
DUTCH_DOCUMENT = (
|
||||
"Firewall Segmentatie Handleiding\n\n"
|
||||
"Dit document beschrijft de netwerksegmentatie van de firewall voor het\n"
|
||||
"productienetwerk. De regels worden per zone beheerd en moeten na iedere\n"
|
||||
"wijziging opnieuw worden gecontroleerd door een beheerder van het team.\n"
|
||||
)
|
||||
SOURCE_B_DOCUMENT = (
|
||||
"Certificate Rotation Procedure\n\n"
|
||||
"Rotate the edge certificate CERT-8820 every ninety days using the vault job.\n"
|
||||
"Confirm the fingerprint on the load balancer after the rotation completes.\n"
|
||||
)
|
||||
AD_HOC_DOCUMENT = (
|
||||
"Lunch Menu\n\nThursday soup SOUP-001 is tomato basil, served from noon in the canteen.\n"
|
||||
)
|
||||
|
||||
|
||||
def _deterministic_vector(text: str) -> list[float]:
|
||||
digest = hashlib.sha256(text.encode("utf-8")).digest()
|
||||
raw = [digest[index] / 255.0 - 0.5 for index in range(DIMENSIONS)]
|
||||
norm = math.sqrt(sum(value * value for value in raw)) or 1.0
|
||||
return [value / norm for value in raw]
|
||||
|
||||
|
||||
def _embedding_transport() -> httpx.MockTransport:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(request.content)
|
||||
vectors = [_deterministic_vector(text) for text in body["input"]]
|
||||
return httpx.Response(200, json={"embeddings": vectors})
|
||||
|
||||
return httpx.MockTransport(handler)
|
||||
|
||||
|
||||
class MemoryDocumentRepository:
|
||||
"""Just enough of DocumentRepository for the handler's happy path."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.documents: list[DocumentRecord] = []
|
||||
self.versions: dict[UUID, DocumentVersionRecord] = {}
|
||||
self.published: list[UUID] = []
|
||||
|
||||
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 next(
|
||||
(
|
||||
document
|
||||
for document in self.documents
|
||||
if document.workspace_id == workspace_id
|
||||
and document.source_key == source_key
|
||||
and document.external_id == external_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
async def publish_validated(self, version_id, artifacts, chunks): # type: ignore[no-untyped-def]
|
||||
self.versions[version_id] = replace(
|
||||
self.versions[version_id], state=DocumentVersionState.PUBLISHED
|
||||
)
|
||||
self.published.append(version_id)
|
||||
return self.versions[version_id]
|
||||
|
||||
async def reactivate_source(self, document_id: UUID) -> None:
|
||||
raise AssertionError("not exercised by this gate")
|
||||
|
||||
|
||||
class MemorySourceRegistry:
|
||||
"""Enough of ``SourceRegistry`` for ingestion that no sync run owns.
|
||||
|
||||
The handler reports every finished ingest back to the registry so a sync
|
||||
waiting on its children can settle. These uploads have no remembered item,
|
||||
so both marks legitimately match nothing and the convergence query is
|
||||
never reached -- which is exactly the branch this gate should exercise.
|
||||
|
||||
The marks used to be absent altogether. The handler's reporting step
|
||||
catches broadly (a lost convergence signal must never mask an ingest
|
||||
result), so the missing methods raised ``AttributeError`` into that catch
|
||||
and the gate quietly ran the failure path on every document while looking
|
||||
green.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.records: dict[UUID, SourceRecord] = {}
|
||||
self.marks: list[tuple[str, UUID, str]] = []
|
||||
|
||||
async def ensure_source(self, record: SourceRecord) -> SourceRecord:
|
||||
return self.records.setdefault(record.id, record)
|
||||
|
||||
async def mark_item_ingested(
|
||||
self,
|
||||
source_id: UUID,
|
||||
external_id: str,
|
||||
*,
|
||||
fingerprint: str,
|
||||
document_id: UUID | None,
|
||||
now: datetime,
|
||||
) -> bool:
|
||||
self.marks.append(("ingested", source_id, external_id))
|
||||
return False
|
||||
|
||||
async def mark_item_failed(
|
||||
self,
|
||||
source_id: UUID,
|
||||
external_id: str,
|
||||
*,
|
||||
fingerprint: str,
|
||||
failure_code: str,
|
||||
retryable: bool,
|
||||
now: datetime,
|
||||
job_id: UUID | None = None,
|
||||
) -> bool:
|
||||
self.marks.append(("failed", source_id, external_id))
|
||||
return False
|
||||
|
||||
async def summarize_items(self, source_id: UUID) -> object:
|
||||
raise AssertionError("no remembered item matched, so nothing should be summarized")
|
||||
|
||||
async def converge_sync_state(self, source_id: UUID, state: object, **kwargs: object) -> None:
|
||||
raise AssertionError("no remembered item matched, so no source should be settled")
|
||||
|
||||
|
||||
class NullAudit:
|
||||
async def record(self, **event: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def qdrant_client() -> AsyncIterator[AsyncQdrantClient]:
|
||||
client = AsyncQdrantClient(
|
||||
url=os.getenv("RAGCORE_QDRANT_URL", "http://qdrant:6333"),
|
||||
api_key=os.getenv("RAGCORE_QDRANT_API_KEY", "replace-me"),
|
||||
timeout=60,
|
||||
)
|
||||
yield client
|
||||
await client.close()
|
||||
|
||||
|
||||
async def _active_profile_store(tmp_path: Path, profile_name: str) -> JsonEmbeddingProfileStore:
|
||||
store = JsonEmbeddingProfileStore(tmp_path / "profiles.json")
|
||||
profile = EmbeddingProfile(
|
||||
id=uuid4(),
|
||||
profile=profile_name,
|
||||
version="v1",
|
||||
provider=EmbeddingProvider.OLLAMA,
|
||||
model_identifier="gate-embed:v1",
|
||||
model_digest="b" * 64,
|
||||
dimensions=DIMENSIONS,
|
||||
context_limit=2048,
|
||||
tokenizer="gate",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
)
|
||||
await store.add(profile)
|
||||
await store.activate(profile.id)
|
||||
return store
|
||||
|
||||
|
||||
def _job_context(payload: dict[str, object]) -> JobContext:
|
||||
return JobContext(
|
||||
job=Job(
|
||||
id=uuid4(),
|
||||
job_key=f"gate-{uuid4().hex}",
|
||||
operation="ingest_upload",
|
||||
target_key="gate",
|
||||
profile_version="upload-v1",
|
||||
payload=payload,
|
||||
available_at=datetime.now(UTC),
|
||||
),
|
||||
attempt_number=1,
|
||||
idempotency_key=uuid4().hex,
|
||||
cancellation=asyncio.Event(),
|
||||
)
|
||||
|
||||
|
||||
def _build_handler(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
profile_store: JsonEmbeddingProfileStore,
|
||||
repository: MemoryDocumentRepository,
|
||||
registry: MemorySourceRegistry,
|
||||
embedding_client: httpx.AsyncClient,
|
||||
qdrant_client: AsyncQdrantClient,
|
||||
) -> IngestUploadHandler:
|
||||
return IngestUploadHandler(
|
||||
document_repository=repository, # type: ignore[arg-type]
|
||||
blob_storage=FilesystemBlobStorage(tmp_path / "blobs"),
|
||||
artifact_publisher=FilesystemArtifactPublisher(tmp_path / "parsed"),
|
||||
embedding_profile_store=profile_store,
|
||||
embedding_adapter_factory=create_embedding_adapter_factory(
|
||||
settings=Settings(env="test", ollama_url="http://ollama:11434"),
|
||||
http_client=embedding_client,
|
||||
),
|
||||
qdrant_store=QdrantRebuildStore(qdrant_client),
|
||||
source_registry=registry,
|
||||
security_gate=ContentSecurityGateService(
|
||||
NullAudit(), policy=production_content_security_policy()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _ingest(
|
||||
handler: IngestUploadHandler,
|
||||
blob_storage: FilesystemBlobStorage,
|
||||
*,
|
||||
content: bytes,
|
||||
name: str,
|
||||
source_id: UUID | None = None,
|
||||
external_id: str | None = None,
|
||||
locale: str | None = None,
|
||||
media_type: str = "text/plain",
|
||||
) -> None:
|
||||
async def _bytes() -> AsyncIterator[bytes]:
|
||||
yield content
|
||||
|
||||
reference = await blob_storage.put(_bytes(), media_type=media_type, original_name=name)
|
||||
payload: dict[str, object] = {
|
||||
"blob_digest": reference.digest,
|
||||
"media_type": media_type,
|
||||
"original_name": name,
|
||||
"space_id": str(SPACE_ID),
|
||||
"workspace_id": str(WORKSPACE_ID),
|
||||
}
|
||||
if source_id is not None:
|
||||
payload["source_id"] = str(source_id)
|
||||
payload["external_id"] = external_id or name
|
||||
if locale is not None:
|
||||
payload["locale"] = locale
|
||||
await handler(_job_context(payload))
|
||||
|
||||
|
||||
def _search_request(
|
||||
query: str, *, filters: SearchFilterData | None = None
|
||||
) -> SearchExecutionRequest:
|
||||
return SearchExecutionRequest(
|
||||
principal=Principal(uuid4(), WORKSPACE_ID, PrincipalType.HUMAN, frozenset()),
|
||||
query=SearchQuery(query=query, max_results=10, filters=filters or SearchFilterData()),
|
||||
scope=AuthorizedRetrievalScope(WORKSPACE_ID, frozenset({SPACE_ID})),
|
||||
retrieval_run_id=uuid4(),
|
||||
)
|
||||
|
||||
|
||||
class _TitleLookup:
|
||||
async def titles(self, document_ids: set[UUID]) -> dict[UUID, str]:
|
||||
return dict.fromkeys(document_ids, "Gate Document")
|
||||
|
||||
|
||||
def _doc_ids(result) -> set[UUID]: # type: ignore[no-untyped-def]
|
||||
return {hit.citation.document_id for hit in result.hits}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_to_hybrid_search_gate(
|
||||
tmp_path: Path, qdrant_client: AsyncQdrantClient
|
||||
) -> None:
|
||||
profile_name = f"gate-{uuid4().hex[:10]}"
|
||||
profile_store = await _active_profile_store(tmp_path, profile_name)
|
||||
blob_storage = FilesystemBlobStorage(tmp_path / "blobs")
|
||||
embedding_client = httpx.AsyncClient(transport=_embedding_transport(), follow_redirects=False)
|
||||
repository = MemoryDocumentRepository()
|
||||
registry = MemorySourceRegistry()
|
||||
handler = _build_handler(
|
||||
tmp_path,
|
||||
profile_store=profile_store,
|
||||
repository=repository,
|
||||
registry=registry,
|
||||
embedding_client=embedding_client,
|
||||
qdrant_client=qdrant_client,
|
||||
)
|
||||
collection_name = f"rag_dense_{profile_name}_v1"
|
||||
try:
|
||||
# 1. Ingest: source A owns two documents, source B one, and one ad
|
||||
# hoc upload names no source at all. The identities below are the
|
||||
# *inputs*; nothing is read back from Qdrant to build the filters.
|
||||
await _ingest(
|
||||
handler,
|
||||
blob_storage,
|
||||
content=ENGLISH_DOCUMENT.encode(),
|
||||
name="runbook.txt",
|
||||
source_id=SOURCE_A,
|
||||
external_id="runbooks/gateway",
|
||||
)
|
||||
await _ingest(
|
||||
handler,
|
||||
blob_storage,
|
||||
content=DUTCH_DOCUMENT.encode(),
|
||||
name="firewall.txt",
|
||||
source_id=SOURCE_A,
|
||||
external_id="handleidingen/firewall",
|
||||
)
|
||||
await _ingest(
|
||||
handler,
|
||||
blob_storage,
|
||||
content=SOURCE_B_DOCUMENT.encode(),
|
||||
name="certificates.txt",
|
||||
source_id=SOURCE_B,
|
||||
external_id="procedures/certificates",
|
||||
)
|
||||
await _ingest(handler, blob_storage, content=AD_HOC_DOCUMENT.encode(), name="menu.txt")
|
||||
assert len(repository.published) == 4
|
||||
documents_by_source: dict[UUID, set[UUID]] = {}
|
||||
for document in repository.documents:
|
||||
documents_by_source.setdefault(document.source_id, set()).add(document.id)
|
||||
assert len(documents_by_source[SOURCE_A]) == 2
|
||||
assert len(documents_by_source[SOURCE_B]) == 1
|
||||
uploads_source = upload_source_id(SPACE_ID)
|
||||
assert len(documents_by_source[uploads_source]) == 1
|
||||
assert set(registry.records) == {SOURCE_A, SOURCE_B, uploads_source}
|
||||
|
||||
# 2 + 3. Every stored point has both a dense and a sparse vector, and
|
||||
# stores exactly the caller's source identity.
|
||||
records, _ = await qdrant_client.scroll(
|
||||
collection_name=collection_name,
|
||||
limit=100,
|
||||
with_payload=True,
|
||||
with_vectors=True,
|
||||
)
|
||||
assert records
|
||||
stored_sources: set[str] = set()
|
||||
for record in records:
|
||||
vectors = record.vector
|
||||
assert isinstance(vectors, dict)
|
||||
assert len(vectors["dense"]) == DIMENSIONS
|
||||
sparse = vectors["sparse"]
|
||||
indices = getattr(sparse, "indices", None) or sparse["indices"]
|
||||
assert len(indices) > 0
|
||||
assert record.payload is not None
|
||||
stored_sources.add(str(record.payload["source_id"]))
|
||||
assert stored_sources == {str(SOURCE_A), str(SOURCE_B), str(uploads_source)}
|
||||
|
||||
pipeline = RetrievalPipeline(
|
||||
qdrant_client=qdrant_client,
|
||||
embedding_profile_store=profile_store,
|
||||
embedding_adapter_factory=create_embedding_adapter_factory(
|
||||
settings=Settings(env="test", ollama_url="http://ollama:11434"),
|
||||
http_client=embedding_client,
|
||||
),
|
||||
)
|
||||
executor = ProductionSearchExecutor(pipeline, _TitleLookup()) # type: ignore[arg-type]
|
||||
|
||||
# 5 + 6. Sparse retrieval: the identifier token only the English
|
||||
# runbook contains must surface it, fused from both backends without
|
||||
# degradation.
|
||||
sparse_led = await executor.execute(_search_request("cycle breaker BRK-4417"))
|
||||
assert sparse_led.degradation_codes == ()
|
||||
assert sparse_led.hits
|
||||
top = sparse_led.hits[0]
|
||||
assert "BRK-4417" in top.text
|
||||
assert top.fused.sparse_rank is not None
|
||||
assert top.sparse_score is not None
|
||||
# Provenance reports the caller's source identity, not a synthetic one.
|
||||
assert top.fused.candidate.provenance.source_id == SOURCE_A
|
||||
assert top.citation.source_uri == f"ragcore://source/{SOURCE_A}"
|
||||
|
||||
# 4. Dense retrieval: querying with the exact stored chunk text embeds
|
||||
# to the identical vector, so it must be the dense top hit.
|
||||
dense_led = await executor.execute(_search_request(top.text))
|
||||
assert dense_led.hits
|
||||
assert dense_led.hits[0].fused.dense_rank == 1
|
||||
assert dense_led.hits[0].dense_score == pytest.approx(1.0, abs=1e-5)
|
||||
assert dense_led.hits[0].citation.chunk_id == top.citation.chunk_id
|
||||
|
||||
# 7a. Language filter: detected Dutch content is retrievable as nl,
|
||||
# and the nl filter excludes the English runbook.
|
||||
dutch_only = await executor.execute(
|
||||
_search_request(
|
||||
"firewall segmentatie productienetwerk",
|
||||
filters=SearchFilterData(languages=("nl",)),
|
||||
)
|
||||
)
|
||||
assert dutch_only.hits
|
||||
assert all("BRK-4417" not in hit.text for hit in dutch_only.hits)
|
||||
|
||||
# 7b. document_ids filter constrains to exactly one document.
|
||||
target_document = top.citation.document_id
|
||||
only_document = await executor.execute(
|
||||
_search_request(
|
||||
"runbook firewall breaker segmentatie certificate",
|
||||
filters=SearchFilterData(document_ids=(target_document,)),
|
||||
)
|
||||
)
|
||||
assert only_document.hits
|
||||
assert _doc_ids(only_document) == {target_document}
|
||||
|
||||
# 8. Source filter on the ORIGINAL input identities.
|
||||
broad_query = "runbook firewall breaker segmentatie certificate rotation soup menu"
|
||||
from_a = await executor.execute(
|
||||
_search_request(broad_query, filters=SearchFilterData(source_ids=(SOURCE_A,)))
|
||||
)
|
||||
assert from_a.hits
|
||||
assert _doc_ids(from_a) == documents_by_source[SOURCE_A]
|
||||
assert {hit.fused.candidate.provenance.source_id for hit in from_a.hits} == {SOURCE_A}
|
||||
|
||||
from_b = await executor.execute(
|
||||
_search_request(broad_query, filters=SearchFilterData(source_ids=(SOURCE_B,)))
|
||||
)
|
||||
assert from_b.hits
|
||||
assert _doc_ids(from_b) == documents_by_source[SOURCE_B]
|
||||
|
||||
# That source B's content is really reachable is proven the way the
|
||||
# identifier assertions above prove it: with the one token that only
|
||||
# the wanted chunk contains.
|
||||
#
|
||||
# It has to be *only* that token. This document chunks into a heading
|
||||
# ("Certificate Rotation Procedure") and a body, both under the same
|
||||
# parent, and near-duplicate suppression in `fuse_candidates` returns
|
||||
# exactly one per parent. A query that also says "certificate" or
|
||||
# "rotation" hands the heading -- three words, every one of them a
|
||||
# match -- the better sparse score, and the body loses its own
|
||||
# identifier. The heading carries no "CERT-8820", so this query can
|
||||
# only be answered by the chunk under test.
|
||||
certificates = await executor.execute(
|
||||
_search_request("CERT-8820", filters=SearchFilterData(source_ids=(SOURCE_B,)))
|
||||
)
|
||||
assert certificates.hits
|
||||
assert any("CERT-8820" in hit.text for hit in certificates.hits)
|
||||
|
||||
from_uploads = await executor.execute(
|
||||
_search_request(broad_query, filters=SearchFilterData(source_ids=(uploads_source,)))
|
||||
)
|
||||
assert from_uploads.hits
|
||||
assert _doc_ids(from_uploads) == documents_by_source[uploads_source]
|
||||
|
||||
# Source + document filters compose; a document of another source is
|
||||
# excluded even when named explicitly.
|
||||
[doc_b] = documents_by_source[SOURCE_B]
|
||||
crossed = await executor.execute(
|
||||
_search_request(
|
||||
broad_query,
|
||||
filters=SearchFilterData(source_ids=(SOURCE_A,), document_ids=(doc_b,)),
|
||||
)
|
||||
)
|
||||
assert crossed.hits == ()
|
||||
|
||||
# A filter for a foreign source excludes everything.
|
||||
nothing = await executor.execute(
|
||||
_search_request(broad_query, filters=SearchFilterData(source_ids=(uuid4(),)))
|
||||
)
|
||||
assert nothing.hits == ()
|
||||
finally:
|
||||
await qdrant_client.delete_collection(collection_name)
|
||||
await embedding_client.aclose()
|
||||
|
||||
|
||||
def _minimal_pdf() -> bytes:
|
||||
"""A one-page PDF with a real text layer, built without external tools."""
|
||||
|
||||
content_stream = (
|
||||
b"BT /F1 18 Tf 72 720 Td (Backup Restore Procedure) Tj"
|
||||
b" 0 -30 Td (Verify snapshot SNAP-9911 before restoring the archive.) Tj ET"
|
||||
)
|
||||
objects = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R"
|
||||
b" /Resources << /Font << /F1 5 0 R >> >> >>",
|
||||
b"<< /Length %d >>\nstream\n" % len(content_stream) + content_stream + b"\nendstream",
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
]
|
||||
out = b"%PDF-1.4\n"
|
||||
offsets = []
|
||||
for index, body in enumerate(objects, 1):
|
||||
offsets.append(len(out))
|
||||
out += b"%d 0 obj\n" % index + body + b"\nendobj\n"
|
||||
xref = len(out)
|
||||
out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objects) + 1)
|
||||
for offset in offsets:
|
||||
out += b"%010d 00000 n \n" % offset
|
||||
out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (
|
||||
len(objects) + 1,
|
||||
xref,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_pdf_upload_to_citation_gate(
|
||||
tmp_path: Path, qdrant_client: AsyncQdrantClient
|
||||
) -> None:
|
||||
"""Gate B: a real PDF flows through the security gate and Docling to
|
||||
page-located citations."""
|
||||
|
||||
pytest.importorskip("docling")
|
||||
profile_name = f"gate-{uuid4().hex[:10]}"
|
||||
profile_store = await _active_profile_store(tmp_path, profile_name)
|
||||
blob_storage = FilesystemBlobStorage(tmp_path / "blobs")
|
||||
embedding_client = httpx.AsyncClient(transport=_embedding_transport(), follow_redirects=False)
|
||||
repository = MemoryDocumentRepository()
|
||||
handler = _build_handler(
|
||||
tmp_path,
|
||||
profile_store=profile_store,
|
||||
repository=repository,
|
||||
registry=MemorySourceRegistry(),
|
||||
embedding_client=embedding_client,
|
||||
qdrant_client=qdrant_client,
|
||||
)
|
||||
collection_name = f"rag_dense_{profile_name}_v1"
|
||||
try:
|
||||
await _ingest(
|
||||
handler,
|
||||
blob_storage,
|
||||
content=_minimal_pdf(),
|
||||
name="restore.pdf",
|
||||
media_type="application/pdf",
|
||||
)
|
||||
assert len(repository.published) == 1
|
||||
[version] = repository.versions.values()
|
||||
assert version.parser_version.startswith("docling-")
|
||||
|
||||
pipeline = RetrievalPipeline(
|
||||
qdrant_client=qdrant_client,
|
||||
embedding_profile_store=profile_store,
|
||||
embedding_adapter_factory=create_embedding_adapter_factory(
|
||||
settings=Settings(env="test", ollama_url="http://ollama:11434"),
|
||||
http_client=embedding_client,
|
||||
),
|
||||
)
|
||||
executor = ProductionSearchExecutor(pipeline, _TitleLookup()) # type: ignore[arg-type]
|
||||
result = await executor.execute(_search_request("snapshot SNAP-9911 restore archive"))
|
||||
assert result.degradation_codes == ()
|
||||
assert result.hits
|
||||
top = result.hits[0]
|
||||
assert "SNAP-9911" in top.text
|
||||
# The citation carries the page the chunk came from.
|
||||
assert top.citation.location.get("page_start") == 1
|
||||
assert top.citation.location.get("page_end") == 1
|
||||
finally:
|
||||
await qdrant_client.delete_collection(collection_name)
|
||||
await embedding_client.aclose()
|
||||
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from ragcore.application.jobs import (
|
||||
Job,
|
||||
JobContext,
|
||||
JobWorker,
|
||||
LeaseLostError,
|
||||
RetryableJobError,
|
||||
TerminalJobError,
|
||||
)
|
||||
from ragcore.config import Settings
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.jobs import PostgresJobRepository
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop_policy() -> asyncio.AbstractEventLoopPolicy:
|
||||
"""psycopg's async mode refuses Windows' default proactor loop; the other
|
||||
PostgreSQL-backed modules already declare this, so this one does too."""
|
||||
|
||||
if sys.platform == "win32":
|
||||
return asyncio.WindowsSelectorEventLoopPolicy()
|
||||
return asyncio.get_event_loop_policy()
|
||||
|
||||
|
||||
async def _empty_the_queue(factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
"""Leave ``jobs.jobs`` as this test found it --- before *and* after.
|
||||
|
||||
The queue is one shared table, and the ingestion and system gates later
|
||||
in a full run drive a real ``JobWorker``, which claims the oldest
|
||||
*available* job in it. A leftover fixture job is therefore claimed instead
|
||||
of theirs, and they fail for a reason that has nothing to do with what
|
||||
they test.
|
||||
"""
|
||||
|
||||
async with factory() as session:
|
||||
await session.execute(text("TRUNCATE jobs.events, jobs.attempts, jobs.jobs CASCADE"))
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def session_factory() -> async_sessionmaker[AsyncSession]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
factory = create_session_factory(engine)
|
||||
await _empty_the_queue(factory)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
await _empty_the_queue(factory)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
WORKSPACE_ID = uuid4()
|
||||
|
||||
|
||||
def _job(
|
||||
now: datetime, *, key: str = "job-1", operation: str = "parse", max_attempts: int = 3
|
||||
) -> Job:
|
||||
return Job(
|
||||
id=uuid4(),
|
||||
# Every job the platform enqueues is owned by exactly one workspace;
|
||||
# the repository refuses to write one that is not.
|
||||
workspace_id=WORKSPACE_ID,
|
||||
job_key=key,
|
||||
operation=operation,
|
||||
target_key="blob:abc",
|
||||
profile_version="parser-v1",
|
||||
payload={"source": "fixture"},
|
||||
available_at=now,
|
||||
max_attempts=max_attempts,
|
||||
trace_id="trace-job",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_concurrent_workers_only_one_claims_a_ready_job(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
now = datetime.now(UTC)
|
||||
first = PostgresJobRepository(session_factory)
|
||||
second = PostgresJobRepository(session_factory)
|
||||
await first.enqueue(_job(now))
|
||||
|
||||
claims = await asyncio.gather(
|
||||
first.claim_next("worker-a", now=now, lease_duration=timedelta(minutes=1)),
|
||||
second.claim_next("worker-b", now=now, lease_duration=timedelta(minutes=1)),
|
||||
)
|
||||
|
||||
assert sum(claim is not None for claim in claims) == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_expired_lease_is_reclaimed_and_old_owner_cannot_complete(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
now = datetime.now(UTC)
|
||||
repository = PostgresJobRepository(session_factory)
|
||||
await repository.enqueue(_job(now))
|
||||
first = await repository.claim_next(
|
||||
"worker-a", now=now, lease_duration=timedelta(seconds=1)
|
||||
)
|
||||
assert first is not None
|
||||
|
||||
reclaimed_at = now + timedelta(seconds=2)
|
||||
second = await repository.claim_next(
|
||||
"worker-b", now=reclaimed_at, lease_duration=timedelta(minutes=1)
|
||||
)
|
||||
assert second is not None
|
||||
assert second.attempt_number == 2
|
||||
with pytest.raises(LeaseLostError):
|
||||
await repository.complete(first, now=reclaimed_at)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_worker_distinguishes_retryable_and_terminal_errors(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
now = datetime.now(UTC)
|
||||
repository = PostgresJobRepository(session_factory)
|
||||
retry_job = _job(now, key="retry", max_attempts=2)
|
||||
terminal_job = replace(
|
||||
_job(now, key="terminal", operation="terminal", max_attempts=2),
|
||||
available_at=now + timedelta(seconds=1),
|
||||
)
|
||||
await repository.enqueue(retry_job)
|
||||
await repository.enqueue(terminal_job)
|
||||
received: list[JobContext] = []
|
||||
|
||||
async def retry(context: JobContext) -> None:
|
||||
received.append(context)
|
||||
raise RetryableJobError("temporary", retry_after_seconds=30)
|
||||
|
||||
async def terminal(_context: JobContext) -> None:
|
||||
raise TerminalJobError("invalid input")
|
||||
|
||||
worker = JobWorker(
|
||||
repository,
|
||||
{"parse": retry},
|
||||
worker_id="worker-retry",
|
||||
clock=lambda: now,
|
||||
)
|
||||
assert await worker.run_once()
|
||||
worker_terminal = JobWorker(
|
||||
repository,
|
||||
{"terminal": terminal},
|
||||
worker_id="worker-terminal",
|
||||
clock=lambda: now + timedelta(seconds=2),
|
||||
)
|
||||
assert await worker_terminal.run_once()
|
||||
|
||||
assert received[0].idempotency_key == "retry"
|
||||
assert isinstance(received[0].cancellation, asyncio.Event)
|
||||
async with session_factory() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
text("SELECT job_key, status, last_error_code FROM jobs.jobs ORDER BY job_key")
|
||||
)
|
||||
).all()
|
||||
assert rows == [
|
||||
("retry", "retry_wait", "retryable_error"),
|
||||
("terminal", "terminal", "terminal_error"),
|
||||
]
|
||||
async with session_factory() as session:
|
||||
event_rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT event_type, error_code FROM jobs.events "
|
||||
"WHERE event_type IN ('retry_scheduled', 'terminal') "
|
||||
"ORDER BY event_type"
|
||||
)
|
||||
)
|
||||
).all()
|
||||
assert ("retry_scheduled", "retryable_error") in event_rows
|
||||
assert ("terminal", "terminal_error") in event_rows
|
||||
@@ -0,0 +1,236 @@
|
||||
"""The jobs listing filters and pages in SQL, against real PostgreSQL.
|
||||
|
||||
The bug these exist for was invisible to every fake: the screen asked for the
|
||||
most recently updated hundred jobs of a workspace and then dropped the
|
||||
non-matching ones in Python. With fewer than a hundred jobs in the fixture
|
||||
that is indistinguishable from a real query -- and with more than a hundred it
|
||||
silently answers "no jobs match" for a job that plainly exists.
|
||||
|
||||
So the fixtures here are deliberately larger than one page, and the failed job
|
||||
under test is deliberately *older* than everything on it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from ragcore.application.jobs import (
|
||||
Job,
|
||||
JobListFilter,
|
||||
JobStatus,
|
||||
RetryableJobError,
|
||||
)
|
||||
from ragcore.config import Settings
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.jobs import PostgresJobRepository
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.security]
|
||||
|
||||
WORKSPACE_A = uuid4()
|
||||
WORKSPACE_B = uuid4()
|
||||
|
||||
PAGE_SIZE = 50
|
||||
# What the screen used to fetch before filtering in Python. The fixture for
|
||||
# the filtering test has to exceed it, or the old implementation would happen
|
||||
# to pass: with fewer jobs than this, "fetch a bounded page, then filter" and
|
||||
# "filter, then fetch a bounded page" return the same rows.
|
||||
LEGACY_FETCH_LIMIT = 100
|
||||
BASE = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop_policy() -> asyncio.AbstractEventLoopPolicy:
|
||||
"""psycopg's async mode cannot run on Windows' default proactor loop."""
|
||||
|
||||
if sys.platform == "win32":
|
||||
return asyncio.WindowsSelectorEventLoopPolicy()
|
||||
return asyncio.get_event_loop_policy()
|
||||
|
||||
|
||||
async def _empty_the_queue(factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
"""Leave ``jobs.jobs`` as this test found it --- before *and* after.
|
||||
|
||||
The queue is one shared table, and the ingestion and system gates later
|
||||
in a full run drive a real ``JobWorker``, which claims the oldest
|
||||
*available* job in it. A leftover fixture job is therefore claimed instead
|
||||
of theirs, and they fail for a reason that has nothing to do with what
|
||||
they test.
|
||||
"""
|
||||
|
||||
async with factory() as session:
|
||||
await session.execute(text("TRUNCATE jobs.events, jobs.attempts, jobs.jobs CASCADE"))
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def session_factory() -> async_sessionmaker[AsyncSession]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
factory = create_session_factory(engine)
|
||||
await _empty_the_queue(factory)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
await _empty_the_queue(factory)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def _job(
|
||||
workspace_id: UUID,
|
||||
key: str,
|
||||
*,
|
||||
operation: str = "ingest_upload",
|
||||
available_at: datetime,
|
||||
) -> Job:
|
||||
return Job(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
job_key=key,
|
||||
operation=operation,
|
||||
target_key="blob:abc",
|
||||
profile_version="upload-v1",
|
||||
payload={"original_name": f"{key}.md"},
|
||||
available_at=available_at,
|
||||
max_attempts=1,
|
||||
trace_id=f"trace-{key}",
|
||||
)
|
||||
|
||||
|
||||
async def _fail_terminally(
|
||||
repository: PostgresJobRepository, *, at: datetime
|
||||
) -> None:
|
||||
"""Drive the next claimable job to TERMINAL through the real transition.
|
||||
|
||||
``now`` is a parameter of both calls, so the failure can be stamped in the
|
||||
past -- which is the whole point: a job that failed long ago is exactly the
|
||||
one a truncated listing loses.
|
||||
"""
|
||||
|
||||
claim = await repository.claim_next("worker-1", now=at, lease_duration=timedelta(minutes=5))
|
||||
assert claim is not None
|
||||
await repository.fail(claim, RetryableJobError("nope"), now=at)
|
||||
|
||||
|
||||
async def _plant_old_failure(
|
||||
repository: PostgresJobRepository,
|
||||
*,
|
||||
workspace_id: UUID,
|
||||
key: str,
|
||||
operation: str = "ingest_upload",
|
||||
) -> UUID:
|
||||
job = await repository.enqueue(
|
||||
_job(workspace_id, key, operation=operation, available_at=BASE)
|
||||
)
|
||||
await _fail_terminally(repository, at=BASE + timedelta(seconds=1))
|
||||
return job.id
|
||||
|
||||
|
||||
async def _pad_with_newer_jobs(
|
||||
repository: PostgresJobRepository, workspace_id: UUID, count: int
|
||||
) -> None:
|
||||
for index in range(count):
|
||||
await repository.enqueue(
|
||||
_job(
|
||||
workspace_id,
|
||||
f"pad-{workspace_id.hex[:6]}-{index}",
|
||||
available_at=BASE + timedelta(hours=1, minutes=index),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_a_failure_older_than_one_page_is_still_found_by_the_status_filter(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
repository = PostgresJobRepository(session_factory)
|
||||
failed_id = await _plant_old_failure(repository, workspace_id=WORKSPACE_A, key="old-failure")
|
||||
padding = LEGACY_FETCH_LIMIT + 20
|
||||
await _pad_with_newer_jobs(repository, WORKSPACE_A, padding)
|
||||
|
||||
# It is genuinely out of reach of the old implementation: not on the first
|
||||
# page, and not even among the rows that page was cut from.
|
||||
unfiltered = await repository.list_jobs(
|
||||
workspace_id=WORKSPACE_A, filters=JobListFilter(limit=PAGE_SIZE)
|
||||
)
|
||||
assert unfiltered.total == padding + 1
|
||||
assert failed_id not in {job.id for job in unfiltered.items}
|
||||
|
||||
failed = await repository.list_jobs(
|
||||
workspace_id=WORKSPACE_A,
|
||||
filters=JobListFilter(status=JobStatus.TERMINAL, limit=PAGE_SIZE),
|
||||
)
|
||||
assert [job.id for job in failed.items] == [failed_id]
|
||||
assert failed.total == 1
|
||||
|
||||
|
||||
async def test_the_status_filter_never_widens_the_workspace_scope(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
repository = PostgresJobRepository(session_factory)
|
||||
mine = await _plant_old_failure(repository, workspace_id=WORKSPACE_A, key="mine-failed")
|
||||
theirs = await _plant_old_failure(repository, workspace_id=WORKSPACE_B, key="theirs-failed")
|
||||
|
||||
failed_for_a = await repository.list_jobs(
|
||||
workspace_id=WORKSPACE_A, filters=JobListFilter(status=JobStatus.TERMINAL)
|
||||
)
|
||||
|
||||
assert [job.id for job in failed_for_a.items] == [mine]
|
||||
assert failed_for_a.total == 1
|
||||
assert theirs not in {job.id for job in failed_for_a.items}
|
||||
|
||||
# The platform-global scope is the only one that sees both, and it has to
|
||||
# be asked for explicitly.
|
||||
everything = await repository.list_jobs(
|
||||
workspace_id=None, filters=JobListFilter(status=JobStatus.TERMINAL)
|
||||
)
|
||||
assert {job.id for job in everything.items} == {mine, theirs}
|
||||
|
||||
|
||||
async def test_status_and_operation_narrow_the_same_query(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
repository = PostgresJobRepository(session_factory)
|
||||
failed_upload = await _plant_old_failure(
|
||||
repository, workspace_id=WORKSPACE_A, key="failed-upload"
|
||||
)
|
||||
failed_sync = await _plant_old_failure(
|
||||
repository, workspace_id=WORKSPACE_A, key="failed-sync", operation="sync_source"
|
||||
)
|
||||
await _pad_with_newer_jobs(repository, WORKSPACE_A, PAGE_SIZE + 10)
|
||||
|
||||
page = await repository.list_jobs(
|
||||
workspace_id=WORKSPACE_A,
|
||||
filters=JobListFilter(status=JobStatus.TERMINAL, operation="sync_source"),
|
||||
)
|
||||
|
||||
assert [job.id for job in page.items] == [failed_sync]
|
||||
assert page.total == 1
|
||||
assert failed_upload not in {job.id for job in page.items}
|
||||
|
||||
|
||||
async def test_paging_covers_every_matching_job_exactly_once(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
repository = PostgresJobRepository(session_factory)
|
||||
total = PAGE_SIZE + 10
|
||||
await _pad_with_newer_jobs(repository, WORKSPACE_A, total)
|
||||
|
||||
first = await repository.list_jobs(
|
||||
workspace_id=WORKSPACE_A, filters=JobListFilter(status=JobStatus.READY, limit=PAGE_SIZE)
|
||||
)
|
||||
second = await repository.list_jobs(
|
||||
workspace_id=WORKSPACE_A,
|
||||
filters=JobListFilter(status=JobStatus.READY, limit=PAGE_SIZE, offset=PAGE_SIZE),
|
||||
)
|
||||
|
||||
assert first.total == second.total == total
|
||||
assert len(first.items) == PAGE_SIZE
|
||||
assert len(second.items) == total - PAGE_SIZE
|
||||
seen = [job.id for job in first.items] + [job.id for job in second.items]
|
||||
assert len(set(seen)) == total
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Job ownership against real PostgreSQL.
|
||||
|
||||
The web-level tests prove the routes ask for the right scope. These prove the
|
||||
storage layer answers it correctly -- including the two things a fake can
|
||||
never really check:
|
||||
|
||||
* that ``workspace_id = :id`` genuinely excludes legacy ``NULL`` rows (SQL
|
||||
three-valued logic, not Python's ``==``);
|
||||
* that a foreign retry is refused by the ``UPDATE``'s own predicate, so the
|
||||
row is provably unchanged afterwards rather than merely un-returned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from ragcore.application.jobs import Job, JobListFilter, JobStatus
|
||||
from ragcore.config import Settings
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.jobs import PostgresJobRepository
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.security]
|
||||
|
||||
WORKSPACE_A = uuid4()
|
||||
WORKSPACE_B = uuid4()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop_policy() -> asyncio.AbstractEventLoopPolicy:
|
||||
"""psycopg's async mode cannot run on Windows' default proactor loop."""
|
||||
|
||||
if sys.platform == "win32":
|
||||
return asyncio.WindowsSelectorEventLoopPolicy()
|
||||
return asyncio.get_event_loop_policy()
|
||||
|
||||
|
||||
async def _empty_the_queue(factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
"""Leave ``jobs.jobs`` as this test found it --- before *and* after.
|
||||
|
||||
The queue is one shared table, and the ingestion and system gates later
|
||||
in a full run drive a real ``JobWorker``, which claims the oldest
|
||||
*available* job in it. A leftover fixture job is therefore claimed instead
|
||||
of theirs, and they fail for a reason that has nothing to do with what
|
||||
they test.
|
||||
"""
|
||||
|
||||
async with factory() as session:
|
||||
await session.execute(text("TRUNCATE jobs.events, jobs.attempts, jobs.jobs CASCADE"))
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def session_factory() -> async_sessionmaker[AsyncSession]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
factory = create_session_factory(engine)
|
||||
await _empty_the_queue(factory)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
await _empty_the_queue(factory)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def _job(workspace_id: UUID, key: str) -> Job:
|
||||
return Job(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
job_key=key,
|
||||
operation="ingest_upload",
|
||||
target_key="blob:abc",
|
||||
profile_version="upload-v1",
|
||||
payload={"workspace_id": str(workspace_id)},
|
||||
available_at=datetime.now(UTC),
|
||||
trace_id=f"trace-{key}",
|
||||
)
|
||||
|
||||
|
||||
async def _plant_legacy_job(factory: async_sessionmaker[AsyncSession]) -> UUID:
|
||||
"""A row exactly as migration 0018 leaves an unbackfillable job: a payload
|
||||
that still names a workspace, and no ``workspace_id`` column value."""
|
||||
|
||||
job_id = uuid4()
|
||||
now = datetime.now(UTC)
|
||||
async with factory() as session, session.begin():
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO jobs.jobs (id, job_key, operation, target_key, profile_version,
|
||||
payload, status, available_at, max_attempts,
|
||||
attempt_count, trace_id, last_error_code,
|
||||
last_error_details, created_at, updated_at)
|
||||
VALUES (:id, 'legacy', 'ingest_upload', 'blob:legacy', 'upload-v1',
|
||||
CAST(:payload AS json), 'terminal', :now, 3, 3, 'trace-legacy',
|
||||
'retryable_error', CAST('{}' AS json), :now, :now)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": job_id,
|
||||
"payload": f'{{"workspace_id": "{WORKSPACE_A}"}}',
|
||||
"now": now,
|
||||
},
|
||||
)
|
||||
return job_id
|
||||
|
||||
|
||||
async def test_a_new_job_cannot_be_written_without_an_owner(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
repository = PostgresJobRepository(session_factory)
|
||||
|
||||
with pytest.raises(ValueError, match="must name the workspace"):
|
||||
await repository.enqueue(
|
||||
Job(
|
||||
id=uuid4(),
|
||||
job_key="ownerless",
|
||||
operation="ingest_upload",
|
||||
target_key="blob:abc",
|
||||
profile_version="upload-v1",
|
||||
available_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_listing_and_counting_are_confined_to_one_workspace(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
repository = PostgresJobRepository(session_factory)
|
||||
await repository.enqueue(_job(WORKSPACE_A, "a1"))
|
||||
await repository.enqueue(_job(WORKSPACE_A, "a2"))
|
||||
await repository.enqueue(_job(WORKSPACE_B, "b1"))
|
||||
legacy_id = await _plant_legacy_job(session_factory)
|
||||
|
||||
page_a = await repository.list_jobs(workspace_id=WORKSPACE_A, filters=JobListFilter())
|
||||
assert {job.job_key for job in page_a.items} == {"a1", "a2"}
|
||||
assert page_a.total == 2
|
||||
assert legacy_id not in {job.id for job in page_a.items}
|
||||
|
||||
counts_a = await repository.status_counts(workspace_id=WORKSPACE_A)
|
||||
assert counts_a == {JobStatus.READY.value: 2}
|
||||
|
||||
# The platform-global scope is a different, explicit query.
|
||||
page_all = await repository.list_jobs(workspace_id=None, filters=JobListFilter())
|
||||
assert {job.job_key for job in page_all.items} == {"a1", "a2", "b1", "legacy"}
|
||||
assert page_all.total == 4
|
||||
|
||||
|
||||
async def test_a_foreign_or_legacy_job_reads_as_missing(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
repository = PostgresJobRepository(session_factory)
|
||||
foreign = await repository.enqueue(_job(WORKSPACE_B, "b1"))
|
||||
legacy_id = await _plant_legacy_job(session_factory)
|
||||
|
||||
assert await repository.get_owned(foreign.id, workspace_id=WORKSPACE_A) is None
|
||||
assert await repository.get_owned(legacy_id, workspace_id=WORKSPACE_A) is None
|
||||
# Both exist; only the explicit global scope may see them.
|
||||
assert await repository.get_owned(foreign.id, workspace_id=None) is not None
|
||||
assert await repository.get_owned(legacy_id, workspace_id=None) is not None
|
||||
|
||||
|
||||
async def test_a_foreign_retry_leaves_the_job_byte_for_byte_unchanged(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
repository = PostgresJobRepository(session_factory)
|
||||
foreign = await repository.enqueue(_job(WORKSPACE_B, "b1"))
|
||||
now = datetime.now(UTC)
|
||||
async with session_factory() as session, session.begin():
|
||||
await session.execute(
|
||||
text(
|
||||
"UPDATE jobs.jobs SET status = 'terminal', attempt_count = 3, "
|
||||
"last_error_code = 'retryable_error' WHERE id = :id"
|
||||
),
|
||||
{"id": foreign.id},
|
||||
)
|
||||
|
||||
async def snapshot() -> tuple[object, ...]:
|
||||
async with session_factory() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT status, attempt_count, available_at, updated_at, leased_by "
|
||||
"FROM jobs.jobs WHERE id = :id"
|
||||
),
|
||||
{"id": foreign.id},
|
||||
)
|
||||
).one()
|
||||
return tuple(row)
|
||||
|
||||
before = await snapshot()
|
||||
assert await repository.requeue(foreign.id, workspace_id=WORKSPACE_A, now=now) is None
|
||||
assert await snapshot() == before
|
||||
|
||||
# And the owner can still recover it, so the refusal is scoping and not a
|
||||
# blanket "requeue is broken".
|
||||
requeued = await repository.requeue(foreign.id, workspace_id=WORKSPACE_B, now=now)
|
||||
assert requeued is not None and requeued.status is JobStatus.READY
|
||||
|
||||
|
||||
async def test_a_legacy_job_is_immutable_for_workspace_scoped_callers(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
repository = PostgresJobRepository(session_factory)
|
||||
legacy_id = await _plant_legacy_job(session_factory)
|
||||
now = datetime.now(UTC)
|
||||
|
||||
assert await repository.requeue(legacy_id, workspace_id=WORKSPACE_A, now=now) is None
|
||||
still = await repository.get(legacy_id)
|
||||
assert still is not None and still.status is JobStatus.TERMINAL
|
||||
assert still.workspace_id is None
|
||||
|
||||
# Only the explicit global scope may act on it.
|
||||
assert await repository.requeue(legacy_id, workspace_id=None, now=now) is not None
|
||||
|
||||
|
||||
async def test_enqueued_jobs_round_trip_their_owner(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
repository = PostgresJobRepository(session_factory)
|
||||
|
||||
stored = await repository.enqueue(_job(WORKSPACE_A, "a1"))
|
||||
|
||||
assert stored.workspace_id == WORKSPACE_A
|
||||
reread = await repository.get_owned(stored.id, workspace_id=WORKSPACE_A)
|
||||
assert reread is not None and reread.workspace_id == WORKSPACE_A
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Real Postgres coverage for ``JobRepository.get`` -- the read side ``GET
|
||||
/v1/jobs/{job_id}`` needs so ``POST /v1/uploads``'s ``status_url`` resolves
|
||||
instead of 404ing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from ragcore.application.jobs.models import Job
|
||||
from ragcore.config import Settings
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.jobs import PostgresJobRepository
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop_policy() -> asyncio.AbstractEventLoopPolicy:
|
||||
if sys.platform == "win32":
|
||||
return asyncio.WindowsSelectorEventLoopPolicy()
|
||||
return asyncio.get_event_loop_policy()
|
||||
|
||||
|
||||
async def _empty_the_queue(factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
"""Leave ``jobs.jobs`` as this test found it --- before *and* after.
|
||||
|
||||
The queue is one shared table, and the ingestion and system gates later in
|
||||
a full run drive a real ``JobWorker``, which claims the oldest *available*
|
||||
job in it. A single leftover READY job is enough to make one of those
|
||||
gates process the wrong work and fail for a reason that has nothing to do
|
||||
with what it tests.
|
||||
"""
|
||||
|
||||
async with factory() as session:
|
||||
await session.execute(text("TRUNCATE jobs.events, jobs.attempts, jobs.jobs CASCADE"))
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session_factory() -> async_sessionmaker[AsyncSession]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
factory = create_session_factory(engine)
|
||||
await _empty_the_queue(factory)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
await _empty_the_queue(factory)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_get_returns_the_enqueued_job(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
repository = PostgresJobRepository(session_factory)
|
||||
workspace_id = uuid4()
|
||||
job = Job(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
job_key=f"get-job-{uuid4()}",
|
||||
operation="ingest_upload",
|
||||
target_key="blob:abc",
|
||||
profile_version="upload-v1",
|
||||
payload={"workspace_id": str(workspace_id)},
|
||||
available_at=datetime.now(UTC),
|
||||
trace_id="trace-get-job",
|
||||
)
|
||||
await repository.enqueue(job)
|
||||
|
||||
found = await repository.get(job.id)
|
||||
|
||||
assert found is not None
|
||||
assert found.id == job.id
|
||||
assert found.operation == "ingest_upload"
|
||||
assert found.payload == job.payload
|
||||
assert found.workspace_id == workspace_id
|
||||
|
||||
|
||||
async def test_get_returns_none_for_an_unknown_id(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
repository = PostgresJobRepository(session_factory)
|
||||
assert await repository.get(uuid4()) is None
|
||||
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
from dataclasses import replace
|
||||
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 ragcore.application.limits import ConcurrencyBudget
|
||||
from ragcore.application.models import EmbeddingProfileRegistry
|
||||
from ragcore.application.models.registry import EmbeddingProfileNotFoundError
|
||||
from ragcore.domain.control import DataPolicy
|
||||
from ragcore.domain.models import EmbeddingProfile, EmbeddingProvider, NormalizationMode
|
||||
from ragcore.infrastructure.models import InMemoryEmbeddingProfileStore, OllamaEmbeddingAdapter
|
||||
from ragcore.infrastructure.models.ollama import EmbeddingProviderError
|
||||
from ragcore.infrastructure.models.persistence import JsonEmbeddingProfileStore
|
||||
from ragcore.platform.telemetry import TelemetryMetrics
|
||||
|
||||
|
||||
def profile(**changes: object) -> EmbeddingProfile:
|
||||
value: dict[str, object] = {
|
||||
"id": uuid4(),
|
||||
"profile": "dense",
|
||||
"version": "v1",
|
||||
"provider": EmbeddingProvider.OLLAMA,
|
||||
"model_identifier": "nomic-embed-text:1.5",
|
||||
"model_digest": "a" * 64,
|
||||
"dimensions": 2,
|
||||
"context_limit": 4096,
|
||||
"tokenizer": "sentencepiece-v1",
|
||||
"data_policy": DataPolicy.LOCAL_ONLY,
|
||||
"max_batch_size": 2,
|
||||
"max_retries": 2,
|
||||
"normalization": NormalizationMode.L2,
|
||||
}
|
||||
value.update(changes)
|
||||
return EmbeddingProfile(**value)
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, responses: list[httpx.Response]) -> None:
|
||||
self.responses = responses
|
||||
self.requests: list[dict[str, object]] = []
|
||||
|
||||
async def post(self, url: str, **kwargs: object) -> httpx.Response:
|
||||
self.requests.append({"url": url, **kwargs})
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
async def no_sleep(_: float) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def response(status: int, body: object) -> httpx.Response:
|
||||
return httpx.Response(status, json=body, request=httpx.Request("POST", "http://test"))
|
||||
|
||||
|
||||
|
||||
def test_adapter_rejects_nonlocal_provider_hosts() -> None:
|
||||
with pytest.raises(ValueError, match="allowlisted local"):
|
||||
OllamaEmbeddingAdapter(
|
||||
profile(),
|
||||
FakeClient([]), # type: ignore[arg-type]
|
||||
base_url="http://169.254.169.254",
|
||||
)
|
||||
def test_profile_is_immutable_and_rejects_floating_identifiers() -> None:
|
||||
value = profile()
|
||||
with pytest.raises(AttributeError):
|
||||
value.model_identifier = "nomic-embed-text:latest" # type: ignore[misc]
|
||||
with pytest.raises(ValueError, match="floating"):
|
||||
profile(model_identifier="nomic-embed-text:latest")
|
||||
with pytest.raises(ValueError, match="digest"):
|
||||
profile(model_digest="not-a-digest")
|
||||
with pytest.raises(ValueError, match="dimensions"):
|
||||
profile(dimensions=0)
|
||||
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_json_store_persists_profile_and_exact_active_id(tmp_path) -> None:
|
||||
path = tmp_path / "models.json"
|
||||
first = profile()
|
||||
store = JsonEmbeddingProfileStore(path)
|
||||
await store.add(first)
|
||||
await store.activate(first.id)
|
||||
|
||||
recovered = JsonEmbeddingProfileStore(path)
|
||||
assert await recovered.get(first.id) == first
|
||||
assert await recovered.get_active() == first
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_registry_activates_by_exact_profile_id_only() -> None:
|
||||
store = InMemoryEmbeddingProfileStore()
|
||||
registry = EmbeddingProfileRegistry(store)
|
||||
first = profile()
|
||||
second = profile(version="v2")
|
||||
await registry.register(first)
|
||||
with pytest.raises(ValueError, match="logical revision"):
|
||||
await registry.register(replace(first, id=uuid4(), model_digest="b" * 64))
|
||||
await registry.register(second)
|
||||
await registry.activate(second.id)
|
||||
assert (await registry.active()).id == second.id
|
||||
with pytest.raises(EmbeddingProfileNotFoundError):
|
||||
await registry.activate(uuid4())
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_adapter_batches_prefixes_and_normalizes() -> None:
|
||||
client = FakeClient(
|
||||
[
|
||||
response(200, {"embeddings": [[3.0, 4.0], [0.0, 2.0]]}),
|
||||
response(200, {"embeddings": [[1.0, 0.0]]}),
|
||||
]
|
||||
)
|
||||
adapter = OllamaEmbeddingAdapter(
|
||||
profile(document_prefix="passage: "),
|
||||
client, # type: ignore[arg-type]
|
||||
base_url="http://ollama",
|
||||
sleep=no_sleep,
|
||||
)
|
||||
vectors = await adapter.embed(("one", "two", "three"))
|
||||
assert len(vectors) == 3
|
||||
assert all(math.isclose(math.sqrt(sum(x * x for x in vector)), 1.0) for vector in vectors)
|
||||
assert len(client.requests) == 2
|
||||
assert client.requests[0]["json"] == {
|
||||
"model": "nomic-embed-text:1.5",
|
||||
"input": ["passage: one", "passage: two"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_adapter_bounds_retries_timeout_and_response_shape() -> None:
|
||||
client = FakeClient(
|
||||
[
|
||||
response(503, {"error": "busy"}),
|
||||
response(429, {"error": "busy"}),
|
||||
response(200, {"embeddings": [[1.0, 0.0]]}),
|
||||
]
|
||||
)
|
||||
adapter = OllamaEmbeddingAdapter(profile(), client, base_url="http://ollama", sleep=no_sleep) # type: ignore[arg-type]
|
||||
assert await adapter.embed(("one",)) == ((1.0, 0.0),)
|
||||
assert len(client.requests) == 3
|
||||
|
||||
malformed = OllamaEmbeddingAdapter(
|
||||
profile(),
|
||||
FakeClient([response(200, {"embeddings": [[1.0]]})]), # type: ignore[arg-type]
|
||||
base_url="http://ollama",
|
||||
sleep=no_sleep,
|
||||
)
|
||||
with pytest.raises(EmbeddingProviderError, match="dimensions"):
|
||||
await malformed.embed(("one",))
|
||||
|
||||
cardinality = OllamaEmbeddingAdapter(
|
||||
profile(),
|
||||
FakeClient([response(200, {"embeddings": [[1.0, 0.0]]})]), # type: ignore[arg-type]
|
||||
base_url="http://ollama",
|
||||
sleep=no_sleep,
|
||||
)
|
||||
with pytest.raises(EmbeddingProviderError, match="cardinality"):
|
||||
await cardinality.embed(("one", "two"))
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_adapter_surfaces_concurrency_exhaustion_as_a_provider_error() -> None:
|
||||
budget = ConcurrencyBudget(name="embedding-calls", max_concurrent=1, max_queued=0)
|
||||
release = asyncio.Event()
|
||||
|
||||
async def occupy() -> None:
|
||||
async with budget.acquire():
|
||||
await release.wait()
|
||||
|
||||
holder = asyncio.create_task(occupy())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
adapter = OllamaEmbeddingAdapter(
|
||||
profile(),
|
||||
FakeClient([]), # type: ignore[arg-type]
|
||||
base_url="http://ollama",
|
||||
concurrency_budget=budget,
|
||||
)
|
||||
with pytest.raises(EmbeddingProviderError, match="concurrency"):
|
||||
await adapter.embed(("one",))
|
||||
|
||||
release.set()
|
||||
await holder
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_adapter_rejects_zero_vector_and_unbounded_input() -> None:
|
||||
zero = OllamaEmbeddingAdapter(
|
||||
profile(),
|
||||
FakeClient([response(200, {"embeddings": [[0.0, 0.0]]})]), # type: ignore[arg-type]
|
||||
base_url="http://ollama",
|
||||
sleep=no_sleep,
|
||||
)
|
||||
with pytest.raises(EmbeddingProviderError, match="zero"):
|
||||
await zero.embed(("one",))
|
||||
with pytest.raises(ValueError, match="input"):
|
||||
await zero.embed(("x",) * 4_097)
|
||||
await zero.embed(("x" * 32_001,))
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_adapter_records_provider_duration_on_success_and_error_on_failure() -> None:
|
||||
reader = InMemoryMetricReader()
|
||||
provider = MeterProvider(
|
||||
resource=Resource.create({"service.name": "test"}), metric_readers=[reader]
|
||||
)
|
||||
metrics = TelemetryMetrics(meter_provider=provider)
|
||||
|
||||
ok_adapter = OllamaEmbeddingAdapter(
|
||||
profile(),
|
||||
FakeClient([response(200, {"embeddings": [[1.0, 0.0]]})]), # type: ignore[arg-type]
|
||||
base_url="http://ollama",
|
||||
sleep=no_sleep,
|
||||
metrics=metrics,
|
||||
)
|
||||
await ok_adapter.embed(("one",))
|
||||
|
||||
failing_adapter = OllamaEmbeddingAdapter(
|
||||
profile(),
|
||||
FakeClient([response(200, {"embeddings": [[1.0]]})]), # type: ignore[arg-type]
|
||||
base_url="http://ollama",
|
||||
sleep=no_sleep,
|
||||
metrics=metrics,
|
||||
)
|
||||
with pytest.raises(EmbeddingProviderError):
|
||||
await failing_adapter.embed(("one",))
|
||||
|
||||
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_provider_duration_seconds" in names
|
||||
assert "ragcore_provider_errors_total" in names
|
||||
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from ragcore.config import Settings
|
||||
from ragcore.domain.control import DataPolicy
|
||||
from ragcore.domain.models import EmbeddingProfile, EmbeddingProvider
|
||||
from ragcore.infrastructure.models.embedding_factory import (
|
||||
EmbeddingAdapterFactory,
|
||||
create_embedding_adapter_factory,
|
||||
)
|
||||
from ragcore.infrastructure.models.ollama import EmbeddingProviderError
|
||||
|
||||
|
||||
def ollama_profile() -> EmbeddingProfile:
|
||||
return EmbeddingProfile(
|
||||
id=uuid4(),
|
||||
profile="test-ollama",
|
||||
version="v1",
|
||||
provider=EmbeddingProvider.OLLAMA,
|
||||
model_identifier="nomic-embed-text:v1",
|
||||
model_digest="a" * 64,
|
||||
dimensions=2,
|
||||
context_limit=8192,
|
||||
tokenizer="sentencepiece-v1",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
document_prefix="doc: ",
|
||||
query_prefix="query: ",
|
||||
)
|
||||
|
||||
|
||||
def modelforge_profile(**changes: object) -> EmbeddingProfile:
|
||||
values: dict[str, object] = {
|
||||
"id": uuid4(),
|
||||
"profile": "test-modelforge",
|
||||
"version": "v1",
|
||||
"provider": EmbeddingProvider.MODELFORGE,
|
||||
"model_identifier": "rag.embedding@1",
|
||||
"model_digest": "b" * 64,
|
||||
"dimensions": 3,
|
||||
"context_limit": 8192,
|
||||
"tokenizer": "capability-managed",
|
||||
"data_policy": DataPolicy.LOCAL_ONLY,
|
||||
"max_retries": 0,
|
||||
"embedding_space_id": uuid4(),
|
||||
}
|
||||
values.update(changes)
|
||||
return EmbeddingProfile(**values)
|
||||
|
||||
|
||||
def gateway_response(profile: EmbeddingProfile, count: int) -> dict[str, object]:
|
||||
return {
|
||||
"capability": "rag.embedding@1",
|
||||
"dimension": profile.dimensions,
|
||||
"normalized": True,
|
||||
"embedding_space_id": str(profile.embedding_space_id),
|
||||
"data": [[1.0, 0.0, 0.0] for _ in range(count)],
|
||||
"request_id": str(uuid4()),
|
||||
}
|
||||
|
||||
|
||||
def factory(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
credential_file: Path,
|
||||
modelforge_base_url: str = "http://modelforge:8000",
|
||||
experiment_route: str | None = None,
|
||||
) -> EmbeddingAdapterFactory:
|
||||
settings = Settings(
|
||||
env="test",
|
||||
ollama_url="http://ollama:11434",
|
||||
modelforge_base_url=modelforge_base_url,
|
||||
modelforge_credential_file=credential_file,
|
||||
modelforge_embedding_capability="rag.embedding@1",
|
||||
modelforge_embedding_experiment_route=experiment_route,
|
||||
)
|
||||
return create_embedding_adapter_factory(settings=settings, http_client=client)
|
||||
|
||||
|
||||
def write_credential(path: Path) -> None:
|
||||
path.write_text("mf_" + "x" * 48, encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_factory_routes_modelforge_profile_to_pinned_capability(tmp_path: Path) -> None:
|
||||
profile = modelforge_profile()
|
||||
credential_file = tmp_path / "credential"
|
||||
write_credential(credential_file)
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
body = json.loads(request.content)
|
||||
return httpx.Response(200, json=gateway_response(profile, len(body["input"])))
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(respond), follow_redirects=False
|
||||
) as client:
|
||||
adapter = factory(client, credential_file=credential_file)(profile)
|
||||
vectors = await adapter.embed(["question"], query=True)
|
||||
|
||||
assert vectors == ((1.0, 0.0, 0.0),)
|
||||
assert len(seen) == 1
|
||||
assert seen[0].url.host == "modelforge"
|
||||
assert seen[0].url.path == "/api/v1/capabilities/rag.embedding@1/invoke"
|
||||
assert json.loads(seen[0].content) == {"input": ["question"], "input_type": "query"}
|
||||
assert seen[0].headers["authorization"].startswith("Bearer mf_")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_factory_routes_exact_selection_experiment_key(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
profile = modelforge_profile()
|
||||
credential_file = tmp_path / "credential"
|
||||
write_credential(credential_file)
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
body = json.loads(request.content)
|
||||
return httpx.Response(200, json=gateway_response(profile, len(body["input"])))
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(respond), follow_redirects=False
|
||||
) as client:
|
||||
adapter = factory(
|
||||
client,
|
||||
credential_file=credential_file,
|
||||
experiment_route="ignored-global-route",
|
||||
)(profile, experiment_route_key="rc-canary-2026-09")
|
||||
vectors = await adapter.embed(["document"])
|
||||
|
||||
assert vectors == ((1.0, 0.0, 0.0),)
|
||||
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": ["document"], "input_type": "document"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_factory_rejects_an_unsafe_configured_experiment_route(tmp_path: Path) -> None:
|
||||
def unused_handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(500)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(unused_handler), follow_redirects=False
|
||||
) as client:
|
||||
configured = factory(
|
||||
client,
|
||||
credential_file=tmp_path / "credential",
|
||||
experiment_route="valid-global-route",
|
||||
)
|
||||
with pytest.raises(ValueError, match="experiment_route"):
|
||||
configured(modelforge_profile(), experiment_route_key="../production")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_modelforge_credential_fails_before_any_provider_request(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return httpx.Response(200, json={"embeddings": [[0.0, 1.0]]})
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(respond), follow_redirects=False
|
||||
) as client:
|
||||
adapter = factory(client, credential_file=tmp_path / "missing")(modelforge_profile())
|
||||
with pytest.raises(EmbeddingProviderError, match="credential file"):
|
||||
await adapter.embed(["question"])
|
||||
|
||||
assert seen == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_modelforge_gateway_never_falls_back_to_ollama(tmp_path: Path) -> None:
|
||||
profile = modelforge_profile()
|
||||
credential_file = tmp_path / "credential"
|
||||
write_credential(credential_file)
|
||||
seen: list[str] = []
|
||||
|
||||
def unavailable(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request.url.host + request.url.path)
|
||||
return httpx.Response(503)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(unavailable), follow_redirects=False
|
||||
) as client:
|
||||
adapter = factory(client, credential_file=credential_file)(profile)
|
||||
with pytest.raises(EmbeddingProviderError, match="unavailable"):
|
||||
await adapter.embed(["question"])
|
||||
|
||||
assert seen == ["modelforge/api/v1/capabilities/rag.embedding@1/invoke"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_factory_keeps_ollama_request_model_and_prefix_contract(tmp_path: Path) -> None:
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return httpx.Response(200, json={"embeddings": [[0.6, 0.8]]})
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(respond), follow_redirects=False
|
||||
) as client:
|
||||
adapter = factory(client, credential_file=tmp_path / "unused")(ollama_profile())
|
||||
vectors = await adapter.embed(["question"], query=True)
|
||||
|
||||
assert vectors == ((0.6, 0.8),)
|
||||
assert len(seen) == 1
|
||||
assert seen[0].url.host == "ollama"
|
||||
assert seen[0].url.path == "/api/embed"
|
||||
assert json.loads(seen[0].content) == {
|
||||
"model": "nomic-embed-text:v1",
|
||||
"input": ["query: question"],
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from ragcore.domain.control import DataPolicy
|
||||
from ragcore.domain.models import EmbeddingProfile, EmbeddingProvider
|
||||
from ragcore.infrastructure.models import EmbeddingProviderError, ModelForgeEmbeddingAdapter
|
||||
|
||||
|
||||
def profile() -> EmbeddingProfile:
|
||||
return EmbeddingProfile(
|
||||
id=uuid4(),
|
||||
profile="modelforge-rag",
|
||||
version="v1",
|
||||
provider=EmbeddingProvider.MODELFORGE,
|
||||
model_identifier="rag.embedding@1",
|
||||
model_digest="a" * 64,
|
||||
dimensions=3,
|
||||
context_limit=8192,
|
||||
tokenizer="capability-managed",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
max_batch_size=2,
|
||||
max_retries=1,
|
||||
embedding_space_id=uuid4(),
|
||||
)
|
||||
|
||||
|
||||
def credential(tmp_path: Path) -> Path:
|
||||
path = tmp_path / "credential"
|
||||
path.write_text("mf_" + "x" * 48, encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
return path
|
||||
|
||||
|
||||
def response(item: EmbeddingProfile, vectors: list[list[float]]) -> dict[str, object]:
|
||||
return {
|
||||
"capability": "rag.embedding@1",
|
||||
"dimension": item.dimensions,
|
||||
"normalized": True,
|
||||
"embedding_space_id": str(item.embedding_space_id),
|
||||
"data": vectors,
|
||||
"request_id": str(uuid4()),
|
||||
"execution": {
|
||||
"cold": False,
|
||||
"node": "Tower",
|
||||
"residency": "warm",
|
||||
"load_count": 1,
|
||||
"timings": {
|
||||
"gateway_ms": 1,
|
||||
"queue_ms": 0,
|
||||
"load_ms": 0,
|
||||
"inference_ms": 2,
|
||||
"total_ms": 3,
|
||||
},
|
||||
},
|
||||
"usage": {"input_count": len(vectors), "input_tokens": 1},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capability_first_batch_auth_space_and_correlation(tmp_path: Path) -> None:
|
||||
item = profile()
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
count = len(json.loads(request.content)["input"])
|
||||
return httpx.Response(200, json=response(item, [[1.0, 0.0, 0.0]] * count))
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = ModelForgeEmbeddingAdapter(
|
||||
item, client, base_url="http://modelforge:8000", credential_file=credential(tmp_path)
|
||||
)
|
||||
vectors, evidence = await adapter.embed_with_evidence(["one", "two", "three"])
|
||||
assert len(vectors) == 3
|
||||
assert len(seen) == 2
|
||||
assert all(
|
||||
request.url.path.endswith("/capabilities/rag.embedding@1/invoke") for request in seen
|
||||
)
|
||||
assert all(request.headers["authorization"].startswith("Bearer mf_") for request in seen)
|
||||
assert all(request.headers["x-correlation-id"].startswith("ragcore-") for request in seen)
|
||||
assert all(
|
||||
b"model" not in request.content and b"Tower" not in request.content for request in seen
|
||||
)
|
||||
assert evidence.total_ms >= evidence.gateway_round_trip_ms >= 0
|
||||
assert evidence.preparation_ms >= 0 and evidence.response_validation_ms >= 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("failure", ["capability", "space", "dimension", "nan"])
|
||||
async def test_response_contract_fails_closed(tmp_path: Path, failure: str) -> None:
|
||||
item = profile()
|
||||
body = response(item, [[1.0, 0.0, 0.0]])
|
||||
if failure == "capability":
|
||||
body["capability"] = "other.embedding@1"
|
||||
elif failure == "space":
|
||||
body["embedding_space_id"] = str(uuid4())
|
||||
elif failure == "dimension":
|
||||
body["dimension"] = 4
|
||||
else:
|
||||
body["data"] = [[float("nan"), 0.0, 0.0]]
|
||||
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
if failure == "nan":
|
||||
raw = json.dumps(body, allow_nan=True).encode()
|
||||
return httpx.Response(200, content=raw, headers={"content-type": "application/json"})
|
||||
return httpx.Response(200, json=body)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = ModelForgeEmbeddingAdapter(
|
||||
item, client, base_url="http://127.0.0.1:8000", credential_file=credential(tmp_path)
|
||||
)
|
||||
with pytest.raises(EmbeddingProviderError):
|
||||
await adapter.embed(["one"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_rejects_profile_that_does_not_match_configured_capability(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
item = profile()
|
||||
async with httpx.AsyncClient(follow_redirects=False) as async_client:
|
||||
with pytest.raises(ValueError, match="capability does not match"):
|
||||
ModelForgeEmbeddingAdapter(
|
||||
item,
|
||||
async_client,
|
||||
base_url="http://modelforge:8000",
|
||||
credential_file=credential(tmp_path),
|
||||
capability="other.embedding@1",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normalization_uses_the_m5_bfloat16_contract_tolerance(tmp_path: Path) -> None:
|
||||
item = profile()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
text = json.loads(request.content)["input"][0]
|
||||
vector = [1.019, 0.0, 0.0] if text == "inside" else [1.021, 0.0, 0.0]
|
||||
return httpx.Response(200, json=response(item, [vector]))
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = ModelForgeEmbeddingAdapter(
|
||||
item,
|
||||
client,
|
||||
base_url="http://127.0.0.1:8000",
|
||||
credential_file=credential(tmp_path),
|
||||
)
|
||||
assert await adapter.embed(["inside"]) == ((1.019, 0.0, 0.0),)
|
||||
with pytest.raises(EmbeddingProviderError, match="not normalized"):
|
||||
await adapter.embed(["outside"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_transient_but_not_unauthorized(tmp_path: Path) -> None:
|
||||
item = profile()
|
||||
attempts = 0
|
||||
|
||||
def transient(_request: httpx.Request) -> httpx.Response:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
return httpx.Response(503 if attempts == 1 else 200, json=response(item, [[1, 0, 0]]))
|
||||
|
||||
async def no_sleep(_seconds: float) -> None:
|
||||
return None
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(transient)) as client:
|
||||
adapter = ModelForgeEmbeddingAdapter(
|
||||
item,
|
||||
client,
|
||||
base_url="http://127.0.0.1:8000",
|
||||
credential_file=credential(tmp_path),
|
||||
sleep=no_sleep,
|
||||
)
|
||||
assert await adapter.embed(["one"]) == ((1.0, 0.0, 0.0),)
|
||||
assert attempts == 2
|
||||
|
||||
denied_attempts = 0
|
||||
|
||||
def denied(_request: httpx.Request) -> httpx.Response:
|
||||
nonlocal denied_attempts
|
||||
denied_attempts += 1
|
||||
return httpx.Response(401)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(denied)) as client:
|
||||
adapter = ModelForgeEmbeddingAdapter(
|
||||
item,
|
||||
client,
|
||||
base_url="http://127.0.0.1:8000",
|
||||
credential_file=credential(tmp_path),
|
||||
sleep=no_sleep,
|
||||
)
|
||||
with pytest.raises(EmbeddingProviderError, match="credential"):
|
||||
await adapter.embed(["one"])
|
||||
assert denied_attempts == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_cloud_host_is_rejected(tmp_path: Path) -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
with pytest.raises(ValueError, match="local/private"):
|
||||
ModelForgeEmbeddingAdapter(
|
||||
profile(),
|
||||
client,
|
||||
base_url="https://api.example.com",
|
||||
credential_file=credential(tmp_path),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_batch_gateway_ids_are_retained_in_order(tmp_path: Path) -> None:
|
||||
item = profile()
|
||||
identifiers = [uuid4(), uuid4()]
|
||||
calls = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal calls
|
||||
count = len(json.loads(request.content)["input"])
|
||||
body = response(item, [[1, 0, 0]] * count)
|
||||
body["request_id"] = str(identifiers[calls])
|
||||
calls += 1
|
||||
return httpx.Response(200, json=body)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = ModelForgeEmbeddingAdapter(
|
||||
item, client, base_url="http://modelforge:8000", credential_file=credential(tmp_path)
|
||||
)
|
||||
vectors, evidence = await adapter.embed_with_evidence(["one", "two", "three"])
|
||||
assert len(vectors) == 3
|
||||
assert evidence.gateway_request_ids == tuple(identifiers)
|
||||
empty, empty_evidence = await adapter.embed_with_evidence([])
|
||||
assert empty == () and empty_evidence.gateway_request_ids == ()
|
||||
assert calls == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"identifier", [None, "not-a-uuid", "00000000-0000-0000-0000-000000000000", 12, True]
|
||||
)
|
||||
async def test_missing_or_invalid_gateway_identity_fails_closed(
|
||||
tmp_path: Path, identifier: object
|
||||
) -> None:
|
||||
item = profile()
|
||||
calls = 0
|
||||
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
body = response(item, [[1, 0, 0]])
|
||||
body["request_id"] = identifier
|
||||
return httpx.Response(200, json=body)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = ModelForgeEmbeddingAdapter(
|
||||
item, client, base_url="http://modelforge:8000", credential_file=credential(tmp_path)
|
||||
)
|
||||
with pytest.raises(EmbeddingProviderError, match="gateway request identity"):
|
||||
await adapter.embed_with_evidence(["one"])
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_gateway_identity_across_batches_is_rejected(tmp_path: Path) -> None:
|
||||
item = profile()
|
||||
identifier = uuid4()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
count = len(json.loads(request.content)["input"])
|
||||
body = response(item, [[1, 0, 0]] * count)
|
||||
body["request_id"] = str(identifier)
|
||||
return httpx.Response(200, json=body)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = ModelForgeEmbeddingAdapter(
|
||||
item, client, base_url="http://modelforge:8000", credential_file=credential(tmp_path)
|
||||
)
|
||||
with pytest.raises(EmbeddingProviderError, match="gateway request identity"):
|
||||
await adapter.embed_with_evidence(["one", "two", "three"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_successful_retry_contributes_gateway_evidence(tmp_path: Path) -> None:
|
||||
item = profile()
|
||||
identifiers = [uuid4(), uuid4()]
|
||||
calls = 0
|
||||
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
nonlocal calls
|
||||
body = response(item, [[1, 0, 0]])
|
||||
body["request_id"] = str(identifiers[calls])
|
||||
calls += 1
|
||||
return httpx.Response(503 if calls == 1 else 200, json=body)
|
||||
|
||||
async def no_sleep(_seconds: float) -> None:
|
||||
return None
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = ModelForgeEmbeddingAdapter(
|
||||
item,
|
||||
client,
|
||||
base_url="http://modelforge:8000",
|
||||
credential_file=credential(tmp_path),
|
||||
sleep=no_sleep,
|
||||
)
|
||||
_, evidence = await adapter.embed_with_evidence(["unchanged exact query"], query=True)
|
||||
assert evidence.gateway_request_ids == (identifiers[1],)
|
||||
assert calls == 2
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Real Postgres coverage for the generation profile registry store."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from ragcore.config import Settings
|
||||
from ragcore.domain.control.entities import DataPolicy
|
||||
from ragcore.domain.models import GenerationProfile, GenerationProvider
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.models.generation import (
|
||||
GenerationProfileRepositoryError,
|
||||
PostgresGenerationProfileStore,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _profile(**overrides: object) -> GenerationProfile:
|
||||
values: dict[str, object] = {
|
||||
"id": uuid4(),
|
||||
"profile": "answer-hybrid",
|
||||
"version": uuid4().hex[:12],
|
||||
"provider": GenerationProvider.OLLAMA,
|
||||
"model_identifier": "llama3.1:8b-instruct-q4_0",
|
||||
"model_digest": "a" * 64,
|
||||
"context_limit": 8192,
|
||||
"max_output_tokens": 1024,
|
||||
"temperature": 0.4,
|
||||
"data_policy": DataPolicy.LOCAL_ONLY,
|
||||
}
|
||||
values.update(overrides)
|
||||
return GenerationProfile(**values)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine() -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def store(db_engine: AsyncEngine) -> AsyncIterator[PostgresGenerationProfileStore]:
|
||||
async with db_engine.connect() as connection:
|
||||
await connection.execute(text("TRUNCATE models.generation_profiles"))
|
||||
await connection.commit()
|
||||
yield PostgresGenerationProfileStore(create_session_factory(db_engine))
|
||||
|
||||
|
||||
async def test_add_and_get_round_trip(store: PostgresGenerationProfileStore) -> None:
|
||||
profile = _profile()
|
||||
await store.add(profile)
|
||||
fetched = await store.get(profile.id)
|
||||
assert fetched == profile
|
||||
|
||||
|
||||
async def test_get_returns_none_for_an_unknown_id(store: PostgresGenerationProfileStore) -> None:
|
||||
assert await store.get(uuid4()) is None
|
||||
|
||||
|
||||
async def test_adding_the_same_id_twice_is_rejected(store: PostgresGenerationProfileStore) -> None:
|
||||
profile = _profile()
|
||||
await store.add(profile)
|
||||
with pytest.raises(GenerationProfileRepositoryError):
|
||||
await store.add(profile)
|
||||
|
||||
|
||||
async def test_adding_the_same_profile_version_twice_is_rejected(
|
||||
store: PostgresGenerationProfileStore,
|
||||
) -> None:
|
||||
profile = _profile()
|
||||
await store.add(profile)
|
||||
with pytest.raises(GenerationProfileRepositoryError):
|
||||
await store.add(_profile(id=uuid4(), profile=profile.profile, version=profile.version))
|
||||
|
||||
|
||||
async def test_activate_requires_a_registered_profile(
|
||||
store: PostgresGenerationProfileStore,
|
||||
) -> None:
|
||||
with pytest.raises(GenerationProfileRepositoryError):
|
||||
await store.activate(uuid4())
|
||||
|
||||
|
||||
async def test_activating_a_second_profile_deactivates_the_first(
|
||||
store: PostgresGenerationProfileStore,
|
||||
) -> None:
|
||||
first = _profile()
|
||||
second = _profile(id=uuid4(), version=uuid4().hex[:12])
|
||||
await store.add(first)
|
||||
await store.add(second)
|
||||
|
||||
await store.activate(first.id)
|
||||
assert (await store.get_active()).id == first.id # type: ignore[union-attr]
|
||||
|
||||
await store.activate(second.id)
|
||||
active = await store.get_active()
|
||||
assert active is not None
|
||||
assert active.id == second.id
|
||||
|
||||
|
||||
async def test_get_active_returns_none_when_nothing_is_activated(
|
||||
store: PostgresGenerationProfileStore,
|
||||
) -> None:
|
||||
assert await store.get_active() is None
|
||||
@@ -0,0 +1,256 @@
|
||||
"""OllamaGenerationAdapter: local-host validation, streaming, retries, breaker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from ragcore.application.limits import ConcurrencyBudget
|
||||
from ragcore.domain.control.entities import DataPolicy
|
||||
from ragcore.domain.models import GenerationProfile, GenerationProvider
|
||||
from ragcore.infrastructure.models.ollama_generation import (
|
||||
CIRCUIT_FAILURE_THRESHOLD,
|
||||
CircuitOpenError,
|
||||
GenerationProviderError,
|
||||
OllamaGenerationAdapter,
|
||||
assemble,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def profile(**overrides: object) -> GenerationProfile:
|
||||
values: dict[str, object] = {
|
||||
"id": uuid4(),
|
||||
"profile": "answer-hybrid",
|
||||
"version": "v1",
|
||||
"provider": GenerationProvider.OLLAMA,
|
||||
"model_identifier": "llama3.1:8b-instruct-q4_0",
|
||||
"model_digest": "a" * 64,
|
||||
"context_limit": 4096,
|
||||
"max_output_tokens": 512,
|
||||
"temperature": 0.2,
|
||||
"data_policy": DataPolicy.LOCAL_ONLY,
|
||||
"timeout_seconds": 5.0,
|
||||
"max_retries": 2,
|
||||
}
|
||||
values.update(overrides)
|
||||
return GenerationProfile(**values)
|
||||
|
||||
|
||||
async def no_sleep(_: float) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _ndjson(*lines: dict[str, object]) -> bytes:
|
||||
return b"\n".join(json.dumps(line).encode() for line in lines)
|
||||
|
||||
|
||||
def _client(handler) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(handler), follow_redirects=False)
|
||||
|
||||
|
||||
def _adapter(handler, **overrides: object) -> OllamaGenerationAdapter:
|
||||
clock = overrides.pop("clock", None)
|
||||
kwargs: dict[str, object] = {"base_url": "http://ollama", "sleep": no_sleep}
|
||||
if clock is not None:
|
||||
kwargs["clock"] = clock
|
||||
return OllamaGenerationAdapter(profile(**overrides), _client(handler), **kwargs)
|
||||
|
||||
|
||||
def test_adapter_rejects_nonlocal_provider_hosts() -> None:
|
||||
with pytest.raises(ValueError, match="allowlisted local"):
|
||||
OllamaGenerationAdapter(
|
||||
profile(), _client(lambda request: httpx.Response(200)), base_url="http://169.254.169.254"
|
||||
)
|
||||
|
||||
|
||||
async def test_generate_stream_yields_each_chunk_in_order() -> None:
|
||||
body = _ndjson(
|
||||
{"response": "Hel", "done": False},
|
||||
{"response": "lo", "done": False},
|
||||
{"response": "", "done": True},
|
||||
)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert json.loads(request.content)["stream"] is True
|
||||
return httpx.Response(200, content=body)
|
||||
|
||||
adapter = _adapter(handler)
|
||||
chunks = [chunk async for chunk in adapter.generate_stream("hi")]
|
||||
assert [chunk.text for chunk in chunks] == ["Hel", "lo", ""]
|
||||
assert chunks[-1].done is True
|
||||
|
||||
|
||||
async def test_generate_assembles_the_same_content_a_stream_would() -> None:
|
||||
body = _ndjson(
|
||||
{"response": "The answer is ", "done": False},
|
||||
{"response": "42.", "done": True},
|
||||
)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, content=body)
|
||||
|
||||
adapter = _adapter(handler)
|
||||
result = await adapter.generate("what is the answer?")
|
||||
streamed = [chunk async for chunk in adapter.generate_stream("what is the answer?")]
|
||||
|
||||
assert result.text == "The answer is 42."
|
||||
assert result == assemble(streamed)
|
||||
|
||||
|
||||
async def test_breaking_out_of_the_stream_early_does_not_raise() -> None:
|
||||
body = _ndjson(
|
||||
{"response": "one", "done": False},
|
||||
{"response": "two", "done": False},
|
||||
{"response": "three", "done": True},
|
||||
)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, content=body)
|
||||
|
||||
adapter = _adapter(handler)
|
||||
seen = []
|
||||
async for chunk in adapter.generate_stream("hi"):
|
||||
seen.append(chunk.text)
|
||||
if len(seen) == 1:
|
||||
break
|
||||
assert seen == ["one"]
|
||||
|
||||
|
||||
async def test_retryable_status_retries_before_any_streaming_then_succeeds() -> None:
|
||||
attempts = {"count": 0}
|
||||
body = _ndjson({"response": "ok", "done": True})
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
attempts["count"] += 1
|
||||
if attempts["count"] < 3:
|
||||
return httpx.Response(503)
|
||||
return httpx.Response(200, content=body)
|
||||
|
||||
adapter = _adapter(handler)
|
||||
result = await adapter.generate("hi")
|
||||
assert result.text == "ok"
|
||||
assert attempts["count"] == 3
|
||||
|
||||
|
||||
async def test_exhausting_retries_raises_generation_unavailable() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(503)
|
||||
|
||||
adapter = _adapter(handler, max_retries=1)
|
||||
with pytest.raises(GenerationProviderError, match="generation_unavailable"):
|
||||
await adapter.generate("hi")
|
||||
|
||||
|
||||
async def test_non_retryable_status_raises_generation_rejected() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(400)
|
||||
|
||||
adapter = _adapter(handler)
|
||||
with pytest.raises(GenerationProviderError, match="generation_rejected"):
|
||||
await adapter.generate("hi")
|
||||
|
||||
|
||||
async def test_invalid_response_line_is_a_stable_error() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, content=b"not json at all")
|
||||
|
||||
adapter = _adapter(handler)
|
||||
with pytest.raises(GenerationProviderError, match="generation_response_invalid"):
|
||||
await adapter.generate("hi")
|
||||
|
||||
|
||||
async def test_stream_ending_without_a_done_line_is_a_stable_error() -> None:
|
||||
body = _ndjson({"response": "partial", "done": False})
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, content=body)
|
||||
|
||||
adapter = _adapter(handler)
|
||||
with pytest.raises(GenerationProviderError, match="generation_response_invalid"):
|
||||
await adapter.generate("hi")
|
||||
|
||||
|
||||
async def test_empty_and_oversized_prompts_are_rejected() -> None:
|
||||
adapter = _adapter(lambda request: httpx.Response(200))
|
||||
with pytest.raises(ValueError, match="empty"):
|
||||
await adapter.generate("")
|
||||
with pytest.raises(ValueError, match="bounded"):
|
||||
await adapter.generate("x" * 200_001)
|
||||
|
||||
|
||||
async def test_circuit_opens_after_consecutive_failures_and_blocks_further_calls() -> None:
|
||||
attempts = {"count": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
attempts["count"] += 1
|
||||
return httpx.Response(503)
|
||||
|
||||
adapter = _adapter(handler, max_retries=0)
|
||||
for _ in range(CIRCUIT_FAILURE_THRESHOLD):
|
||||
with pytest.raises(GenerationProviderError, match="generation_unavailable"):
|
||||
await adapter.generate("hi")
|
||||
|
||||
calls_before_circuit_check = attempts["count"]
|
||||
with pytest.raises(CircuitOpenError):
|
||||
await adapter.generate("hi")
|
||||
assert attempts["count"] == calls_before_circuit_check # the breaker skipped the network call
|
||||
|
||||
|
||||
async def test_circuit_half_opens_after_cooldown_and_closes_on_success() -> None:
|
||||
outcomes = iter(
|
||||
[httpx.Response(503)] * CIRCUIT_FAILURE_THRESHOLD
|
||||
+ [httpx.Response(200, content=_ndjson({"response": "ok", "done": True}))]
|
||||
)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return next(outcomes)
|
||||
|
||||
clock_time = {"value": 0.0}
|
||||
|
||||
def clock() -> float:
|
||||
return clock_time["value"]
|
||||
|
||||
adapter = _adapter(handler, max_retries=0, clock=clock)
|
||||
for _ in range(CIRCUIT_FAILURE_THRESHOLD):
|
||||
with pytest.raises(GenerationProviderError, match="generation_unavailable"):
|
||||
await adapter.generate("hi")
|
||||
|
||||
# Still within the cooldown window: the breaker rejects without a network call.
|
||||
with pytest.raises(CircuitOpenError):
|
||||
await adapter.generate("hi")
|
||||
|
||||
# Advance past the cooldown: the next call is a real, half-open attempt.
|
||||
clock_time["value"] += 3600.0
|
||||
result = await adapter.generate("hi")
|
||||
assert result.text == "ok"
|
||||
|
||||
|
||||
async def test_adapter_surfaces_concurrency_exhaustion_as_a_provider_error() -> None:
|
||||
budget = ConcurrencyBudget(name="generation-calls", max_concurrent=1, max_queued=0)
|
||||
release = asyncio.Event()
|
||||
|
||||
async def occupy() -> None:
|
||||
async with budget.acquire():
|
||||
await release.wait()
|
||||
|
||||
holder = asyncio.create_task(occupy())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
adapter = OllamaGenerationAdapter(
|
||||
profile(),
|
||||
_client(lambda request: httpx.Response(200)),
|
||||
base_url="http://ollama",
|
||||
sleep=no_sleep,
|
||||
concurrency_budget=budget,
|
||||
)
|
||||
with pytest.raises(GenerationProviderError, match="generation_concurrency_exhausted"):
|
||||
await adapter.generate("hi")
|
||||
|
||||
release.set()
|
||||
await holder
|
||||
@@ -0,0 +1,170 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from ragcore.infrastructure.models import (
|
||||
ModelForgeRerankingAdapter,
|
||||
RerankDocument,
|
||||
)
|
||||
from ragcore.retrieval.rerank.models import RerankProviderError
|
||||
|
||||
|
||||
def credential(tmp_path: Path) -> Path:
|
||||
path = tmp_path / "reranking-credential"
|
||||
path.write_text("mf_" + "x" * 48, encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
return path
|
||||
|
||||
|
||||
def response(ids: list[str]) -> dict[str, object]:
|
||||
return {
|
||||
"capability": "rag.reranking@1",
|
||||
"results": [
|
||||
{"id": identifier, "score": 1.0 / rank, "rank": rank}
|
||||
for rank, identifier in enumerate(ids, 1)
|
||||
],
|
||||
"request_id": str(uuid4()),
|
||||
"execution": {
|
||||
"cold": False,
|
||||
"node": "Tower",
|
||||
"residency": "warm",
|
||||
"load_count": 1,
|
||||
"timings": {
|
||||
"gateway_ms": 1,
|
||||
"queue_ms": 0,
|
||||
"load_ms": 0,
|
||||
"inference_ms": 2,
|
||||
"total_ms": 3,
|
||||
},
|
||||
},
|
||||
"usage": {"input_count": len(ids), "input_tokens": 10},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scoped_capability_request_and_ranked_id_validation(tmp_path: Path) -> None:
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
body = json.loads(request.content)
|
||||
return httpx.Response(200, json=response([body["documents"][1]["id"]]))
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = ModelForgeRerankingAdapter(
|
||||
client,
|
||||
base_url="http://modelforge:8000",
|
||||
credential_file=credential(tmp_path),
|
||||
)
|
||||
ranked, evidence = await adapter.rerank(
|
||||
"which passage?",
|
||||
(RerankDocument("one", "irrelevant"), RerankDocument("two", "relevant")),
|
||||
top_n=1,
|
||||
)
|
||||
assert [(item.id, item.rank) for item in ranked] == [("two", 1)]
|
||||
assert seen[0].url.path == "/api/v1/capabilities/rag.reranking@1/invoke"
|
||||
assert seen[0].headers["authorization"].startswith("Bearer mf_")
|
||||
assert seen[0].headers["x-correlation-id"].startswith("ragcore-rerank-")
|
||||
assert evidence.gateway_round_trip_ms >= 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", [401, 403])
|
||||
async def test_scope_or_auth_rejection_fails_closed(tmp_path: Path, status: int) -> None:
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(lambda _request: httpx.Response(status))
|
||||
) as client:
|
||||
adapter = ModelForgeRerankingAdapter(
|
||||
client,
|
||||
base_url="http://127.0.0.1:8000",
|
||||
credential_file=credential(tmp_path),
|
||||
)
|
||||
with pytest.raises(RerankProviderError, match="scope_rejected"):
|
||||
await adapter.rerank("query", (RerankDocument("one", "text"),), top_n=1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("failure", ["duplicate", "unknown", "rank", "nan"])
|
||||
async def test_invalid_ranking_response_fails_closed(tmp_path: Path, failure: str) -> None:
|
||||
body = response(["one", "two"])
|
||||
results = body["results"]
|
||||
assert isinstance(results, list)
|
||||
if failure == "duplicate":
|
||||
results[1]["id"] = "one"
|
||||
elif failure == "unknown":
|
||||
results[1]["id"] = "three"
|
||||
elif failure == "rank":
|
||||
results[1]["rank"] = 1
|
||||
else:
|
||||
results[1]["score"] = float("nan")
|
||||
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
content=json.dumps(body, allow_nan=True).encode(),
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = ModelForgeRerankingAdapter(
|
||||
client,
|
||||
base_url="http://127.0.0.1:8000",
|
||||
credential_file=credential(tmp_path),
|
||||
)
|
||||
with pytest.raises(RerankProviderError, match="result_invalid"):
|
||||
await adapter.rerank(
|
||||
"query",
|
||||
(RerankDocument("one", "a"), RerankDocument("two", "b")),
|
||||
top_n=2,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_input_bounds_apply_before_network(tmp_path: Path) -> None:
|
||||
calls = 0
|
||||
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return httpx.Response(500)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = ModelForgeRerankingAdapter(
|
||||
client,
|
||||
base_url="http://127.0.0.1:8000",
|
||||
credential_file=credential(tmp_path),
|
||||
)
|
||||
with pytest.raises(ValueError, match="document count"):
|
||||
await adapter.rerank(
|
||||
"query", tuple(RerankDocument(str(index), "text") for index in range(41))
|
||||
)
|
||||
with pytest.raises(ValueError, match="unique"):
|
||||
await adapter.rerank(
|
||||
"query",
|
||||
(RerankDocument("same", "a"), RerankDocument("same", "b")),
|
||||
top_n=2,
|
||||
)
|
||||
assert calls == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_host_and_redirecting_client_are_rejected(tmp_path: Path) -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
with pytest.raises(ValueError, match="local/private"):
|
||||
ModelForgeRerankingAdapter(
|
||||
client,
|
||||
base_url="https://api.example.com",
|
||||
credential_file=credential(tmp_path),
|
||||
)
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
with pytest.raises(ValueError, match="redirects"):
|
||||
ModelForgeRerankingAdapter(
|
||||
client,
|
||||
base_url="http://127.0.0.1:8000",
|
||||
credential_file=credential(tmp_path),
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Integration coverage for parse timeout/failure publication semantics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.domain.sources import SourceFingerprint
|
||||
from ragcore.infrastructure.parsers import TextParser
|
||||
from ragcore.ingestion.parsing import (
|
||||
CanonicalArtifact,
|
||||
ParseRequest,
|
||||
ParserFailureError,
|
||||
ParserRouter,
|
||||
ParserTimeoutError,
|
||||
ParsingService,
|
||||
)
|
||||
|
||||
|
||||
def request() -> ParseRequest:
|
||||
return ParseRequest(
|
||||
uuid4(), SourceFingerprint("b" * 64), b"hello", "text/plain", "a.txt"
|
||||
)
|
||||
|
||||
|
||||
class Publisher:
|
||||
def __init__(self) -> None:
|
||||
self.published: list[CanonicalArtifact] = []
|
||||
|
||||
async def publish(self, artifact: CanonicalArtifact) -> None:
|
||||
self.published.append(artifact)
|
||||
|
||||
|
||||
class FailingParser:
|
||||
def supports(self, request: ParseRequest) -> bool:
|
||||
return True
|
||||
|
||||
async def parse(self, request: ParseRequest) -> CanonicalArtifact:
|
||||
raise ValueError("fixture parser failure")
|
||||
|
||||
|
||||
class SlowParser(TextParser):
|
||||
async def parse(self, request: ParseRequest) -> CanonicalArtifact:
|
||||
await asyncio.sleep(0.05)
|
||||
return await super().parse(request)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_publishes_canonical_artifact_after_parse() -> None:
|
||||
publisher = Publisher()
|
||||
service = ParsingService(ParserRouter((TextParser(),)), publisher)
|
||||
|
||||
artifact = await service.parse_and_publish(request())
|
||||
|
||||
assert publisher.published == [artifact]
|
||||
assert artifact.source_fingerprint.value == "b" * 64
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parser_failure_and_timeout_leave_no_published_version() -> None:
|
||||
failed_publisher = Publisher()
|
||||
failing = ParsingService(ParserRouter((FailingParser(),)), failed_publisher)
|
||||
with pytest.raises(ParserFailureError):
|
||||
await failing.parse_and_publish(request())
|
||||
|
||||
timed_publisher = Publisher()
|
||||
timed = ParsingService(
|
||||
ParserRouter((SlowParser(),)), timed_publisher, timeout_seconds=0.001
|
||||
)
|
||||
with pytest.raises(ParserTimeoutError):
|
||||
await timed.parse_and_publish(request())
|
||||
|
||||
assert failed_publisher.published == []
|
||||
assert timed_publisher.published == []
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Real PostgreSQL coverage for durable selection CAS and restart recovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import bindparam, text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from ragcore.application.models.selection import (
|
||||
EmbeddingSelectionConflict,
|
||||
EmbeddingSelectionNotInitialized,
|
||||
EmbeddingSelectionState,
|
||||
selection_request_fingerprint,
|
||||
)
|
||||
from ragcore.config import Settings
|
||||
from ragcore.domain.models import EmbeddingProvider, EmbeddingSelection
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.profiles.selection_repository import (
|
||||
PostgresEmbeddingSelectionStore,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _selection(version: str, digest: str, route: str) -> EmbeddingSelection:
|
||||
return EmbeddingSelection(
|
||||
profile_id=uuid4(),
|
||||
profile="embedding-cutover-test",
|
||||
version=version,
|
||||
provider=EmbeddingProvider.MODELFORGE,
|
||||
model_identifier="rag.embedding@1",
|
||||
model_digest=digest,
|
||||
dimensions=4,
|
||||
embedding_space_id=uuid4(),
|
||||
experiment_route_key=route,
|
||||
collection_name=f"rag_dense_embedding-cutover-test_{version}",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine() -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def store(db_engine: AsyncEngine) -> AsyncIterator[PostgresEmbeddingSelectionStore]:
|
||||
async with _isolated_embedding_selection_store(db_engine) as isolated_store:
|
||||
yield isolated_store
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _isolated_embedding_selection_store(
|
||||
db_engine: AsyncEngine,
|
||||
) -> AsyncIterator[PostgresEmbeddingSelectionStore]:
|
||||
"""Run selection tests from the migration sentinel and restore prior state.
|
||||
|
||||
The selection row is a deployment-wide singleton, so tests must not leave
|
||||
their synthetic profile active for later app-startup tests. Existing
|
||||
operation journal rows are retained; only IDs added inside this context
|
||||
are removed during cleanup.
|
||||
"""
|
||||
|
||||
async with db_engine.begin() as connection:
|
||||
previous_result = await connection.execute(
|
||||
text(
|
||||
"SELECT generation, initialized, active_selection, previous_selection, updated_at "
|
||||
"FROM profiles.embedding_selection_state WHERE singleton_id=1"
|
||||
)
|
||||
)
|
||||
previous_row = previous_result.mappings().one_or_none()
|
||||
if previous_row is None:
|
||||
pytest.fail("migration sentinel missing; apply Alembic head before integration tests")
|
||||
previous_state: dict[str, Any] = dict(previous_row)
|
||||
operation_result = await connection.execute(
|
||||
text("SELECT operation_id FROM profiles.embedding_selection_operations")
|
||||
)
|
||||
previous_operation_ids = set(operation_result.scalars().all())
|
||||
reset = await connection.execute(
|
||||
text(
|
||||
"UPDATE profiles.embedding_selection_state "
|
||||
"SET generation=0, initialized=false, active_selection=NULL, "
|
||||
"previous_selection=NULL, updated_at=now() WHERE singleton_id=1"
|
||||
)
|
||||
)
|
||||
if reset.rowcount != 1:
|
||||
pytest.fail("migration sentinel missing; apply Alembic head before integration tests")
|
||||
|
||||
try:
|
||||
yield PostgresEmbeddingSelectionStore(create_session_factory(db_engine))
|
||||
finally:
|
||||
async with db_engine.begin() as connection:
|
||||
operation_result = await connection.execute(
|
||||
text("SELECT operation_id FROM profiles.embedding_selection_operations")
|
||||
)
|
||||
created_operation_ids = set(operation_result.scalars().all()) - previous_operation_ids
|
||||
if created_operation_ids:
|
||||
delete_created_operations = text(
|
||||
"DELETE FROM profiles.embedding_selection_operations "
|
||||
"WHERE operation_id IN :operation_ids"
|
||||
).bindparams(bindparam("operation_ids", expanding=True))
|
||||
await connection.execute(
|
||||
delete_created_operations,
|
||||
{"operation_ids": list(created_operation_ids)},
|
||||
)
|
||||
restored = await connection.execute(
|
||||
text(
|
||||
"UPDATE profiles.embedding_selection_state SET generation=:generation, "
|
||||
"initialized=:initialized, "
|
||||
"active_selection=CAST(:active_selection AS json), "
|
||||
"previous_selection=CAST(:previous_selection AS json), "
|
||||
"updated_at=:updated_at WHERE singleton_id=1"
|
||||
),
|
||||
{
|
||||
"generation": previous_state["generation"],
|
||||
"initialized": previous_state["initialized"],
|
||||
"active_selection": (
|
||||
json.dumps(previous_state["active_selection"])
|
||||
if previous_state["active_selection"] is not None
|
||||
else None
|
||||
),
|
||||
"previous_selection": (
|
||||
json.dumps(previous_state["previous_selection"])
|
||||
if previous_state["previous_selection"] is not None
|
||||
else None
|
||||
),
|
||||
"updated_at": previous_state["updated_at"],
|
||||
},
|
||||
)
|
||||
if restored.rowcount != 1:
|
||||
pytest.fail("embedding selection state row disappeared during integration test")
|
||||
|
||||
|
||||
async def test_selection_isolation_restores_state_and_preserves_prior_journal(
|
||||
db_engine: AsyncEngine,
|
||||
store: PostgresEmbeddingSelectionStore,
|
||||
) -> None:
|
||||
initial = _selection("v1", "a" * 64, "lab-current-route")
|
||||
promoted = _selection("v2", "b" * 64, "lab-promoted-route")
|
||||
await store.initialize(initial)
|
||||
prior_operation_id = uuid4()
|
||||
prior_fingerprint = selection_request_fingerprint(
|
||||
action="promote", expected_generation=0, target=promoted
|
||||
)
|
||||
await store.compare_and_swap(
|
||||
expected_generation=0,
|
||||
target=promoted,
|
||||
operation_id=prior_operation_id,
|
||||
action="promote",
|
||||
request_fingerprint=prior_fingerprint,
|
||||
)
|
||||
prior_state = await store.get()
|
||||
|
||||
transient = _selection("v3", "c" * 64, "lab-transient-route")
|
||||
transient_target = _selection("v4", "d" * 64, "lab-transient-next-route")
|
||||
transient_operation_id = uuid4()
|
||||
transient_fingerprint = selection_request_fingerprint(
|
||||
action="promote", expected_generation=0, target=transient_target
|
||||
)
|
||||
async with _isolated_embedding_selection_store(db_engine) as isolated_store:
|
||||
with pytest.raises(EmbeddingSelectionNotInitialized):
|
||||
await isolated_store.get()
|
||||
await isolated_store.initialize(transient)
|
||||
await isolated_store.compare_and_swap(
|
||||
expected_generation=0,
|
||||
target=transient_target,
|
||||
operation_id=transient_operation_id,
|
||||
action="promote",
|
||||
request_fingerprint=transient_fingerprint,
|
||||
)
|
||||
|
||||
assert await store.get() == prior_state
|
||||
assert (
|
||||
await store.find_operation(prior_operation_id, request_fingerprint=prior_fingerprint)
|
||||
is not None
|
||||
)
|
||||
assert (
|
||||
await store.find_operation(
|
||||
transient_operation_id, request_fingerprint=transient_fingerprint
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
async def test_selection_survives_store_reopen(
|
||||
db_engine: AsyncEngine,
|
||||
store: PostgresEmbeddingSelectionStore,
|
||||
) -> None:
|
||||
current = _selection("v1", "a" * 64, "lab-current-route")
|
||||
candidate = _selection("v2", "b" * 64, "lab-candidate-route")
|
||||
await store.initialize(current)
|
||||
await store.compare_and_swap(
|
||||
expected_generation=0,
|
||||
target=candidate,
|
||||
operation_id=uuid4(),
|
||||
action="promote",
|
||||
request_fingerprint=selection_request_fingerprint(
|
||||
action="promote", expected_generation=0, target=candidate
|
||||
),
|
||||
operation_evidence={"operator_id": str(uuid4())},
|
||||
)
|
||||
|
||||
reopened = PostgresEmbeddingSelectionStore(create_session_factory(db_engine))
|
||||
persisted = await reopened.get()
|
||||
|
||||
assert persisted.generation == 1
|
||||
assert persisted.active == candidate
|
||||
assert persisted.previous == current
|
||||
|
||||
|
||||
async def test_selection_operation_retry_is_idempotent_in_postgres(
|
||||
store: PostgresEmbeddingSelectionStore,
|
||||
) -> None:
|
||||
current = _selection("v1", "a" * 64, "lab-current-route")
|
||||
candidate = _selection("v2", "b" * 64, "lab-candidate-route")
|
||||
conflicting = _selection("v3", "c" * 64, "lab-other-route")
|
||||
operation_id = uuid4()
|
||||
fingerprint = selection_request_fingerprint(
|
||||
action="promote", expected_generation=0, target=candidate
|
||||
)
|
||||
await store.initialize(current)
|
||||
|
||||
first = await store.compare_and_swap(
|
||||
expected_generation=0,
|
||||
target=candidate,
|
||||
operation_id=operation_id,
|
||||
action="promote",
|
||||
request_fingerprint=fingerprint,
|
||||
operation_evidence={"comparison_id": str(uuid4())},
|
||||
)
|
||||
retried = await store.compare_and_swap(
|
||||
expected_generation=0,
|
||||
target=candidate,
|
||||
operation_id=operation_id,
|
||||
action="promote",
|
||||
request_fingerprint=fingerprint,
|
||||
operation_evidence={"comparison_id": "ignored-on-replay"},
|
||||
)
|
||||
|
||||
assert first.generation == retried.generation == 1
|
||||
assert retried.selection == candidate
|
||||
assert retried.replayed is True
|
||||
with pytest.raises(EmbeddingSelectionConflict, match="different selection request"):
|
||||
await store.compare_and_swap(
|
||||
expected_generation=0,
|
||||
target=conflicting,
|
||||
operation_id=operation_id,
|
||||
action="promote",
|
||||
request_fingerprint=selection_request_fingerprint(
|
||||
action="promote", expected_generation=0, target=conflicting
|
||||
),
|
||||
)
|
||||
|
||||
persisted = await store.get()
|
||||
assert persisted.generation == 1
|
||||
assert persisted.active == candidate
|
||||
assert persisted.previous == current
|
||||
|
||||
|
||||
async def test_first_selection_compare_and_initialize_is_cas_and_idempotent_in_postgres(
|
||||
store: PostgresEmbeddingSelectionStore,
|
||||
) -> None:
|
||||
target = _selection("rc1", "e" * 64, "rc-canary-route")
|
||||
operation_id = uuid4()
|
||||
fingerprint = selection_request_fingerprint(
|
||||
action="initialize", expected_generation=0, target=target
|
||||
)
|
||||
|
||||
first = await store.compare_and_swap(
|
||||
expected_generation=0,
|
||||
target=target,
|
||||
operation_id=operation_id,
|
||||
action="initialize",
|
||||
request_fingerprint=fingerprint,
|
||||
operation_evidence={"operator_id": str(uuid4())},
|
||||
)
|
||||
replay = await store.compare_and_swap(
|
||||
expected_generation=0,
|
||||
target=target,
|
||||
operation_id=operation_id,
|
||||
action="initialize",
|
||||
request_fingerprint=fingerprint,
|
||||
)
|
||||
|
||||
assert first.generation == replay.generation == 0
|
||||
assert first.selection == replay.selection == target
|
||||
assert replay.replayed is True
|
||||
assert await store.get() == EmbeddingSelectionState(0, target, None)
|
||||
conflicting_target = _selection("rc2", "f" * 64, "rc-next-route")
|
||||
with pytest.raises(EmbeddingSelectionConflict, match="different selection request"):
|
||||
await store.compare_and_swap(
|
||||
expected_generation=0,
|
||||
target=conflicting_target,
|
||||
operation_id=operation_id,
|
||||
action="initialize",
|
||||
request_fingerprint=selection_request_fingerprint(
|
||||
action="initialize",
|
||||
expected_generation=0,
|
||||
target=conflicting_target,
|
||||
),
|
||||
)
|
||||
unchanged = await store.get()
|
||||
assert unchanged == EmbeddingSelectionState(0, target, None)
|
||||
with pytest.raises(EmbeddingSelectionConflict, match="already initialized"):
|
||||
rejected_operation_id = uuid4()
|
||||
await store.compare_and_swap(
|
||||
expected_generation=0,
|
||||
target=conflicting_target,
|
||||
operation_id=rejected_operation_id,
|
||||
action="initialize",
|
||||
request_fingerprint=selection_request_fingerprint(
|
||||
action="initialize",
|
||||
expected_generation=0,
|
||||
target=conflicting_target,
|
||||
),
|
||||
)
|
||||
assert await store.get() == unchanged
|
||||
assert (
|
||||
await store.find_operation(
|
||||
rejected_operation_id,
|
||||
request_fingerprint=selection_request_fingerprint(
|
||||
action="initialize",
|
||||
expected_generation=0,
|
||||
target=conflicting_target,
|
||||
),
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
async def test_concurrent_first_initializations_allow_only_one_postgres_cas(
|
||||
store: PostgresEmbeddingSelectionStore,
|
||||
) -> None:
|
||||
first_target = _selection("rc1", "e" * 64, "rc-first-route")
|
||||
second_target = _selection("rc2", "f" * 64, "rc-second-route")
|
||||
outcomes = await asyncio.gather(
|
||||
*(
|
||||
store.compare_and_swap(
|
||||
expected_generation=0,
|
||||
target=target,
|
||||
operation_id=uuid4(),
|
||||
action="initialize",
|
||||
request_fingerprint=selection_request_fingerprint(
|
||||
action="initialize", expected_generation=0, target=target
|
||||
),
|
||||
)
|
||||
for target in (first_target, second_target)
|
||||
),
|
||||
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)
|
||||
state = await store.get()
|
||||
assert state.generation == 0
|
||||
assert state.active in {first_target, second_target}
|
||||
assert state.previous is None
|
||||
|
||||
|
||||
async def test_postgres_serializes_concurrent_generation_cas(
|
||||
store: PostgresEmbeddingSelectionStore,
|
||||
) -> None:
|
||||
current = _selection("v1", "a" * 64, "lab-current-route")
|
||||
first = _selection("v2", "b" * 64, "lab-first-route")
|
||||
second = _selection("v3", "c" * 64, "lab-second-route")
|
||||
await store.initialize(current)
|
||||
|
||||
results = await asyncio.gather(
|
||||
store.compare_and_swap(
|
||||
expected_generation=0,
|
||||
target=first,
|
||||
operation_id=uuid4(),
|
||||
action="promote",
|
||||
request_fingerprint=selection_request_fingerprint(
|
||||
action="promote", expected_generation=0, target=first
|
||||
),
|
||||
),
|
||||
store.compare_and_swap(
|
||||
expected_generation=0,
|
||||
target=second,
|
||||
operation_id=uuid4(),
|
||||
action="promote",
|
||||
request_fingerprint=selection_request_fingerprint(
|
||||
action="promote", expected_generation=0, target=second
|
||||
),
|
||||
),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
assert sum(not isinstance(result, BaseException) for result in results) == 1
|
||||
state = await store.get()
|
||||
assert state.generation == 1
|
||||
assert state.active in {first, second}
|
||||
assert state.previous == current
|
||||
@@ -0,0 +1,301 @@
|
||||
"""Real Postgres + real Qdrant coverage for profile comparison and activation.
|
||||
|
||||
Mirrors tests/integration/indexes/test_rebuild.py's fixture style exactly
|
||||
(same real ``IndexBuildService``/``QdrantRebuildStore``/``PostgresIndexOperationStore``
|
||||
wiring): only ``AuthoritativeIndexSource`` stays a fixture (as in that file too --
|
||||
a real, content-reading, live-embedding source is out of scope for this task).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Iterable
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from ragcore.application.indexes import IndexBuildService
|
||||
from ragcore.application.profiles import (
|
||||
ActivationRejectedError,
|
||||
CandidateAliasTarget,
|
||||
ProfileActivationService,
|
||||
)
|
||||
from ragcore.config import Settings
|
||||
from ragcore.evaluation import EvaluationCase, EvaluationDataset, EvaluationRun, RetrievalRunProfile
|
||||
from ragcore.evaluation.metrics.models import RegressionThresholds
|
||||
from ragcore.evaluation.models import AnswerabilityStatus, CaseResult
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.indexes import PostgresIndexOperationStore
|
||||
from ragcore.infrastructure.database.profiles import PostgresActivationDecisionStore
|
||||
from ragcore.infrastructure.qdrant import (
|
||||
CollectionKind,
|
||||
CollectionProfile,
|
||||
IndexPayload,
|
||||
IndexPoint,
|
||||
)
|
||||
from ragcore.infrastructure.qdrant.rebuild import QdrantRebuildStore
|
||||
from ragcore.infrastructure.qdrant.smoke import QdrantGoldenSmoke
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
class FixtureSource:
|
||||
def __init__(self, points: Iterable[IndexPoint]) -> None:
|
||||
self.points = tuple(points)
|
||||
|
||||
async def iter_points(self, profile: CollectionProfile) -> AsyncIterator[IndexPoint]:
|
||||
for point in self.points:
|
||||
yield point
|
||||
|
||||
|
||||
def make_profile() -> CollectionProfile:
|
||||
return CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile="profile-activation-test",
|
||||
version=uuid4().hex[:12],
|
||||
dimensions=4,
|
||||
model_digest="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
def make_point(profile: CollectionProfile, index: int) -> IndexPoint:
|
||||
point_id = uuid4()
|
||||
return IndexPoint(
|
||||
point_id=point_id,
|
||||
dense_vector=(0.1 + index, 0.2, 0.3, 0.4),
|
||||
sparse_indices=(index + 1, index + 9),
|
||||
sparse_values=(0.8, 0.3),
|
||||
payload=IndexPayload(
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
document_id=uuid4(),
|
||||
document_version_id=uuid4(),
|
||||
chunk_id=point_id,
|
||||
parent_chunk_id=None,
|
||||
source_id=uuid4(),
|
||||
status="published",
|
||||
content_type="text/markdown",
|
||||
language="en",
|
||||
trust_level="source",
|
||||
location={"page": index + 1},
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version=profile.version,
|
||||
embedding_model_digest=profile.model_digest,
|
||||
text=f"A safe indexed passage {index}.",
|
||||
excerpt=f"A safe indexed passage {index}.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def target_for(profile: CollectionProfile) -> CandidateAliasTarget:
|
||||
return CandidateAliasTarget(
|
||||
alias_name=profile.alias_name,
|
||||
collection_name=profile.collection_name,
|
||||
version=profile.version,
|
||||
model_digest=profile.model_digest,
|
||||
)
|
||||
|
||||
|
||||
def _dataset() -> EvaluationDataset:
|
||||
case = EvaluationCase(
|
||||
id="case-1",
|
||||
question="What does the runbook say about failover?",
|
||||
language="en",
|
||||
expected_answerability=AnswerabilityStatus.ANSWERABLE,
|
||||
expected_document_ids=(uuid4(),),
|
||||
expected_chunk_ids=(uuid4(),),
|
||||
required_facts=("restart the payment service",),
|
||||
)
|
||||
return EvaluationDataset(id="profile-activation-sample", version="v1", cases=(case,))
|
||||
|
||||
|
||||
def _run_profile(alias: str, version: str) -> RetrievalRunProfile:
|
||||
return RetrievalRunProfile(
|
||||
embedding_profile_digest="a" * 64,
|
||||
sparse_profile_version="technical-v1",
|
||||
index_alias=alias,
|
||||
index_collection_version=version,
|
||||
)
|
||||
|
||||
|
||||
def _complete_run(
|
||||
dataset: EvaluationDataset, *, alias: str, version: str, hit: bool
|
||||
) -> 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(alias, 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=(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def qdrant() -> AsyncIterator[AsyncQdrantClient]:
|
||||
client = AsyncQdrantClient(
|
||||
url=os.getenv("RAGCORE_QDRANT_URL", "http://qdrant:6333"),
|
||||
api_key=os.getenv("RAGCORE_QDRANT_API_KEY", "replace-me"),
|
||||
timeout=60,
|
||||
)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine() -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def operations(db_engine: AsyncEngine) -> PostgresIndexOperationStore:
|
||||
return PostgresIndexOperationStore(create_session_factory(db_engine))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def decisions(db_engine: AsyncEngine) -> PostgresActivationDecisionStore:
|
||||
return PostgresActivationDecisionStore(create_session_factory(db_engine))
|
||||
|
||||
|
||||
async def test_passing_gate_activates_the_real_qdrant_alias_and_persists_the_decision(
|
||||
qdrant: AsyncQdrantClient,
|
||||
operations: PostgresIndexOperationStore,
|
||||
decisions: PostgresActivationDecisionStore,
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
builder = IndexBuildService(store, operations)
|
||||
service = ProfileActivationService(index_builder=builder, decisions=decisions)
|
||||
dataset = _dataset()
|
||||
baseline = _complete_run(dataset, alias=profile.alias_name, version="baseline", hit=True)
|
||||
candidate = _complete_run(dataset, alias=profile.alias_name, version=profile.version, hit=True)
|
||||
try:
|
||||
decision = await service.activate(
|
||||
dataset=dataset,
|
||||
baseline_run=baseline,
|
||||
candidate_run=candidate,
|
||||
thresholds=RegressionThresholds(version="v1"),
|
||||
candidate_profile=profile,
|
||||
source=FixtureSource((make_point(profile, 0), make_point(profile, 1))),
|
||||
smoke=QdrantGoldenSmoke(qdrant),
|
||||
)
|
||||
|
||||
assert decision.activated is True
|
||||
assert decision.gate.passed is True
|
||||
assert await store.current_alias_target(profile.alias_name) == profile.collection_name
|
||||
|
||||
stored = await decisions.get(decision.id)
|
||||
assert stored is not None
|
||||
assert stored.activated is True
|
||||
assert stored.candidate_target == target_for(profile)
|
||||
assert stored.previous_collection is None
|
||||
assert stored.rolled_back is False
|
||||
|
||||
recent = await decisions.list_recent(limit=5)
|
||||
assert decision.id in {item.id for item in recent}
|
||||
finally:
|
||||
await store.rollback(profile.alias_name, None)
|
||||
await qdrant.delete_collection(profile.collection_name)
|
||||
|
||||
|
||||
async def test_failing_gate_never_touches_qdrant_and_records_a_rejected_decision(
|
||||
qdrant: AsyncQdrantClient,
|
||||
operations: PostgresIndexOperationStore,
|
||||
decisions: PostgresActivationDecisionStore,
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
builder = IndexBuildService(store, operations)
|
||||
service = ProfileActivationService(index_builder=builder, decisions=decisions)
|
||||
dataset = _dataset()
|
||||
baseline = _complete_run(dataset, alias=profile.alias_name, version="baseline", hit=True)
|
||||
candidate = _complete_run(dataset, alias=profile.alias_name, version=profile.version, 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=profile,
|
||||
source=FixtureSource((make_point(profile, 0),)),
|
||||
smoke=QdrantGoldenSmoke(qdrant),
|
||||
)
|
||||
assert excinfo.value.gate.passed is False
|
||||
assert await store.current_alias_target(profile.alias_name) is None
|
||||
|
||||
decision_list = await decisions.list_recent(limit=50)
|
||||
matching = [item for item in decision_list if item.candidate_run_id == candidate.id]
|
||||
assert len(matching) == 1
|
||||
assert matching[0].activated is False
|
||||
assert matching[0].candidate_target is None
|
||||
|
||||
|
||||
async def test_rollback_restores_the_previous_active_collection(
|
||||
qdrant: AsyncQdrantClient,
|
||||
operations: PostgresIndexOperationStore,
|
||||
decisions: PostgresActivationDecisionStore,
|
||||
) -> None:
|
||||
first = make_profile()
|
||||
second = CollectionProfile(
|
||||
kind=first.kind,
|
||||
profile=first.profile,
|
||||
version=uuid4().hex[:12],
|
||||
dimensions=first.dimensions,
|
||||
model_digest=first.model_digest,
|
||||
)
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
builder = IndexBuildService(store, operations)
|
||||
service = ProfileActivationService(index_builder=builder, decisions=decisions)
|
||||
dataset = _dataset()
|
||||
try:
|
||||
await builder.build(
|
||||
first, FixtureSource((make_point(first, 0),)), QdrantGoldenSmoke(qdrant)
|
||||
)
|
||||
assert await store.current_alias_target(first.alias_name) == first.collection_name
|
||||
|
||||
baseline = _complete_run(dataset, alias=first.alias_name, version=first.version, hit=True)
|
||||
candidate = _complete_run(dataset, alias=first.alias_name, version=second.version, hit=True)
|
||||
decision = await service.activate(
|
||||
dataset=dataset,
|
||||
baseline_run=baseline,
|
||||
candidate_run=candidate,
|
||||
thresholds=RegressionThresholds(version="v1"),
|
||||
candidate_profile=second,
|
||||
source=FixtureSource((make_point(second, 0),)),
|
||||
smoke=QdrantGoldenSmoke(qdrant),
|
||||
)
|
||||
assert decision.previous_collection == first.collection_name
|
||||
assert await store.current_alias_target(first.alias_name) == second.collection_name
|
||||
|
||||
rolled_back = await service.rollback(decision.id)
|
||||
assert rolled_back.rolled_back is True
|
||||
assert await store.current_alias_target(first.alias_name) == first.collection_name
|
||||
|
||||
stored = await decisions.get(decision.id)
|
||||
assert stored is not None
|
||||
assert stored.rolled_back is True
|
||||
assert stored.rolled_back_at is not None
|
||||
finally:
|
||||
await store.rollback(first.alias_name, None)
|
||||
await qdrant.delete_collection(first.collection_name)
|
||||
await qdrant.delete_collection(second.collection_name)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""PostgreSQL integration tests for atomic document publication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from ragcore.application.documents import (
|
||||
ArtifactRecord,
|
||||
ChunkRecord,
|
||||
DocumentPublicationService,
|
||||
DocumentRecord,
|
||||
DocumentVersionRecord,
|
||||
DocumentVersionState,
|
||||
)
|
||||
from ragcore.application.sources import implicit_source_record
|
||||
from ragcore.config import Settings
|
||||
from ragcore.infrastructure.database import (
|
||||
create_database_engine,
|
||||
create_session_factory,
|
||||
)
|
||||
from ragcore.infrastructure.database.content import PostgresDocumentRepository
|
||||
from ragcore.infrastructure.database.sources import PostgresSourceRegistry
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def publication_context() -> tuple[
|
||||
PostgresDocumentRepository, async_sessionmaker[AsyncSession]
|
||||
]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
factory = create_session_factory(engine)
|
||||
try:
|
||||
yield PostgresDocumentRepository(factory), factory
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _register_source(factory, document) -> None: # type: ignore[no-untyped-def]
|
||||
"""The content schema requires a registry row for every document's source."""
|
||||
|
||||
await PostgresSourceRegistry(factory).ensure_source(
|
||||
implicit_source_record(
|
||||
source_id=document.source_id,
|
||||
workspace_id=document.workspace_id,
|
||||
space_id=document.space_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def version(document_id, state: DocumentVersionState, *, fingerprint: str) -> DocumentVersionRecord:
|
||||
return DocumentVersionRecord(
|
||||
id=uuid4(),
|
||||
document_id=document_id,
|
||||
source_fingerprint=fingerprint,
|
||||
content_hash=fingerprint,
|
||||
parser_version="parser-v1",
|
||||
chunk_profile_version="prose-v1",
|
||||
state=state,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_publication_supersedes_previous_and_is_idempotent(
|
||||
publication_context: tuple[PostgresDocumentRepository, async_sessionmaker[AsyncSession]],
|
||||
) -> None:
|
||||
repository, factory = publication_context
|
||||
document = DocumentRecord(uuid4(), uuid4(), uuid4(), "source-1", f"doc-{uuid4()}", "Runbook")
|
||||
await _register_source(factory, document)
|
||||
await repository.create_document(document)
|
||||
first = version(document.id, DocumentVersionState.VALIDATED, fingerprint="1" * 64)
|
||||
second = version(document.id, DocumentVersionState.VALIDATED, fingerprint="2" * 64)
|
||||
await repository.create_version(first)
|
||||
await repository.create_version(second)
|
||||
service = DocumentPublicationService(repository)
|
||||
artifact = ArtifactRecord(uuid4(), second.id, "canonical", "2" * 64, "parsed/key", 10)
|
||||
chunk = ChunkRecord(uuid4(), second.id, "/blocks/0000", "3" * 64, "prose-v1", "safe", 1)
|
||||
|
||||
await service.publish_validated(first.id, artifacts=(), chunks=())
|
||||
await service.publish_validated(second.id, artifacts=(artifact,), chunks=(chunk,))
|
||||
await service.publish_validated(second.id, artifacts=(artifact,), chunks=(chunk,))
|
||||
|
||||
assert (await repository.list_searchable_versions(document.id)) == (
|
||||
await repository.get_version(second.id),
|
||||
)
|
||||
assert (await repository.get_version(first.id)).state is DocumentVersionState.SUPERSEDED
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_failed_version_does_not_replace_previous_active_version(
|
||||
publication_context: tuple[PostgresDocumentRepository, async_sessionmaker[AsyncSession]],
|
||||
) -> None:
|
||||
repository, factory = publication_context
|
||||
document = DocumentRecord(uuid4(), uuid4(), uuid4(), "source-2", f"doc-{uuid4()}", "Stable")
|
||||
await _register_source(factory, document)
|
||||
await repository.create_document(document)
|
||||
active = version(document.id, DocumentVersionState.VALIDATED, fingerprint="4" * 64)
|
||||
failed = version(document.id, DocumentVersionState.FAILED, fingerprint="5" * 64)
|
||||
await repository.create_version(active)
|
||||
await repository.create_version(failed)
|
||||
service = DocumentPublicationService(repository)
|
||||
await service.publish_validated(active.id, artifacts=(), chunks=())
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await service.publish_validated(failed.id, artifacts=(), chunks=())
|
||||
|
||||
assert len(await repository.list_searchable_versions(document.id)) == 1
|
||||
assert (await repository.list_searchable_versions(document.id))[0].id == active.id
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_source_deletion_retires_active_version_immediately(
|
||||
publication_context: tuple[PostgresDocumentRepository, async_sessionmaker[AsyncSession]],
|
||||
) -> None:
|
||||
repository, factory = publication_context
|
||||
document = DocumentRecord(uuid4(), uuid4(), uuid4(), "source-3", f"doc-{uuid4()}", "Deleted")
|
||||
await _register_source(factory, document)
|
||||
await repository.create_document(document)
|
||||
active = version(document.id, DocumentVersionState.VALIDATED, fingerprint="6" * 64)
|
||||
await repository.create_version(active)
|
||||
await DocumentPublicationService(repository).publish_validated(
|
||||
active.id, artifacts=(), chunks=()
|
||||
)
|
||||
|
||||
await DocumentPublicationService(repository).mark_source_deleted(document.id)
|
||||
|
||||
assert await repository.list_searchable_versions(document.id) == ()
|
||||
assert (await repository.get_version(active.id)).state is DocumentVersionState.RETIRED
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import replace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
|
||||
from ragcore.infrastructure.qdrant import (
|
||||
CollectionKind,
|
||||
CollectionProfile,
|
||||
CollectionProfileMismatchError,
|
||||
IndexPayload,
|
||||
IndexPoint,
|
||||
QdrantIndexAdapter,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def profile() -> CollectionProfile:
|
||||
return CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile="test",
|
||||
version=uuid4().hex[:12],
|
||||
dimensions=4,
|
||||
model_digest="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def adapter(
|
||||
profile: CollectionProfile,
|
||||
) -> AsyncIterator[tuple[QdrantIndexAdapter, CollectionProfile]]:
|
||||
client = AsyncQdrantClient(
|
||||
url=os.getenv("RAGCORE_QDRANT_URL", "http://qdrant:6333"),
|
||||
api_key=os.getenv("RAGCORE_QDRANT_API_KEY", "replace-me"),
|
||||
timeout=60,
|
||||
)
|
||||
index = QdrantIndexAdapter(client)
|
||||
try:
|
||||
yield index, profile
|
||||
finally:
|
||||
await client.delete_collection(profile.collection_name)
|
||||
await client.close()
|
||||
|
||||
|
||||
def make_point(profile: CollectionProfile) -> IndexPoint:
|
||||
document_id = uuid4()
|
||||
version_id = uuid4()
|
||||
chunk_id = uuid4()
|
||||
return IndexPoint(
|
||||
point_id=chunk_id,
|
||||
dense_vector=(0.1, 0.2, 0.3, 0.4),
|
||||
sparse_indices=(2, 9),
|
||||
sparse_values=(0.8, 0.3),
|
||||
payload=IndexPayload(
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
document_id=document_id,
|
||||
document_version_id=version_id,
|
||||
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,
|
||||
embedding_space_id=profile.embedding_space_id,
|
||||
text="A safe indexed passage.",
|
||||
excerpt="A safe indexed passage.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_profile_rejects_invalid_dimensions_and_digest() -> None:
|
||||
with pytest.raises(ValueError, match="dimensions"):
|
||||
CollectionProfile(CollectionKind.DENSE, "test", "v1", 0, "a" * 64)
|
||||
with pytest.raises(ValueError, match="16384"):
|
||||
CollectionProfile(CollectionKind.DENSE, "test", "v1", 16_385, "a" * 64)
|
||||
with pytest.raises(ValueError, match="SHA-256"):
|
||||
CollectionProfile(CollectionKind.DENSE, "test", "v1", 4, "not-a-digest")
|
||||
|
||||
|
||||
def test_point_payload_rejects_unbounded_or_nonfinite_values() -> None:
|
||||
profile = CollectionProfile(CollectionKind.DENSE, "test", "v1", 4, "a" * 64)
|
||||
point = make_point(profile)
|
||||
with pytest.raises(ValueError, match="payload limit"):
|
||||
replace(point.payload, text="x" * (1_048_576 + 1))
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
IndexPoint(point_id=point.point_id, dense_vector=(float("nan"),), payload=point.payload)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_collection_profile_indexes_alias_and_idempotent_setup(
|
||||
adapter: tuple[QdrantIndexAdapter, CollectionProfile],
|
||||
) -> None:
|
||||
index, profile = adapter
|
||||
await index.ensure_collection(profile)
|
||||
await index.ensure_collection(profile)
|
||||
|
||||
client = index._client
|
||||
info = await client.get_collection(profile.collection_name)
|
||||
assert info.config.params.vectors["dense"].size == profile.dimensions
|
||||
assert "sparse" in info.config.params.sparse_vectors
|
||||
assert set(
|
||||
(
|
||||
"workspace_id",
|
||||
"space_id",
|
||||
"document_id",
|
||||
"document_version_id",
|
||||
"status",
|
||||
"content_type",
|
||||
"language",
|
||||
"trust_level",
|
||||
)
|
||||
).issubset(info.payload_schema)
|
||||
aliases = await client.get_aliases()
|
||||
assert any(
|
||||
alias.alias_name == profile.alias_name and alias.collection_name == profile.collection_name
|
||||
for alias in aliases.aliases
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_point_upsert_and_explicit_delete_are_idempotent(
|
||||
adapter: tuple[QdrantIndexAdapter, CollectionProfile],
|
||||
) -> None:
|
||||
index, profile = adapter
|
||||
await index.ensure_collection(profile)
|
||||
point = make_point(profile)
|
||||
|
||||
await index.upsert_points(profile, (point,))
|
||||
await index.upsert_points(profile, (point,))
|
||||
stored = await index._client.retrieve(
|
||||
collection_name=profile.collection_name,
|
||||
ids=[str(point.point_id)],
|
||||
)
|
||||
assert len(stored) == 1
|
||||
with pytest.raises(CollectionProfileMismatchError):
|
||||
await index.upsert_points(
|
||||
profile,
|
||||
(
|
||||
IndexPoint(
|
||||
point_id=point.point_id,
|
||||
dense_vector=(0.1, 0.2),
|
||||
payload=point.payload,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
await index.delete_points(profile, (point.point_id,))
|
||||
await index.delete_points(profile, (point.point_id,))
|
||||
assert (
|
||||
await index._client.retrieve(
|
||||
collection_name=profile.collection_name,
|
||||
ids=[str(point.point_id)],
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_public_point_operations_do_not_accept_raw_filters() -> None:
|
||||
for method_name in ("upsert_points", "delete_points"):
|
||||
parameters = inspect.signature(getattr(QdrantIndexAdapter, method_name)).parameters
|
||||
assert "filter" not in parameters
|
||||
assert "raw_filter" not in parameters
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_point_upsert_rejects_a_different_embedding_space_before_qdrant_io() -> None:
|
||||
profile = CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile="test",
|
||||
version="v2",
|
||||
dimensions=4,
|
||||
model_digest="a" * 64,
|
||||
embedding_space_id=uuid4(),
|
||||
)
|
||||
point = make_point(profile)
|
||||
mismatched_point = replace(point, payload=replace(point.payload, embedding_space_id=uuid4()))
|
||||
adapter = QdrantIndexAdapter(None) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(CollectionProfileMismatchError, match="embedding space"):
|
||||
await adapter.upsert_points(profile, (mismatched_point,))
|
||||
@@ -0,0 +1,36 @@
|
||||
"""ops/alerts/provider_circuit_open.yaml stays in sync with the real code."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from ragcore.infrastructure.models.resilience import CIRCUIT_OPEN_EVENT
|
||||
|
||||
|
||||
def _alert_document() -> dict[str, object]:
|
||||
path = Path(__file__).parents[3] / "ops" / "alerts" / "provider_circuit_open.yaml"
|
||||
return yaml.safe_load(path.read_text())
|
||||
|
||||
|
||||
def test_alert_file_exists_and_parses() -> None:
|
||||
document = _alert_document()
|
||||
assert document["alert"] == "ProviderCircuitOpen"
|
||||
assert document["severity"] == "warning"
|
||||
|
||||
|
||||
def test_alert_signal_event_matches_the_literal_logged_event_name() -> None:
|
||||
document = _alert_document()
|
||||
signal = document["signal"]
|
||||
assert signal["type"] == "log_event"
|
||||
# This is the actual regression guard: if CIRCUIT_OPEN_EVENT is ever
|
||||
# renamed in code without updating the alert, this test fails instead
|
||||
# of the alert silently going stale and never firing again.
|
||||
assert signal["event"] == CIRCUIT_OPEN_EVENT
|
||||
|
||||
|
||||
def test_alert_declares_the_expected_fields() -> None:
|
||||
document = _alert_document()
|
||||
fields = document["signal"]["fields"]
|
||||
assert set(fields) == {"provider", "from_state", "to_state"}
|
||||
@@ -0,0 +1,115 @@
|
||||
"""CircuitBreaker: observable state transitions, cooldown and recovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.infrastructure.models.resilience import (
|
||||
CIRCUIT_CLOSED_EVENT,
|
||||
CIRCUIT_HALF_OPEN_EVENT,
|
||||
CIRCUIT_OPEN_EVENT,
|
||||
CircuitBreaker,
|
||||
CircuitOpenError,
|
||||
CircuitState,
|
||||
)
|
||||
|
||||
|
||||
def _clock(value: dict[str, float]) -> Callable[[], float]:
|
||||
return lambda: value["now"]
|
||||
|
||||
|
||||
def test_circuit_starts_closed_and_allows_calls() -> None:
|
||||
breaker = CircuitBreaker(provider="test-provider")
|
||||
assert breaker.state is CircuitState.CLOSED
|
||||
breaker.check() # must not raise
|
||||
|
||||
|
||||
def test_circuit_opens_after_consecutive_failures_and_blocks_calls() -> None:
|
||||
breaker = CircuitBreaker(provider="test-provider", failure_threshold=3)
|
||||
for _ in range(2):
|
||||
breaker.record_failure()
|
||||
assert breaker.state is CircuitState.CLOSED
|
||||
breaker.record_failure()
|
||||
assert breaker.state is CircuitState.OPEN
|
||||
with pytest.raises(CircuitOpenError):
|
||||
breaker.check()
|
||||
|
||||
|
||||
def test_circuit_transitions_are_observable_via_state_and_logs(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
breaker = CircuitBreaker(provider="test-provider", failure_threshold=1)
|
||||
with caplog.at_level(logging.WARNING, logger="ragcore.infrastructure.models.resilience"):
|
||||
breaker.record_failure()
|
||||
assert breaker.state is CircuitState.OPEN
|
||||
open_records = [r for r in caplog.records if r.getMessage() == CIRCUIT_OPEN_EVENT]
|
||||
assert len(open_records) == 1
|
||||
assert open_records[0].provider == "test-provider"
|
||||
assert open_records[0].from_state == "closed"
|
||||
assert open_records[0].to_state == "open"
|
||||
|
||||
|
||||
def test_circuit_half_opens_after_cooldown_and_closes_on_success_recovery_probe() -> None:
|
||||
time_value = {"now": 0.0}
|
||||
breaker = CircuitBreaker(
|
||||
provider="test-provider",
|
||||
failure_threshold=1,
|
||||
cooldown_seconds=10.0,
|
||||
clock=_clock(time_value),
|
||||
)
|
||||
breaker.record_failure()
|
||||
assert breaker.state is CircuitState.OPEN
|
||||
|
||||
# Still within cooldown: blocked without a real recovery attempt.
|
||||
with pytest.raises(CircuitOpenError):
|
||||
breaker.check()
|
||||
assert breaker.state is CircuitState.OPEN
|
||||
|
||||
# Cooldown elapsed: exactly one recovery probe is allowed through.
|
||||
time_value["now"] = 10.1
|
||||
breaker.check()
|
||||
assert breaker.state is CircuitState.HALF_OPEN
|
||||
|
||||
breaker.record_success()
|
||||
assert breaker.state is CircuitState.CLOSED
|
||||
breaker.check() # closed again: no longer raises
|
||||
|
||||
|
||||
def test_circuit_half_open_failure_reopens_immediately_without_a_fresh_threshold() -> None:
|
||||
time_value = {"now": 0.0}
|
||||
breaker = CircuitBreaker(
|
||||
provider="test-provider",
|
||||
failure_threshold=5,
|
||||
cooldown_seconds=10.0,
|
||||
clock=_clock(time_value),
|
||||
)
|
||||
for _ in range(5):
|
||||
breaker.record_failure()
|
||||
assert breaker.state is CircuitState.OPEN
|
||||
|
||||
time_value["now"] = 10.1
|
||||
breaker.check()
|
||||
assert breaker.state is CircuitState.HALF_OPEN
|
||||
|
||||
# A single half-open failure reopens immediately -- it does not require
|
||||
# accumulating the full failure_threshold all over again.
|
||||
breaker.record_failure()
|
||||
assert breaker.state is CircuitState.OPEN
|
||||
|
||||
|
||||
def test_transition_events_are_named_consistently() -> None:
|
||||
assert CIRCUIT_OPEN_EVENT == "provider_circuit_open"
|
||||
assert CIRCUIT_HALF_OPEN_EVENT == "provider_circuit_half_open"
|
||||
assert CIRCUIT_CLOSED_EVENT == "provider_circuit_closed"
|
||||
|
||||
|
||||
def test_invalid_construction_arguments_are_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="provider"):
|
||||
CircuitBreaker(provider="")
|
||||
with pytest.raises(ValueError, match="failure_threshold"):
|
||||
CircuitBreaker(provider="p", failure_threshold=0)
|
||||
with pytest.raises(ValueError, match="cooldown_seconds"):
|
||||
CircuitBreaker(provider="p", cooldown_seconds=0)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""ProviderResilienceGuard: bounded overload, and circuit integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.infrastructure.models.resilience import (
|
||||
CircuitOpenError,
|
||||
CircuitState,
|
||||
ProviderOverloadedError,
|
||||
ProviderResilienceGuard,
|
||||
)
|
||||
|
||||
|
||||
async def test_provider_overload_is_bounded_not_queued_without_limit() -> None:
|
||||
guard = ProviderResilienceGuard(provider="test-provider", max_concurrent=1, max_queued=1)
|
||||
release = asyncio.Event()
|
||||
|
||||
async def occupy() -> None:
|
||||
async with guard.guard():
|
||||
await release.wait()
|
||||
|
||||
first = asyncio.create_task(occupy())
|
||||
await asyncio.sleep(0) # let `first` acquire the single concurrency slot
|
||||
|
||||
second = asyncio.create_task(occupy())
|
||||
await asyncio.sleep(0) # `second` should be admitted into the one queue slot
|
||||
assert guard.waiting == 1
|
||||
|
||||
# A third call arrives while the budget and its queue are both full.
|
||||
with pytest.raises(ProviderOverloadedError):
|
||||
async with guard.guard():
|
||||
pass # pragma: no cover -- must never be entered
|
||||
|
||||
release.set()
|
||||
await first
|
||||
await second
|
||||
|
||||
|
||||
async def test_guard_records_success_and_failure_against_the_circuit() -> None:
|
||||
guard = ProviderResilienceGuard(
|
||||
provider="test-provider", max_concurrent=2, max_queued=2, failure_threshold=2
|
||||
)
|
||||
|
||||
async with guard.guard():
|
||||
pass
|
||||
assert guard.circuit.state is CircuitState.CLOSED
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
async with guard.guard():
|
||||
raise RuntimeError("boom")
|
||||
assert guard.circuit.state is CircuitState.CLOSED # one failure, below threshold
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
async with guard.guard():
|
||||
raise RuntimeError("boom")
|
||||
assert guard.circuit.state is CircuitState.OPEN # threshold reached
|
||||
|
||||
|
||||
async def test_open_circuit_rejects_before_touching_the_concurrency_budget() -> None:
|
||||
guard = ProviderResilienceGuard(
|
||||
provider="test-provider", max_concurrent=1, max_queued=0, failure_threshold=1
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
async with guard.guard():
|
||||
raise RuntimeError("boom")
|
||||
assert guard.circuit.state is CircuitState.OPEN
|
||||
|
||||
with pytest.raises(CircuitOpenError):
|
||||
async with guard.guard():
|
||||
pass # pragma: no cover -- must never be entered
|
||||
assert guard.waiting == 0
|
||||
|
||||
|
||||
async def test_cancellation_while_waiting_releases_the_queue_slot() -> None:
|
||||
guard = ProviderResilienceGuard(provider="test-provider", max_concurrent=1, max_queued=1)
|
||||
release = asyncio.Event()
|
||||
|
||||
async def occupy() -> None:
|
||||
async with guard.guard():
|
||||
await release.wait()
|
||||
|
||||
holder = asyncio.create_task(occupy())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
async def waiter() -> None:
|
||||
async with guard.guard():
|
||||
pass # pragma: no cover -- cancelled before ever acquiring
|
||||
|
||||
waiting_task = asyncio.create_task(waiter())
|
||||
await asyncio.sleep(0)
|
||||
assert guard.waiting == 1
|
||||
|
||||
waiting_task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await waiting_task
|
||||
assert guard.waiting == 0
|
||||
|
||||
release.set()
|
||||
await holder
|
||||
|
||||
|
||||
def test_invalid_construction_arguments_are_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="max_concurrent"):
|
||||
ProviderResilienceGuard(provider="p", max_concurrent=0, max_queued=1)
|
||||
with pytest.raises(ValueError, match="max_queued"):
|
||||
ProviderResilienceGuard(provider="p", max_concurrent=1, max_queued=-1)
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Search/context succeed even while every generation provider is down.
|
||||
|
||||
Search and Context never call a generation provider at all (confirmed by
|
||||
grep: neither module imports anything from ``ragcore.generation``,
|
||||
``ragcore.infrastructure.models``, or ``ragcore.application.models``).
|
||||
This test proves that architectural independence behaviorally: a
|
||||
:class:`ProviderResilienceGuard` standing in for "the generation provider"
|
||||
is driven fully open (simulating every generation provider being down),
|
||||
and real ``/v1/search``/``/v1/context`` requests -- built the same way
|
||||
their own contract tests build them -- still return 200.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ragcore.api.v1.context.dependencies import get_context_application, get_context_principal
|
||||
from ragcore.api.v1.context.service import (
|
||||
ContextExecutionRequest,
|
||||
ContextExecutionResult,
|
||||
ContextService,
|
||||
)
|
||||
from ragcore.api.v1.search.dependencies import get_search_application, get_search_principal
|
||||
from ragcore.api.v1.search.service import (
|
||||
AuthorizationInputs,
|
||||
SearchExecutionRequest,
|
||||
SearchExecutionResult,
|
||||
SearchHit,
|
||||
SearchService,
|
||||
)
|
||||
from ragcore.application.retrieval.authorization import RetrievalAuthorizationService
|
||||
from ragcore.domain.authorization import Principal, PrincipalType
|
||||
from ragcore.domain.citations import Citation
|
||||
from ragcore.domain.control import Role, Workspace
|
||||
from ragcore.infrastructure.models.resilience import (
|
||||
CircuitOpenError,
|
||||
CircuitState,
|
||||
ProviderResilienceGuard,
|
||||
)
|
||||
from ragcore.main import create_app
|
||||
from ragcore.retrieval.context import ContextCandidate
|
||||
from ragcore.retrieval.ranking import FusedCandidate
|
||||
from ragcore.retrieval.search.models import Candidate, CandidateBackendKind, CandidateProvenance
|
||||
|
||||
|
||||
def _fully_down_generation_guard() -> ProviderResilienceGuard:
|
||||
"""Simulate every generation provider being down: an open circuit."""
|
||||
|
||||
guard = ProviderResilienceGuard(
|
||||
provider="generation-ollama", max_concurrent=1, max_queued=0, failure_threshold=1
|
||||
)
|
||||
|
||||
# A single synthetic failure trips the circuit open, matching what a
|
||||
# real generation adapter would do after its own connection attempts
|
||||
# fail (see OllamaGenerationAdapter's own circuit breaker).
|
||||
async def _fail() -> None:
|
||||
async with guard.guard():
|
||||
raise RuntimeError("every generation provider is unreachable")
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
asyncio.run(_fail())
|
||||
assert guard.circuit.state is CircuitState.OPEN
|
||||
return guard
|
||||
|
||||
|
||||
class _SearchInputs:
|
||||
def __init__(self, workspace: Workspace, space_id: UUID) -> None:
|
||||
self.workspace = workspace
|
||||
self.space_id = space_id
|
||||
|
||||
async def load(self, principal: Principal) -> AuthorizationInputs:
|
||||
return AuthorizationInputs(
|
||||
spaces=(
|
||||
self.workspace.create_knowledge_space(
|
||||
id=self.space_id, name="Space", slug="space"
|
||||
),
|
||||
),
|
||||
user_grants=(
|
||||
self.workspace.grant_user(
|
||||
id=uuid4(),
|
||||
user_id=principal.id,
|
||||
knowledge_space=self.workspace.create_knowledge_space(
|
||||
id=self.space_id, name="Space", slug="space"
|
||||
),
|
||||
role=Role.EDITOR,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _SearchExecutor:
|
||||
def __init__(self, hit: SearchHit) -> None:
|
||||
self.hit = hit
|
||||
|
||||
async def execute(self, request: SearchExecutionRequest) -> SearchExecutionResult:
|
||||
return SearchExecutionResult(hits=(_refresh_hit(self.hit, request.retrieval_run_id),))
|
||||
|
||||
|
||||
class _ContextExecutor:
|
||||
def __init__(self, candidate: ContextCandidate) -> None:
|
||||
self.candidate = candidate
|
||||
|
||||
async def execute(self, request: ContextExecutionRequest) -> ContextExecutionResult:
|
||||
return ContextExecutionResult(
|
||||
candidates=(_refresh_candidate(self.candidate, request.retrieval_run_id),)
|
||||
)
|
||||
|
||||
|
||||
def _refresh_citation(citation: Citation, retrieval_run_id: UUID) -> Citation:
|
||||
return Citation.create(
|
||||
workspace_id=citation.workspace_id,
|
||||
space_id=citation.space_id,
|
||||
document_id=citation.document_id,
|
||||
document_version_id=citation.document_version_id,
|
||||
chunk_id=citation.chunk_id,
|
||||
parent_chunk_id=citation.parent_chunk_id,
|
||||
location=citation.location,
|
||||
excerpt=citation.excerpt,
|
||||
source_uri=citation.source_uri,
|
||||
acquired_at=citation.acquired_at,
|
||||
retrieval_run_id=retrieval_run_id,
|
||||
index_alias=citation.index_alias,
|
||||
profile_version=citation.profile_version,
|
||||
)
|
||||
|
||||
|
||||
def _refresh_hit(hit: SearchHit, retrieval_run_id: UUID) -> SearchHit:
|
||||
return replace(hit, citation=_refresh_citation(hit.citation, retrieval_run_id))
|
||||
|
||||
|
||||
def _refresh_candidate(candidate: ContextCandidate, retrieval_run_id: UUID) -> ContextCandidate:
|
||||
return replace(candidate, citation=_refresh_citation(candidate.citation, retrieval_run_id))
|
||||
|
||||
|
||||
def _fixture() -> tuple[object, UUID]:
|
||||
workspace = Workspace(uuid4(), "Resilience workspace", "resilience-workspace")
|
||||
space_id = uuid4()
|
||||
principal = Principal(uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.EDITOR}))
|
||||
document_id, version_id, chunk_id = uuid4(), uuid4(), uuid4()
|
||||
citation = Citation.create(
|
||||
workspace_id=workspace.id,
|
||||
space_id=space_id,
|
||||
document_id=document_id,
|
||||
document_version_id=version_id,
|
||||
chunk_id=chunk_id,
|
||||
parent_chunk_id=None,
|
||||
location={"page_start": 1},
|
||||
excerpt="Independent evidence",
|
||||
source_uri="https://example.test/doc.md",
|
||||
acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC),
|
||||
retrieval_run_id=uuid4(),
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
profile_version="hybrid-v1",
|
||||
)
|
||||
candidate = Candidate(
|
||||
point_id=uuid4(),
|
||||
score=0.9,
|
||||
rank=1,
|
||||
backend=CandidateBackendKind.DENSE,
|
||||
provenance=CandidateProvenance(
|
||||
workspace_id=workspace.id,
|
||||
space_id=space_id,
|
||||
document_id=document_id,
|
||||
document_version_id=version_id,
|
||||
chunk_id=chunk_id,
|
||||
source_id=uuid4(),
|
||||
parent_chunk_id=None,
|
||||
status="published",
|
||||
content_type="text/markdown",
|
||||
language="en",
|
||||
trust_level="trusted_internal",
|
||||
),
|
||||
)
|
||||
hit = SearchHit(
|
||||
fused=FusedCandidate(candidate, 1 / 61, 1, None),
|
||||
text="Independent evidence",
|
||||
title="Doc",
|
||||
citation=citation,
|
||||
)
|
||||
context_candidate = ContextCandidate(citation, "Independent evidence", 1, 1.0)
|
||||
|
||||
search_service = SearchService(
|
||||
authorization=RetrievalAuthorizationService(),
|
||||
inputs=_SearchInputs(workspace, space_id),
|
||||
executor=_SearchExecutor(hit),
|
||||
)
|
||||
context_service = ContextService(
|
||||
authorization=RetrievalAuthorizationService(),
|
||||
inputs=_SearchInputs(workspace, space_id),
|
||||
executor=_ContextExecutor(context_candidate),
|
||||
)
|
||||
|
||||
api = create_app()
|
||||
api.dependency_overrides[get_search_principal] = lambda: principal
|
||||
api.dependency_overrides[get_context_principal] = lambda: principal
|
||||
api.dependency_overrides[get_search_application] = lambda: search_service
|
||||
api.dependency_overrides[get_context_application] = lambda: context_service
|
||||
return api, space_id
|
||||
|
||||
|
||||
def test_search_succeeds_while_the_generation_provider_circuit_is_open() -> None:
|
||||
generation_guard = _fully_down_generation_guard()
|
||||
api, space_id = _fixture()
|
||||
|
||||
response = TestClient(api).post(
|
||||
"/v1/search",
|
||||
json={"query": "anything", "requested_space_ids": [str(space_id)]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["results"]
|
||||
# The generation provider's outage is still in effect throughout --
|
||||
# search never touched it, so it never has a chance to recover either.
|
||||
assert generation_guard.circuit.state is CircuitState.OPEN
|
||||
with pytest.raises(CircuitOpenError):
|
||||
generation_guard.circuit.check()
|
||||
|
||||
|
||||
def test_context_succeeds_while_the_generation_provider_circuit_is_open() -> None:
|
||||
generation_guard = _fully_down_generation_guard()
|
||||
api, space_id = _fixture()
|
||||
|
||||
response = TestClient(api).post(
|
||||
"/v1/context",
|
||||
json={"query": "anything", "requested_space_ids": [str(space_id)]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["blocks"]
|
||||
assert generation_guard.circuit.state is CircuitState.OPEN
|
||||
@@ -0,0 +1,337 @@
|
||||
"""End-to-end empty-environment restore gate: backup a real source, restore
|
||||
it into disposable Postgres/Qdrant, and prove counts, golden queries and
|
||||
cross-space denial all reproduce -- without ever touching the source."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from qdrant_client.http import models as qdrant_models
|
||||
from qdrant_client.http.exceptions import UnexpectedResponse
|
||||
|
||||
from ragcore.application.backup import BackupService
|
||||
from ragcore.application.restore import GoldenQueryProbe, RestoreGateService, SecurityDenialProbe
|
||||
from ragcore.config import Settings
|
||||
from ragcore.domain.citations import Citation
|
||||
from ragcore.domain.control import DataPolicy, KnowledgeSpace, Role, UserGrant, Workspace
|
||||
from ragcore.infrastructure.backup import (
|
||||
LocalFileCopier,
|
||||
PgDumpPostgresDumper,
|
||||
QdrantHttpSnapshotter,
|
||||
)
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.control.repositories import ControlPlaneRepository
|
||||
from ragcore.infrastructure.restore import (
|
||||
QdrantHttpRestorer,
|
||||
SqlAlchemyControlPlaneReader,
|
||||
SqlAlchemyPostgresRestorer,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
QUERY_VECTOR = (0.1, 0.2, 0.3, 0.4)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def event_loop_policy():
|
||||
if sys.platform == "win32":
|
||||
return asyncio.WindowsSelectorEventLoopPolicy()
|
||||
return asyncio.get_event_loop_policy()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings() -> Settings:
|
||||
return Settings(env="test")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def session_factory(settings: Settings):
|
||||
engine = create_database_engine(settings)
|
||||
try:
|
||||
yield create_session_factory(engine)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def qdrant_client(settings: Settings) -> AsyncIterator[AsyncQdrantClient]:
|
||||
client = AsyncQdrantClient(
|
||||
url=settings.qdrant_url, api_key=settings.qdrant_api_key.get_secret_value(), timeout=300
|
||||
)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
class _SourceFixture:
|
||||
"""Real control-plane rows and a real Qdrant collection, torn down after."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.workspace_id = uuid4()
|
||||
self.granted_space_id = uuid4()
|
||||
self.denied_space_id = uuid4()
|
||||
self.principal_id = uuid4()
|
||||
self.document_id = uuid4()
|
||||
self.document_version_id = uuid4()
|
||||
self.chunk_id = uuid4()
|
||||
self.collection_name = f"restore-gate-{uuid4().hex[:12]}"
|
||||
self.alias = f"{self.collection_name}-live"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def source(
|
||||
session_factory, qdrant_client: AsyncQdrantClient
|
||||
) -> AsyncIterator[_SourceFixture]:
|
||||
fixture = _SourceFixture()
|
||||
async with session_factory() as session:
|
||||
repo = ControlPlaneRepository(session)
|
||||
await repo.create_workspace(
|
||||
Workspace(id=fixture.workspace_id, name="Restore Gate WS", slug=f"rg-{uuid4().hex[:8]}")
|
||||
)
|
||||
await repo.create_space(
|
||||
KnowledgeSpace(
|
||||
id=fixture.granted_space_id,
|
||||
workspace_id=fixture.workspace_id,
|
||||
name="Granted",
|
||||
slug=f"granted-{uuid4().hex[:8]}",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
)
|
||||
)
|
||||
await repo.create_space(
|
||||
KnowledgeSpace(
|
||||
id=fixture.denied_space_id,
|
||||
workspace_id=fixture.workspace_id,
|
||||
name="Denied",
|
||||
slug=f"denied-{uuid4().hex[:8]}",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
)
|
||||
)
|
||||
await repo.create_user_grant(
|
||||
UserGrant(
|
||||
id=uuid4(),
|
||||
workspace_id=fixture.workspace_id,
|
||||
user_id=fixture.principal_id,
|
||||
knowledge_space_id=fixture.granted_space_id,
|
||||
role=Role.VIEWER,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
await qdrant_client.create_collection(
|
||||
collection_name=fixture.collection_name,
|
||||
vectors_config={
|
||||
"dense": qdrant_models.VectorParams(size=4, distance=qdrant_models.Distance.COSINE)
|
||||
},
|
||||
)
|
||||
await qdrant_client.upsert(
|
||||
collection_name=fixture.collection_name,
|
||||
points=[
|
||||
qdrant_models.PointStruct(
|
||||
id=str(fixture.chunk_id),
|
||||
vector={"dense": list(QUERY_VECTOR)},
|
||||
payload={
|
||||
"workspace_id": str(fixture.workspace_id),
|
||||
"space_id": str(fixture.granted_space_id),
|
||||
"document_id": str(fixture.document_id),
|
||||
"document_version_id": str(fixture.document_version_id),
|
||||
"chunk_id": str(fixture.chunk_id),
|
||||
"location": {"page": 1},
|
||||
"text": "The restore gate must reproduce this exact passage.",
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
await qdrant_client.update_collection_aliases(
|
||||
change_aliases_operations=[
|
||||
qdrant_models.CreateAliasOperation(
|
||||
create_alias=qdrant_models.CreateAlias(
|
||||
collection_name=fixture.collection_name, alias_name=fixture.alias
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
try:
|
||||
yield fixture
|
||||
finally:
|
||||
await qdrant_client.delete_collection(fixture.collection_name)
|
||||
|
||||
|
||||
async def _snapshot_source(
|
||||
fixture: _SourceFixture, settings: Settings, session_factory, tmp_path: Path
|
||||
) -> Path:
|
||||
qdrant_client = AsyncQdrantClient(
|
||||
url=settings.qdrant_url, api_key=settings.qdrant_api_key.get_secret_value(), timeout=300
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=300) as http_client:
|
||||
backup_service = BackupService(
|
||||
postgres=PgDumpPostgresDumper(
|
||||
database_url=settings.database_url, session_factory=session_factory
|
||||
),
|
||||
qdrant=QdrantHttpSnapshotter(
|
||||
client=qdrant_client,
|
||||
http_client=http_client,
|
||||
base_url=settings.qdrant_url,
|
||||
api_key=settings.qdrant_api_key.get_secret_value(),
|
||||
),
|
||||
files=LocalFileCopier(),
|
||||
)
|
||||
manifest = await backup_service.run(
|
||||
backup_root=tmp_path / "backups",
|
||||
blob_root=tmp_path / "blobs-src",
|
||||
parsed_root=tmp_path / "parsed-src",
|
||||
collection_names=[fixture.collection_name],
|
||||
active_profiles={},
|
||||
)
|
||||
finally:
|
||||
await qdrant_client.close()
|
||||
return tmp_path / "backups" / manifest.backup_id
|
||||
|
||||
|
||||
async def test_restore_gate_reproduces_counts_citations_and_denies_cross_space_access(
|
||||
source: _SourceFixture, settings: Settings, session_factory, tmp_path: Path
|
||||
) -> None:
|
||||
backup_dir = await _snapshot_source(source, settings, session_factory, tmp_path)
|
||||
|
||||
admin_qdrant = AsyncQdrantClient(
|
||||
url=settings.qdrant_url, api_key=settings.qdrant_api_key.get_secret_value(), timeout=300
|
||||
)
|
||||
postgres_restorer = SqlAlchemyPostgresRestorer(admin_database_url=settings.database_url)
|
||||
disposable_database_name = f"restore_gate_{uuid4().hex[:12]}"
|
||||
disposable_collection_prefix = f"restored-{uuid4().hex[:8]}"
|
||||
try:
|
||||
service = RestoreGateService(
|
||||
postgres=postgres_restorer,
|
||||
qdrant=QdrantHttpRestorer(client=admin_qdrant),
|
||||
control_plane=SqlAlchemyControlPlaneReader(),
|
||||
files=LocalFileCopier(),
|
||||
)
|
||||
|
||||
report = await service.run(
|
||||
backup_dir=backup_dir,
|
||||
source_database_url=settings.database_url,
|
||||
disposable_database_name=disposable_database_name,
|
||||
disposable_collection_prefix=disposable_collection_prefix,
|
||||
disposable_blob_root=tmp_path / "restored-blobs",
|
||||
disposable_parsed_root=tmp_path / "restored-parsed",
|
||||
golden_queries=(
|
||||
GoldenQueryProbe(
|
||||
collection_alias=source.alias,
|
||||
point_id=source.chunk_id,
|
||||
query_vector=QUERY_VECTOR,
|
||||
expected_excerpt_checksum=Citation.create(
|
||||
workspace_id=source.workspace_id,
|
||||
space_id=source.granted_space_id,
|
||||
document_id=source.document_id,
|
||||
document_version_id=source.document_version_id,
|
||||
chunk_id=source.chunk_id,
|
||||
parent_chunk_id=None,
|
||||
location={"page": 1},
|
||||
excerpt="The restore gate must reproduce this exact passage.",
|
||||
source_uri="ragcore://source/restore-gate",
|
||||
acquired_at=datetime.now(UTC),
|
||||
retrieval_run_id=uuid4(),
|
||||
index_alias=source.alias,
|
||||
profile_version="restore-gate",
|
||||
).excerpt_checksum,
|
||||
),
|
||||
),
|
||||
security_probe=SecurityDenialProbe(
|
||||
principal_id=source.principal_id,
|
||||
workspace_id=source.workspace_id,
|
||||
granted_space_id=source.granted_space_id,
|
||||
denied_space_id=source.denied_space_id,
|
||||
),
|
||||
)
|
||||
|
||||
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 source.granted_space_id in report.security_denial.effective_space_ids
|
||||
assert source.denied_space_id not in report.security_denial.effective_space_ids
|
||||
assert (
|
||||
report.postgres_row_counts["source_spaces"]
|
||||
== report.postgres_row_counts["restored_spaces"]
|
||||
)
|
||||
finally:
|
||||
await postgres_restorer.drop_database(database_name=disposable_database_name)
|
||||
with contextlib.suppress(UnexpectedResponse):
|
||||
await admin_qdrant.delete_collection(
|
||||
f"{disposable_collection_prefix}-{source.collection_name}"
|
||||
)
|
||||
await admin_qdrant.close()
|
||||
|
||||
|
||||
async def test_source_data_is_byte_identical_after_a_restore_gate_run(
|
||||
source: _SourceFixture,
|
||||
settings: Settings,
|
||||
session_factory,
|
||||
qdrant_client: AsyncQdrantClient,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
backup_dir = await _snapshot_source(source, settings, session_factory, tmp_path)
|
||||
|
||||
before_points = await qdrant_client.scroll(
|
||||
collection_name=source.collection_name, limit=100, with_payload=True, with_vectors=True
|
||||
)
|
||||
async with session_factory() as session:
|
||||
before_space = await ControlPlaneRepository(session).get_workspace(source.workspace_id)
|
||||
|
||||
admin_qdrant = AsyncQdrantClient(
|
||||
url=settings.qdrant_url, api_key=settings.qdrant_api_key.get_secret_value(), timeout=300
|
||||
)
|
||||
postgres_restorer = SqlAlchemyPostgresRestorer(admin_database_url=settings.database_url)
|
||||
disposable_database_name = f"restore_gate_{uuid4().hex[:12]}"
|
||||
disposable_collection_prefix = f"restored-{uuid4().hex[:8]}"
|
||||
try:
|
||||
service = RestoreGateService(
|
||||
postgres=postgres_restorer,
|
||||
qdrant=QdrantHttpRestorer(client=admin_qdrant),
|
||||
control_plane=SqlAlchemyControlPlaneReader(),
|
||||
files=LocalFileCopier(),
|
||||
)
|
||||
await service.run(
|
||||
backup_dir=backup_dir,
|
||||
source_database_url=settings.database_url,
|
||||
disposable_database_name=disposable_database_name,
|
||||
disposable_collection_prefix=disposable_collection_prefix,
|
||||
disposable_blob_root=tmp_path / "restored-blobs",
|
||||
disposable_parsed_root=tmp_path / "restored-parsed",
|
||||
golden_queries=(),
|
||||
security_probe=SecurityDenialProbe(
|
||||
principal_id=source.principal_id,
|
||||
workspace_id=source.workspace_id,
|
||||
granted_space_id=source.granted_space_id,
|
||||
denied_space_id=source.denied_space_id,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
await postgres_restorer.drop_database(database_name=disposable_database_name)
|
||||
with contextlib.suppress(UnexpectedResponse):
|
||||
await admin_qdrant.delete_collection(
|
||||
f"{disposable_collection_prefix}-{source.collection_name}"
|
||||
)
|
||||
await admin_qdrant.close()
|
||||
|
||||
after_points = await qdrant_client.scroll(
|
||||
collection_name=source.collection_name, limit=100, with_payload=True, with_vectors=True
|
||||
)
|
||||
async with session_factory() as session:
|
||||
after_space = await ControlPlaneRepository(session).get_workspace(source.workspace_id)
|
||||
|
||||
assert before_points == after_points
|
||||
assert before_space == after_space
|
||||
@@ -0,0 +1,445 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from qdrant_client.http import models
|
||||
|
||||
from ragcore.application.retrieval import CandidateSearchRequest, CandidateSearchService
|
||||
from ragcore.application.retrieval.authorization import AuthorizedRetrievalScope
|
||||
from ragcore.infrastructure.qdrant.filters import compile_retrieval_filter
|
||||
from ragcore.infrastructure.qdrant.models import CollectionKind, CollectionProfile
|
||||
from ragcore.retrieval.search import (
|
||||
BackendSearchRequest,
|
||||
Candidate,
|
||||
CandidateBackendError,
|
||||
CandidateBackendKind,
|
||||
CandidateBackendState,
|
||||
CandidateProvenance,
|
||||
DenseQuery,
|
||||
EvaluationRetrievalTarget,
|
||||
QdrantCandidateBackend,
|
||||
)
|
||||
from ragcore.retrieval.sparse import SparseEncoder
|
||||
|
||||
WORKSPACE_ID = uuid4()
|
||||
SPACE_ID = uuid4()
|
||||
|
||||
|
||||
PROFILE = CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile="hybrid",
|
||||
version="v1",
|
||||
dimensions=2,
|
||||
model_digest="0" * 64,
|
||||
)
|
||||
|
||||
|
||||
def candidate(
|
||||
backend: CandidateBackendKind,
|
||||
rank: int = 1,
|
||||
*,
|
||||
workspace_id: object = WORKSPACE_ID,
|
||||
space_id: object = SPACE_ID,
|
||||
) -> Candidate:
|
||||
return Candidate(
|
||||
point_id=uuid4(),
|
||||
score=1.0 / rank,
|
||||
rank=rank,
|
||||
backend=backend,
|
||||
provenance=CandidateProvenance(
|
||||
workspace_id=workspace_id,
|
||||
space_id=space_id,
|
||||
document_id=uuid4(),
|
||||
document_version_id=uuid4(),
|
||||
chunk_id=uuid4(),
|
||||
source_id=uuid4(),
|
||||
parent_chunk_id=None,
|
||||
status="published",
|
||||
content_type="text/plain",
|
||||
language="en",
|
||||
trust_level="source",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class RecordingBackend:
|
||||
def __init__(self, result: tuple[Candidate, ...] = ()) -> None:
|
||||
self.result = result
|
||||
self.requests: list[BackendSearchRequest] = []
|
||||
|
||||
async def search(self, request: BackendSearchRequest) -> tuple[Candidate, ...]:
|
||||
self.requests.append(request)
|
||||
return self.result
|
||||
|
||||
|
||||
class FailingBackend:
|
||||
async def search(self, request: BackendSearchRequest) -> tuple[Candidate, ...]:
|
||||
raise CandidateBackendError("backend_unavailable")
|
||||
|
||||
|
||||
class SlowBackend:
|
||||
async def search(self, request: BackendSearchRequest) -> tuple[Candidate, ...]:
|
||||
await asyncio.sleep(1)
|
||||
return ()
|
||||
|
||||
|
||||
def request(*, timeout_seconds: float = 1.0, dense_limit: int = 50) -> CandidateSearchRequest:
|
||||
return CandidateSearchRequest(
|
||||
profile=PROFILE,
|
||||
dense_query=DenseQuery((0.1, 0.2)),
|
||||
sparse_query=SparseEncoder().encode_query("HTTP_500"),
|
||||
filter_plan=compile_retrieval_filter(
|
||||
AuthorizedRetrievalScope(WORKSPACE_ID, frozenset({SPACE_ID}))
|
||||
),
|
||||
dense_limit=dense_limit,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dense_and_sparse_run_in_parallel_and_preserve_provenance() -> None:
|
||||
dense = RecordingBackend((candidate(CandidateBackendKind.DENSE),))
|
||||
sparse = RecordingBackend((candidate(CandidateBackendKind.SPARSE),))
|
||||
|
||||
result = await CandidateSearchService(dense, sparse).search(request())
|
||||
|
||||
assert result.degraded is False
|
||||
assert result.dense.candidates[0].provenance.document_id.int != 0
|
||||
assert result.sparse.candidates[0].provenance.source_id.int != 0
|
||||
assert dense.requests[0].limit == 50
|
||||
assert sparse.requests[0].backend is CandidateBackendKind.SPARSE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_backend_failure_is_explicit_degraded_metadata() -> None:
|
||||
dense = RecordingBackend((candidate(CandidateBackendKind.DENSE),))
|
||||
|
||||
result = await CandidateSearchService(dense, FailingBackend()).search(request())
|
||||
|
||||
assert result.degraded is True
|
||||
assert result.dense.timing.state is CandidateBackendState.AVAILABLE
|
||||
assert result.sparse.timing.state is CandidateBackendState.DEGRADED
|
||||
assert result.sparse.timing.error_code == "backend_unavailable"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_out_of_scope_backend_result_is_explicitly_degraded() -> None:
|
||||
result = await CandidateSearchService(
|
||||
RecordingBackend((candidate(CandidateBackendKind.DENSE, space_id=uuid4()),)),
|
||||
RecordingBackend(),
|
||||
).search(request())
|
||||
|
||||
assert result.dense.timing.state is CandidateBackendState.DEGRADED
|
||||
assert result.dense.timing.error_code == "candidate_contract_violation"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backend_timeout_is_explicit_and_bounded() -> None:
|
||||
result = await CandidateSearchService(SlowBackend(), RecordingBackend()).search(
|
||||
request(timeout_seconds=0.05)
|
||||
)
|
||||
|
||||
assert result.degraded is True
|
||||
assert result.dense.timing.state is CandidateBackendState.TIMEOUT
|
||||
assert result.dense.timing.error_code == "backend_timeout"
|
||||
|
||||
|
||||
def test_candidate_limits_are_enforced_before_backend_execution() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
request(dense_limit=51)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qdrant_backend_decodes_provenance_and_uses_named_active_alias() -> None:
|
||||
point_id = uuid4()
|
||||
workspace_id = uuid4()
|
||||
space_id = uuid4()
|
||||
provenance = {
|
||||
"workspace_id": str(workspace_id),
|
||||
"space_id": str(space_id),
|
||||
"document_id": str(uuid4()),
|
||||
"document_version_id": str(uuid4()),
|
||||
"chunk_id": str(uuid4()),
|
||||
"source_id": str(uuid4()),
|
||||
"parent_chunk_id": None,
|
||||
"status": "published",
|
||||
"content_type": "text/plain",
|
||||
"language": "en",
|
||||
"trust_level": "source",
|
||||
"embedding_model_digest": PROFILE.model_digest,
|
||||
"chunk_profile_version": PROFILE.version,
|
||||
}
|
||||
|
||||
class StaticQdrantClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, object]] = []
|
||||
|
||||
async def get_aliases(self, **kwargs: object) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
aliases=[
|
||||
SimpleNamespace(
|
||||
alias_name=PROFILE.alias_name,
|
||||
collection_name=PROFILE.collection_name,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
async def get_collection(self, collection_name: str, **kwargs: object) -> SimpleNamespace:
|
||||
assert collection_name == PROFILE.collection_name
|
||||
return SimpleNamespace(
|
||||
config=SimpleNamespace(
|
||||
params=SimpleNamespace(
|
||||
vectors={
|
||||
"dense": models.VectorParams(
|
||||
size=PROFILE.dimensions,
|
||||
distance=models.Distance.COSINE,
|
||||
)
|
||||
},
|
||||
sparse_vectors={"sparse": models.SparseVectorParams()},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async def query_points(self, **kwargs: object) -> models.QueryResponse:
|
||||
self.calls.append(kwargs)
|
||||
return models.QueryResponse(
|
||||
points=[
|
||||
models.ScoredPoint(id=str(point_id), version=0, score=0.9, payload=provenance)
|
||||
]
|
||||
)
|
||||
|
||||
client = StaticQdrantClient()
|
||||
backend = QdrantCandidateBackend(client, CandidateBackendKind.DENSE)
|
||||
plan = compile_retrieval_filter(AuthorizedRetrievalScope(workspace_id, frozenset({space_id})))
|
||||
|
||||
results = await backend.search(
|
||||
BackendSearchRequest(
|
||||
backend=CandidateBackendKind.DENSE,
|
||||
profile=PROFILE,
|
||||
query=DenseQuery((0.1, 0.2)),
|
||||
filter_plan=plan,
|
||||
limit=1,
|
||||
)
|
||||
)
|
||||
|
||||
assert results[0].point_id == point_id
|
||||
assert str(results[0].provenance.document_id) == provenance["document_id"]
|
||||
assert client.calls[0]["collection_name"] == PROFILE.collection_name
|
||||
assert client.calls[0]["using"] == "dense"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_internal_shadow_target_reads_named_collection_without_alias_cutover() -> None:
|
||||
shadow_space = uuid4()
|
||||
shadow_profile = CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile="modelforge-rag",
|
||||
version="m6",
|
||||
dimensions=2,
|
||||
model_digest="a" * 64,
|
||||
embedding_space_id=shadow_space,
|
||||
)
|
||||
workspace_id, space_id, point_id = uuid4(), uuid4(), uuid4()
|
||||
|
||||
class ShadowClient:
|
||||
async def get_aliases(self) -> SimpleNamespace:
|
||||
return SimpleNamespace(aliases=[])
|
||||
|
||||
async def get_collection(self, collection_name: str) -> SimpleNamespace:
|
||||
assert collection_name == shadow_profile.collection_name
|
||||
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:
|
||||
return models.QueryResponse(
|
||||
points=[
|
||||
models.ScoredPoint(
|
||||
id=str(point_id),
|
||||
version=0,
|
||||
score=0.9,
|
||||
payload={
|
||||
"workspace_id": str(workspace_id),
|
||||
"space_id": str(space_id),
|
||||
"document_id": str(uuid4()),
|
||||
"document_version_id": str(uuid4()),
|
||||
"chunk_id": str(point_id),
|
||||
"source_id": str(uuid4()),
|
||||
"parent_chunk_id": None,
|
||||
"status": "published",
|
||||
"content_type": "text/plain",
|
||||
"language": "en",
|
||||
"trust_level": "source",
|
||||
"embedding_model_digest": shadow_profile.model_digest,
|
||||
"chunk_profile_version": shadow_profile.version,
|
||||
"embedding_space_id": str(shadow_space),
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
backend = QdrantCandidateBackend(
|
||||
ShadowClient(),
|
||||
CandidateBackendKind.DENSE,
|
||||
target=EvaluationRetrievalTarget.SHADOW,
|
||||
)
|
||||
results = await backend.search(
|
||||
BackendSearchRequest(
|
||||
backend=CandidateBackendKind.DENSE,
|
||||
profile=shadow_profile,
|
||||
query=DenseQuery((1.0, 0.0)),
|
||||
filter_plan=compile_retrieval_filter(
|
||||
AuthorizedRetrievalScope(workspace_id, frozenset({space_id}))
|
||||
),
|
||||
limit=1,
|
||||
)
|
||||
)
|
||||
assert results[0].point_id == point_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_current_target_reads_the_explicit_pinned_collection_and_alias() -> None:
|
||||
pinned_collection = "rag_dense_current_pinned_20260912"
|
||||
point_id = uuid4()
|
||||
workspace_id, space_id = uuid4(), uuid4()
|
||||
|
||||
class PinnedClient:
|
||||
def __init__(self) -> None:
|
||||
self.collection_names: list[str] = []
|
||||
|
||||
async def get_aliases(self) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
aliases=[
|
||||
SimpleNamespace(
|
||||
alias_name=PROFILE.alias_name,
|
||||
collection_name=pinned_collection,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
async def get_collection(self, collection_name: str) -> SimpleNamespace:
|
||||
self.collection_names.append(collection_name)
|
||||
assert collection_name == pinned_collection
|
||||
return SimpleNamespace(
|
||||
config=SimpleNamespace(
|
||||
params=SimpleNamespace(
|
||||
vectors={
|
||||
"dense": models.VectorParams(
|
||||
size=PROFILE.dimensions, distance=models.Distance.COSINE
|
||||
)
|
||||
},
|
||||
sparse_vectors={"sparse": models.SparseVectorParams()},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async def query_points(self, **kwargs: object) -> models.QueryResponse:
|
||||
assert kwargs["collection_name"] == pinned_collection
|
||||
return models.QueryResponse(
|
||||
points=[
|
||||
models.ScoredPoint(
|
||||
id=str(point_id),
|
||||
version=0,
|
||||
score=0.9,
|
||||
payload={
|
||||
"workspace_id": str(workspace_id),
|
||||
"space_id": str(space_id),
|
||||
"document_id": str(uuid4()),
|
||||
"document_version_id": str(uuid4()),
|
||||
"chunk_id": str(point_id),
|
||||
"source_id": str(uuid4()),
|
||||
"parent_chunk_id": None,
|
||||
"status": "published",
|
||||
"content_type": "text/plain",
|
||||
"language": "en",
|
||||
"trust_level": "source",
|
||||
"embedding_model_digest": PROFILE.model_digest,
|
||||
"chunk_profile_version": PROFILE.version,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
client = PinnedClient()
|
||||
backend = QdrantCandidateBackend(
|
||||
client, CandidateBackendKind.DENSE, collection_name=pinned_collection
|
||||
)
|
||||
result = await backend.search(
|
||||
BackendSearchRequest(
|
||||
backend=CandidateBackendKind.DENSE,
|
||||
profile=PROFILE,
|
||||
query=DenseQuery((0.1, 0.2)),
|
||||
filter_plan=compile_retrieval_filter(
|
||||
AuthorizedRetrievalScope(workspace_id, frozenset({space_id}))
|
||||
),
|
||||
limit=1,
|
||||
)
|
||||
)
|
||||
|
||||
assert result[0].point_id == point_id
|
||||
assert client.collection_names == [pinned_collection]
|
||||
|
||||
|
||||
def test_qdrant_backend_rejects_unsafe_pinned_collection_name() -> None:
|
||||
with pytest.raises(ValueError, match="safe Qdrant collection"):
|
||||
QdrantCandidateBackend(
|
||||
object(), CandidateBackendKind.DENSE, collection_name="../production"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dense_and_sparse_tasks_start_before_either_completes() -> None:
|
||||
started = [0]
|
||||
both_started = asyncio.Event()
|
||||
|
||||
class GatedBackend:
|
||||
async def search(self, request: BackendSearchRequest) -> tuple[Candidate, ...]:
|
||||
started[0] += 1
|
||||
if started[0] == 2:
|
||||
both_started.set()
|
||||
await both_started.wait()
|
||||
return ()
|
||||
|
||||
result = await CandidateSearchService(GatedBackend(), GatedBackend()).search(
|
||||
request(timeout_seconds=0.5)
|
||||
)
|
||||
|
||||
assert started[0] == 2
|
||||
assert result.degraded is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancellation_is_propagated_to_both_backends() -> None:
|
||||
started = asyncio.Event()
|
||||
cancelled = [0]
|
||||
blocked = asyncio.Event()
|
||||
|
||||
class CancellableBackend:
|
||||
async def search(self, request: BackendSearchRequest) -> tuple[Candidate, ...]:
|
||||
started.set()
|
||||
try:
|
||||
await blocked.wait()
|
||||
except asyncio.CancelledError:
|
||||
cancelled[0] += 1
|
||||
raise
|
||||
return ()
|
||||
|
||||
task = asyncio.create_task(
|
||||
CandidateSearchService(CancellableBackend(), CancellableBackend()).search(request())
|
||||
)
|
||||
await started.wait()
|
||||
task.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert cancelled[0] == 2
|
||||
@@ -0,0 +1,219 @@
|
||||
"""OllamaRerankAdapter: local-host validation, retries, and response-shape checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from ragcore.application.limits import ConcurrencyBudget
|
||||
from ragcore.domain.control import DataPolicy
|
||||
from ragcore.infrastructure.models.reranker import RerankerProfile, RerankerProvider
|
||||
from ragcore.infrastructure.models.reranker.ollama import OllamaRerankAdapter
|
||||
from ragcore.retrieval.rerank import RerankProviderError
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def profile(**changes: object) -> RerankerProfile:
|
||||
value: dict[str, object] = {
|
||||
"id": uuid4(),
|
||||
"profile": "local",
|
||||
"version": "v1",
|
||||
"provider": RerankerProvider.OLLAMA,
|
||||
"model_identifier": "bge-reranker-v2-m3",
|
||||
"model_digest": "a" * 64,
|
||||
"context_limit": 512,
|
||||
"data_policy": DataPolicy.LOCAL_ONLY,
|
||||
"timeout_seconds": 1.0,
|
||||
"max_candidates": 4,
|
||||
"max_retries": 2,
|
||||
}
|
||||
value.update(changes)
|
||||
return RerankerProfile(**value)
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, responses: list[httpx.Response]) -> None:
|
||||
self.responses = responses
|
||||
self.requests: list[dict[str, object]] = []
|
||||
|
||||
async def post(self, url: str, **kwargs: object) -> httpx.Response:
|
||||
self.requests.append({"url": url, **kwargs})
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
async def no_sleep(_: float) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def response(status: int, body: object) -> httpx.Response:
|
||||
return httpx.Response(status, json=body, request=httpx.Request("POST", "http://test"))
|
||||
|
||||
|
||||
def test_adapter_rejects_nonlocal_provider_hosts() -> None:
|
||||
with pytest.raises(ValueError, match="allowlisted local"):
|
||||
OllamaRerankAdapter(
|
||||
profile(),
|
||||
FakeClient([]), # type: ignore[arg-type]
|
||||
base_url="http://169.254.169.254",
|
||||
)
|
||||
|
||||
|
||||
def test_profile_is_immutable_and_rejects_floating_identifiers() -> None:
|
||||
value = profile()
|
||||
with pytest.raises(AttributeError):
|
||||
value.model_identifier = "bge-reranker:latest" # type: ignore[misc]
|
||||
with pytest.raises(ValueError, match="floating"):
|
||||
profile(model_identifier="bge-reranker:latest")
|
||||
with pytest.raises(ValueError, match="digest"):
|
||||
profile(model_digest="not-a-digest")
|
||||
with pytest.raises(ValueError, match="context_limit"):
|
||||
profile(context_limit=0)
|
||||
|
||||
|
||||
async def test_adapter_sends_query_and_documents_and_reorders_by_index() -> None:
|
||||
client = FakeClient(
|
||||
[
|
||||
response(
|
||||
200,
|
||||
{
|
||||
"results": [
|
||||
{"index": 1, "relevance_score": 0.9},
|
||||
{"index": 0, "relevance_score": 0.1},
|
||||
]
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
adapter = OllamaRerankAdapter(profile(), client, base_url="http://ollama", sleep=no_sleep) # type: ignore[arg-type]
|
||||
|
||||
scores = await adapter.score("a query", ("doc one", "doc two"))
|
||||
|
||||
assert scores == (0.1, 0.9)
|
||||
assert client.requests[0]["json"] == {
|
||||
"model": "bge-reranker-v2-m3",
|
||||
"query": "a query",
|
||||
"documents": ["doc one", "doc two"],
|
||||
}
|
||||
|
||||
|
||||
async def test_adapter_retries_on_retryable_status_then_succeeds() -> None:
|
||||
client = FakeClient(
|
||||
[
|
||||
response(503, {"error": "busy"}),
|
||||
response(429, {"error": "busy"}),
|
||||
response(200, {"results": [{"index": 0, "relevance_score": 0.5}]}),
|
||||
]
|
||||
)
|
||||
adapter = OllamaRerankAdapter(profile(), client, base_url="http://ollama", sleep=no_sleep) # type: ignore[arg-type]
|
||||
|
||||
assert await adapter.score("q", ("one",)) == (0.5,)
|
||||
assert len(client.requests) == 3
|
||||
|
||||
|
||||
async def test_adapter_raises_after_exhausting_retries() -> None:
|
||||
client = FakeClient([response(503, {}), response(503, {}), response(503, {})])
|
||||
adapter = OllamaRerankAdapter(
|
||||
profile(max_retries=2), client, base_url="http://ollama", sleep=no_sleep # type: ignore[arg-type]
|
||||
)
|
||||
with pytest.raises(RerankProviderError, match="rerank_unavailable"):
|
||||
await adapter.score("q", ("one",))
|
||||
|
||||
|
||||
async def test_adapter_rejects_cardinality_mismatch_and_duplicate_index() -> None:
|
||||
mismatch = OllamaRerankAdapter(
|
||||
profile(),
|
||||
FakeClient([response(200, {"results": [{"index": 0, "relevance_score": 0.5}]})]), # type: ignore[arg-type]
|
||||
base_url="http://ollama",
|
||||
sleep=no_sleep,
|
||||
)
|
||||
with pytest.raises(RerankProviderError, match="rerank_cardinality_mismatch"):
|
||||
await mismatch.score("q", ("one", "two"))
|
||||
|
||||
duplicate = OllamaRerankAdapter(
|
||||
profile(),
|
||||
FakeClient(
|
||||
[
|
||||
response(
|
||||
200,
|
||||
{
|
||||
"results": [
|
||||
{"index": 0, "relevance_score": 0.1},
|
||||
{"index": 0, "relevance_score": 0.9},
|
||||
]
|
||||
},
|
||||
)
|
||||
]
|
||||
), # type: ignore[arg-type]
|
||||
base_url="http://ollama",
|
||||
sleep=no_sleep,
|
||||
)
|
||||
with pytest.raises(RerankProviderError, match="rerank_result_duplicate_index"):
|
||||
await duplicate.score("q", ("one", "two"))
|
||||
|
||||
|
||||
async def test_adapter_rejects_nonfinite_score() -> None:
|
||||
# json.dumps allows NaN by default (unlike httpx's stricter json= encoder), so a raw
|
||||
# content body is used here to construct an out-of-spec response body deterministically.
|
||||
body = json.dumps({"results": [{"index": 0, "relevance_score": float("nan")}]}).encode()
|
||||
non_finite_response = httpx.Response(
|
||||
200, content=body, request=httpx.Request("POST", "http://test")
|
||||
)
|
||||
adapter = OllamaRerankAdapter(
|
||||
profile(),
|
||||
FakeClient([non_finite_response]), # type: ignore[arg-type]
|
||||
base_url="http://ollama",
|
||||
sleep=no_sleep,
|
||||
)
|
||||
with pytest.raises(RerankProviderError, match="rerank_result_score_invalid"):
|
||||
await adapter.score("q", ("one",))
|
||||
|
||||
|
||||
async def test_adapter_rejects_input_exceeding_profile_bounds() -> None:
|
||||
adapter = OllamaRerankAdapter(
|
||||
profile(max_candidates=1), FakeClient([]), base_url="http://ollama", sleep=no_sleep # type: ignore[arg-type]
|
||||
)
|
||||
with pytest.raises(ValueError, match="candidate limit"):
|
||||
await adapter.score("q", ("one", "two"))
|
||||
|
||||
empty_query = OllamaRerankAdapter(
|
||||
profile(), FakeClient([]), base_url="http://ollama", sleep=no_sleep # type: ignore[arg-type]
|
||||
)
|
||||
with pytest.raises(ValueError, match="query"):
|
||||
await empty_query.score("", ("one",))
|
||||
|
||||
|
||||
async def test_adapter_returns_empty_tuple_for_no_documents() -> None:
|
||||
adapter = OllamaRerankAdapter(
|
||||
profile(), FakeClient([]), base_url="http://ollama", sleep=no_sleep # type: ignore[arg-type]
|
||||
)
|
||||
assert await adapter.score("q", ()) == ()
|
||||
|
||||
|
||||
async def test_adapter_surfaces_concurrency_exhaustion_as_a_provider_error() -> None:
|
||||
budget = ConcurrencyBudget(name="rerank-calls", max_concurrent=1, max_queued=0)
|
||||
release = asyncio.Event()
|
||||
|
||||
async def occupy() -> None:
|
||||
async with budget.acquire():
|
||||
await release.wait()
|
||||
|
||||
holder = asyncio.create_task(occupy())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
adapter = OllamaRerankAdapter(
|
||||
profile(),
|
||||
FakeClient([]), # type: ignore[arg-type]
|
||||
base_url="http://ollama",
|
||||
sleep=no_sleep,
|
||||
concurrency_budget=budget,
|
||||
)
|
||||
with pytest.raises(RerankProviderError, match="rerank_concurrency_exhausted"):
|
||||
await adapter.score("q", ("one",))
|
||||
|
||||
release.set()
|
||||
await holder
|
||||
@@ -0,0 +1,188 @@
|
||||
"""RerankService: bounded reranking, deterministic ordering and degraded fallback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.domain.control import DataPolicy
|
||||
from ragcore.infrastructure.models.reranker import RerankerProfile, RerankerProvider
|
||||
from ragcore.retrieval.ranking.rrf import FusedCandidate
|
||||
from ragcore.retrieval.rerank import (
|
||||
MAX_RERANK_CANDIDATES,
|
||||
RerankCandidateInput,
|
||||
RerankOutcomeState,
|
||||
RerankProviderError,
|
||||
RerankService,
|
||||
)
|
||||
from ragcore.retrieval.search.models import Candidate, CandidateBackendKind, CandidateProvenance
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def reranker_profile(**changes: object) -> RerankerProfile:
|
||||
value: dict[str, object] = {
|
||||
"id": uuid4(),
|
||||
"profile": "local",
|
||||
"version": "v1",
|
||||
"provider": RerankerProvider.OLLAMA,
|
||||
"model_identifier": "bge-reranker-v2-m3",
|
||||
"model_digest": "a" * 64,
|
||||
"context_limit": 512,
|
||||
"data_policy": DataPolicy.LOCAL_ONLY,
|
||||
"timeout_seconds": 1.0,
|
||||
"max_candidates": 40,
|
||||
"max_retries": 1,
|
||||
}
|
||||
value.update(changes)
|
||||
return RerankerProfile(**value)
|
||||
|
||||
|
||||
def _candidate(index: int) -> Candidate:
|
||||
chunk_id = uuid4()
|
||||
return Candidate(
|
||||
point_id=uuid4(),
|
||||
score=1.0 / (index + 1),
|
||||
rank=index + 1,
|
||||
backend=CandidateBackendKind.DENSE,
|
||||
provenance=CandidateProvenance(
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
document_id=uuid4(),
|
||||
document_version_id=uuid4(),
|
||||
chunk_id=chunk_id,
|
||||
source_id=uuid4(),
|
||||
parent_chunk_id=None,
|
||||
status="published",
|
||||
content_type="text/markdown",
|
||||
language="en",
|
||||
trust_level="source",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _fused(index: int) -> FusedCandidate:
|
||||
return FusedCandidate(
|
||||
candidate=_candidate(index),
|
||||
rrf_score=1.0 / (60 + index + 1),
|
||||
dense_rank=index + 1,
|
||||
sparse_rank=None,
|
||||
)
|
||||
|
||||
|
||||
def _inputs(count: int) -> tuple[RerankCandidateInput, ...]:
|
||||
return tuple(
|
||||
RerankCandidateInput(fused=_fused(index), text=f"passage {index}") for index in range(count)
|
||||
)
|
||||
|
||||
|
||||
class FixtureReranker:
|
||||
"""Deterministic fixture: score is simply the reverse of input order."""
|
||||
|
||||
def __init__(self, scores: Sequence[float] | None = None) -> None:
|
||||
self._scores = scores
|
||||
|
||||
async def score(self, query: str, documents: Sequence[str]) -> tuple[float, ...]:
|
||||
if self._scores is not None:
|
||||
return tuple(self._scores)
|
||||
count = len(documents)
|
||||
return tuple(float(count - index) for index in range(count))
|
||||
|
||||
|
||||
class HangingReranker:
|
||||
async def score(self, query: str, documents: Sequence[str]) -> tuple[float, ...]:
|
||||
await asyncio.sleep(10)
|
||||
return tuple(0.0 for _ in documents)
|
||||
|
||||
|
||||
class FailingReranker:
|
||||
def __init__(self, code: str) -> None:
|
||||
self._code = code
|
||||
|
||||
async def score(self, query: str, documents: Sequence[str]) -> tuple[float, ...]:
|
||||
raise RerankProviderError(self._code)
|
||||
|
||||
|
||||
async def test_rerank_reorders_by_score_deterministically() -> None:
|
||||
service = RerankService(reranker_profile(), FixtureReranker())
|
||||
result = await service.rerank("query", _inputs(5))
|
||||
|
||||
assert result.state is RerankOutcomeState.RERANKED
|
||||
assert not result.degraded
|
||||
# FixtureReranker scores highest for the first document, so order is preserved.
|
||||
assert [item.fused.candidate.rank for item in result.candidates] == [1, 2, 3, 4, 5]
|
||||
assert [item.rerank_rank for item in result.candidates] == [1, 2, 3, 4, 5]
|
||||
assert all(item.rerank_score is not None for item in result.candidates)
|
||||
|
||||
|
||||
async def test_rerank_ties_break_deterministically_by_input_order() -> None:
|
||||
service = RerankService(reranker_profile(), FixtureReranker(scores=[1.0, 1.0, 1.0]))
|
||||
result = await service.rerank("query", _inputs(3))
|
||||
|
||||
assert [item.fused.candidate.rank for item in result.candidates] == [1, 2, 3]
|
||||
|
||||
|
||||
async def test_rerank_enforces_result_limit() -> None:
|
||||
service = RerankService(reranker_profile(), FixtureReranker())
|
||||
result = await service.rerank("query", _inputs(8), result_limit=3)
|
||||
|
||||
assert len(result.candidates) == 3
|
||||
assert [item.fused.candidate.rank for item in result.candidates] == [1, 2, 3]
|
||||
|
||||
|
||||
async def test_rerank_rejects_candidate_input_over_the_bound() -> None:
|
||||
service = RerankService(reranker_profile(), FixtureReranker())
|
||||
with pytest.raises(ValueError, match="bounded limit"):
|
||||
await service.rerank("query", _inputs(MAX_RERANK_CANDIDATES + 1))
|
||||
|
||||
|
||||
async def test_rerank_rejects_result_limit_outside_bound() -> None:
|
||||
service = RerankService(reranker_profile(), FixtureReranker())
|
||||
with pytest.raises(ValueError, match="result_limit"):
|
||||
await service.rerank("query", _inputs(3), result_limit=0)
|
||||
|
||||
|
||||
async def test_rerank_timeout_leaves_fused_order_with_degraded_flag() -> None:
|
||||
service = RerankService(reranker_profile(timeout_seconds=0.1), HangingReranker())
|
||||
result = await service.rerank("query", _inputs(5), result_limit=3)
|
||||
|
||||
assert result.state is RerankOutcomeState.DEGRADED
|
||||
assert result.degraded
|
||||
assert result.degradation_code == "rerank_timeout"
|
||||
assert len(result.candidates) == 3
|
||||
assert [item.fused.candidate.rank for item in result.candidates] == [1, 2, 3]
|
||||
assert all(item.rerank_score is None for item in result.candidates)
|
||||
|
||||
|
||||
async def test_rerank_provider_failure_degrades_with_its_error_code() -> None:
|
||||
service = RerankService(reranker_profile(), FailingReranker("rerank_unavailable"))
|
||||
result = await service.rerank("query", _inputs(4))
|
||||
|
||||
assert result.degraded
|
||||
assert result.degradation_code == "rerank_unavailable"
|
||||
assert [item.fused.candidate.rank for item in result.candidates] == [1, 2, 3, 4]
|
||||
|
||||
|
||||
async def test_rerank_malformed_response_degrades() -> None:
|
||||
wrong_count = RerankService(reranker_profile(), FixtureReranker(scores=[1.0, 2.0]))
|
||||
result = await wrong_count.rerank("query", _inputs(3))
|
||||
assert result.degraded
|
||||
assert result.degradation_code == "rerank_response_invalid"
|
||||
|
||||
non_finite = RerankService(
|
||||
reranker_profile(), FixtureReranker(scores=[float("nan"), 1.0, 2.0])
|
||||
)
|
||||
result = await non_finite.rerank("query", _inputs(3))
|
||||
assert result.degraded
|
||||
assert result.degradation_code == "rerank_response_invalid"
|
||||
|
||||
|
||||
async def test_rerank_empty_candidates_returns_empty_reranked_result() -> None:
|
||||
service = RerankService(reranker_profile(), FixtureReranker())
|
||||
result = await service.rerank("query", ())
|
||||
|
||||
assert result.candidates == ()
|
||||
assert result.state is RerankOutcomeState.RERANKED
|
||||
@@ -0,0 +1,497 @@
|
||||
"""Real-Postgres coverage for the source registry and its document linkage:
|
||||
ensure-on-first-use idempotency, list/filter/count, status and sync-state
|
||||
bookkeeping, sync runs and remembered items, and the documents.source_id
|
||||
foreign key the content schema now enforces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from ragcore.application.documents import DocumentListFilter, DocumentRecord
|
||||
from ragcore.application.sources import (
|
||||
SourceItemRecord,
|
||||
SourceItemSummary,
|
||||
SourceListFilter,
|
||||
SourceOwnershipConflict,
|
||||
SourceRecord,
|
||||
SourceStatus,
|
||||
SourceSyncOutcome,
|
||||
SourceSyncRunRecord,
|
||||
implicit_source_record,
|
||||
uploads_source_record,
|
||||
)
|
||||
from ragcore.config import Settings
|
||||
from ragcore.domain.sources import ConnectorKind, SourceSyncState, upload_source_id
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.content import PostgresDocumentRepository
|
||||
from ragcore.infrastructure.database.sources import PostgresSourceRegistry
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop_policy() -> asyncio.AbstractEventLoopPolicy:
|
||||
if sys.platform == "win32":
|
||||
return asyncio.WindowsSelectorEventLoopPolicy()
|
||||
return asyncio.get_event_loop_policy()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine() -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_ensure_source_is_idempotent_and_upload_sources_are_builtin(
|
||||
db_engine: AsyncEngine,
|
||||
) -> None:
|
||||
registry = PostgresSourceRegistry(create_session_factory(db_engine))
|
||||
workspace_id, space_id = uuid4(), uuid4()
|
||||
record = uploads_source_record(workspace_id=workspace_id, space_id=space_id)
|
||||
|
||||
first = await registry.ensure_source(record)
|
||||
second = await registry.ensure_source(
|
||||
SourceRecord(
|
||||
id=record.id,
|
||||
workspace_id=workspace_id,
|
||||
space_id=space_id,
|
||||
name="Renamed by a later caller",
|
||||
kind=ConnectorKind.UPLOAD,
|
||||
)
|
||||
)
|
||||
assert first.id == upload_source_id(space_id)
|
||||
assert second.name == "Uploads", "ensure never overwrites an existing source"
|
||||
assert second.is_builtin_uploads
|
||||
assert not second.syncable
|
||||
|
||||
|
||||
async def test_documents_require_a_registered_source(db_engine: AsyncEngine) -> None:
|
||||
factory = create_session_factory(db_engine)
|
||||
documents = PostgresDocumentRepository(factory)
|
||||
orphan = DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
source_key=str(uuid4()),
|
||||
external_id="page",
|
||||
title="Orphan",
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
await documents.create_document(orphan)
|
||||
|
||||
|
||||
async def test_list_filter_count_status_and_sync_bookkeeping(db_engine: AsyncEngine) -> None:
|
||||
factory = create_session_factory(db_engine)
|
||||
registry = PostgresSourceRegistry(factory)
|
||||
documents = PostgresDocumentRepository(factory)
|
||||
workspace_id, space_id = uuid4(), uuid4()
|
||||
folder = await registry.create_source(
|
||||
SourceRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
space_id=space_id,
|
||||
name="Infra runbooks",
|
||||
kind=ConnectorKind.FOLDER,
|
||||
config={"root": "/mnt/runbooks", "trust_level": "trusted_internal"},
|
||||
)
|
||||
)
|
||||
git = await registry.create_source(
|
||||
SourceRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
space_id=space_id,
|
||||
name="Platform repo",
|
||||
kind=ConnectorKind.GIT,
|
||||
config={"repository": "platform", "ref": "main", "checkout_root": "/srv/platform"},
|
||||
)
|
||||
)
|
||||
for index in range(3):
|
||||
document = DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
space_id=space_id,
|
||||
source_key=str(folder.id),
|
||||
external_id=f"runbooks/{index}.md",
|
||||
title=f"Runbook {index}",
|
||||
source_id=folder.id,
|
||||
)
|
||||
await documents.create_document(document)
|
||||
|
||||
page, *_ = (await registry.list_sources(workspace_id, SourceListFilter()),)
|
||||
assert {source.name for source in page.items} == {"Infra runbooks", "Platform repo"}
|
||||
only_git = await registry.list_sources(workspace_id, SourceListFilter(kind=ConnectorKind.GIT))
|
||||
assert [source.id for source in only_git.items] == [git.id]
|
||||
searched = await registry.list_sources(workspace_id, SourceListFilter(search="infra"))
|
||||
assert [source.id for source in searched.items] == [folder.id]
|
||||
assert (await registry.list_sources(uuid4(), SourceListFilter())).total == 0
|
||||
|
||||
counts = await documents.count_documents_by_source((folder.id, git.id))
|
||||
assert counts == {folder.id: 3, git.id: 0}
|
||||
listed = await documents.list_documents(
|
||||
workspace_id, DocumentListFilter(source_id=folder.id, search="Runbook 1")
|
||||
)
|
||||
assert [item.document.title for item in listed.items] == ["Runbook 1"]
|
||||
assert listed.total == 1
|
||||
|
||||
now = datetime.now(UTC)
|
||||
paused = await registry.set_status(folder.id, SourceStatus.PAUSED, now=now)
|
||||
assert paused is not None and paused.status is SourceStatus.PAUSED
|
||||
deleted = await registry.set_status(git.id, SourceStatus.DELETED, now=now)
|
||||
assert deleted is not None
|
||||
assert (await registry.list_sources(workspace_id, SourceListFilter())).total == 1, (
|
||||
"deleted sources are hidden unless asked for"
|
||||
)
|
||||
assert (
|
||||
await registry.list_sources(workspace_id, SourceListFilter(status=SourceStatus.DELETED))
|
||||
).total == 1
|
||||
|
||||
run = SourceSyncRunRecord(
|
||||
id=uuid4(),
|
||||
source_id=folder.id,
|
||||
started_at=now,
|
||||
outcome=SourceSyncOutcome.RUNNING,
|
||||
)
|
||||
await registry.record_sync_run(run)
|
||||
finished = SourceSyncRunRecord(
|
||||
id=run.id,
|
||||
source_id=folder.id,
|
||||
started_at=now,
|
||||
finished_at=now,
|
||||
outcome=SourceSyncOutcome.COMPLETED,
|
||||
discovered=3,
|
||||
new=3,
|
||||
revision="abc1234",
|
||||
)
|
||||
await registry.record_sync_run(finished)
|
||||
[stored_run] = await registry.list_sync_runs(folder.id)
|
||||
assert stored_run.outcome is SourceSyncOutcome.COMPLETED
|
||||
assert stored_run.new == 3 and stored_run.revision == "abc1234"
|
||||
synced = await registry.set_sync_state(
|
||||
folder.id, SourceSyncState.COMPLETED, now=now, revision="abc1234", successful=True
|
||||
)
|
||||
assert synced is not None
|
||||
assert synced.last_successful_sync_at is not None
|
||||
assert synced.last_revision == "abc1234"
|
||||
|
||||
await registry.upsert_items(
|
||||
(
|
||||
SourceItemRecord(
|
||||
source_id=folder.id,
|
||||
external_id="runbooks/0.md",
|
||||
fingerprint="f" * 64,
|
||||
locator="runbooks/0.md",
|
||||
last_seen_at=now,
|
||||
),
|
||||
)
|
||||
)
|
||||
await registry.mark_items_removed(folder.id, ("runbooks/0.md",), now=now)
|
||||
[item] = await registry.list_items(folder.id)
|
||||
assert item.removed is True
|
||||
|
||||
implied = implicit_source_record(
|
||||
source_id=folder.id, workspace_id=workspace_id, space_id=space_id
|
||||
)
|
||||
assert implied.kind is ConnectorKind.UPLOAD # an implied record never guesses a connector
|
||||
assert (await registry.ensure_source(implied)).kind is ConnectorKind.FOLDER
|
||||
|
||||
|
||||
# -- ownership: a source belongs to exactly one workspace and space ----------
|
||||
|
||||
|
||||
def _source(source_id, workspace_id, space_id, *, name: str = "Runbooks") -> SourceRecord:
|
||||
return SourceRecord(
|
||||
id=source_id,
|
||||
workspace_id=workspace_id,
|
||||
space_id=space_id,
|
||||
name=name,
|
||||
kind=ConnectorKind.FOLDER,
|
||||
config={"root": "/mnt/runbooks", "trust_level": "trusted_internal"},
|
||||
)
|
||||
|
||||
|
||||
async def test_ensure_source_refuses_an_id_owned_by_another_workspace(
|
||||
db_engine: AsyncEngine,
|
||||
) -> None:
|
||||
"""``ON CONFLICT DO NOTHING`` alone would silently hand the caller back
|
||||
another tenant's row, and every document of this ingest would attach to
|
||||
it. The ownership assertion is what turns "some source with this id
|
||||
exists" into "this source, in this scope, exists"."""
|
||||
|
||||
registry = PostgresSourceRegistry(create_session_factory(db_engine))
|
||||
source_id, space_id = uuid4(), uuid4()
|
||||
owner = uuid4()
|
||||
await registry.ensure_source(_source(source_id, owner, space_id))
|
||||
|
||||
with pytest.raises(SourceOwnershipConflict) as raised:
|
||||
await registry.ensure_source(_source(source_id, uuid4(), space_id, name="Theirs"))
|
||||
|
||||
conflict = raised.value
|
||||
assert conflict.cross_workspace is True
|
||||
assert conflict.audit_details()["boundary"] == "workspace"
|
||||
assert "another workspace" in str(conflict)
|
||||
stored = await registry.get_source(source_id)
|
||||
assert stored is not None and stored.workspace_id == owner
|
||||
|
||||
|
||||
async def test_ensure_source_refuses_an_id_owned_by_another_space(db_engine: AsyncEngine) -> None:
|
||||
"""The space boundary rejects too: a space is what grants and policies
|
||||
attach to, so a source shared between two spaces would make one space's
|
||||
retrieval filter answer for content the other owns."""
|
||||
|
||||
registry = PostgresSourceRegistry(create_session_factory(db_engine))
|
||||
source_id, workspace_id = uuid4(), uuid4()
|
||||
await registry.ensure_source(_source(source_id, workspace_id, uuid4()))
|
||||
|
||||
with pytest.raises(SourceOwnershipConflict) as raised:
|
||||
await registry.ensure_source(_source(source_id, workspace_id, uuid4()))
|
||||
|
||||
assert raised.value.cross_workspace is False
|
||||
assert raised.value.audit_details()["boundary"] == "space"
|
||||
|
||||
|
||||
async def test_a_document_cannot_reference_a_source_from_another_space(
|
||||
db_engine: AsyncEngine,
|
||||
) -> None:
|
||||
"""Enforced by PostgreSQL, not by remembering to check: the composite
|
||||
foreign key makes the cross-scope reference unrepresentable."""
|
||||
|
||||
factory = create_session_factory(db_engine)
|
||||
registry = PostgresSourceRegistry(factory)
|
||||
documents = PostgresDocumentRepository(factory)
|
||||
workspace_id, space_id = uuid4(), uuid4()
|
||||
source = await registry.create_source(_source(uuid4(), workspace_id, space_id))
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
await documents.create_document(
|
||||
DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
space_id=uuid4(), # a different space than the source's
|
||||
source_key=str(source.id),
|
||||
external_id="page",
|
||||
title="Smuggled",
|
||||
source_id=source.id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_get_owned_source_hides_a_foreign_source_rather_than_refusing_it(
|
||||
db_engine: AsyncEngine,
|
||||
) -> None:
|
||||
"""A 404/403 difference would itself tell a caller which source ids are
|
||||
real in someone else's tenant."""
|
||||
|
||||
registry = PostgresSourceRegistry(create_session_factory(db_engine))
|
||||
workspace_id, space_id = uuid4(), uuid4()
|
||||
source = await registry.create_source(_source(uuid4(), workspace_id, space_id))
|
||||
|
||||
assert await registry.get_owned_source(source.id, workspace_id=workspace_id) is not None
|
||||
assert await registry.get_owned_source(source.id, workspace_id=uuid4()) is None
|
||||
assert (
|
||||
await registry.get_owned_source(source.id, workspace_id=workspace_id, space_id=uuid4())
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
async def test_update_source_cannot_move_a_source_between_spaces(db_engine: AsyncEngine) -> None:
|
||||
"""Ownership is fixed at creation: an update may change what a source
|
||||
*is*, never which tenant or space it belongs to."""
|
||||
|
||||
registry = PostgresSourceRegistry(create_session_factory(db_engine))
|
||||
workspace_id, space_id = uuid4(), uuid4()
|
||||
source = await registry.create_source(_source(uuid4(), workspace_id, space_id))
|
||||
|
||||
with pytest.raises(SourceOwnershipConflict):
|
||||
await registry.update_source(
|
||||
_source(source.id, workspace_id, uuid4(), name="Moved elsewhere")
|
||||
)
|
||||
|
||||
unchanged = await registry.get_source(source.id)
|
||||
assert unchanged is not None
|
||||
assert unchanged.space_id == space_id and unchanged.name == "Runbooks"
|
||||
|
||||
|
||||
# -- convergence: discovered versus applied ----------------------------------
|
||||
|
||||
|
||||
async def test_items_converge_only_when_an_ingestion_reports_the_same_fingerprint(
|
||||
db_engine: AsyncEngine,
|
||||
) -> None:
|
||||
registry = PostgresSourceRegistry(create_session_factory(db_engine))
|
||||
source = await registry.create_source(_source(uuid4(), uuid4(), uuid4()))
|
||||
now = datetime.now(UTC)
|
||||
await registry.upsert_items(
|
||||
(
|
||||
SourceItemRecord(
|
||||
source_id=source.id,
|
||||
external_id="a.md",
|
||||
fingerprint="a" * 64,
|
||||
locator="a.md",
|
||||
last_seen_at=now,
|
||||
),
|
||||
SourceItemRecord(
|
||||
source_id=source.id,
|
||||
external_id="b.md",
|
||||
fingerprint="b" * 64,
|
||||
locator="b.md",
|
||||
last_seen_at=now,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert await registry.summarize_items(source.id) == SourceItemSummary(total=2, pending=2)
|
||||
|
||||
# A report for content that has since changed applies nothing.
|
||||
assert (
|
||||
await registry.mark_item_ingested(
|
||||
source.id, "a.md", fingerprint="f" * 64, document_id=None, now=now
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert await registry.summarize_items(source.id) == SourceItemSummary(total=2, pending=2)
|
||||
|
||||
assert (
|
||||
await registry.mark_item_ingested(
|
||||
source.id, "a.md", fingerprint="a" * 64, document_id=None, now=now
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
await registry.mark_item_failed(
|
||||
source.id,
|
||||
"b.md",
|
||||
fingerprint="b" * 64,
|
||||
failure_code="security_quarantined",
|
||||
retryable=False,
|
||||
now=now,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
summary = await registry.summarize_items(source.id)
|
||||
assert summary == SourceItemSummary(total=2, applied=1, failed=1)
|
||||
assert summary.converged is False
|
||||
assert summary.outcome() is SourceSyncOutcome.COMPLETED_WITH_FAILURES
|
||||
|
||||
[applied] = [item for item in await registry.list_items(source.id) if item.applied]
|
||||
assert applied.external_id == "a.md" and applied.applied_at is not None
|
||||
|
||||
|
||||
async def test_a_retired_item_counts_as_converged(db_engine: AsyncEngine) -> None:
|
||||
registry = PostgresSourceRegistry(create_session_factory(db_engine))
|
||||
source = await registry.create_source(_source(uuid4(), uuid4(), uuid4()))
|
||||
now = datetime.now(UTC)
|
||||
await registry.upsert_items(
|
||||
(
|
||||
SourceItemRecord(
|
||||
source_id=source.id,
|
||||
external_id="gone.md",
|
||||
fingerprint="c" * 64,
|
||||
locator="gone.md",
|
||||
last_seen_at=now,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
await registry.mark_items_removed(source.id, ("gone.md",), now=now)
|
||||
|
||||
summary = await registry.summarize_items(source.id)
|
||||
assert summary == SourceItemSummary(total=1, removed=1)
|
||||
assert summary.converged is True
|
||||
|
||||
|
||||
async def test_convergence_never_overwrites_a_sync_that_is_still_running(
|
||||
db_engine: AsyncEngine,
|
||||
) -> None:
|
||||
"""A child job finishing while a *new* sync is already scanning must not
|
||||
stamp its stale conclusion over the live run."""
|
||||
|
||||
registry = PostgresSourceRegistry(create_session_factory(db_engine))
|
||||
source = await registry.create_source(_source(uuid4(), uuid4(), uuid4()))
|
||||
now = datetime.now(UTC)
|
||||
|
||||
await registry.set_sync_state(source.id, SourceSyncState.AWAITING_INGEST, now=now)
|
||||
settled = await registry.converge_sync_state(
|
||||
source.id, SourceSyncState.COMPLETED, now=now, successful=True
|
||||
)
|
||||
assert settled is not None
|
||||
assert settled.sync_state is SourceSyncState.COMPLETED
|
||||
assert settled.last_successful_sync_at is not None
|
||||
|
||||
await registry.set_sync_state(source.id, SourceSyncState.SCANNING, now=now)
|
||||
assert (
|
||||
await registry.converge_sync_state(
|
||||
source.id, SourceSyncState.COMPLETED, now=now, successful=True
|
||||
)
|
||||
is None
|
||||
)
|
||||
live = await registry.get_source(source.id)
|
||||
assert live is not None and live.sync_state is SourceSyncState.SCANNING
|
||||
|
||||
|
||||
async def test_list_due_for_sync_finds_only_what_should_run_now(db_engine: AsyncEngine) -> None:
|
||||
"""The scheduler's query against real PostgreSQL, including the interval
|
||||
arithmetic -- which is done in SQL, so a Python-side rewrite of the same
|
||||
rule would not prove it."""
|
||||
|
||||
registry = PostgresSourceRegistry(create_session_factory(db_engine))
|
||||
workspace_id, space_id = uuid4(), uuid4()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
async def _register(
|
||||
name: str,
|
||||
*,
|
||||
interval: int | None,
|
||||
last_sync_at: datetime | None,
|
||||
status: SourceStatus = SourceStatus.ACTIVE,
|
||||
sync_state: SourceSyncState = SourceSyncState.COMPLETED,
|
||||
kind: ConnectorKind = ConnectorKind.FOLDER,
|
||||
) -> UUID:
|
||||
record = await registry.create_source(
|
||||
SourceRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
space_id=space_id,
|
||||
name=name,
|
||||
kind=kind,
|
||||
status=status,
|
||||
sync_state=sync_state,
|
||||
last_sync_at=last_sync_at,
|
||||
sync_interval_seconds=interval,
|
||||
)
|
||||
)
|
||||
return record.id
|
||||
|
||||
overdue = await _register("overdue", interval=3600, last_sync_at=now - timedelta(hours=2))
|
||||
never = await _register("never synced", interval=3600, last_sync_at=None)
|
||||
await _register("recent", interval=3600, last_sync_at=now - timedelta(minutes=5))
|
||||
await _register("manual", interval=None, last_sync_at=now - timedelta(days=9))
|
||||
await _register(
|
||||
"paused", interval=3600, last_sync_at=now - timedelta(days=1), status=SourceStatus.PAUSED
|
||||
)
|
||||
await _register(
|
||||
"already running",
|
||||
interval=3600,
|
||||
last_sync_at=now - timedelta(days=1),
|
||||
sync_state=SourceSyncState.AWAITING_INGEST,
|
||||
)
|
||||
await _register(
|
||||
"upload", interval=3600, last_sync_at=None, kind=ConnectorKind.UPLOAD
|
||||
)
|
||||
|
||||
due = await registry.list_due_for_sync(now=now, limit=50)
|
||||
|
||||
assert {source.id for source in due} == {overdue, never}
|
||||
# Longest-neglected first, so a small batch cannot starve one source.
|
||||
assert due[0].id == never
|
||||
Reference in new issue
Block a user