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

@@ -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)