143 lines
5.0 KiB
Python
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()
|