Files
MailRelay/modules/scheduler.py
T
2026-03-24 17:01:09 -04:00

102 lines
3.2 KiB
Python

"""APScheduler wrapper for MailRelay's polling interval.
The scheduler runs a single persistent background job that fires the sync
function at the user-configured interval. It also exposes helpers so main.py
can trigger an immediate run or print the next scheduled time.
"""
from datetime import datetime, timezone
from typing import Callable, Optional
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from .logger import get_logger
log = get_logger(__name__)
JOB_ID = "mailrelay_sync"
_scheduler: Optional[BackgroundScheduler] = None
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def start(sync_fn: Callable, interval_minutes: int) -> None:
"""Initialise and start the scheduler with *interval_minutes* between runs.
*sync_fn* is called with no arguments each time the interval fires.
"""
global _scheduler
if _scheduler and _scheduler.running:
log.warning("Scheduler already running — ignoring start() call.")
return
_scheduler = BackgroundScheduler(timezone="UTC")
_scheduler.add_job(
_guarded(sync_fn),
trigger=IntervalTrigger(minutes=interval_minutes),
id=JOB_ID,
name="MailRelay sync",
replace_existing=True,
max_instances=1, # prevent overlapping runs
coalesce=True, # skip missed fires rather than catching up
)
_scheduler.start()
log.info(
"Scheduler started. Sync will run every %d minute(s).", interval_minutes
)
def stop() -> None:
"""Gracefully shut down the scheduler."""
global _scheduler
if _scheduler and _scheduler.running:
_scheduler.shutdown(wait=False)
log.info("Scheduler stopped.")
_scheduler = None
def run_now(sync_fn: Callable) -> None:
"""Trigger an immediate sync outside the normal schedule."""
log.info("Manual run triggered.")
_guarded(sync_fn)()
def next_run_time() -> Optional[datetime]:
"""Return the next scheduled run time (UTC), or None if not scheduled."""
if not _scheduler or not _scheduler.running:
return None
job = _scheduler.get_job(JOB_ID)
if job and job.next_run_time:
return job.next_run_time
return None
def update_interval(interval_minutes: int) -> None:
"""Change the polling interval without restarting the scheduler."""
if not _scheduler or not _scheduler.running:
raise RuntimeError("Scheduler is not running.")
_scheduler.reschedule_job(
JOB_ID,
trigger=IntervalTrigger(minutes=interval_minutes),
)
log.info("Polling interval updated to %d minute(s).", interval_minutes)
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
def _guarded(fn: Callable) -> Callable:
"""Wrap *fn* so unhandled exceptions are logged but don't kill the scheduler."""
def wrapper(*args, **kwargs):
try:
fn(*args, **kwargs)
except Exception as exc:
log.error("Unhandled exception in sync function: %s", exc, exc_info=True)
return wrapper