Why: - PointStorage is deliberately the two bulk operations ingestion performs. Reads, single-point edits, and keyword search have a different caller, a different failure vocabulary, and a different tenant-filter obligation, so they get their own port rather than accreting onto the ingestion one. Changes: - tenant_id is a required keyword argument on every port method, making a forgotten tenant filter a type error rather than a review question. - Reads go through scroll with a HasIdCondition, not retrieve: retrieve takes no filter and would push the tenant check into Python after Qdrant already answered -- the shape ADR-0002's isolation rule exists to prevent. - Ordered listing paginates by order_id value, not offset. Qdrant returns no page offset under order_by, and an offset cursor skips or repeats rows when a concurrent insert shifts positions underneath the reader. - Point.from_payload takes a Mapping, not a dict: dict is invariant in its value type, so the SDK's concrete vector union is not a dict[str, object]. - Request schemas forbid extra keys and omit server-owned fields, so a client sending tenant_id or version gets 422 rather than having it silently ignored. Impact: - No route uses this yet; the /v1/points surface is Phase 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
96 lines
2.9 KiB
Python
96 lines
2.9 KiB
Python
from collections.abc import AsyncIterator, Sequence
|
|
from dataclasses import dataclass
|
|
|
|
from anyio import CapacityLimiter, Semaphore
|
|
from fastapi import Request
|
|
from minio import Minio
|
|
from qdrant_client import AsyncQdrantClient
|
|
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
|
|
|
from src.application.ports.embedding import DenseEmbedder, SparseEmbedder
|
|
from src.application.ports.object_storage import ObjectStorage
|
|
from src.application.ports.point_repository import PointRepository
|
|
from src.application.ports.point_storage import PointStorage
|
|
from src.config import Settings
|
|
|
|
|
|
@dataclass
|
|
class AppResources:
|
|
settings: Settings
|
|
db_engine: AsyncEngine
|
|
db_sessionmaker: async_sessionmaker[AsyncSession]
|
|
minio_client: Minio
|
|
qdrant_client: AsyncQdrantClient
|
|
object_storage: ObjectStorage
|
|
point_storage: PointStorage
|
|
point_repository: PointRepository
|
|
ingestion_limiter: CapacityLimiter
|
|
dense_embedders: Sequence[DenseEmbedder]
|
|
sparse_embedder: SparseEmbedder
|
|
ingestion_concurrency_limiter: Semaphore
|
|
|
|
|
|
def _resources(request: Request) -> AppResources:
|
|
return request.app.state.resources
|
|
|
|
|
|
def get_settings(request: Request) -> Settings:
|
|
return _resources(request).settings
|
|
|
|
|
|
def get_minio_client(request: Request) -> Minio:
|
|
return _resources(request).minio_client
|
|
|
|
|
|
def get_qdrant_client(request: Request) -> AsyncQdrantClient:
|
|
return _resources(request).qdrant_client
|
|
|
|
|
|
def get_object_storage(request: Request) -> ObjectStorage:
|
|
return _resources(request).object_storage
|
|
|
|
|
|
def get_point_storage(request: Request) -> PointStorage:
|
|
return _resources(request).point_storage
|
|
|
|
|
|
def get_point_repository(request: Request) -> PointRepository:
|
|
return _resources(request).point_repository
|
|
|
|
|
|
def get_ingestion_limiter(request: Request) -> CapacityLimiter:
|
|
return _resources(request).ingestion_limiter
|
|
|
|
|
|
def get_dense_embedders(request: Request) -> Sequence[DenseEmbedder]:
|
|
return _resources(request).dense_embedders
|
|
|
|
|
|
def get_sparse_embedder(request: Request) -> SparseEmbedder:
|
|
return _resources(request).sparse_embedder
|
|
|
|
|
|
def get_ingestion_concurrency_limiter(request: Request) -> Semaphore:
|
|
return _resources(request).ingestion_concurrency_limiter
|
|
|
|
|
|
def get_sessionmaker(request: Request) -> async_sessionmaker[AsyncSession]:
|
|
"""The session *factory*, not a request-scoped session.
|
|
|
|
Application services that own more than one transaction in a single
|
|
request (ADR-0017's two-phase upload) need to open and close sessions
|
|
themselves rather than borrow one request-scoped session that would
|
|
otherwise stay open across the whole request.
|
|
"""
|
|
return _resources(request).db_sessionmaker
|
|
|
|
|
|
async def get_db_session(request: Request) -> AsyncIterator[AsyncSession]:
|
|
sessionmaker = _resources(request).db_sessionmaker
|
|
async with sessionmaker() as session:
|
|
try:
|
|
yield session
|
|
except Exception:
|
|
await session.rollback()
|
|
raise
|