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,171 @@
"""Batch byte-quota boundaries: exact limit, one byte over, and fail-closed I/O."""
from __future__ import annotations
from dataclasses import replace
from types import SimpleNamespace
from typing import Any
from uuid import uuid4
import pytest
from ragcore.application.indexes.models import point_wire_bytes
from ragcore.infrastructure.qdrant import adapter as adapter_module
from ragcore.infrastructure.qdrant.adapter import QdrantIndexAdapter
from ragcore.infrastructure.qdrant.models import (
CollectionKind,
CollectionProfile,
IndexPayload,
IndexPoint,
)
class _RefusingClient:
"""Proves a quota rejection happens before any network mutation is attempted."""
async def upsert(self, *args: Any, **kwargs: Any) -> None:
raise AssertionError("upsert must not run once the batch quota is exceeded")
async def retrieve(self, *args: Any, **kwargs: Any) -> None:
raise AssertionError("retrieve must not run once the batch quota is exceeded")
async def set_payload(self, *args: Any, **kwargs: Any) -> None:
raise AssertionError("set_payload must not run once the batch quota is exceeded")
class _RecordingClient:
"""A minimal working double so an at-quota batch can complete normally."""
def __init__(self) -> None:
self.upserted_ids: list[str] = []
async def upsert(self, *, points: Any, **kwargs: Any) -> None:
self.upserted_ids = [point.id for point in points]
async def retrieve(self, *, ids: list[str], **kwargs: Any) -> list[SimpleNamespace]:
return [
SimpleNamespace(
id=point_id,
vector={
"dense": [0.1, 0.2, 0.3, 0.4],
"sparse": SimpleNamespace(indices=[1], values=[0.5]),
},
)
for point_id in ids
]
async def set_payload(self, **kwargs: Any) -> None:
return None
def _profile() -> CollectionProfile:
return CollectionProfile(
kind=CollectionKind.DENSE,
profile="quota",
version="v1",
dimensions=4,
model_digest="a" * 64,
)
def _point(profile: CollectionProfile, *, text: str = "x" * 1000) -> IndexPoint:
chunk_id = uuid4()
return IndexPoint(
point_id=chunk_id,
dense_vector=(0.1, 0.2, 0.3, 0.4),
sparse_indices=(1, 2),
sparse_values=(0.5, 0.5),
payload=IndexPayload(
workspace_id=uuid4(),
space_id=uuid4(),
document_id=uuid4(),
document_version_id=uuid4(),
chunk_id=chunk_id,
parent_chunk_id=None,
source_id=uuid4(),
status="published",
content_type="text/markdown",
language="en",
trust_level="source",
location={"page": 1},
parser_version="text-v1",
chunk_profile_version=profile.version,
embedding_model_digest=profile.model_digest,
text=text,
excerpt="short excerpt",
),
)
async def test_batch_exactly_at_the_byte_limit_is_accepted(monkeypatch: pytest.MonkeyPatch) -> None:
profile = _profile()
point = _point(profile)
monkeypatch.setattr(adapter_module, "MAX_BATCH_BYTES", point_wire_bytes(point))
adapter = QdrantIndexAdapter(_RecordingClient()) # type: ignore[arg-type]
await adapter.upsert_points(profile, (point,))
async def test_batch_one_byte_over_the_limit_is_rejected_with_no_partial_mutation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
profile = _profile()
point = _point(profile)
monkeypatch.setattr(adapter_module, "MAX_BATCH_BYTES", point_wire_bytes(point) - 1)
adapter = QdrantIndexAdapter(_RefusingClient()) # type: ignore[arg-type]
with pytest.raises(ValueError, match="serialized batch limit"):
await adapter.upsert_points(profile, (point,))
async def test_batch_quota_counts_every_metadata_field_not_only_text(
monkeypatch: pytest.MonkeyPatch,
) -> None:
profile = _profile()
small = _point(profile, text="short")
large_location = replace(
small.payload, location={f"k{i}": f"v{i}" for i in range(500)}
)
large_metadata_point = replace(small, payload=large_location)
# Set the limit strictly between the two sizes: only the metadata-heavy point
# must be rejected, proving the estimate is not text/excerpt-only.
threshold = (point_wire_bytes(small) + point_wire_bytes(large_metadata_point)) // 2
monkeypatch.setattr(adapter_module, "MAX_BATCH_BYTES", threshold)
accepting_adapter = QdrantIndexAdapter(_RecordingClient()) # type: ignore[arg-type]
await accepting_adapter.upsert_points(profile, (small,))
refusing_adapter = QdrantIndexAdapter(_RefusingClient()) # type: ignore[arg-type]
with pytest.raises(ValueError, match="serialized batch limit"):
await refusing_adapter.upsert_points(profile, (large_metadata_point,))
async def test_batch_quota_counts_multibyte_unicode_by_real_utf8_bytes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
profile = _profile()
ascii_point = _point(profile, text="a" * 300)
multibyte_point = _point(profile, text="漢" * 300)
assert point_wire_bytes(multibyte_point) > point_wire_bytes(ascii_point)
monkeypatch.setattr(adapter_module, "MAX_BATCH_BYTES", point_wire_bytes(ascii_point))
refusing_adapter = QdrantIndexAdapter(_RefusingClient()) # type: ignore[arg-type]
with pytest.raises(ValueError, match="serialized batch limit"):
await refusing_adapter.upsert_points(profile, (multibyte_point,))
async def test_batch_quota_is_cumulative_across_many_small_points(
monkeypatch: pytest.MonkeyPatch,
) -> None:
profile = _profile()
points = tuple(_point(profile, text=f"passage {i}") for i in range(20))
per_point = point_wire_bytes(points[0])
# Allow 5 points' worth of bytes; the 6th must trip the cumulative check even
# though no single point is anywhere near the limit on its own.
monkeypatch.setattr(adapter_module, "MAX_BATCH_BYTES", per_point * 5 + per_point // 2)
refusing_adapter = QdrantIndexAdapter(_RefusingClient()) # type: ignore[arg-type]
with pytest.raises(ValueError, match="serialized batch limit"):
await refusing_adapter.upsert_points(profile, points)
@@ -0,0 +1,109 @@
"""Cumulative per-build byte quota: many small points must not evade it."""
from __future__ import annotations
from collections.abc import AsyncIterator
from uuid import uuid4
import pytest
import ragcore.application.indexes.service as service_module
from ragcore.application.indexes import models as models_module
from ragcore.application.indexes.models import IndexBuildError
from ragcore.application.indexes.service import _stream_build
from ragcore.infrastructure.qdrant.models import (
CollectionKind,
CollectionProfile,
IndexPayload,
IndexPoint,
)
def _profile() -> CollectionProfile:
return CollectionProfile(
kind=CollectionKind.DENSE,
profile="build-quota",
version="v1",
dimensions=4,
model_digest="a" * 64,
)
def _point(profile: CollectionProfile, index: int) -> IndexPoint:
chunk_id = uuid4()
return IndexPoint(
point_id=chunk_id,
dense_vector=(0.1, 0.2, 0.3, 0.4),
sparse_indices=(),
sparse_values=(),
payload=IndexPayload(
workspace_id=uuid4(),
space_id=uuid4(),
document_id=uuid4(),
document_version_id=uuid4(),
chunk_id=chunk_id,
parent_chunk_id=None,
source_id=uuid4(),
status="published",
content_type="text/markdown",
language="en",
trust_level="source",
location={"page": index},
parser_version="text-v1",
chunk_profile_version=profile.version,
embedding_model_digest=profile.model_digest,
text=f"passage {index}",
excerpt=f"passage {index}",
),
)
class _RecordingStore:
def __init__(self) -> None:
self.upserted: list[IndexPoint] = []
async def upsert_points(self, profile: CollectionProfile, points: object) -> None:
self.upserted.extend(points) # type: ignore[arg-type]
class _ListSource:
def __init__(self, points: tuple[IndexPoint, ...]) -> None:
self._points = points
async def iter_points(self, profile: CollectionProfile) -> AsyncIterator[IndexPoint]:
for point in self._points:
yield point
async def test_many_small_points_are_bounded_by_the_cumulative_build_quota(
monkeypatch: pytest.MonkeyPatch,
) -> None:
profile = _profile()
points = tuple(_point(profile, index) for index in range(10))
per_point_bytes = models_module.point_wire_bytes(points[0])
# _stream_build resolves MAX_BUILD_BYTES against service.py's own module
# globals (bound at import time), so it must be patched there, not on models.
monkeypatch.setattr(
service_module, "MAX_BUILD_BYTES", per_point_bytes * 5 + per_point_bytes // 2
)
store = _RecordingStore()
with pytest.raises(IndexBuildError, match="byte quota"):
await _stream_build(store, profile, _ListSource(points))
# Fewer than all ten points may have been accepted; none beyond the quota.
assert len(store.upserted) < len(points)
async def test_build_quota_is_not_evaded_by_many_tiny_points(
monkeypatch: pytest.MonkeyPatch,
) -> None:
profile = _profile()
points = tuple(_point(profile, index) for index in range(50))
per_point_bytes = models_module.point_wire_bytes(points[0])
monkeypatch.setattr(service_module, "MAX_BUILD_BYTES", per_point_bytes * 10)
store = _RecordingStore()
with pytest.raises(IndexBuildError, match="byte quota"):
await _stream_build(store, profile, _ListSource(points))
@@ -0,0 +1,101 @@
"""Pure unit coverage for the point_wire_bytes byte-quoting estimator."""
from __future__ import annotations
from dataclasses import replace
from uuid import uuid4
from ragcore.application.indexes.models import point_wire_bytes
from ragcore.infrastructure.qdrant.models import (
CollectionKind,
CollectionProfile,
IndexPayload,
IndexPoint,
)
def _profile() -> CollectionProfile:
return CollectionProfile(
kind=CollectionKind.DENSE,
profile="quota-test",
version="v1",
dimensions=4,
model_digest="a" * 64,
)
def _point(profile: CollectionProfile, *, text: str = "safe passage") -> IndexPoint:
chunk_id = uuid4()
return IndexPoint(
point_id=chunk_id,
dense_vector=(0.1, 0.2, 0.3, 0.4),
sparse_indices=(1, 2),
sparse_values=(0.5, 0.5),
payload=IndexPayload(
workspace_id=uuid4(),
space_id=uuid4(),
document_id=uuid4(),
document_version_id=uuid4(),
chunk_id=chunk_id,
parent_chunk_id=None,
source_id=uuid4(),
status="published",
content_type="text/markdown",
language="en",
trust_level="source",
location={"page": 1, "block": "heading"},
parser_version="text-v1",
chunk_profile_version=profile.version,
embedding_model_digest=profile.model_digest,
text=text,
excerpt=text,
),
)
def test_wire_bytes_grows_with_non_text_metadata_fields() -> None:
profile = _profile()
small = _point(profile)
large_location = replace(
small.payload, location={f"key_{index}": f"value_{index}" for index in range(200)}
)
large = replace(small, payload=large_location)
assert point_wire_bytes(large) > point_wire_bytes(small)
def test_wire_bytes_grows_with_sparse_vector_size() -> None:
profile = _profile()
small = _point(profile)
large = replace(
small,
sparse_indices=tuple(range(500)),
sparse_values=tuple(0.1 for _ in range(500)),
)
assert point_wire_bytes(large) > point_wire_bytes(small)
def test_wire_bytes_counts_real_utf8_bytes_for_multibyte_text() -> None:
profile = _profile()
ascii_point = _point(profile, text="a" * 100)
multibyte_point = _point(profile, text="漢" * 100) # each character is 3 UTF-8 bytes
assert point_wire_bytes(multibyte_point) > point_wire_bytes(ascii_point) + 150
def test_wire_bytes_includes_fingerprint_field_overhead() -> None:
import json
profile = _profile()
point = _point(profile)
raw_payload_bytes = len(
json.dumps(dict(point.payload.as_dict()), sort_keys=True, separators=(",", ":")).encode(
"utf-8"
)
)
# The adapter attaches three 64-character fingerprint fields after the payload is
# built; the estimate must already include their size so quota cannot be evaded
# by omitting them.
assert point_wire_bytes(point) > raw_payload_bytes + 3 * 64
@@ -0,0 +1,265 @@
"""Incremental ingest and authoritative rebuild must agree on every logical
payload field -- the one canonical ``IndexableChunk`` mapping, exercised from
both ends.
The handler indexes a document (fake Qdrant store records the points, fake
repository records the chunk rows exactly as PostgreSQL would store them).
The rebuild source then reads those *stored* chunk rows back as
``PublishedChunkRecord``s -- the same shape ``iter_published_content`` yields --
and re-embeds them. Apart from the embedding vector (a different profile
would produce a different one) every payload field must be identical: source
identity, document/version/chunk identity, language, content type, trust
level, section, page/line ranges, structure path, parser version.
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from pathlib import Path
from uuid import UUID, uuid4
import httpx
import pytest
from ragcore.application.documents import (
ChunkRecord,
DocumentRecord,
DocumentVersionRecord,
DocumentVersionState,
PublishedChunkRecord,
)
from ragcore.application.jobs.models import Job, JobContext
from ragcore.application.sources import SourceRecord
from ragcore.config import Settings
from ragcore.domain.control import DataPolicy
from ragcore.domain.models import EmbeddingProfile, EmbeddingProvider
from ragcore.infrastructure.blob.filesystem import FilesystemBlobStorage
from ragcore.infrastructure.indexes import ProductionAuthoritativeIndexSource
from ragcore.infrastructure.ingestion.handler import IngestUploadHandler
from ragcore.infrastructure.models.embedding_factory import create_embedding_adapter_factory
from ragcore.infrastructure.models.persistence import JsonEmbeddingProfileStore
from ragcore.infrastructure.parsers.publisher import FilesystemArtifactPublisher
from ragcore.infrastructure.qdrant.models import CollectionProfile, IndexPoint
from ragcore.infrastructure.security import (
ContentSecurityGateService,
production_content_security_policy,
)
WORKSPACE_ID = uuid4()
SPACE_ID = uuid4()
SOURCE_ID = uuid4()
DIMENSIONS = 4
class RecordingRepository:
"""Stores documents, versions and chunk rows; replays them as published content."""
def __init__(self) -> None:
self.documents: list[DocumentRecord] = []
self.versions: dict[UUID, DocumentVersionRecord] = {}
self.chunks: dict[UUID, tuple[ChunkRecord, ...]] = {}
async def create_document(self, document: DocumentRecord) -> DocumentRecord:
self.documents.append(document)
return document
async def create_version(self, version: DocumentVersionRecord) -> DocumentVersionRecord:
self.versions[version.id] = version
return version
async def get_version(self, version_id: UUID) -> DocumentVersionRecord | None:
return self.versions.get(version_id)
async def get_document_by_source(
self, workspace_id: UUID, source_key: str, external_id: str
) -> DocumentRecord | None:
return None
async def publish_validated(self, version_id, artifacts, chunks): # type: ignore[no-untyped-def]
self.chunks[version_id] = tuple(chunks)
version = self.versions[version_id]
self.versions[version_id] = DocumentVersionRecord(
id=version.id,
document_id=version.document_id,
source_fingerprint=version.source_fingerprint,
content_hash=version.content_hash,
parser_version=version.parser_version,
chunk_profile_version=version.chunk_profile_version,
state=DocumentVersionState.PUBLISHED,
job_id=version.job_id,
)
return self.versions[version_id]
async def iter_published_content(self) -> AsyncIterator[PublishedChunkRecord]:
for version_id, chunk_rows in self.chunks.items():
version = self.versions[version_id]
document = next(d for d in self.documents if d.id == version.document_id)
for chunk in chunk_rows:
yield PublishedChunkRecord(
workspace_id=document.workspace_id,
space_id=document.space_id,
source_id=document.source_id,
document_id=document.id,
document_version_id=version.id,
chunk_id=chunk.id,
parent_id=chunk.parent_id,
structure_path=chunk.structure_path,
content=chunk.content,
parser_version=version.parser_version,
metadata=dict(chunk.metadata),
)
class RecordingQdrant:
def __init__(self) -> None:
self.points: list[IndexPoint] = []
self.alias_target: str | None = None
async def current_alias_target(self, alias_name: str) -> str | None:
return self.alias_target
async def create_shadow(self, profile: CollectionProfile) -> None:
return None
async def activate(self, profile: CollectionProfile) -> None:
self.alias_target = profile.collection_name
async def upsert_points(self, profile: CollectionProfile, points: list[IndexPoint]) -> None:
self.points.extend(points)
class NullRegistry:
async def ensure_source(self, record: SourceRecord) -> SourceRecord:
return record
class NullAudit:
async def record(self, **event: object) -> None:
return None
def _embed(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
return httpx.Response(200, json={"embeddings": [[0.5, 0.5, 0.5, 0.5] for _ in body["input"]]})
async def _profile_store(tmp_path: Path) -> tuple[JsonEmbeddingProfileStore, EmbeddingProfile]:
store = JsonEmbeddingProfileStore(tmp_path / "profiles.json")
profile = EmbeddingProfile(
id=uuid4(),
profile="parity-embed",
version="v1",
provider=EmbeddingProvider.OLLAMA,
model_identifier="parity:v1",
model_digest="d" * 64,
dimensions=DIMENSIONS,
context_limit=2048,
tokenizer="test",
data_policy=DataPolicy.LOCAL_ONLY,
)
await store.add(profile)
await store.activate(profile.id)
return store, profile
async def _bytes(data: bytes) -> AsyncIterator[bytes]:
yield data
@pytest.mark.asyncio
async def test_incremental_ingest_and_authoritative_rebuild_produce_identical_payloads(
tmp_path: Path,
) -> None:
store, profile = await _profile_store(tmp_path)
repository = RecordingRepository()
qdrant = RecordingQdrant()
client = httpx.AsyncClient(transport=httpx.MockTransport(_embed), follow_redirects=False)
adapter_factory = create_embedding_adapter_factory(
settings=Settings(env="test", ollama_url="http://ollama:11434"), http_client=client
)
handler = IngestUploadHandler(
document_repository=repository, # type: ignore[arg-type]
blob_storage=FilesystemBlobStorage(tmp_path / "blobs"),
artifact_publisher=FilesystemArtifactPublisher(tmp_path / "parsed"),
embedding_profile_store=store,
embedding_adapter_factory=adapter_factory,
qdrant_store=qdrant, # type: ignore[arg-type]
source_registry=NullRegistry(),
security_gate=ContentSecurityGateService(
NullAudit(), policy=production_content_security_policy()
),
)
blob = FilesystemBlobStorage(tmp_path / "blobs")
reference = await blob.put(
_bytes(
b"# Backup Runbook\n\n"
b"## Nightly snapshot\n\n"
b"Run the snapshot job SNAP-22 before midnight and verify the checksum.\n\n"
b"## Restore drill\n\n"
b"Restore into the disposable database and compare row counts with production.\n"
),
media_type="text/markdown",
original_name="backup.md",
)
job = Job(
id=uuid4(),
job_key="parity",
operation="ingest_upload",
target_key=reference.digest,
profile_version="upload-v1",
payload={
"blob_digest": reference.digest,
"media_type": "text/markdown",
"original_name": "backup.md",
"space_id": str(SPACE_ID),
"workspace_id": str(WORKSPACE_ID),
"source_id": str(SOURCE_ID),
"external_id": "runbooks/backup.md",
"locale": "en",
"trust_level": "trusted_internal",
},
available_at=datetime.now(UTC),
)
await handler(
JobContext(job=job, attempt_number=1, idempotency_key="k", cancellation=asyncio.Event())
)
ingested = {point.point_id: point for point in qdrant.points}
assert ingested
rebuilt_source = ProductionAuthoritativeIndexSource(
document_repository=repository, # type: ignore[arg-type]
embedding_profile=profile,
embedding_http_client=client,
embedding_base_url="http://ollama:11434",
)
collection = CollectionProfile(
kind="dense", # type: ignore[arg-type]
profile=profile.profile,
version=profile.version,
dimensions=profile.dimensions,
model_digest=profile.model_digest,
)
rebuilt = {point.point_id: point async for point in rebuilt_source.iter_points(collection)}
assert set(rebuilt) == set(ingested), "rebuild must cover exactly the ingested child chunks"
for point_id, original in ingested.items():
again = rebuilt[point_id]
assert isinstance(again, IndexPoint)
assert again.payload.as_dict() == original.payload.as_dict(), point_id
assert again.sparse_indices == original.sparse_indices
assert again.sparse_values == original.sparse_values
# And the provenance that matters is really there, not vacuously equal.
payload = again.payload
assert payload.source_id == SOURCE_ID
assert payload.trust_level == "trusted_internal"
assert payload.language == "en"
assert payload.content_type == "text/markdown"
assert payload.location["structure_path"]
assert isinstance(payload.location.get("line_start"), int)
assert isinstance(payload.location.get("line_end"), int)
sections = {point.payload.location.get("section") for point in rebuilt.values()}
assert sections & {"Nightly snapshot", "Restore drill"}, sections
await client.aclose()
@@ -0,0 +1,152 @@
from __future__ import annotations
from collections.abc import Sequence
from uuid import UUID, uuid4
import pytest
from ragcore.application.documents import (
DocumentRecord,
DocumentVersionRecord,
DocumentVersionState,
)
from ragcore.application.indexes.space_reconcile import (
MAX_DELETE_BATCH,
SpaceIndexReconciler,
SpacePoint,
)
class _Profile:
collection_name = "rag_dense_test_v1"
alias_name = "rag_dense_test_active"
version = "v1"
model_digest = "a" * 64
class FakeDocuments:
def __init__(self) -> None:
self.documents: list[DocumentRecord] = []
self.versions: dict[UUID, list[DocumentVersionRecord]] = {}
def add(self, document: DocumentRecord, *versions: DocumentVersionRecord) -> None:
self.documents.append(document)
self.versions[document.id] = list(versions)
async def list_documents_by_space(self, space_id: UUID) -> tuple[DocumentRecord, ...]:
return tuple(d for d in self.documents if d.space_id == space_id)
async def list_searchable_versions(
self, document_id: UUID
) -> tuple[DocumentVersionRecord, ...]:
document = next(d for d in self.documents if d.id == document_id)
if document.source_deleted:
return ()
return tuple(
v
for v in self.versions.get(document_id, ())
if v.state is DocumentVersionState.PUBLISHED
)
class FakeStore:
def __init__(self, points: dict[UUID, list[SpacePoint]]) -> None:
self.points = points
self.deleted: list[list[UUID]] = []
async def list_space_points(self, profile: object, space_id: UUID) -> tuple[SpacePoint, ...]:
return tuple(self.points.get(space_id, ()))
async def delete_points(self, profile: object, point_ids: Sequence[UUID]) -> None:
self.deleted.append(list(point_ids))
def _document(space_id: UUID, *, retired: bool = False) -> DocumentRecord:
return DocumentRecord(
id=uuid4(),
workspace_id=uuid4(),
space_id=space_id,
source_key=str(uuid4()),
external_id=f"record-{uuid4()}",
title="record",
source_deleted=retired,
)
def _version(document_id: UUID, state: DocumentVersionState) -> DocumentVersionRecord:
return DocumentVersionRecord(
id=uuid4(),
document_id=document_id,
source_fingerprint="a" * 64,
content_hash="b" * 64,
parser_version="text-v1",
chunk_profile_version="prose-v1",
state=state,
)
def _points(document: DocumentRecord, version: DocumentVersionRecord, n: int) -> list[SpacePoint]:
return [
SpacePoint(point_id=uuid4(), document_id=document.id, document_version_id=version.id)
for _ in range(n)
]
@pytest.mark.asyncio
async def test_reconcile_drops_superseded_and_retired_points_only_in_the_space() -> None:
space, other_space = uuid4(), uuid4()
documents = FakeDocuments()
current = _document(space)
published = _version(current.id, DocumentVersionState.PUBLISHED)
superseded = _version(current.id, DocumentVersionState.SUPERSEDED)
documents.add(current, published, superseded)
retired = _document(space, retired=True)
retired_version = _version(retired.id, DocumentVersionState.PUBLISHED)
documents.add(retired, retired_version)
foreign = _document(other_space)
foreign_version = _version(foreign.id, DocumentVersionState.PUBLISHED)
documents.add(foreign, foreign_version)
keep = _points(current, published, 3)
stale = _points(current, superseded, 2)
gone = _points(retired, retired_version, 4)
store = FakeStore(
{space: keep + stale + gone, other_space: _points(foreign, foreign_version, 5)}
)
report = await SpaceIndexReconciler(store, _Profile()).reconcile(space, documents) # type: ignore[arg-type]
deleted = {point_id for batch in store.deleted for point_id in batch}
assert deleted == {p.point_id for p in stale + gone}
assert report.points_before == 9
assert report.deleted_points == 6
assert report.points_after == 3
assert report.searchable_versions == 1
assert report.documents_total == 2
assert report.documents_retired == 1
assert report.orphaned_documents == 2
# The other space was never touched.
assert store.points[other_space] and all(
p.point_id not in deleted for p in store.points[other_space]
)
@pytest.mark.asyncio
async def test_reconcile_is_idempotent_and_batches_deletes() -> None:
space = uuid4()
documents = FakeDocuments()
document = _document(space)
old = _version(document.id, DocumentVersionState.SUPERSEDED)
new = _version(document.id, DocumentVersionState.PUBLISHED)
documents.add(document, old, new)
stale = _points(document, old, MAX_DELETE_BATCH + 10)
store = FakeStore({space: stale + _points(document, new, 1)})
first = await SpaceIndexReconciler(store, _Profile()).reconcile(space, documents) # type: ignore[arg-type]
assert first.deleted_points == MAX_DELETE_BATCH + 10
assert [len(batch) for batch in store.deleted] == [MAX_DELETE_BATCH, 10]
store.points[space] = [p for p in store.points[space] if p.document_version_id == new.id]
second = await SpaceIndexReconciler(store, _Profile()).reconcile(space, documents) # type: ignore[arg-type]
assert second.deleted_points == 0
assert second.points_after == 1