Initial curated RAGcore source from c16667f0c9397e2bfc8dd0255b114fba23c8d05e
This commit is contained in:
commit
7ca726caf2
659 files changed
+98171
No files matched your search
@@ -0,0 +1,233 @@
|
||||
"""Distributed alias-lock primitive: concurrency, staleness and release."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from ragcore.application.indexes.models import IndexOperationStatus
|
||||
from ragcore.config import Settings
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.indexes import PostgresIndexOperationStore
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def engine() -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(engine: AsyncEngine) -> PostgresIndexOperationStore:
|
||||
return PostgresIndexOperationStore(create_session_factory(engine))
|
||||
|
||||
|
||||
def alias() -> str:
|
||||
return f"rag_dense_lock-test-{uuid4().hex}_active"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_begin_grants_lock_when_alias_is_free(store: PostgresIndexOperationStore) -> None:
|
||||
now = datetime.now(UTC)
|
||||
operation_id = await store.begin(
|
||||
alias_name=alias(),
|
||||
operation="build",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token=uuid4().hex,
|
||||
now=now,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert operation_id is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_begin_only_one_succeeds(store: PostgresIndexOperationStore) -> None:
|
||||
target = alias()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
async def attempt(owner: str) -> object:
|
||||
return await store.begin(
|
||||
alias_name=target,
|
||||
operation="build",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token=owner,
|
||||
now=now,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
|
||||
results = await asyncio.gather(attempt(uuid4().hex), attempt(uuid4().hex))
|
||||
granted = [result for result in results if result is not None]
|
||||
assert len(granted) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_second_promotion_is_rejected_until_first_finishes(
|
||||
store: PostgresIndexOperationStore,
|
||||
) -> None:
|
||||
target = alias()
|
||||
now = datetime.now(UTC)
|
||||
first = await store.begin(
|
||||
alias_name=target,
|
||||
operation="build",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token="owner-a",
|
||||
now=now,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert first is not None
|
||||
|
||||
blocked = await store.begin(
|
||||
alias_name=target,
|
||||
operation="rollback",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token="owner-b",
|
||||
now=now,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert blocked is None
|
||||
|
||||
released = await store.finish(
|
||||
first, "owner-a", IndexOperationStatus.SUCCEEDED, now=now
|
||||
)
|
||||
assert released is True
|
||||
|
||||
second = await store.begin(
|
||||
alias_name=target,
|
||||
operation="rollback",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token="owner-b",
|
||||
now=now,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert second is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_lease_is_reclaimed_as_needs_repair(
|
||||
store: PostgresIndexOperationStore,
|
||||
) -> None:
|
||||
target = alias()
|
||||
started = datetime.now(UTC)
|
||||
crashed = await store.begin(
|
||||
alias_name=target,
|
||||
operation="build",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token="owner-crashed",
|
||||
now=started,
|
||||
lease_duration=timedelta(seconds=1),
|
||||
)
|
||||
assert crashed is not None
|
||||
|
||||
later = started + timedelta(seconds=5)
|
||||
resumed = await store.begin(
|
||||
alias_name=target,
|
||||
operation="build",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token="owner-resumed",
|
||||
now=later,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert resumed is not None
|
||||
assert resumed != crashed
|
||||
|
||||
# The crashed operation must stay detectable, not silently vanish.
|
||||
still_blocked_third_party = await store.begin(
|
||||
alias_name=target,
|
||||
operation="build",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token="owner-third",
|
||||
now=later,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert still_blocked_third_party is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finish_is_idempotent_and_requires_terminal_status(
|
||||
store: PostgresIndexOperationStore,
|
||||
) -> None:
|
||||
target = alias()
|
||||
now = datetime.now(UTC)
|
||||
operation_id = await store.begin(
|
||||
alias_name=target,
|
||||
operation="build",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token="owner-a",
|
||||
now=now,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert operation_id is not None
|
||||
|
||||
with pytest.raises(ValueError, match="terminal"):
|
||||
await store.finish(operation_id, "owner-a", IndexOperationStatus.IN_PROGRESS, now=now)
|
||||
|
||||
first_finish = await store.finish(
|
||||
operation_id, "owner-a", IndexOperationStatus.FAILED_COMPENSATED, now=now
|
||||
)
|
||||
assert first_finish is True
|
||||
|
||||
second_finish = await store.finish(
|
||||
operation_id, "owner-a", IndexOperationStatus.FAILED_COMPENSATED, now=now
|
||||
)
|
||||
assert second_finish is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lock_scope_is_specific_to_one_alias(store: PostgresIndexOperationStore) -> None:
|
||||
now = datetime.now(UTC)
|
||||
first_alias, second_alias = alias(), alias()
|
||||
first = await store.begin(
|
||||
alias_name=first_alias,
|
||||
operation="build",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token="owner-a",
|
||||
now=now,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
second = await store.begin(
|
||||
alias_name=second_alias,
|
||||
operation="build",
|
||||
collection_name="rag_dense_y_v1",
|
||||
owner_token="owner-b",
|
||||
now=now,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert first is not None
|
||||
assert second is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_step_appends_ordered_entries(store: PostgresIndexOperationStore) -> None:
|
||||
target = alias()
|
||||
now = datetime.now(UTC)
|
||||
operation_id = await store.begin(
|
||||
alias_name=target,
|
||||
operation="build",
|
||||
collection_name="rag_dense_x_v1",
|
||||
owner_token="owner-a",
|
||||
now=now,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert operation_id is not None
|
||||
|
||||
await store.record_step(operation_id, "owner-a", "created_shadow", now=now)
|
||||
await store.record_step(
|
||||
operation_id,
|
||||
"owner-a",
|
||||
"activated",
|
||||
now=now,
|
||||
details={"previous_collection": "rag_dense_x_v0"},
|
||||
)
|
||||
|
||||
# A step recorded by a non-owner must not be applied.
|
||||
await store.record_step(operation_id, "owner-mismatched", "hijacked", now=now)
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Real Postgres + real Qdrant coverage for the production ``AuthoritativeIndexSource``.
|
||||
|
||||
Mirrors ``test_rebuild.py``/``test_profile_activation.py``'s fixture style, but --
|
||||
unlike those, which both stay on a fixture source deliberately -- seeds real
|
||||
published content through ``PostgresDocumentRepository``/``DocumentPublicationService``
|
||||
and proves ``ProductionAuthoritativeIndexSource`` reads it back, re-embeds it, and
|
||||
carries it through a real shadow build, validation and alias activation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from ragcore.application.documents import (
|
||||
ChunkRecord,
|
||||
DocumentPublicationService,
|
||||
DocumentRecord,
|
||||
DocumentVersionRecord,
|
||||
DocumentVersionState,
|
||||
)
|
||||
from ragcore.application.indexes import IndexBuildService, IndexBuildState
|
||||
from ragcore.application.sources import implicit_source_record
|
||||
from ragcore.config import Settings
|
||||
from ragcore.domain.control import DataPolicy
|
||||
from ragcore.domain.models import EmbeddingProfile, EmbeddingProvider, NormalizationMode
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.content import PostgresDocumentRepository
|
||||
from ragcore.infrastructure.database.content.tables import documents as documents_table
|
||||
from ragcore.infrastructure.database.indexes import PostgresIndexOperationStore
|
||||
from ragcore.infrastructure.database.sources import PostgresSourceRegistry
|
||||
from ragcore.infrastructure.indexes import ProductionAuthoritativeIndexSource
|
||||
from ragcore.infrastructure.qdrant import CollectionKind, CollectionProfile
|
||||
from ragcore.infrastructure.qdrant.rebuild import QdrantRebuildStore
|
||||
from ragcore.infrastructure.qdrant.smoke import QdrantGoldenSmoke
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop_policy() -> asyncio.AbstractEventLoopPolicy:
|
||||
if sys.platform == "win32":
|
||||
return asyncio.WindowsSelectorEventLoopPolicy()
|
||||
return asyncio.get_event_loop_policy()
|
||||
|
||||
|
||||
class FakeEmbeddingClient:
|
||||
"""Duck-typed ``httpx.AsyncClient`` stand-in: echoes one dummy vector per
|
||||
requested input, no network. ``iter_published_content`` scans every
|
||||
published chunk in this shared test database, not just the one this test
|
||||
seeds, so the batch size here is whatever else happens to be published --
|
||||
echoing rather than pre-queuing a fixed-size response keeps this correct
|
||||
regardless of that ambient state."""
|
||||
|
||||
async def post(self, url: str, **kwargs: object) -> httpx.Response:
|
||||
body = kwargs["json"]
|
||||
assert isinstance(body, dict)
|
||||
input_count = len(body["input"])
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"embeddings": [[0.1, 0.2, 0.3, 0.4] for _ in range(input_count)]},
|
||||
request=httpx.Request("POST", "http://test"),
|
||||
)
|
||||
|
||||
|
||||
def _embedding_profile() -> EmbeddingProfile:
|
||||
return EmbeddingProfile(
|
||||
id=uuid4(),
|
||||
profile="reindex-test",
|
||||
version=uuid4().hex[:12],
|
||||
provider=EmbeddingProvider.OLLAMA,
|
||||
model_identifier="nomic-embed-text:1.5",
|
||||
model_digest="b" * 64,
|
||||
dimensions=4,
|
||||
context_limit=4096,
|
||||
tokenizer="sentencepiece-v1",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
max_batch_size=8,
|
||||
normalization=NormalizationMode.NONE,
|
||||
)
|
||||
|
||||
|
||||
def _collection_profile(embedding_profile: EmbeddingProfile) -> CollectionProfile:
|
||||
return CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile=embedding_profile.profile,
|
||||
version=embedding_profile.version,
|
||||
dimensions=embedding_profile.dimensions,
|
||||
model_digest=embedding_profile.model_digest,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def qdrant() -> AsyncIterator[AsyncQdrantClient]:
|
||||
client = AsyncQdrantClient(
|
||||
url=os.getenv("RAGCORE_QDRANT_URL", "http://qdrant:6333"),
|
||||
api_key=os.getenv("RAGCORE_QDRANT_API_KEY", "replace-me"),
|
||||
timeout=60,
|
||||
)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine() -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _register_source(factory, document) -> None: # type: ignore[no-untyped-def]
|
||||
"""The content schema requires a registry row for every document's source."""
|
||||
|
||||
await PostgresSourceRegistry(factory).ensure_source(
|
||||
implicit_source_record(
|
||||
source_id=document.source_id,
|
||||
workspace_id=document.workspace_id,
|
||||
space_id=document.space_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_published_chunks_flow_through_shadow_build_validation_and_activation(
|
||||
qdrant: AsyncQdrantClient,
|
||||
db_engine: AsyncEngine,
|
||||
) -> None:
|
||||
session_factory = create_session_factory(db_engine)
|
||||
documents = PostgresDocumentRepository(session_factory)
|
||||
publication = DocumentPublicationService(documents)
|
||||
|
||||
embedding_profile = _embedding_profile()
|
||||
profile = _collection_profile(embedding_profile)
|
||||
|
||||
document = DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
source_key="authoritative-source-test",
|
||||
external_id=f"doc-{uuid4()}",
|
||||
title="Authoritative Source Runbook",
|
||||
)
|
||||
await _register_source(session_factory, document)
|
||||
await documents.create_document(document)
|
||||
version = DocumentVersionRecord(
|
||||
id=uuid4(),
|
||||
document_id=document.id,
|
||||
source_fingerprint="f" * 64,
|
||||
content_hash="c" * 64,
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version="prose-v1",
|
||||
state=DocumentVersionState.VALIDATED,
|
||||
)
|
||||
await documents.create_version(version)
|
||||
chunk = ChunkRecord(
|
||||
id=uuid4(),
|
||||
document_version_id=version.id,
|
||||
structure_path="/blocks/0000",
|
||||
content_hash="d" * 64,
|
||||
chunk_profile_version="prose-v1",
|
||||
content="Restart the payment service before escalating to on-call.",
|
||||
token_count=10,
|
||||
metadata={
|
||||
"role": "child",
|
||||
"content_type": "text/plain",
|
||||
"language": "en",
|
||||
"trust_level": "user_uploaded",
|
||||
},
|
||||
)
|
||||
await publication.publish_validated(version.id, artifacts=(), chunks=(chunk,))
|
||||
|
||||
source = ProductionAuthoritativeIndexSource(
|
||||
document_repository=documents,
|
||||
embedding_profile=embedding_profile,
|
||||
embedding_http_client=FakeEmbeddingClient(), # type: ignore[arg-type]
|
||||
embedding_base_url="http://ollama",
|
||||
)
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
operations = PostgresIndexOperationStore(session_factory)
|
||||
builder = IndexBuildService(store, operations)
|
||||
|
||||
try:
|
||||
report = await builder.build(profile, source, QdrantGoldenSmoke(qdrant))
|
||||
|
||||
assert report.state is IndexBuildState.ACTIVE
|
||||
assert report.validation.passed is True
|
||||
assert report.validation.expected_count >= 1
|
||||
assert await store.current_alias_target(profile.alias_name) == profile.collection_name
|
||||
|
||||
stored_points, malformed = await store.list_points(profile)
|
||||
assert malformed == ()
|
||||
# This shared test database accumulates published content across
|
||||
# runs (``iter_published_content`` is deliberately unscoped), so
|
||||
# assert *our* chunk made it through rather than an exact total.
|
||||
assert chunk.id in {point.point_id for point in stored_points}
|
||||
finally:
|
||||
await store.rollback(profile.alias_name, None)
|
||||
await qdrant.delete_collection(profile.collection_name)
|
||||
async with session_factory() as session:
|
||||
await session.execute(
|
||||
documents_table.delete().where(documents_table.c.id == document.id)
|
||||
)
|
||||
await session.commit()
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Fault injection at multiple build stages: verify compensation and status."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from qdrant_client.http.exceptions import UnexpectedResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from ragcore.application.indexes import (
|
||||
IndexBuildError,
|
||||
IndexBuildService,
|
||||
IndexRepairError,
|
||||
)
|
||||
from ragcore.config import Settings
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.indexes import PostgresIndexOperationStore
|
||||
from ragcore.infrastructure.qdrant import (
|
||||
CollectionKind,
|
||||
CollectionProfile,
|
||||
IndexPayload,
|
||||
IndexPoint,
|
||||
)
|
||||
from ragcore.infrastructure.qdrant.rebuild import QdrantRebuildStore
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
class FixtureSource:
|
||||
def __init__(self, points: Iterable[IndexPoint]) -> None:
|
||||
self.points = tuple(points)
|
||||
|
||||
async def iter_points(self, profile: CollectionProfile) -> AsyncIterator[IndexPoint]:
|
||||
for point in self.points:
|
||||
yield point
|
||||
|
||||
|
||||
class FailingAfterNSource:
|
||||
"""Yields real points, then raises to simulate an authoritative-source failure."""
|
||||
|
||||
def __init__(self, points: Iterable[IndexPoint], *, fail_after: int) -> None:
|
||||
self.points = tuple(points)
|
||||
self.fail_after = fail_after
|
||||
|
||||
async def iter_points(self, profile: CollectionProfile) -> AsyncIterator[IndexPoint]:
|
||||
for index, point in enumerate(self.points):
|
||||
if index >= self.fail_after:
|
||||
raise RuntimeError("simulated authoritative source outage")
|
||||
yield point
|
||||
|
||||
|
||||
class FailingSmoke:
|
||||
async def run(self, profile: CollectionProfile) -> None:
|
||||
raise AssertionError("simulated golden smoke failure")
|
||||
|
||||
|
||||
class PassingSmoke:
|
||||
async def run(self, profile: CollectionProfile) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class HookableStore:
|
||||
"""Delegates to a real QdrantRebuildStore; lets tests hook activate/rollback."""
|
||||
|
||||
def __init__(self, inner: QdrantRebuildStore) -> None:
|
||||
self._inner = inner
|
||||
self.activate_hook: Callable[[], None] | None = None
|
||||
self.rollback_hook: Callable[[], None] | None = None
|
||||
|
||||
async def create_shadow(self, profile: CollectionProfile) -> None:
|
||||
await self._inner.create_shadow(profile)
|
||||
|
||||
async def delete_shadow(self, profile: CollectionProfile) -> None:
|
||||
await self._inner.delete_shadow(profile)
|
||||
|
||||
async def upsert_points(self, profile: CollectionProfile, points: Sequence[IndexPoint]) -> None:
|
||||
await self._inner.upsert_points(profile, points)
|
||||
|
||||
async def delete_points(self, profile: CollectionProfile, point_ids: Sequence[UUID]) -> None:
|
||||
await self._inner.delete_points(profile, point_ids)
|
||||
|
||||
async def delete_raw_points(self, profile: CollectionProfile, raw_ids: Sequence[str]) -> None:
|
||||
await self._inner.delete_raw_points(profile, raw_ids)
|
||||
|
||||
async def count_points(self, profile: CollectionProfile) -> int:
|
||||
return await self._inner.count_points(profile)
|
||||
|
||||
async def list_points(self, profile: CollectionProfile) -> Any:
|
||||
return await self._inner.list_points(profile)
|
||||
|
||||
async def current_alias_target(self, alias_name: str) -> str | None:
|
||||
return await self._inner.current_alias_target(alias_name)
|
||||
|
||||
async def activate(self, profile: CollectionProfile) -> None:
|
||||
await self._inner.activate(profile)
|
||||
if self.activate_hook is not None:
|
||||
self.activate_hook()
|
||||
|
||||
async def rollback(self, alias_name: str, previous_collection: str | None) -> None:
|
||||
if self.rollback_hook is not None:
|
||||
self.rollback_hook()
|
||||
await self._inner.rollback(alias_name, previous_collection)
|
||||
|
||||
|
||||
def _raise(message: str) -> None:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def make_profile() -> CollectionProfile:
|
||||
return CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile="compensate-test",
|
||||
version=uuid4().hex[:12],
|
||||
dimensions=4,
|
||||
model_digest="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
def make_point(profile: CollectionProfile, index: int) -> IndexPoint:
|
||||
point_id = uuid4()
|
||||
return IndexPoint(
|
||||
point_id=point_id,
|
||||
dense_vector=(0.1 + index, 0.2, 0.3, 0.4),
|
||||
sparse_indices=(index + 1, index + 9),
|
||||
sparse_values=(0.8, 0.3),
|
||||
payload=IndexPayload(
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
document_id=uuid4(),
|
||||
document_version_id=uuid4(),
|
||||
chunk_id=point_id,
|
||||
parent_chunk_id=None,
|
||||
source_id=uuid4(),
|
||||
status="published",
|
||||
content_type="text/markdown",
|
||||
language="en",
|
||||
trust_level="source",
|
||||
location={"page": index + 1},
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version=profile.version,
|
||||
embedding_model_digest=profile.model_digest,
|
||||
text=f"A safe indexed passage {index}.",
|
||||
excerpt=f"A safe indexed passage {index}.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def qdrant() -> AsyncIterator[AsyncQdrantClient]:
|
||||
client = AsyncQdrantClient(
|
||||
url=os.getenv("RAGCORE_QDRANT_URL", "http://qdrant:6333"),
|
||||
api_key=os.getenv("RAGCORE_QDRANT_API_KEY", "replace-me"),
|
||||
timeout=60,
|
||||
)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine() -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def operations(db_engine: AsyncEngine) -> PostgresIndexOperationStore:
|
||||
return PostgresIndexOperationStore(create_session_factory(db_engine))
|
||||
|
||||
|
||||
async def _delete_collection_if_exists(client: AsyncQdrantClient, name: str) -> None:
|
||||
try:
|
||||
await client.delete_collection(collection_name=name, timeout=30)
|
||||
except UnexpectedResponse as error:
|
||||
if error.status_code != 404:
|
||||
raise
|
||||
|
||||
|
||||
async def _collection_exists(client: AsyncQdrantClient, name: str) -> bool:
|
||||
try:
|
||||
await client.get_collection(name)
|
||||
except UnexpectedResponse as error:
|
||||
if error.status_code == 404:
|
||||
return False
|
||||
raise
|
||||
return True
|
||||
|
||||
|
||||
async def test_failure_before_validation_deletes_orphan_shadow_and_leaves_alias_untouched(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
points = (make_point(profile, 0), make_point(profile, 1), make_point(profile, 2))
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
with pytest.raises(IndexBuildError, match="index build failed"):
|
||||
await service.build(
|
||||
profile, FailingAfterNSource(points, fail_after=1), PassingSmoke()
|
||||
)
|
||||
|
||||
assert await store.current_alias_target(profile.alias_name) is None
|
||||
assert await _collection_exists(qdrant, profile.collection_name) is False
|
||||
finally:
|
||||
await _delete_collection_if_exists(qdrant, profile.collection_name)
|
||||
|
||||
|
||||
async def test_smoke_failure_deletes_shadow_and_preserves_prior_active_collection(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
good = make_profile()
|
||||
bad = CollectionProfile(
|
||||
kind=good.kind,
|
||||
profile=good.profile,
|
||||
version=uuid4().hex[:12],
|
||||
dimensions=good.dimensions,
|
||||
model_digest=good.model_digest,
|
||||
)
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
good_report = await service.build(
|
||||
good, FixtureSource((make_point(good, 0),)), PassingSmoke()
|
||||
)
|
||||
assert good_report.state.value == "active"
|
||||
|
||||
with pytest.raises(IndexBuildError, match="golden smoke"):
|
||||
await service.build(bad, FixtureSource((make_point(bad, 1),)), FailingSmoke())
|
||||
|
||||
# The previously active collection must still be the one serving the alias.
|
||||
assert await store.current_alias_target(good.alias_name) == good.collection_name
|
||||
assert await _collection_exists(qdrant, bad.collection_name) is False
|
||||
finally:
|
||||
await _delete_collection_if_exists(qdrant, good.collection_name)
|
||||
await _delete_collection_if_exists(qdrant, bad.collection_name)
|
||||
|
||||
|
||||
async def test_activation_verification_failure_restores_previous_alias(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
good = make_profile()
|
||||
bad = CollectionProfile(
|
||||
kind=good.kind,
|
||||
profile=good.profile,
|
||||
version=uuid4().hex[:12],
|
||||
dimensions=good.dimensions,
|
||||
model_digest=good.model_digest,
|
||||
)
|
||||
inner = QdrantRebuildStore(qdrant)
|
||||
hookable = HookableStore(inner)
|
||||
service = IndexBuildService(hookable, operations)
|
||||
try:
|
||||
good_report = await service.build(
|
||||
good, FixtureSource((make_point(good, 0),)), PassingSmoke()
|
||||
)
|
||||
assert good_report.state.value == "active"
|
||||
|
||||
hookable.activate_hook = lambda: _raise("simulated activation verification failure")
|
||||
with pytest.raises(IndexBuildError, match="index build failed"):
|
||||
await service.build(bad, FixtureSource((make_point(bad, 1),)), PassingSmoke())
|
||||
|
||||
# Even though the low-level alias flip landed, compensation must force it
|
||||
# back to the last known-good collection and confirm that before returning.
|
||||
assert await inner.current_alias_target(good.alias_name) == good.collection_name
|
||||
finally:
|
||||
await _delete_collection_if_exists(qdrant, good.collection_name)
|
||||
await _delete_collection_if_exists(qdrant, bad.collection_name)
|
||||
|
||||
|
||||
async def test_activation_rollback_that_also_fails_raises_repair_error(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
good = make_profile()
|
||||
bad = CollectionProfile(
|
||||
kind=good.kind,
|
||||
profile=good.profile,
|
||||
version=uuid4().hex[:12],
|
||||
dimensions=good.dimensions,
|
||||
model_digest=good.model_digest,
|
||||
)
|
||||
inner = QdrantRebuildStore(qdrant)
|
||||
hookable = HookableStore(inner)
|
||||
service = IndexBuildService(hookable, operations)
|
||||
try:
|
||||
await service.build(good, FixtureSource((make_point(good, 0),)), PassingSmoke())
|
||||
|
||||
hookable.activate_hook = lambda: _raise("simulated activation verification failure")
|
||||
hookable.rollback_hook = lambda: _raise("simulated rollback failure")
|
||||
with pytest.raises(IndexRepairError, match="manual repair required"):
|
||||
await service.build(bad, FixtureSource((make_point(bad, 1),)), PassingSmoke())
|
||||
|
||||
# A rollback that itself fails must not be silently reported as a clean
|
||||
# failure: the alias state after this is unconfirmed and must be fixed
|
||||
# out of band, which is exactly what IndexRepairError signals.
|
||||
finally:
|
||||
hookable.activate_hook = None
|
||||
hookable.rollback_hook = None
|
||||
await inner.rollback(good.alias_name, good.collection_name)
|
||||
await _delete_collection_if_exists(qdrant, good.collection_name)
|
||||
await _delete_collection_if_exists(qdrant, bad.collection_name)
|
||||
|
||||
|
||||
async def test_repeating_a_failed_build_after_cleanup_succeeds(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
points = (make_point(profile, 0), make_point(profile, 1))
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
with pytest.raises(IndexBuildError):
|
||||
await service.build(profile, FailingAfterNSource(points, fail_after=1), PassingSmoke())
|
||||
|
||||
# Retrying the exact same alias/collection after compensation must work,
|
||||
# proving the lock and the shadow collection were both fully released.
|
||||
report = await service.build(profile, FixtureSource(points), PassingSmoke())
|
||||
assert report.state.value == "active"
|
||||
assert await store.current_alias_target(profile.alias_name) == profile.collection_name
|
||||
finally:
|
||||
await _delete_collection_if_exists(qdrant, profile.collection_name)
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Service-level concurrency: racing promotions, promote-vs-rollback, timeouts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Iterable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from ragcore.application.indexes import (
|
||||
IndexBuildError,
|
||||
IndexBuildService,
|
||||
IndexLockTimeoutError,
|
||||
IndexOperationStatus,
|
||||
)
|
||||
from ragcore.config import Settings
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.indexes import PostgresIndexOperationStore
|
||||
from ragcore.infrastructure.qdrant import (
|
||||
CollectionKind,
|
||||
CollectionProfile,
|
||||
IndexPayload,
|
||||
IndexPoint,
|
||||
)
|
||||
from ragcore.infrastructure.qdrant.rebuild import QdrantRebuildStore
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
# Real build latency in this environment (collection create + 8 payload indexes +
|
||||
# per-point upsert/retrieve/set_payload round trips) can run into the tens of
|
||||
# seconds, so timeouts here are generous. What is asserted is boundedness and
|
||||
# correctness of the outcome, not a tight wall-clock bound.
|
||||
_GENEROUS_TIMEOUT = timedelta(seconds=60)
|
||||
|
||||
|
||||
class FixtureSource:
|
||||
def __init__(self, points: Iterable[IndexPoint]) -> None:
|
||||
self.points = tuple(points)
|
||||
|
||||
async def iter_points(self, profile: CollectionProfile) -> AsyncIterator[IndexPoint]:
|
||||
for point in self.points:
|
||||
yield point
|
||||
|
||||
|
||||
class PassingSmoke:
|
||||
async def run(self, profile: CollectionProfile) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def make_profile() -> CollectionProfile:
|
||||
# A fresh profile name (not just version) per call keeps each test's alias
|
||||
# namespace isolated, so leftover/slow state from one test can never block
|
||||
# another test's lock acquisition.
|
||||
return CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile=f"concurrency-{uuid4().hex[:8]}",
|
||||
version=uuid4().hex[:12],
|
||||
dimensions=4,
|
||||
model_digest="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
def make_point(profile: CollectionProfile, index: int) -> IndexPoint:
|
||||
point_id = uuid4()
|
||||
return IndexPoint(
|
||||
point_id=point_id,
|
||||
dense_vector=(0.1 + index, 0.2, 0.3, 0.4),
|
||||
sparse_indices=(index + 1,),
|
||||
sparse_values=(0.8,),
|
||||
payload=IndexPayload(
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
document_id=uuid4(),
|
||||
document_version_id=uuid4(),
|
||||
chunk_id=point_id,
|
||||
parent_chunk_id=None,
|
||||
source_id=uuid4(),
|
||||
status="published",
|
||||
content_type="text/markdown",
|
||||
language="en",
|
||||
trust_level="source",
|
||||
location={"page": index + 1},
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version=profile.version,
|
||||
embedding_model_digest=profile.model_digest,
|
||||
text=f"A safe indexed passage {index}.",
|
||||
excerpt=f"A safe indexed passage {index}.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def qdrant() -> AsyncIterator[AsyncQdrantClient]:
|
||||
client = AsyncQdrantClient(
|
||||
url=os.getenv("RAGCORE_QDRANT_URL", "http://qdrant:6333"),
|
||||
api_key=os.getenv("RAGCORE_QDRANT_API_KEY", "replace-me"),
|
||||
timeout=60,
|
||||
)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine() -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def operations(db_engine: AsyncEngine) -> PostgresIndexOperationStore:
|
||||
return PostgresIndexOperationStore(create_session_factory(db_engine))
|
||||
|
||||
|
||||
async def test_two_concurrent_promotions_only_one_activates(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
points = (make_point(profile, 0), make_point(profile, 1))
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations, lock_acquire_timeout=_GENEROUS_TIMEOUT)
|
||||
try:
|
||||
results = await asyncio.gather(
|
||||
service.build(profile, FixtureSource(points), PassingSmoke()),
|
||||
service.build(profile, FixtureSource(points), PassingSmoke()),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
succeeded = [result for result in results if not isinstance(result, BaseException)]
|
||||
rejected = [result for result in results if isinstance(result, BaseException)]
|
||||
assert len(succeeded) == 1
|
||||
assert len(rejected) == 1
|
||||
# Whichever caller loses the race is safely turned away: either it waits
|
||||
# its turn and then sees the alias already active, or it times out first.
|
||||
# Both prove mutual exclusion held; neither corrupts state.
|
||||
assert isinstance(rejected[0], (IndexBuildError, IndexLockTimeoutError))
|
||||
assert await store.current_alias_target(profile.alias_name) == profile.collection_name
|
||||
finally:
|
||||
await store.rollback(profile.alias_name, None)
|
||||
await qdrant.delete_collection(profile.collection_name)
|
||||
|
||||
|
||||
async def test_promotion_and_rollback_starting_together_are_serialized_not_interleaved(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
"""A rollback racing a promotion on the same alias must wait, not interleave.
|
||||
|
||||
The lock is held directly through the primitive for a short, fully controlled
|
||||
window rather than by racing a real build's variable duration, so the outcome
|
||||
is deterministic without depending on Qdrant/container timing.
|
||||
"""
|
||||
|
||||
profile = make_profile()
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations, lock_acquire_timeout=_GENEROUS_TIMEOUT)
|
||||
try:
|
||||
report = await service.build(
|
||||
profile, FixtureSource((make_point(profile, 0),)), PassingSmoke()
|
||||
)
|
||||
|
||||
holder_token = uuid4().hex
|
||||
hold_started = datetime.now(UTC)
|
||||
held_operation_id = await operations.begin(
|
||||
alias_name=profile.alias_name,
|
||||
operation="reconcile",
|
||||
collection_name=profile.collection_name,
|
||||
owner_token=holder_token,
|
||||
now=hold_started,
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert held_operation_id is not None
|
||||
|
||||
async def release_after_delay() -> None:
|
||||
await asyncio.sleep(1.0)
|
||||
await operations.finish(
|
||||
held_operation_id,
|
||||
holder_token,
|
||||
IndexOperationStatus.SUCCEEDED,
|
||||
now=datetime.now(UTC),
|
||||
)
|
||||
|
||||
release_task = asyncio.create_task(release_after_delay())
|
||||
try:
|
||||
started = time.monotonic()
|
||||
await service.rollback(report)
|
||||
elapsed = time.monotonic() - started
|
||||
finally:
|
||||
await release_task # always await: never leak a background task
|
||||
|
||||
# The rollback could only have completed after the manual hold released.
|
||||
assert elapsed >= 0.9
|
||||
assert await store.current_alias_target(profile.alias_name) is None
|
||||
finally:
|
||||
await store.rollback(profile.alias_name, None)
|
||||
await qdrant.delete_collection(profile.collection_name)
|
||||
|
||||
|
||||
async def test_lock_acquire_timeout_is_bounded_and_raises(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
|
||||
holder_token = uuid4().hex
|
||||
operation_id = await operations.begin(
|
||||
alias_name=profile.alias_name,
|
||||
operation="build",
|
||||
collection_name=profile.collection_name,
|
||||
owner_token=holder_token,
|
||||
now=datetime.now(UTC),
|
||||
lease_duration=timedelta(seconds=30),
|
||||
)
|
||||
assert operation_id is not None
|
||||
|
||||
service = IndexBuildService(
|
||||
store,
|
||||
operations,
|
||||
lock_acquire_timeout=timedelta(seconds=1),
|
||||
lock_poll_interval_seconds=0.1,
|
||||
)
|
||||
try:
|
||||
started = time.monotonic()
|
||||
with pytest.raises(IndexLockTimeoutError):
|
||||
await service.build(
|
||||
profile, FixtureSource((make_point(profile, 0),)), PassingSmoke()
|
||||
)
|
||||
elapsed = time.monotonic() - started
|
||||
# Bounded, not an indefinite hang -- not a tight bound on the nominal
|
||||
# 1s `lock_acquire_timeout` itself. Each of the ~10 poll iterations
|
||||
# this loop takes still does a real Postgres round trip
|
||||
# (`operations.begin()`), and this suite's own real build latency in
|
||||
# this environment can run into the tens of seconds (see
|
||||
# `_GENEROUS_TIMEOUT` above), so a narrow millisecond-scale margin
|
||||
# here flakes under real system load without the timeout logic
|
||||
# itself having failed at all.
|
||||
assert elapsed < _GENEROUS_TIMEOUT.total_seconds()
|
||||
finally:
|
||||
await operations.finish(
|
||||
operation_id,
|
||||
holder_token,
|
||||
IndexOperationStatus.SUCCEEDED,
|
||||
now=datetime.now(UTC),
|
||||
)
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Malformed/legacy Qdrant points must be detected, excluded, repaired or quarantined."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Iterable
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from qdrant_client.http import models
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from ragcore.application.indexes import IndexBuildService
|
||||
from ragcore.config import Settings
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.indexes import PostgresIndexOperationStore
|
||||
from ragcore.infrastructure.qdrant import (
|
||||
CollectionKind,
|
||||
CollectionProfile,
|
||||
IndexPayload,
|
||||
IndexPoint,
|
||||
)
|
||||
from ragcore.infrastructure.qdrant.rebuild import QdrantRebuildStore
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
class FixtureSource:
|
||||
def __init__(self, points: Iterable[IndexPoint]) -> None:
|
||||
self.points = tuple(points)
|
||||
|
||||
async def iter_points(self, profile: CollectionProfile) -> AsyncIterator[IndexPoint]:
|
||||
for point in self.points:
|
||||
yield point
|
||||
|
||||
|
||||
class PassingSmoke:
|
||||
async def run(self, profile: CollectionProfile) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def make_profile() -> CollectionProfile:
|
||||
return CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile="malformed-test",
|
||||
version=uuid4().hex[:12],
|
||||
dimensions=4,
|
||||
model_digest="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
def make_point(profile: CollectionProfile, index: int) -> IndexPoint:
|
||||
point_id = uuid4()
|
||||
return IndexPoint(
|
||||
point_id=point_id,
|
||||
dense_vector=(0.1 + index, 0.2, 0.3, 0.4),
|
||||
sparse_indices=(index + 1, index + 9),
|
||||
sparse_values=(0.8, 0.3),
|
||||
payload=IndexPayload(
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
document_id=uuid4(),
|
||||
document_version_id=uuid4(),
|
||||
chunk_id=point_id,
|
||||
parent_chunk_id=None,
|
||||
source_id=uuid4(),
|
||||
status="published",
|
||||
content_type="text/markdown",
|
||||
language="en",
|
||||
trust_level="source",
|
||||
location={"page": index + 1},
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version=profile.version,
|
||||
embedding_model_digest=profile.model_digest,
|
||||
text=f"A safe indexed passage {index}.",
|
||||
excerpt=f"A safe indexed passage {index}.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def qdrant() -> AsyncIterator[AsyncQdrantClient]:
|
||||
client = AsyncQdrantClient(
|
||||
url=os.getenv("RAGCORE_QDRANT_URL", "http://qdrant:6333"),
|
||||
api_key=os.getenv("RAGCORE_QDRANT_API_KEY", "replace-me"),
|
||||
timeout=60,
|
||||
)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine() -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def operations(db_engine: AsyncEngine) -> PostgresIndexOperationStore:
|
||||
return PostgresIndexOperationStore(create_session_factory(db_engine))
|
||||
|
||||
|
||||
def _legacy_point_struct(point_id: object, profile: CollectionProfile) -> models.PointStruct:
|
||||
"""A point written without the fingerprint fields our adapter now requires."""
|
||||
|
||||
return models.PointStruct(
|
||||
id=point_id,
|
||||
vector={
|
||||
"dense": [0.5, 0.5, 0.5, 0.5],
|
||||
"sparse": models.SparseVector(indices=[1], values=[1.0]),
|
||||
},
|
||||
payload={
|
||||
"workspace_id": str(uuid4()),
|
||||
"space_id": str(uuid4()),
|
||||
"document_id": str(uuid4()),
|
||||
"status": "published",
|
||||
"text": "a legacy point written before fingerprinting existed",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def test_malformed_point_matching_expected_id_is_repaired_not_served(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
keep, repairable = make_point(profile, 0), make_point(profile, 1)
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
await service.build(profile, FixtureSource((keep, repairable)), PassingSmoke())
|
||||
|
||||
# Simulate a corrupted/legacy point sharing the deterministic id of a real,
|
||||
# currently-published chunk: the fingerprint fields fail the wire-integrity
|
||||
# shape check.
|
||||
await qdrant.set_payload(
|
||||
collection_name=profile.collection_name,
|
||||
payload={"index_fingerprint": "not-a-valid-fingerprint"},
|
||||
points=[str(repairable.point_id)],
|
||||
wait=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
valid_before, malformed_before = await store.list_points(profile)
|
||||
assert {item.point_id for item in valid_before} == {keep.point_id}
|
||||
assert {item.point_id for item in malformed_before} == {repairable.point_id}
|
||||
|
||||
report = await service.reconcile(profile, FixtureSource((keep, repairable)))
|
||||
|
||||
assert report.repairable_malformed_point_ids == (repairable.point_id,)
|
||||
assert report.quarantined_raw_ids == ()
|
||||
|
||||
valid_after, malformed_after = await store.list_points(profile)
|
||||
assert malformed_after == ()
|
||||
assert {item.point_id for item in valid_after} == {keep.point_id, repairable.point_id}
|
||||
finally:
|
||||
await store.rollback(profile.alias_name, None)
|
||||
await qdrant.delete_collection(profile.collection_name)
|
||||
|
||||
|
||||
async def test_malformed_point_with_no_matching_expected_id_is_quarantined(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
keep = make_point(profile, 0)
|
||||
orphan_id = uuid4()
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
await service.build(profile, FixtureSource((keep,)), PassingSmoke())
|
||||
await qdrant.upsert(
|
||||
collection_name=profile.collection_name,
|
||||
points=[_legacy_point_struct(str(orphan_id), profile)],
|
||||
wait=True,
|
||||
)
|
||||
|
||||
report = await service.reconcile(profile, FixtureSource((keep,)))
|
||||
|
||||
assert report.repairable_malformed_point_ids == ()
|
||||
assert report.quarantined_raw_ids == (str(orphan_id),)
|
||||
|
||||
valid_after, malformed_after = await store.list_points(profile)
|
||||
assert malformed_after == ()
|
||||
assert {item.point_id for item in valid_after} == {keep.point_id}
|
||||
assert await store.count_points(profile) == 1
|
||||
finally:
|
||||
await store.rollback(profile.alias_name, None)
|
||||
await qdrant.delete_collection(profile.collection_name)
|
||||
|
||||
|
||||
async def test_point_with_non_deterministic_id_is_quarantined_by_raw_id(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
keep = make_point(profile, 0)
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
await service.build(profile, FixtureSource((keep,)), PassingSmoke())
|
||||
# Qdrant accepts unsigned-integer ids too; our contract only issues UUIDv5
|
||||
# chunk ids, so an integer id can only be a foreign/legacy point.
|
||||
await qdrant.upsert(
|
||||
collection_name=profile.collection_name,
|
||||
points=[_legacy_point_struct(424242, profile)],
|
||||
wait=True,
|
||||
)
|
||||
|
||||
_valid_before, malformed_before = await store.list_points(profile)
|
||||
assert len(malformed_before) == 1
|
||||
assert malformed_before[0].point_id is None
|
||||
assert malformed_before[0].raw_id == "424242"
|
||||
|
||||
report = await service.reconcile(profile, FixtureSource((keep,)))
|
||||
|
||||
assert report.quarantined_raw_ids == ("424242",)
|
||||
assert await store.count_points(profile) == 1
|
||||
finally:
|
||||
await store.rollback(profile.alias_name, None)
|
||||
await qdrant.delete_collection(profile.collection_name)
|
||||
|
||||
|
||||
async def test_repeated_reconcile_after_repair_is_idempotent(
|
||||
qdrant: AsyncQdrantClient, operations: PostgresIndexOperationStore
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
keep, repairable = make_point(profile, 0), make_point(profile, 1)
|
||||
orphan_id = uuid4()
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
await service.build(profile, FixtureSource((keep, repairable)), PassingSmoke())
|
||||
await qdrant.set_payload(
|
||||
collection_name=profile.collection_name,
|
||||
payload={"index_fingerprint": "not-a-valid-fingerprint"},
|
||||
points=[str(repairable.point_id)],
|
||||
wait=True,
|
||||
timeout=30,
|
||||
)
|
||||
await qdrant.upsert(
|
||||
collection_name=profile.collection_name,
|
||||
points=[_legacy_point_struct(str(orphan_id), profile)],
|
||||
wait=True,
|
||||
)
|
||||
|
||||
first = await service.reconcile(profile, FixtureSource((keep, repairable)))
|
||||
assert first.repairable_malformed_point_ids == (repairable.point_id,)
|
||||
assert first.quarantined_raw_ids == (str(orphan_id),)
|
||||
|
||||
second = await service.reconcile(profile, FixtureSource((keep, repairable)))
|
||||
assert second.missing_point_ids == ()
|
||||
assert second.orphan_point_ids == ()
|
||||
assert second.changed_point_ids == ()
|
||||
assert second.repairable_malformed_point_ids == ()
|
||||
assert second.quarantined_raw_ids == ()
|
||||
assert await store.count_points(profile) == 2
|
||||
finally:
|
||||
await store.rollback(profile.alias_name, None)
|
||||
await qdrant.delete_collection(profile.collection_name)
|
||||
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Iterable
|
||||
from dataclasses import replace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from ragcore.application.indexes import IndexBuildService, IndexBuildState
|
||||
from ragcore.config import Settings
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.indexes import PostgresIndexOperationStore
|
||||
from ragcore.infrastructure.qdrant import (
|
||||
CollectionKind,
|
||||
CollectionProfile,
|
||||
IndexPayload,
|
||||
IndexPoint,
|
||||
QdrantIndexAdapter,
|
||||
)
|
||||
from ragcore.infrastructure.qdrant.rebuild import QdrantRebuildStore
|
||||
|
||||
|
||||
class FixtureSource:
|
||||
def __init__(self, points: Iterable[IndexPoint]) -> None:
|
||||
self.points = tuple(points)
|
||||
|
||||
async def iter_points(self, profile: CollectionProfile) -> AsyncIterator[IndexPoint]:
|
||||
for point in self.points:
|
||||
yield point
|
||||
|
||||
|
||||
class QdrantGoldenSmoke:
|
||||
def __init__(self, client: AsyncQdrantClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def run(self, profile: CollectionProfile) -> None:
|
||||
response = await self.client.query_points(
|
||||
collection_name=profile.collection_name,
|
||||
query=[0.1, 0.2, 0.3, 0.4],
|
||||
using="dense",
|
||||
limit=1,
|
||||
with_payload=True,
|
||||
timeout=30,
|
||||
)
|
||||
if not response.points:
|
||||
raise AssertionError("golden smoke query returned no points")
|
||||
|
||||
|
||||
def make_profile() -> CollectionProfile:
|
||||
return CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile="rebuild-test",
|
||||
version=uuid4().hex[:12],
|
||||
dimensions=4,
|
||||
model_digest="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
def make_point(profile: CollectionProfile, index: int) -> IndexPoint:
|
||||
point_id = uuid4()
|
||||
return IndexPoint(
|
||||
point_id=point_id,
|
||||
dense_vector=(0.1 + index, 0.2, 0.3, 0.4),
|
||||
sparse_indices=(index + 1, index + 9),
|
||||
sparse_values=(0.8, 0.3),
|
||||
payload=IndexPayload(
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
document_id=uuid4(),
|
||||
document_version_id=uuid4(),
|
||||
chunk_id=point_id,
|
||||
parent_chunk_id=None,
|
||||
source_id=uuid4(),
|
||||
status="published",
|
||||
content_type="text/markdown",
|
||||
language="en",
|
||||
trust_level="source",
|
||||
location={"page": index + 1},
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version=profile.version,
|
||||
embedding_model_digest=profile.model_digest,
|
||||
text=f"A safe indexed passage {index}.",
|
||||
excerpt=f"A safe indexed passage {index}.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def qdrant() -> AsyncIterator[AsyncQdrantClient]:
|
||||
client = AsyncQdrantClient(
|
||||
url=os.getenv("RAGCORE_QDRANT_URL", "http://qdrant:6333"),
|
||||
api_key=os.getenv("RAGCORE_QDRANT_API_KEY", "replace-me"),
|
||||
timeout=60,
|
||||
)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine() -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def operations(db_engine: AsyncEngine) -> PostgresIndexOperationStore:
|
||||
return PostgresIndexOperationStore(create_session_factory(db_engine))
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_rebuild_from_empty_validates_and_activates(
|
||||
qdrant: AsyncQdrantClient,
|
||||
operations: PostgresIndexOperationStore,
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
points = (make_point(profile, 0), make_point(profile, 1))
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
report = await service.build(profile, FixtureSource(points), QdrantGoldenSmoke(qdrant))
|
||||
|
||||
assert report.state is IndexBuildState.ACTIVE
|
||||
assert report.previous_collection is None
|
||||
assert report.validation.expected_count == 2
|
||||
assert report.validation.actual_count == 2
|
||||
assert report.validation.passed
|
||||
assert await store.current_alias_target(profile.alias_name) == profile.collection_name
|
||||
finally:
|
||||
await store.rollback(profile.alias_name, None)
|
||||
await qdrant.delete_collection(profile.collection_name)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_alias_rollback_restores_previous_active_collection(
|
||||
qdrant: AsyncQdrantClient,
|
||||
operations: PostgresIndexOperationStore,
|
||||
) -> None:
|
||||
first = make_profile()
|
||||
second = CollectionProfile(
|
||||
kind=first.kind,
|
||||
profile=first.profile,
|
||||
version=uuid4().hex[:12],
|
||||
dimensions=first.dimensions,
|
||||
model_digest=first.model_digest,
|
||||
)
|
||||
first_points = (make_point(first, 0),)
|
||||
second_points = (make_point(second, 1),)
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
await service.build(first, FixtureSource(first_points), QdrantGoldenSmoke(qdrant))
|
||||
second_report = await service.build(
|
||||
second, FixtureSource(second_points), QdrantGoldenSmoke(qdrant)
|
||||
)
|
||||
assert second_report.previous_collection == first.collection_name
|
||||
|
||||
await service.rollback(second_report)
|
||||
|
||||
assert await store.current_alias_target(first.alias_name) == first.collection_name
|
||||
finally:
|
||||
await store.rollback(first.alias_name, None)
|
||||
await qdrant.delete_collection(first.collection_name)
|
||||
await qdrant.delete_collection(second.collection_name)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_rebuild_rejects_direct_payload_tampering(
|
||||
qdrant: AsyncQdrantClient,
|
||||
operations: PostgresIndexOperationStore,
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
point = make_point(profile, 0)
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
await service.build(profile, FixtureSource((point,)), QdrantGoldenSmoke(qdrant))
|
||||
await qdrant.set_payload(
|
||||
collection_name=profile.collection_name,
|
||||
payload={"text": "tampered indexed content"},
|
||||
points=[str(point.point_id)],
|
||||
wait=True,
|
||||
timeout=30,
|
||||
)
|
||||
valid, malformed = await store.list_points(profile)
|
||||
assert valid == ()
|
||||
assert len(malformed) == 1
|
||||
assert malformed[0].point_id == point.point_id
|
||||
assert "wire integrity" in malformed[0].reason
|
||||
finally:
|
||||
await store.rollback(profile.alias_name, None)
|
||||
await qdrant.delete_collection(profile.collection_name)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_reconciliation_repairs_missing_changed_and_orphan_points(
|
||||
qdrant: AsyncQdrantClient,
|
||||
operations: PostgresIndexOperationStore,
|
||||
) -> None:
|
||||
profile = make_profile()
|
||||
points = (make_point(profile, 0), make_point(profile, 1))
|
||||
rogue = make_point(profile, 99)
|
||||
store = QdrantRebuildStore(qdrant)
|
||||
adapter = QdrantIndexAdapter(qdrant)
|
||||
service = IndexBuildService(store, operations)
|
||||
try:
|
||||
await service.build(profile, FixtureSource(points), QdrantGoldenSmoke(qdrant))
|
||||
await adapter.delete_points(profile, (points[0].point_id,))
|
||||
await adapter.upsert_points(profile, (rogue,))
|
||||
changed = replace(
|
||||
points[1],
|
||||
payload=replace(
|
||||
points[1].payload,
|
||||
text="A changed indexed passage.",
|
||||
excerpt="A changed indexed passage.",
|
||||
),
|
||||
)
|
||||
await adapter.upsert_points(profile, (changed,))
|
||||
|
||||
report = await service.reconcile(profile, FixtureSource(points))
|
||||
|
||||
assert report.missing_point_ids == (points[0].point_id,)
|
||||
assert report.orphan_point_ids == (rogue.point_id,)
|
||||
assert report.changed_point_ids == (points[1].point_id,)
|
||||
# repaired_point_ids is deterministically sorted by str(uuid), not by
|
||||
# fixture insertion order, since both ids are independently random.
|
||||
assert set(report.repaired_point_ids) == {points[0].point_id, points[1].point_id}
|
||||
assert report.repaired_point_ids == tuple(
|
||||
sorted((points[0].point_id, points[1].point_id), key=str)
|
||||
)
|
||||
assert await store.count_points(profile) == 2
|
||||
finally:
|
||||
await store.rollback(profile.alias_name, None)
|
||||
await qdrant.delete_collection(profile.collection_name)
|
||||
Reference in new issue
Block a user