feat: deliver alert events through persistent outbox

This commit is contained in:
2026-08-04 15:08:06 +08:00
parent 304479441e
commit fea537b9d7
6 changed files with 1297 additions and 369 deletions
+106 -58
View File
@@ -1,94 +1,142 @@
"""
定时任务调度器
使用 asyncio 循环驱动 Ping 引擎,协调 Pinger 和 Alerter。
"""
"""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 sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.services.pinger import Pinger
from app.services.alerter import Alerter
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:
"""
调度器职责:
1. 按间隔驱动 Ping 引擎
2. 每轮结束后触发 Alerter 处理待发送告警
3. 控制并发和清理
"""
"""Serialize monitoring cycles and retain one cancellable background task."""
def __init__(self):
self._pinger = Pinger()
self._alerter = Alerter()
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()
# 注册状态变化回调
self._pinger.on_state_change(self._on_state_change)
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()
async def _on_state_change(self, change):
"""收到设备状态变化,转给 alerter"""
async with async_session() as db:
try:
await self._alerter.on_state_change(change, db)
except Exception as e:
logger.error(f"告警处理异常: {e}", exc_info=True)
async def _run_loop(self):
"""主循环"""
logger.info("Ping 调度器已启动")
self._running = True
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:
async with async_session() as db:
# 执行一轮 ping
results = await self._pinger.run_one_round(db)
if results:
# 直接用启用的设备数
total = len(results)
# 处理待发送告警
await self._alerter.flush_pending(db, total)
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 e:
logger.error(f"调度器异常: {e}", exc_info=True)
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
# 等待下一轮
await asyncio.sleep(settings.PING_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")
logger.info("Ping 调度器已停止")
def start(self):
"""启动调度器(后台任务)"""
if self._running:
logger.warning("调度器已在运行")
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):
"""停止调度器"""
async def stop(self) -> None:
"""Cancel and await the active cycle before releasing its HTTP client."""
self._running = False
if self._task:
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()