ForcePilot/backend/package/yuxi/channel/cron/normalize.py

170 lines
5.8 KiB
Python
Raw Normal View History

import logging
import time
from datetime import datetime
from yuxi.channel.cron.schedule import (
compute_next_run_at_ms,
is_recurring_top_of_hour_cron_expr,
resolve_default_cron_stagger_ms,
)
from yuxi.channel.cron.types import (
CronDeliveryTarget,
CronFailureAlert,
CronJob,
ScheduleKind,
)
logger = logging.getLogger(__name__)
CRON_FIELD_COUNT = 5
CRON_FIELD_RANGES = [
(0, 59),
(0, 23),
(1, 31),
(1, 12),
(0, 7),
]
def _validate_cron_expr_format(expr: str) -> str | None:
parts = expr.strip().split()
if len(parts) != CRON_FIELD_COUNT:
return f"cron 表达式应包含 {CRON_FIELD_COUNT} 个字段,实际 {len(parts)} 个: {expr}"
for i, (field, (lo, hi)) in enumerate(zip(parts, CRON_FIELD_RANGES)):
for segment in field.split(","):
step = 1
if "/" in segment:
segment, step_str = segment.split("/", 1)
try:
step = int(step_str)
except ValueError:
return f"cron 表达式字段 {i} 步长无效: {step_str}"
if step < 1:
return f"cron 表达式字段 {i} 步长必须 >= 1: {step}"
if segment == "*":
continue
if "-" in segment:
try:
low_str, high_str = segment.split("-", 1)
low = int(low_str)
high = int(high_str)
except ValueError:
return f"cron 表达式字段 {i} 范围格式无效: {segment}"
if low < lo or high > hi or low > high:
return f"cron 表达式字段 {i} 范围 {low}-{high} 超出 [{lo}, {hi}]"
else:
try:
val = int(segment)
except ValueError:
return f"cron 表达式字段 {i} 值无效: {segment}"
if val < lo or val > hi:
return f"cron 表达式字段 {i}{val} 超出 [{lo}, {hi}]"
return None
def _validate_at_value(value: str) -> str | None:
try:
at_ms = int(value)
if at_ms <= 0:
return f"AT 调度值必须为正整数 (毫秒时间戳): {value}"
return None
except (ValueError, TypeError):
pass
try:
datetime.fromisoformat(value)
return None
except (ValueError, TypeError):
return f"AT 调度值格式无效 (需为毫秒时间戳或 ISO 8601): {value}"
def _validate_every_value(every_ms: int | None, anchor_ms: int | None) -> str | None:
if every_ms is None:
return "EVERY 调度必须设置 every_ms"
if every_ms <= 0:
return f"EVERY 调度的 every_ms 必须 > 0: {every_ms}"
if anchor_ms is not None and anchor_ms < 0:
return f"EVERY 调度的 anchor_ms 不能为负数: {anchor_ms}"
return None
def _validate_delivery_target(target: CronDeliveryTarget) -> str | None:
if not target.channel or not target.channel.strip():
return f"投递目标缺少 channel: {target.to_dict()}"
if not target.target_id or not target.target_id.strip():
return f"投递目标缺少 target_id: {target.to_dict()}"
return None
def normalize_job(job: CronJob) -> list[str]:
warnings: list[str] = []
if not job.id or not job.id.strip():
warnings.append("CronJob.id 不能为空")
job.id = job.id or f"unnamed_{int(time.time() * 1000)}"
if not job.name:
job.name = job.id
warnings.append(f"未设置 name已使用 id 作为名称: {job.name}")
if job.error_threshold < 1:
job.error_threshold = 5
warnings.append(f"error_threshold 必须 >= 1已重置为 {job.error_threshold}")
if job.max_runs < 0:
job.max_runs = 0
warnings.append(f"max_runs 不能为负数,已重置为 0 (无限制)")
if job.stagger_ms < 0:
job.stagger_ms = 0
warnings.append(f"stagger_ms 不能为负数,已重置为 0")
if job.schedule_kind == ScheduleKind.CRON:
err = _validate_cron_expr_format(job.schedule_value)
if err:
warnings.append(err)
if job.stagger_ms == 0 and job.schedule_value:
default_stagger = resolve_default_cron_stagger_ms(job.schedule_value)
if default_stagger > 0:
job.stagger_ms = default_stagger
warnings.append(f"整点 CRON 表达式未设置 stagger已自动设置 {default_stagger}ms 错峰")
elif job.schedule_kind == ScheduleKind.AT:
err = _validate_at_value(job.schedule_value)
if err:
warnings.append(err)
if job.stagger_ms > 0:
warnings.append("AT 调度不支持 stagger已忽略")
job.stagger_ms = 0
elif job.schedule_kind == ScheduleKind.EVERY:
err = _validate_every_value(job.every_ms, job.anchor_ms)
if err:
warnings.append(err)
for i, target in enumerate(job.delivery):
err = _validate_delivery_target(target)
if err:
warnings.append(f"投递目标 [{i}]: {err}")
if job.failure_alert.threshold < 1:
job.failure_alert.threshold = 5
warnings.append(f"告警阈值必须 >= 1已重置为 {job.failure_alert.threshold}")
if job.failure_alert.cooldown_ms < 0:
job.failure_alert.cooldown_ms = 600_000
warnings.append(f"告警冷却时间不能为负数,已重置为 {job.failure_alert.cooldown_ms}ms")
try:
next_at = compute_next_run_at_ms(job)
if next_at is not None:
job.state.next_run_at_ms = next_at
except Exception as e:
warnings.append(f"无法计算下次执行时间: {e}")
if warnings:
logger.warning("CronJob '%s' 规范化产生 %d 条警告: %s", job.id, len(warnings), warnings)
return warnings