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

319 lines
9.7 KiB
Python

import calendar
import logging
import random
import time
from collections import OrderedDict
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from yuxi.channel.cron.types import CronJob, CronJobStatus, ScheduleKind
logger = logging.getLogger(__name__)
CRON_EVAL_CACHE_MAX = 512
_cron_eval_cache: OrderedDict[str, tuple[set[int] | None, ...]] = OrderedDict()
DEFAULT_TOP_OF_HOUR_STAGGER_MS = 5 * 60 * 1000
def _clear_cron_cache_for_test():
_cron_eval_cache.clear()
def _get_cron_cache_size_for_test():
return len(_cron_eval_cache)
def _resolve_field(field: str, lo: int, hi: int) -> set[int] | None:
if field == "*":
return None
result: set[int] = set()
for part in field.split(","):
step = 1
if "/" in part:
part, step_str = part.split("/", 1)
step = int(step_str)
low = lo
high = hi
if "-" in part:
low_str, high_str = part.split("-", 1)
low = int(low_str)
high = int(high_str)
elif part != "*":
low = high = int(part)
for v in range(low, high + 1, step):
if lo <= v <= hi:
result.add(v)
return result or None
def _parse_cron_expr(cron_expr: str) -> tuple[set[int] | None, ...]:
cached = _cron_eval_cache.get(cron_expr)
if cached is not None:
_cron_eval_cache.move_to_end(cron_expr)
return cached
parts = cron_expr.strip().split()
if len(parts) != 5:
raise ValueError(f"Invalid cron expression: {cron_expr}")
parsed = (
_resolve_field(parts[0], 0, 59),
_resolve_field(parts[1], 0, 23),
_resolve_field(parts[2], 1, 31),
_resolve_field(parts[3], 1, 12),
_resolve_field(parts[4], 0, 7),
)
if len(_cron_eval_cache) >= CRON_EVAL_CACHE_MAX:
_cron_eval_cache.popitem(last=False)
_cron_eval_cache[cron_expr] = parsed
return parsed
def _resolve_tz(tz_name: str | None) -> ZoneInfo:
if tz_name:
try:
return ZoneInfo(tz_name)
except Exception:
logger.warning("Invalid timezone '%s', using system local time", tz_name)
local_tz = datetime.now().astimezone().tzinfo
if local_tz is not None:
return local_tz
return ZoneInfo("UTC")
def _compute_cron_next(expr: str, tz_name: str | None, now_ms: int) -> int | None:
parsed = _parse_cron_expr(expr)
tz = _resolve_tz(tz_name)
dt = datetime.fromtimestamp(now_ms / 1000.0, tz=tz)
current_min = dt.minute
current_hour = dt.hour
current_day = dt.day
current_month = dt.month
current_year = dt.year
current_wday = (dt.weekday() + 1) % 7
minutes_set, hours_set, days_set, months_set, weekdays_set = parsed
max_iterations = 525600
for _ in range(max_iterations):
if months_set is not None and current_month not in months_set:
current_month += 1
if current_month > 12:
current_month = 1
current_year += 1
current_day = 1
current_hour = 0
current_min = -1
continue
month_days = calendar.monthrange(current_year, current_month)[1]
if days_set is not None and current_day not in days_set:
current_day += 1
if current_day > month_days:
current_day = 1
current_month += 1
if current_month > 12:
current_month = 1
current_year += 1
current_hour = 0
current_min = -1
continue
if hours_set is not None and current_hour not in hours_set:
current_hour += 1
if current_hour >= 24:
current_hour = 0
current_day += 1
current_wday = (current_wday + 1) % 7
if current_day > month_days:
current_day = 1
current_month += 1
if current_month > 12:
current_month = 1
current_year += 1
current_min = -1
continue
current_min += 1
if current_min >= 60:
current_min = 0
current_hour += 1
if current_hour >= 24:
current_hour = 0
current_day += 1
current_wday = (current_wday + 1) % 7
month_days = calendar.monthrange(current_year, current_month)[1]
if current_day > month_days:
current_day = 1
current_month += 1
if current_month > 12:
current_month = 1
current_year += 1
if (
minutes_set is not None
and current_min not in minutes_set
or hours_set is not None
and current_hour not in hours_set
or days_set is not None
and current_day not in days_set
or months_set is not None
and current_month not in months_set
or weekdays_set is not None
and current_wday not in weekdays_set
):
continue
try:
candidate = datetime(
current_year,
current_month,
current_day,
current_hour,
current_min,
0,
tzinfo=tz,
)
candidate_ms = int(candidate.timestamp() * 1000)
except (OverflowError, ValueError):
continue
if candidate_ms > now_ms:
return candidate_ms
logger.warning("Cron next-run computation exceeded %d iterations for expr='%s'", max_iterations, expr)
return now_ms + 60000
def _compute_cron_previous(expr: str, tz_name: str | None, now_ms: int) -> int | None:
parsed = _parse_cron_expr(expr)
tz = _resolve_tz(tz_name)
minutes_set, hours_set, days_set, months_set, weekdays_set = parsed
search_dt = datetime.fromtimestamp(now_ms / 1000.0, tz=tz)
max_iterations = 525600
for _ in range(max_iterations):
search_dt -= timedelta(minutes=1)
sm = search_dt.minute
sh = search_dt.hour
sd = search_dt.day
sM = search_dt.month
sw = (search_dt.weekday() + 1) % 7
if (
minutes_set is not None
and sm not in minutes_set
or hours_set is not None
and sh not in hours_set
or days_set is not None
and sd not in days_set
or months_set is not None
and sM not in months_set
or weekdays_set is not None
and sw not in weekdays_set
):
continue
candidate_ms = int(search_dt.timestamp() * 1000)
if candidate_ms < now_ms:
return candidate_ms
logger.warning("Cron previous-run computation exceeded %d iterations for expr='%s'", max_iterations, expr)
return None
def _compute_every_next(every_ms: int, anchor_ms: int, now_ms: int) -> int:
every = max(1, every_ms)
anchor = max(0, anchor_ms)
if now_ms < anchor:
return anchor
elapsed = now_ms - anchor
steps = max(1, (elapsed + every - 1) // every)
return anchor + steps * every
def is_recurring_top_of_hour_cron_expr(expr: str) -> bool:
fields = expr.strip().split()
if len(fields) == 5:
minute_field, hour_field = fields[0], fields[1]
return minute_field == "0" and "*" in hour_field
return False
def resolve_default_cron_stagger_ms(expr: str) -> int:
return DEFAULT_TOP_OF_HOUR_STAGGER_MS if is_recurring_top_of_hour_cron_expr(expr) else 0
def resolve_cron_stagger_ms(job: CronJob) -> int:
if job.stagger_ms > 0:
return job.stagger_ms
if job.schedule_kind == ScheduleKind.CRON:
return resolve_default_cron_stagger_ms(job.schedule_value)
return 0
def compute_next_run_at_ms(job: CronJob) -> int | None:
now_ms = int(time.time() * 1000)
if job.schedule_kind == ScheduleKind.AT:
try:
at_ms = int(job.schedule_value)
except (ValueError, TypeError):
try:
parsed = datetime.fromisoformat(job.schedule_value)
at_ms = int(parsed.timestamp() * 1000)
except (ValueError, TypeError):
return None
return at_ms if at_ms > now_ms else None
if job.schedule_kind == ScheduleKind.EVERY:
if job.every_ms is None or job.every_ms <= 0:
return None
raw = _compute_every_next(job.every_ms, job.anchor_ms or now_ms, now_ms)
stagger = job.stagger_ms
if stagger > 0:
raw += random.randint(0, stagger)
return raw
if job.schedule_kind == ScheduleKind.CRON:
raw = _compute_cron_next(job.schedule_value, job.schedule_tz, now_ms)
if raw is None:
return None
stagger = resolve_cron_stagger_ms(job)
if stagger > 0:
raw += random.randint(0, stagger)
return raw
return None
def compute_previous_run_at_ms(job: CronJob) -> int | None:
now_ms = int(time.time() * 1000)
if job.schedule_kind == ScheduleKind.CRON:
return _compute_cron_previous(job.schedule_value, job.schedule_tz, now_ms)
return None
def compute_next_with_error_tracking(job: CronJob) -> int | None:
try:
return compute_next_run_at_ms(job)
except Exception:
job.state.schedule_error_count += 1
logger.exception("CronEngine schedule computation error for '%s'", job.id)
if job.state.schedule_error_count >= 10:
job.status = CronJobStatus.FAILED
logger.error(
"CronEngine disabling job '%s' after %d schedule errors",
job.id,
job.state.schedule_error_count,
)
return None