123 lines
3.8 KiB
Python
123 lines
3.8 KiB
Python
import asyncio
|
||
import logging
|
||
import time
|
||
|
||
from yuxi.channel.cron.types import CronFailureAlert, CronJob, DeliveryHandler
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
ALERT_COOLDOWN_MS = 600_000
|
||
RECOVERY_COOLDOWN_MS = 300_000
|
||
_GLOBAL_ALERT_MAX_PER_MINUTE = 10
|
||
_global_alert_timestamps: list[int] = []
|
||
|
||
|
||
def _global_rate_check(now_ms: int) -> bool:
|
||
cutoff = now_ms - 60_000
|
||
global _global_alert_timestamps
|
||
_global_alert_timestamps = [t for t in _global_alert_timestamps if t > cutoff]
|
||
if len(_global_alert_timestamps) >= _GLOBAL_ALERT_MAX_PER_MINUTE:
|
||
return False
|
||
_global_alert_timestamps.append(now_ms)
|
||
return True
|
||
|
||
|
||
def _format_cooldown_ms(ms: int) -> str:
|
||
if ms < 60_000:
|
||
return f"{ms // 1000}s"
|
||
if ms < 3_600_000:
|
||
return f"{ms // 60_000}min"
|
||
return f"{ms // 3_600_000}h"
|
||
|
||
|
||
def format_alert_message(job: CronJob, consecutive_errors: int, last_error: str | None) -> str:
|
||
job_label = job.name or job.id
|
||
return (
|
||
f"🚨 Cron 任务连续失败告警\n\n"
|
||
f"任务:{job_label}\n"
|
||
f"连续失败次数:{consecutive_errors}\n"
|
||
f"告警触发阈值:{job.failure_alert.threshold}\n"
|
||
f"最近错误:{last_error or '未知'}\n\n"
|
||
f"任务已自动标记为 FAILED,冷却期 {_format_cooldown_ms(job.failure_alert.cooldown_ms)} 内不再告警"
|
||
)
|
||
|
||
|
||
def format_recovery_message(job: CronJob) -> str:
|
||
job_label = job.name or job.id
|
||
return f"✅ Cron 任务已恢复正常\n\n任务:{job_label}\n任务状态已恢复为 ACTIVE"
|
||
|
||
|
||
async def send_alert(
|
||
handler: DeliveryHandler | None,
|
||
delivery_targets: list,
|
||
job: CronJob,
|
||
content: str,
|
||
) -> None:
|
||
if handler is None:
|
||
logger.warning("Cron alert for '%s' cannot be delivered: no delivery handler configured", job.id)
|
||
return
|
||
|
||
if not delivery_targets:
|
||
logger.warning("Cron alert for '%s': no delivery targets configured", job.id)
|
||
return
|
||
|
||
for target in delivery_targets:
|
||
if not target.enabled or not target.channel or not target.target_id:
|
||
continue
|
||
try:
|
||
await handler(target.channel, target.target_id, content)
|
||
except Exception:
|
||
logger.exception("Cron alert delivery to %s:%s failed", target.channel, target.target_id)
|
||
|
||
|
||
async def check_and_alert(
|
||
handler: DeliveryHandler | None,
|
||
job: CronJob,
|
||
) -> bool:
|
||
alert = job.failure_alert
|
||
now_ms = int(time.time() * 1000)
|
||
delivery_targets = job.delivery
|
||
|
||
if alert.should_alert(job.state.consecutive_errors, now_ms):
|
||
if not _global_rate_check(now_ms):
|
||
logger.warning("Cron alert for '%s' suppressed by global rate limit", job.id)
|
||
return False
|
||
content = format_alert_message(job, job.state.consecutive_errors, job.state.last_error)
|
||
await send_alert(handler, delivery_targets, job, content)
|
||
alert.mark_alerted(now_ms)
|
||
logger.warning(
|
||
"Cron failure alert sent for '%s' (consecutive errors: %d)",
|
||
job.id,
|
||
job.state.consecutive_errors,
|
||
)
|
||
return True
|
||
|
||
return False
|
||
|
||
|
||
async def check_and_recover(
|
||
handler: DeliveryHandler | None,
|
||
job: CronJob,
|
||
) -> bool:
|
||
alert = job.failure_alert
|
||
now_ms = int(time.time() * 1000)
|
||
delivery_targets = job.delivery
|
||
|
||
if alert.should_recover(job.state.consecutive_errors, now_ms):
|
||
content = format_recovery_message(job)
|
||
await send_alert(handler, delivery_targets, job, content)
|
||
alert.mark_recovered(now_ms)
|
||
logger.info("Cron recovery notification sent for '%s'", job.id)
|
||
return True
|
||
|
||
return False
|
||
|
||
|
||
async def evaluate_alerts(
|
||
handler: DeliveryHandler | None,
|
||
job: CronJob,
|
||
) -> dict:
|
||
alert_sent = await check_and_alert(handler, job)
|
||
recovery_sent = await check_and_recover(handler, job)
|
||
return {"alert_sent": alert_sent, "recovery_sent": recovery_sent}
|