ForcePilot/backend/package/yuxi/channel/cron/normalize.py
Kris 0dcc370819 feat(cron): 实现完整的定时任务调度模块
新增了从调度引擎、任务注册、状态追踪到结果投递的全套cron任务系统,支持cron表达式、一次性任务和周期任务,包含失败告警、任务持久化和运行日志功能
2026-05-21 10:24:54 +08:00

170 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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