Files
PingWatch/backend/app/services/scheduler.py
T

143 lines
5.0 KiB
Python

"""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()