Convert the ebook-search web app to async end to end and add concurrency to the protected-phrase extraction and judging pipeline so large books no longer block the event loop or the UI. ORM / infra: - Add get_async_postgres_engine and factor shared URL/connect_args building into build_postgres_url (reused by the sync and async engine builders) - Add async FastAPI session helpers (get_async_db, AsyncDbSession) with expire_on_commit=False to avoid implicit IO under asyncio App: - Use AsyncEngine/AsyncSession throughout routes, search, ingest, embeddings, answer, rerank and LLM calls; convert handlers to async - Share a single httpx.AsyncClient in app state for LLM requests; size the connection pool for concurrent phrase-judging workers - Add judge_tasks: run per-book judging as tracked background tasks so a book already being judged isn't double-queued Protected phrases: - Add a process pool (pool.py) and worker-count config (extraction/judge book/phrase workers) to parallelize candidate generation and judging - Split admin actions into all/missing variants for generation and judging Config: - Add protected_phrase_extraction_workers, phrase_judge_book_workers, phrase_judge_phrase_workers
100 lines
3.4 KiB
Python
100 lines
3.4 KiB
Python
"""Liveness and readiness routes for the EPUB search service."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from http import HTTPStatus
|
|
from typing import TYPE_CHECKING
|
|
|
|
from fastapi import APIRouter
|
|
from fastapi.responses import JSONResponse
|
|
from sqlalchemy import literal, select
|
|
from sqlalchemy.exc import SQLAlchemyError
|
|
|
|
from python.ebook_search.api.dependencies import ( # noqa: TC001 FastAPI resolves these annotated dependencies at runtime
|
|
AppConfig,
|
|
AppHttpClient,
|
|
)
|
|
from python.ebook_search.bm25_corpus import bm25_index_exists, bm25_index_path, read_bm25_manifest
|
|
from python.ebook_search.llm_interface import check_chat_endpoint, check_embedding_endpoint
|
|
from python.fastapi_tools import AsyncDbSession # noqa: TC001 FastAPI resolves this annotated dependency at runtime
|
|
|
|
if TYPE_CHECKING:
|
|
import httpx
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from python.ebook_search.config import EbookSearchConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("/health")
|
|
async def health() -> dict[str, str]:
|
|
"""Liveness probe that returns ok without touching dependencies."""
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.get("/ready")
|
|
async def ready(config: AppConfig, session: AsyncDbSession, client: AppHttpClient) -> JSONResponse:
|
|
"""Readiness probe reporting database, embedding endpoint, and BM25 index status."""
|
|
database_ok = await check_database(session)
|
|
embedding_ok = await check_embedding_endpoint(client, config)
|
|
chat_status = await chat_endpoint_status(client, config)
|
|
bm25_status = check_bm25_status(config)
|
|
|
|
checks = {
|
|
"database": "ok" if database_ok else "fail",
|
|
"embedding": "ok" if embedding_ok else "fail",
|
|
"chat": chat_status,
|
|
"bm25": bm25_status,
|
|
}
|
|
if not database_ok:
|
|
status = "unavailable"
|
|
status_code = HTTPStatus.SERVICE_UNAVAILABLE
|
|
elif not embedding_ok or chat_status == "fail" or bm25_status == "missing":
|
|
status = "degraded"
|
|
status_code = HTTPStatus.OK
|
|
else:
|
|
status = "ready"
|
|
status_code = HTTPStatus.OK
|
|
|
|
logger.info(
|
|
"ebook_ready_check status=%s database=%s embedding=%s chat=%s bm25=%s",
|
|
status,
|
|
database_ok,
|
|
embedding_ok,
|
|
chat_status,
|
|
bm25_status,
|
|
)
|
|
return JSONResponse(content={"status": status, "checks": checks}, status_code=status_code)
|
|
|
|
|
|
async def chat_endpoint_status(client: httpx.AsyncClient, config: EbookSearchConfig) -> str:
|
|
"""Return the answering chat endpoint status, or disabled when answers are off."""
|
|
if not config.answer_enabled:
|
|
return "disabled"
|
|
return "ok" if await check_chat_endpoint(client, config) else "fail"
|
|
|
|
|
|
async def check_database(session: AsyncSession) -> bool:
|
|
"""Return whether the database answers a trivial query."""
|
|
try:
|
|
await session.execute(select(literal(1)))
|
|
except SQLAlchemyError as error:
|
|
logger.warning("ebook_ready_database_unavailable error=%s", error)
|
|
return False
|
|
return True
|
|
|
|
|
|
def check_bm25_status(config: EbookSearchConfig) -> str:
|
|
"""Return the persisted BM25 index status without loading it into memory."""
|
|
index_path = bm25_index_path(config)
|
|
manifest = read_bm25_manifest(index_path)
|
|
if manifest is None or not bm25_index_exists(index_path, manifest):
|
|
return "missing"
|
|
if manifest.chunk_count == 0:
|
|
return "empty"
|
|
return "ok"
|