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