Initial curated RAGcore source from c16667f0c9397e2bfc8dd0255b114fba23c8d05e

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:34:21 +02:00
commit 7ca726caf2
659 files changed
+98171

No files matched your search

View File
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
+104
View File
@@ -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)
+241
View File
@@ -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()
+185
View File
@@ -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