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>
183 lines
7.6 KiB
Python
183 lines
7.6 KiB
Python
from collections.abc import AsyncIterator, Callable, Sequence
|
|
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
|
|
import httpx
|
|
import structlog
|
|
from anyio import CapacityLimiter, Semaphore, to_thread
|
|
from fastapi import FastAPI
|
|
|
|
from src.application.ingestion import get_encoder
|
|
from src.application.ports.embedding import DenseEmbedder
|
|
from src.bootstrap.dependencies import AppResources
|
|
from src.config import Settings
|
|
from src.infrastructure.embedding.bm25 import Bm25SparseEmbedder
|
|
from src.infrastructure.embedding.openai_compatible import (
|
|
OpenAICompatibleEmbedder,
|
|
is_ollama_base_url,
|
|
)
|
|
from src.infrastructure.minio.client import create_client as create_minio_client
|
|
from src.infrastructure.minio.storage import MinioObjectStorage
|
|
from src.infrastructure.observability.logging import configure_logging
|
|
from src.infrastructure.postgres.database import create_engine, create_sessionmaker
|
|
from src.infrastructure.qdrant.client import create_client as create_qdrant_client
|
|
from src.infrastructure.qdrant.point_repository import QdrantPointRepository
|
|
from src.infrastructure.qdrant.points import QdrantPointStorage
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
|
|
def _auth_headers(api_key: str | None) -> dict[str, str]:
|
|
"""Bearer header, or none at all when no key is configured.
|
|
|
|
Sending an empty `Bearer ` is worse than sending nothing: some gateways
|
|
treat a malformed credential as an auth failure rather than as anonymous.
|
|
"""
|
|
return {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
|
|
|
|
|
async def _warm_dense_embedders(embedders: Sequence[DenseEmbedder]) -> None:
|
|
"""Force each dense model to load before the first upload needs it.
|
|
|
|
Same rationale as the tiktoken warm-up above, but with the opposite
|
|
failure policy. A self-hosted embedder that has unloaded the model takes
|
|
minutes to serve its first request — longer than
|
|
`INGESTION_TIMEOUT_SECONDS` — so paying that once at boot keeps it off a
|
|
user's upload. Unlike the tokenizer this is best-effort: an embedder that
|
|
is merely *down* must not stop the process from booting and reporting its
|
|
own health, and `/readyz` is where that condition belongs.
|
|
"""
|
|
for embedder in embedders:
|
|
try:
|
|
await embedder.embed_batch(["warmup"])
|
|
logger.info("lifespan.embedder.warmed", embedder=embedder.name)
|
|
except Exception:
|
|
logger.warning("lifespan.embedder.warm_failed", embedder=embedder.name, exc_info=True)
|
|
|
|
|
|
def create_lifespan(
|
|
settings: Settings | None = None,
|
|
) -> Callable[[FastAPI], AbstractAsyncContextManager[None, bool | None]]:
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
resolved_settings = settings or Settings()
|
|
configure_logging(resolved_settings.logging, resolved_settings.app)
|
|
|
|
# tiktoken fetches its vocabulary over the network on first use, so warm
|
|
# it here: a missing vocabulary should fail the process at boot, not the
|
|
# first upload. Blocking, hence the thread.
|
|
await to_thread.run_sync(get_encoder, resolved_settings.chunking.encoding_name)
|
|
logger.info(
|
|
"lifespan.tokenizer.loaded",
|
|
encoding=resolved_settings.chunking.encoding_name,
|
|
)
|
|
|
|
db_engine = create_engine(resolved_settings.postgres)
|
|
db_sessionmaker = create_sessionmaker(db_engine)
|
|
logger.info("lifespan.postgres.engine.created")
|
|
|
|
minio_client = create_minio_client(resolved_settings.minio)
|
|
logger.info("lifespan.minio.client.created")
|
|
|
|
qdrant_client = create_qdrant_client(resolved_settings.qdrant)
|
|
# No collection DDL here: `ensure_chunks_collection` is a deployment
|
|
# step (`python -m src.cli.qdrant_bootstrap`), for the same reason
|
|
# ADR-0009 keeps Alembic out of startup and ADR-0012 makes LangGraph's
|
|
# `.setup()` a deployment step.
|
|
point_storage = QdrantPointStorage(
|
|
qdrant_client, collection=resolved_settings.qdrant.collection
|
|
)
|
|
point_repository = QdrantPointRepository(
|
|
qdrant_client, collection=resolved_settings.qdrant.collection
|
|
)
|
|
logger.info("lifespan.qdrant.client.created")
|
|
|
|
nomic_settings = resolved_settings.embedding.nomic
|
|
nomic_http_client = httpx.AsyncClient(
|
|
base_url=nomic_settings.base_url,
|
|
timeout=nomic_settings.timeout_seconds,
|
|
headers=_auth_headers(nomic_settings.api_key),
|
|
)
|
|
openai_settings = resolved_settings.embedding.openai
|
|
openai_http_client = httpx.AsyncClient(
|
|
base_url=openai_settings.base_url,
|
|
timeout=openai_settings.timeout_seconds,
|
|
headers=_auth_headers(openai_settings.api_key),
|
|
)
|
|
dense_embedders = (
|
|
OpenAICompatibleEmbedder(
|
|
nomic_http_client,
|
|
name="dense_nomic",
|
|
model=nomic_settings.model,
|
|
document_prefix=nomic_settings.document_prefix,
|
|
keep_alive=(
|
|
nomic_settings.keep_alive
|
|
if is_ollama_base_url(nomic_settings.base_url)
|
|
else None
|
|
),
|
|
),
|
|
OpenAICompatibleEmbedder(
|
|
openai_http_client,
|
|
name="dense_openai",
|
|
model=openai_settings.model,
|
|
dimensions=openai_settings.dimensions,
|
|
document_prefix=openai_settings.document_prefix,
|
|
),
|
|
)
|
|
sparse_embedder = Bm25SparseEmbedder(resolved_settings.embedding.sparse)
|
|
logger.info("lifespan.embedders.created")
|
|
|
|
await _warm_dense_embedders(dense_embedders)
|
|
|
|
# Bounds how many ingestions run in this process at once (ADR-0017);
|
|
# a distinct resource from ingestion_limiter, which bounds threads
|
|
# spent on blocking work within a single ingestion.
|
|
ingestion_concurrency_limiter = Semaphore(resolved_settings.ingestion.max_concurrency)
|
|
|
|
# Bounds threads spent on blocking ingestion work (parsing, chunking,
|
|
# hashing, the sync minio SDK) so it cannot exhaust Starlette's own
|
|
# thread pool (ADR-0017).
|
|
ingestion_limiter = CapacityLimiter(resolved_settings.ingestion.thread_pool_size)
|
|
object_storage = MinioObjectStorage(
|
|
minio_client, bucket=resolved_settings.minio.bucket, limiter=ingestion_limiter
|
|
)
|
|
|
|
app.state.resources = AppResources(
|
|
settings=resolved_settings,
|
|
db_engine=db_engine,
|
|
db_sessionmaker=db_sessionmaker,
|
|
minio_client=minio_client,
|
|
qdrant_client=qdrant_client,
|
|
object_storage=object_storage,
|
|
point_storage=point_storage,
|
|
point_repository=point_repository,
|
|
ingestion_limiter=ingestion_limiter,
|
|
dense_embedders=dense_embedders,
|
|
sparse_embedder=sparse_embedder,
|
|
ingestion_concurrency_limiter=ingestion_concurrency_limiter,
|
|
)
|
|
|
|
try:
|
|
yield
|
|
finally:
|
|
try:
|
|
await db_engine.dispose()
|
|
except Exception:
|
|
logger.exception("lifespan.postgres.dispose.failed")
|
|
|
|
try:
|
|
await qdrant_client.close()
|
|
except Exception:
|
|
logger.exception("lifespan.qdrant.close.failed")
|
|
|
|
try:
|
|
await nomic_http_client.aclose()
|
|
except Exception:
|
|
logger.exception("lifespan.embedding.nomic_client.close.failed")
|
|
|
|
try:
|
|
await openai_http_client.aclose()
|
|
except Exception:
|
|
logger.exception("lifespan.embedding.openai_client.close.failed")
|
|
|
|
return lifespan
|