wutongyuonce 48a8978b7b
feat(scheduler): add interval schedule type (#5291)
* feat(scheduler): add interval schedule type

Allow scheduled tasks to fire every N seconds from last dispatch, not
only wall-clock cron or a single run_at. Cadence is UTC now+N with no
missed-beat catch-up, bounded by min_once_delay_seconds and 30 days.

* fix(scheduler): let interval tasks create, edit, and keep next run

Create/edit now keep every_seconds. Unchanged interval spec no longer
resets next_run_at, including timezone-only PATCH.

* fix(scheduler): keep non-minute intervals on edit

Stop rounding every_seconds to whole minutes in the form. Values that
are not whole minutes or hours now use a seconds unit so edit/duplicate
round-trips the stored cadence instead of rewriting it and resetting
next_run_at. Document that min_once_delay_seconds is also the interval
floor.

* fix(scheduler): clamp interval seconds to the default 60s floor

The new seconds unit allowed 1–59, which the API rejects under the
default min_once_delay_seconds. Clamp the form to >= 60 and show the
floor next to the preview. Also mention interval in the scheduler
field_doc, matching config.example.yaml.

* fix(scheduler): do not clamp interval amount while typing

Keystroke clamp made 90 become 9 -> 60, then 600, and backspace could
not leave 60. Keep the raw field text and apply the 60s floor on blur
and emit only.

* test(scheduler): cover interval input editing

* fix(frontend): preserve saved interval cadence until edited

* style(tests): format scheduled task router tests

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-10 16:46:01 +08:00

73 lines
2.6 KiB
Python

from __future__ import annotations
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from croniter import croniter
MAX_INTERVAL_SECONDS = 30 * 24 * 60 * 60
def validate_timezone(timezone_name: str) -> str:
try:
ZoneInfo(timezone_name)
except ZoneInfoNotFoundError as exc:
raise ValueError(f"Unknown timezone: {timezone_name}") from exc
return timezone_name
def normalize_cron_expression(expr: str) -> str:
parts = [part for part in expr.split() if part]
if len(parts) != 5:
raise ValueError("Cron expression must contain exactly 5 fields")
return " ".join(parts)
def parse_interval_seconds(schedule_spec: dict[str, object]) -> int:
raw = schedule_spec.get("every_seconds")
if isinstance(raw, bool) or not isinstance(raw, int) or raw < 1:
raise ValueError("interval schedule requires every_seconds as a positive integer")
return raw
def next_run_at(
schedule_type: str,
schedule_spec: dict[str, object],
timezone_name: str,
*,
now: datetime,
) -> datetime | None:
validate_timezone(timezone_name)
if now.tzinfo is None:
now = now.replace(tzinfo=UTC)
if schedule_type == "once":
run_at_raw = schedule_spec.get("run_at")
if not isinstance(run_at_raw, str):
raise ValueError("once schedule requires run_at")
run_at = datetime.fromisoformat(run_at_raw)
if run_at.tzinfo is None:
# A naive run_at means "wall-clock time in the task's declared
# timezone", matching how cron schedules interpret it.
run_at = run_at.replace(tzinfo=ZoneInfo(timezone_name))
# Normalize to UTC like the cron branch: next_run_at is persisted to
# timezone-discarding columns (SQLite), where a non-UTC offset shifts
# the effective fire time by the whole offset.
run_at = run_at.astimezone(UTC)
return run_at if run_at > now else None
if schedule_type == "cron":
cron_expr = normalize_cron_expression(str(schedule_spec.get("cron", "")))
zone = ZoneInfo(timezone_name)
local_now = now.astimezone(zone)
next_local = croniter(cron_expr, local_now).get_next(datetime)
if next_local.tzinfo is None:
next_local = next_local.replace(tzinfo=zone)
return next_local.astimezone(UTC)
if schedule_type == "interval":
every_seconds = parse_interval_seconds(schedule_spec)
return now.astimezone(UTC) + timedelta(seconds=every_seconds)
raise ValueError(f"Unsupported schedule_type: {schedule_type}")