"""Session cleanup background task. Registers an APScheduler job that periodically removes expired sessions from the ``sessions`` table. Without this cleanup, the table grows unbounded over months of operation and degrades query performance. Individual expired sessions are removed on-demand when validated, but the bulk cleanup ensures comprehensive pruning at a predictable interval. Correlation IDs are propagated through the task using :mod:`app.utils.correlation` so that task logs can be correlated across runs. """ from __future__ import annotations import uuid from typing import TYPE_CHECKING from app.utils.logging_compat import get_logger from app.repositories import session_repo from app.tasks.db import task_db from app.tasks.timeout_utils import run_with_timeout from app.utils.correlation import get_correlation_id, reset_correlation_id, set_correlation_id from app.utils.runtime_state import get_effective_settings from app.utils.time_utils import utc_now if TYPE_CHECKING: from fastapi import FastAPI from app.config import Settings log = get_logger(__name__) #: How often the cleanup job fires (seconds). Configurable tuning constant. SESSION_CLEANUP_INTERVAL: int = 6 * 60 * 60 # 6 hours #: Stable APScheduler job ID — ensures re-registration replaces, not duplicates. JOB_ID: str = "session_cleanup" #: Maximum seconds to allow for session cleanup to complete. TASK_TIMEOUT_SECONDS: int = 30 async def _run_cleanup_with_resources( settings: Settings, correlation_id: str | None = None, ) -> None: """Delete all expired sessions from the database. Args: settings: The resolved application settings used for database access. correlation_id: Optional correlation ID from the triggering request. """ if correlation_id is None: correlation_id = str(uuid.uuid4()) token = set_correlation_id(correlation_id) try: await _do_cleanup_with_settings(settings) finally: reset_correlation_id(token) async def _do_cleanup_with_settings(settings: Settings) -> None: """Inner cleanup logic that runs with correlation context set.""" async def _do_cleanup() -> None: now_iso = utc_now().isoformat() async with task_db(settings) as db: deleted_count = await session_repo.delete_expired_sessions(db, now_iso) log.info( "session_cleanup_ran", correlation_id=get_correlation_id(), deleted_count=deleted_count, cutoff_time=now_iso, ) await run_with_timeout("session_cleanup", _do_cleanup(), TASK_TIMEOUT_SECONDS) async def _run_cleanup(app: FastAPI) -> None: await _run_cleanup_with_resources(get_effective_settings(app)) def register(app: FastAPI) -> None: """Add (or replace) the session cleanup job in the application scheduler. Must be called after the scheduler has been started (i.e., inside the lifespan handler, after ``scheduler.start()``). Args: app: The :class:`fastapi.FastAPI` application instance whose ``app.state.scheduler`` will receive the job. """ settings = get_effective_settings(app) app.state.scheduler.add_job( _run_cleanup_with_resources, trigger="interval", seconds=SESSION_CLEANUP_INTERVAL, kwargs={"settings": settings}, id=JOB_ID, replace_existing=True, ) log.info("session_cleanup_scheduled", interval_seconds=SESSION_CLEANUP_INTERVAL)