Files
chatbot_v3/src/bootstrap/lifespan.py
Ali Zarinkolah 4da30f9983 feat(points): add the point read/edit port and its Qdrant adapter
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>
2026-08-22 13:09:37 +03:30

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