Why:
- Wanted human-readable console output while developing locally, without
losing a machine-parseable log for later grepping/parsing. A single
renderer chosen by a flag can't do both at once.
- ADR-0011 had no way to correlate an issue with a specific deployment
(build/region/instance) independent of any one request.
Changes:
- configure_logging() now builds two independent handlers: console (always
on, colored unless LOG_JSON_FORMAT=true) and an optional rotating JSON file
(LOG_FILE_PATH, unset by default) -- the same structlog event fans out to
both, so call sites are unaffected.
- A static structlog processor binds env/service_version onto every event.
Deliberately not a contextvar: RequestIdMiddleware's clear_contextvars()
would wipe a value bound there before the first request.
- New settings: APP_SERVICE_VERSION, LOG_FILE_PATH/LOG_FILE_MAX_BYTES/
LOG_FILE_BACKUP_COUNT.
- ADR-0011 amended with both decisions ("console and file are independent
sinks locally"; "bind process-level environment context once at startup").
Impact:
- configure_logging() signature changed to (logging_settings, app_settings);
both call sites (lifespan, qdrant_bootstrap CLI) updated.
178 lines
7.4 KiB
Python
178 lines
7.4 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.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
|
|
)
|
|
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,
|
|
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
|