# scheduler_manager.py from __future__ import annotations import logging from dataclasses import asdict from typing import Callable, List, Optional from zoneinfo import ZoneInfo from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from utils.scan_config_loader import ScanConfigFile logger = logging.getLogger(__name__) class ScanScheduler: """ Owns an APScheduler and schedules one job per ScanConfigFile that has scan_options.cron set. """ def __init__(self, timezone: str = "America/Chicago") -> None: self.tz = ZoneInfo(timezone) self.scheduler = BackgroundScheduler(timezone=self.tz) def start(self) -> None: """ Start the underlying scheduler thread. """ if not self.scheduler.running: self.scheduler.start() logger.info("APScheduler started (tz=%s).", self.tz) def shutdown(self) -> None: """ Gracefully stop the scheduler. """ if self.scheduler.running: self.scheduler.shutdown(wait=False) logger.info("APScheduler stopped.") def schedule_configs( self, configs: List[ScanConfigFile], run_scan_fn: Callable[[ScanConfigFile], None], replace_existing: bool = True, ) -> int: """ Create/replace jobs for all configs with a valid cron. Returns number of scheduled jobs. """ count = 0 for cfg in configs: cron = (cfg.scan_options.cron or "").strip() if cfg.scan_options else "" if not cron: logger.info("Skipping schedule (no cron): %s", cfg.name) continue job_id = f"scan::{cfg.name}" trigger = CronTrigger.from_crontab(cron, timezone=self.tz) self.scheduler.add_job( func=run_scan_fn, trigger=trigger, id=job_id, args=[cfg], max_instances=1, replace_existing=replace_existing, misfire_grace_time=300, coalesce=True, ) logger.info("Scheduled '%s' with cron '%s' (next run: %s)", cfg.name, cron, self._next_run_time(job_id)) count += 1 return count def _next_run_time(self, job_id: str): j = self.scheduler.get_job(job_id) if j and hasattr(j, "next_run_time"): return j.next_run_time.isoformat() if j.next_run_time else None return None