"""Background BM25 refresh tasks for the web app. The refresh is scheduled on the event loop instead of a thread because the async psycopg driver only works from the loop; a bare thread cannot open a session on the async engine. """ from __future__ import annotations import asyncio import logging from typing import TYPE_CHECKING from sqlalchemy.ext.asyncio import AsyncSession from python.ebook_search.bm25_corpus import load_bm25_corpus, refresh_bm25_corpus if TYPE_CHECKING: from fastapi import FastAPI from sqlalchemy.ext.asyncio import AsyncEngine from python.ebook_search.config import EbookSearchConfig logger = logging.getLogger(__name__) def schedule_bm25_refresh(app: FastAPI) -> None: """Schedule a delayed BM25 corpus refresh, replacing any pending refresh. Only called from route handlers, so a running event loop is guaranteed. """ cancel_bm25_refresh(app) loop = asyncio.get_running_loop() def start_refresh() -> None: app.state.bm25_refresh_task = loop.create_task(refresh_bm25_for_app(app)) app.state.bm25_refresh_timer = loop.call_later(app.state.config.bm25_refresh_delay_seconds, start_refresh) logger.info( "ebook_bm25_refresh_scheduled delay_seconds=%s", app.state.config.bm25_refresh_delay_seconds, ) def cancel_bm25_refresh(app: FastAPI) -> None: """Cancel any pending BM25 corpus refresh timer and in-flight refresh task.""" existing_timer = getattr(app.state, "bm25_refresh_timer", None) if existing_timer is not None: existing_timer.cancel() app.state.bm25_refresh_timer = None logger.info("ebook_bm25_refresh_cancelled") existing_task = getattr(app.state, "bm25_refresh_task", None) if existing_task is not None: if not existing_task.done(): existing_task.cancel() app.state.bm25_refresh_task = None async def refresh_bm25_for_app(app: FastAPI) -> None: """Refresh the BM25 corpus using the app engine and config.""" try: await refresh_bm25_for_engine(app.state.engine, app.state.config) except Exception: logger.exception("ebook_bm25_refresh_failed") async def refresh_bm25_for_engine(engine: AsyncEngine, config: EbookSearchConfig) -> None: """Refresh the BM25 corpus using an async SQLAlchemy engine.""" async with AsyncSession(engine) as session: await refresh_bm25_corpus(session, config) load_bm25_corpus.cache_clear() logger.info("ebook_bm25_corpus_cache_cleared_after_refresh")