170 lines
5.8 KiB
Python
170 lines
5.8 KiB
Python
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
|