319 lines
9.7 KiB
Python
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
|