"""Periodic task registry shared by the scheduler process. The crawl scheduler (`catlas schedule`, services/scheduler.py) is the single long-running production process besides the API. Any area (intelligence, alerts, metrics, retention, backups…) registers background work here instead of shipping its own daemon: from companyatlas.services.periodic import periodic @periodic("process-changes", every_s=30) async def process_changes_task(): ... @periodic("metrics-hourly", cron="7 * * * *") async def metrics_task(): ... `every_s` tasks run on a fixed cadence (first run `initial_delay_s` after start); `cron` tasks use APScheduler cron syntax in `settings.tz`. Tasks must be idempotent and bounded (do a batch, return). Exceptions are logged, never fatal. The scheduler imports `companyatlas.services.registry_loader` (below) which imports every module that registers tasks — add yours there. """ from __future__ import annotations import asyncio import importlib import logging import time from collections.abc import Awaitable, Callable from dataclasses import dataclass, field log = logging.getLogger(__name__) TaskFn = Callable[[], Awaitable[object]] @dataclass class PeriodicTask: name: str fn: TaskFn every_s: float | None = None cron: str | None = None initial_delay_s: float = 5.0 exclusive: bool = True # never overlap with itself last_started_at: float | None = None last_finished_at: float | None = None last_error: str | None = None runs: int = 0 failures: int = 0 _lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False) async def run(self) -> None: if self.exclusive and self._lock.locked(): return async with self._lock: self.last_started_at = time.time() try: await self.fn() self.runs += 1 self.last_error = None except Exception as exc: self.failures += 1 self.last_error = f"{exc.__class__.__name__}: {exc}"[:500] log.exception("periodic task failed", extra={"task": self.name}) finally: self.last_finished_at = time.time() _TASKS: dict[str, PeriodicTask] = {} # Modules that register periodic tasks at import time. Areas append their module path here (one line each). TASK_MODULES: list[str] = [ "companyatlas.services.events", "companyatlas.services.metrics", "companyatlas.services.alerts", "companyatlas.services.retention", "companyatlas.services.llm.enrich", "companyatlas.services.signals", "companyatlas.services.trends", "companyatlas.services.repair", "companyatlas.services.enrichment", "companyatlas.commands.ops", ] def periodic(name: str, *, every_s: float | None = None, cron: str | None = None, initial_delay_s: float = 5.0, exclusive: bool = True): # type: ignore[no-untyped-def] if not every_s and not cron: raise ValueError("periodic task needs every_s or cron") def deco(fn: TaskFn) -> TaskFn: _TASKS[name] = PeriodicTask(name=name, fn=fn, every_s=every_s, cron=cron, initial_delay_s=initial_delay_s, exclusive=exclusive) return fn return deco def load_task_modules() -> list[str]: """Import every registered task module (missing modules are skipped so areas can land independently).""" loaded: list[str] = [] for mod in TASK_MODULES: try: importlib.import_module(mod) loaded.append(mod) except ModuleNotFoundError as exc: if exc.name and (mod == exc.name or mod.startswith(exc.name + ".")): continue log.exception("task module failed to import", extra={"task_module": mod}) except Exception: log.exception("task module failed to import", extra={"task_module": mod}) return loaded def tasks() -> dict[str, PeriodicTask]: return _TASKS def snapshot() -> list[dict[str, object]]: return [{"name": t.name, "every_s": t.every_s, "cron": t.cron, "runs": t.runs, "failures": t.failures, "last_started_at": t.last_started_at, "last_finished_at": t.last_finished_at, "last_error": t.last_error} for t in _TASKS.values()] __all__ = ["TASK_MODULES", "PeriodicTask", "load_task_modules", "periodic", "snapshot", "tasks"]