"""APScheduler bootstrap (``docs/06-operational-flow.md``). Wraps :class:`AsyncIOScheduler` so the orchestrator can register the documented cron jobs in one place. The scheduler is built but not started; ``start()`` must be called from inside the running event loop. """ from __future__ import annotations import logging from collections.abc import Awaitable, Callable from dataclasses import dataclass from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger __all__ = ["JobSpec", "build_scheduler"] _log = logging.getLogger("cerbero_bite.runtime.scheduler") @dataclass(frozen=True) class JobSpec: """One row in the scheduler manifest.""" name: str cron: str coro_factory: Callable[[], Awaitable[None]] def _parse_cron(expr: str) -> CronTrigger: parts = expr.split() if len(parts) != 5: raise ValueError(f"cron must have 5 fields, got: {expr!r}") minute, hour, day, month, day_of_week = parts return CronTrigger( minute=minute, hour=hour, day=day, month=month, day_of_week=day_of_week, timezone="UTC", ) def build_scheduler(jobs: list[JobSpec]) -> AsyncIOScheduler: """Return an :class:`AsyncIOScheduler` with all *jobs* registered. The scheduler is *not* started — the caller is responsible for invoking ``start()`` after constructing it on a running event loop. """ scheduler = AsyncIOScheduler(timezone="UTC") for spec in jobs: scheduler.add_job( spec.coro_factory, trigger=_parse_cron(spec.cron), id=spec.name, name=spec.name, replace_existing=True, coalesce=True, misfire_grace_time=300, ) _log.info("scheduled job %s with cron %s", spec.name, spec.cron) return scheduler