feat(qdrant): add tenant-scoped point storage for ingestion

Why:
- Ingested chunks need to become searchable Qdrant points before the upload
  response returns, with tenant/domain isolation and a safe re-ingestion
  story per ADR-0001/0017.

Changes:
- src/application/points/: index_chunks() is the sole entry point, owning
  payload construction, batched/bounded-concurrency upserts
  (upsert_concurrency semaphore), and a soft-delete sweep for points a
  shorter re-ingestion leaves behind. The sweep runs only after every upsert
  in the attempt succeeds, so a failed attempt can leave a stale prefix but
  never removes content from a working index.
- PointStorage port (application/ports/) + QdrantPointStorage adapter
  (infrastructure/qdrant/points.py), keeping the qdrant_client SDK out of
  application code per ADR-0015.
- FakePointStorage test double for exercising the ordering/idempotency
  guarantees without a real Qdrant.
This commit is contained in:
Ali Zarinkolah
2026-08-20 18:16:35 +03:30
parent 58ca6109d1
commit d00d436e5c
10 changed files with 944 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
"""Qdrant adapter for the `PointStorage` port (ADR-0001, ADR-0002, ADR-0015).
The `qdrant_client` SDK appears here and nowhere in `application/`. This module
translates the SDK-free `ChunkPoint` into `PointStruct`s and builds every
filter — routers and application services never construct Qdrant filters.
`AsyncQdrantClient` is genuinely async, so unlike the `minio` adapter nothing
here needs a thread offload.
"""
import uuid
from collections.abc import Sequence
from datetime import datetime
from qdrant_client import AsyncQdrantClient, models
from src.application.points.models import ChunkPoint
from src.infrastructure.qdrant.collection import SPARSE_VECTOR
def _tenant_file_filter(
tenant_id: uuid.UUID, file_id: uuid.UUID, *, from_chunk_index: int
) -> models.Filter:
"""Points of one file, at or past `from_chunk_index`, within one tenant.
`tenant_id` is always a condition, never optional: a `file_id` alone is not
authority to mutate anything (ADR-0002's isolation rule applies to every
code path, not just reads).
"""
return models.Filter(
must=[
models.FieldCondition(key="tenant_id", match=models.MatchValue(value=str(tenant_id))),
models.FieldCondition(key="file_id", match=models.MatchValue(value=str(file_id))),
models.FieldCondition(key="chunk_index", range=models.Range(gte=from_chunk_index)),
]
)
class QdrantPointStorage:
"""A `PointStorage` (see `src/application/ports/point_storage.py`)."""
def __init__(self, client: AsyncQdrantClient, *, collection: str) -> None:
self._client = client
self._collection = collection
async def upsert_points(self, points: Sequence[ChunkPoint]) -> None:
if not points:
return
await self._client.upsert(
collection_name=self._collection,
points=[
models.PointStruct(
id=str(point.point_id),
vector={
**point.dense,
SPARSE_VECTOR: models.SparseVector(
indices=point.sparse.indices, values=point.sparse.values
),
},
payload=point.payload,
)
for point in points
],
)
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:
"""Soft-delete via `set_payload` — the points stay for audit (ADR-0002).
Counts first so the caller can report how many points the sweep
touched; `set_payload` itself reports only an operation status.
"""
point_filter = _tenant_file_filter(tenant_id, file_id, from_chunk_index=from_chunk_index)
stale = await self._client.count(
collection_name=self._collection, count_filter=point_filter, exact=True
)
if stale.count == 0:
return 0
await self._client.set_payload(
collection_name=self._collection,
payload={
"is_active": False,
"deleted_at": deleted_at.isoformat(),
"updated_at": deleted_at.isoformat(),
"updated_by": updated_by,
},
points=models.FilterSelector(filter=point_filter),
)
return stale.count