Why: - the slice's reliability invariants (ADR-0016) had no end-to-end coverage. Changes: - 13 Testcontainers-based tests: duplicate upload, retry after a failed job, cross-tenant 404, unregistered domain, capacity 503, timeout 504, parse 400, real Qdrant 502, missing scope 403, and both readiness states - only the dense embedders are faked (ADR-0016 bars live providers); they return the pinned 768/3072 dimensions - the capacity test uses a committing sessionmaker, since the shared-connection fixture cannot serve concurrent sessions Impact: - runs in the default `uv run pytest`; needs Docker, like every integration test Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
142 lines
5.1 KiB
Python
142 lines
5.1 KiB
Python
"""Hand-written fakes for narrow application-owned ports (ADR-0016)."""
|
|
|
|
import asyncio
|
|
import uuid
|
|
from collections.abc import Sequence
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
|
|
from src.application.ingestion.models import SparseVector
|
|
from src.application.points.models import ChunkPoint
|
|
|
|
|
|
@dataclass
|
|
class FakeObjectStorage:
|
|
"""In-memory `ObjectStorage`. `fail_next` simulates one upload failure."""
|
|
|
|
objects: dict[str, bytes] = field(default_factory=dict)
|
|
fail_next: bool = False
|
|
|
|
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None:
|
|
if self.fail_next:
|
|
self.fail_next = False
|
|
raise OSError("simulated object storage failure")
|
|
self.objects[key] = data
|
|
|
|
|
|
@dataclass
|
|
class FakeDenseEmbedder:
|
|
"""A scripted `DenseEmbedder`. Returns a fixed-dimension zero vector per
|
|
text by default; `fail_next` simulates one batch failure.
|
|
"""
|
|
|
|
name: str
|
|
dimensions: int = 4
|
|
value: float = 0.0
|
|
"""Component value of every returned vector. Non-zero where a real Qdrant
|
|
has to score the result, since a zero vector has no direction to compare."""
|
|
model_version: str = "fake-dense-v1"
|
|
calls: list[list[str]] = field(default_factory=list)
|
|
fail_next: bool = False
|
|
delay_seconds: float = 0.0
|
|
"""Simulates a slow provider call, e.g. to exercise timeout handling."""
|
|
|
|
async def embed_batch(self, texts: Sequence[str]) -> list[list[float]]:
|
|
self.calls.append(list(texts))
|
|
if self.delay_seconds:
|
|
await asyncio.sleep(self.delay_seconds)
|
|
if self.fail_next:
|
|
self.fail_next = False
|
|
raise RuntimeError("simulated embedder failure")
|
|
return [[self.value] * self.dimensions for _ in texts]
|
|
|
|
|
|
@dataclass
|
|
class FakeSparseEmbedder:
|
|
"""A scripted `SparseEmbedder`. Returns an empty sparse vector per text."""
|
|
|
|
name: str = "sparse"
|
|
model_version: str = "fake-sparse-v1"
|
|
calls: list[list[str]] = field(default_factory=list)
|
|
fail_next: bool = False
|
|
|
|
def embed_batch(self, texts: Sequence[str], *, query: bool = False) -> list[SparseVector]:
|
|
self.calls.append(list(texts))
|
|
if self.fail_next:
|
|
self.fail_next = False
|
|
raise RuntimeError("simulated embedder failure")
|
|
return [SparseVector(indices=[], values=[]) for _ in texts]
|
|
|
|
|
|
@dataclass
|
|
class FakePointStorage:
|
|
"""An in-memory `PointStorage`.
|
|
|
|
`points` is keyed by point id, so a re-upsert of the same deterministic id
|
|
overwrites rather than accumulating — the property a test asserting "a
|
|
retry produces no duplicate points" needs the fake to actually model.
|
|
|
|
`fail_on_batch` fails the Nth (0-based) upsert batch, which is how a test
|
|
checks that the soft-delete sweep never runs after a partial failure.
|
|
"""
|
|
|
|
points: dict[str, ChunkPoint] = field(default_factory=dict)
|
|
upsert_batches: list[int] = field(default_factory=list)
|
|
deactivate_calls: list[dict[str, object]] = field(default_factory=list)
|
|
fail_on_batch: int | None = None
|
|
fail_deactivate: bool = False
|
|
max_in_flight: int = 0
|
|
_in_flight: int = 0
|
|
|
|
async def upsert_points(self, points: Sequence[ChunkPoint]) -> None:
|
|
self._in_flight += 1
|
|
self.max_in_flight = max(self.max_in_flight, self._in_flight)
|
|
try:
|
|
# Yield so concurrent batches actually overlap; without this the
|
|
# in-flight ceiling is trivially 1 and the bound goes untested.
|
|
await asyncio.sleep(0)
|
|
index = len(self.upsert_batches)
|
|
self.upsert_batches.append(len(points))
|
|
if self.fail_on_batch is not None and index == self.fail_on_batch:
|
|
raise RuntimeError("simulated point storage failure")
|
|
for point in points:
|
|
self.points[str(point.point_id)] = point
|
|
finally:
|
|
self._in_flight -= 1
|
|
|
|
async def deactivate_points_from_index(
|
|
self,
|
|
*,
|
|
tenant_id: uuid.UUID,
|
|
file_id: uuid.UUID,
|
|
from_chunk_index: int,
|
|
deleted_at: datetime,
|
|
updated_by: str,
|
|
) -> int:
|
|
self.deactivate_calls.append(
|
|
{
|
|
"tenant_id": tenant_id,
|
|
"file_id": file_id,
|
|
"from_chunk_index": from_chunk_index,
|
|
"updated_by": updated_by,
|
|
}
|
|
)
|
|
if self.fail_deactivate:
|
|
raise RuntimeError("simulated deactivate failure")
|
|
|
|
def is_stale(point: ChunkPoint) -> bool:
|
|
chunk_index = point.payload.get("chunk_index")
|
|
return (
|
|
point.payload.get("file_id") == str(file_id)
|
|
and point.payload.get("tenant_id") == str(tenant_id)
|
|
and point.payload.get("is_active") is True
|
|
and isinstance(chunk_index, int)
|
|
and chunk_index >= from_chunk_index
|
|
)
|
|
|
|
stale = [point for point in self.points.values() if is_stale(point)]
|
|
for point in stale:
|
|
point.payload["is_active"] = False
|
|
point.payload["deleted_at"] = deleted_at.isoformat()
|
|
return len(stale)
|