"""Background scheduler: fires group runs when cron expressions are due. One daemon thread wakes every 60 s, checks all enabled schedules, and spawns a per-run thread for any that are due. ``last_fired_at`` is written to the DB before the run starts so a slow or crashing run cannot double-fire the same occurrence. Survives server restarts: missed ticks while pipekit was down are detected on the next startup check (5 s after start). """ from __future__ import annotations import logging import threading import time from datetime import datetime, timedelta, timezone log = logging.getLogger(__name__) def _now() -> datetime: return datetime.now() def _check_and_fire() -> None: from . import engine, repo try: schedules = repo.list_all_enabled_schedules() except Exception as e: # noqa: BLE001 log.error("scheduler: failed to load schedules: %s", e) return now = _now() for sched in schedules: try: _maybe_fire(sched, now) except Exception as e: # noqa: BLE001 log.error("scheduler: error on schedule id=%s: %s", sched["id"], e) def _maybe_fire(sched: dict, now: datetime) -> None: from croniter import croniter, CroniterBadCronError from . import engine, repo last_str = sched["last_fired_at"] if last_str: # SQLite stores UTC; convert to local for cron evaluation. last_dt = datetime.fromisoformat(last_str).replace( tzinfo=timezone.utc).astimezone().replace(tzinfo=None) else: last_dt = now - timedelta(seconds=120) try: cron = croniter(sched["cron_expr"], last_dt) next_dt = cron.get_next(datetime) except CroniterBadCronError: log.warning("scheduler: invalid cron_expr %r for schedule id=%s — skipping", sched["cron_expr"], sched["id"]) return if next_dt > now: return log.info("scheduler: firing group_id=%s (schedule id=%s expr=%r)", sched["group_id"], sched["id"], sched["cron_expr"]) # Mark fired before spawning so a slow run can't double-fire. repo.mark_schedule_fired(sched["id"]) group_id = sched["group_id"] sched_id = sched["id"] def _run() -> None: try: group_run_id = repo.create_group_run( group_id, triggered_by=f"schedule:{sched_id}" ) engine.run_group(group_id, group_run_id=group_run_id) except Exception as exc: # noqa: BLE001 log.error("scheduler: run failed for group_id=%s: %s", group_id, exc) threading.Thread(target=_run, daemon=True).start() def start_scheduler() -> None: """Start the background scheduler daemon thread. Safe to call once at startup.""" def _loop() -> None: time.sleep(5) # let the app finish initialising while True: _check_and_fire() time.sleep(60) t = threading.Thread(target=_loop, daemon=True, name="pipekit-scheduler") t.start() log.info("scheduler: started")