First Commit
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user