"""Handler registry for scheduled jobs. Decouples the *what* (runnable Python coroutine) from the *when* (cron / interval stored in the DB). Handlers register themselves at import time so ``scheduled_jobs`` rows only need to name the handler — not import a path that could shift under them. """ from __future__ import annotations from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from typing import Any, Literal from sqlalchemy.ext.asyncio import AsyncSession from app.scheduled_jobs.slo import DEFAULT_SLO, SLOTarget # Execution weight class — drives the service-layer concurrency gate # (2026-07-19 pool-exhaustion post-mortem). ``heavy`` = a long-running # vendor-API sweep that can hold its DB-pool connection for minutes # (cloud resource/billing syncs, HRIS full roster). Heavy handlers are # additionally bounded by the smaller heavy sub-gate so a nightly # cluster of cloud syncs can never occupy every global slot and starve # fast critical crons (provisioning dispatch, identity_sync dispatch, # outbox retry). Default ``light``. SchedJobWeight = Literal["light", "heavy"] @dataclass(slots=True) class SchedJobHandler: key: str display_name: str description: str run: Callable[[AsyncSession, dict[str, Any]], Awaitable[dict[str, Any]]] # SLO target — every handler has one, falling through to # ``DEFAULT_SLO`` when the registrant did not pass an explicit # ``slo=`` kwarg. Used by ``failure_notifier`` (tier-aware card # emoji + recipient fan-out) and the Prometheus / health-console # SLO evaluator. slo: SLOTarget = field(default_factory=lambda: DEFAULT_SLO) # Concurrency weight class — see ``SchedJobWeight`` above. weight: SchedJobWeight = "light" _HANDLERS: dict[str, SchedJobHandler] = {} def register_scheduled_job( key: str, *, display_name: str, description: str = "", slo: SLOTarget | None = None, weight: SchedJobWeight = "light", ) -> Callable[ [Callable[[AsyncSession, dict[str, Any]], Awaitable[dict[str, Any]]]], Callable[[AsyncSession, dict[str, Any]], Awaitable[dict[str, Any]]], ]: """Decorator: register a handler under ``key``. Handler signature is ``async def fn(db, config) -> dict`` — the returned dict is persisted as ``last_result_summary`` on the job row so the admin UI can show "last run: 3 instances, 27 events" without parsing logs. ``slo`` (optional, PR-C 2026-05-31): an ``SLOTarget`` declaring the production expectation for this cron. Omit to inherit ``DEFAULT_SLO`` (P2, 80% success-rate, 3 consecutive failures). ``weight`` (optional, 2026-07-19): pass ``"heavy"`` for handlers that hold their DB-pool connection for minutes per run (cloud vendor sweeps, full-roster HRIS syncs) so the execution gate additionally bounds them with the smaller heavy sub-limit. Omit for everything else. """ def deco(fn: Callable[[AsyncSession, dict[str, Any]], Awaitable[dict[str, Any]]]): _HANDLERS[key] = SchedJobHandler( key=key, display_name=display_name, description=description, run=fn, slo=slo or DEFAULT_SLO, weight=weight, ) return fn return deco def get_handler(key: str) -> SchedJobHandler | None: return _HANDLERS.get(key) def list_handlers() -> list[SchedJobHandler]: return list(_HANDLERS.values()) __all__ = [ "SchedJobHandler", "SchedJobWeight", "get_handler", "list_handlers", "register_scheduled_job", ]