"""Single-task scheduler for probe, event recording, and outbox delivery.""" import asyncio import logging import time from datetime import datetime from typing import Callable from app.config import Settings, settings from app.core.deps import async_session from app.services.alerter import Alerter from app.services.notification_dispatcher import ( NotificationDispatcher, WeComClient, ) from app.services.pinger import Pinger logger = logging.getLogger("pingwatch.scheduler") _DEFAULT_DISPATCH_INTERVAL_SECONDS = 5.0 class PingScheduler: """Serialize monitoring cycles and retain one cancellable background task.""" def __init__( self, *, pinger=None, alerter=None, dispatcher=None, session_factory: Callable = async_session, runtime_settings: Settings | None = None, interval_seconds: float | None = None, dispatch_interval_seconds: float = _DEFAULT_DISPATCH_INTERVAL_SECONDS, ): self._settings = runtime_settings or settings self._pinger = pinger or Pinger(self._settings) self._alerter = alerter or Alerter(self._settings) self._notification_client = None self._dispatch_enabled = ( dispatcher is not None or self._settings.wecom_notification_enabled ) if dispatcher is None: self._notification_client = WeComClient(self._settings) dispatcher = NotificationDispatcher( self._notification_client, self._settings, ) self._dispatcher = dispatcher self._session_factory = session_factory self._probe_interval_seconds = max( 1.0, float( interval_seconds if interval_seconds is not None else self._settings.PING_INTERVAL_SECONDS ), ) self._dispatch_interval_seconds = min( max(1.0, float(dispatch_interval_seconds)), 30.0, ) self._running = False self._task: asyncio.Task | None = None self._cycle_lock = asyncio.Lock() async def _run_cycle(self, *, run_probe: bool = True) -> None: """Run one serialized transaction cycle and close its session on cancel.""" async with self._cycle_lock: async with self._session_factory() as db: try: if run_probe: changes = await self._pinger.run_one_round(db) for change in changes: await self._alerter.record_transition(change, db) await db.commit() if self._dispatch_enabled: await self._dispatcher.dispatch_due(db, datetime.now()) await db.commit() except BaseException: await db.rollback() raise async def _run_loop(self) -> None: """Probe at its configured cadence and service retries every few seconds.""" logger.info("Ping scheduler started") next_probe_at = 0.0 while self._running: now = time.monotonic() run_probe = now >= next_probe_at try: await self._run_cycle(run_probe=run_probe) if run_probe: next_probe_at = time.monotonic() + self._probe_interval_seconds except asyncio.CancelledError: break except Exception as exc: logger.error( "Scheduler cycle failed type=%s", type(exc).__name__, exc_info=True, ) if run_probe: next_probe_at = time.monotonic() + self._probe_interval_seconds seconds_until_probe = max(0.0, next_probe_at - time.monotonic()) sleep_seconds = min( self._dispatch_interval_seconds, seconds_until_probe or self._dispatch_interval_seconds, ) try: await asyncio.sleep(sleep_seconds) except asyncio.CancelledError: break logger.info("Ping scheduler stopped") def start(self) -> None: """Start exactly one background scheduler task.""" if self._task is not None and not self._task.done(): logger.warning("Ping scheduler is already running") return self._running = True self._task = asyncio.create_task(self._run_loop()) async def stop(self) -> None: """Cancel and await the active cycle before releasing its HTTP client.""" self._running = False if self._task is not None: self._task.cancel() try: await self._task except asyncio.CancelledError: pass self._task = None if self._notification_client is not None: await self._notification_client.aclose() self._notification_client = None scheduler = PingScheduler()