mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 16:08:41 +00:00
* 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>
132 lines
3.4 KiB
Python
132 lines
3.4 KiB
Python
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
|
|
from deerflow.scheduler.schedules import (
|
|
next_run_at,
|
|
normalize_cron_expression,
|
|
parse_interval_seconds,
|
|
validate_timezone,
|
|
)
|
|
|
|
|
|
def test_validate_timezone_accepts_iana_name():
|
|
assert validate_timezone("Asia/Shanghai") == "Asia/Shanghai"
|
|
|
|
|
|
def test_validate_timezone_rejects_unknown_name():
|
|
with pytest.raises(ValueError):
|
|
validate_timezone("Mars/Base")
|
|
|
|
|
|
def test_normalize_cron_accepts_five_fields():
|
|
assert normalize_cron_expression("0 9 * * 1") == "0 9 * * 1"
|
|
|
|
|
|
def test_normalize_cron_rejects_seconds_field():
|
|
with pytest.raises(ValueError):
|
|
normalize_cron_expression("0 0 9 * * 1")
|
|
|
|
|
|
def test_next_run_at_for_once_returns_none_after_fire_time():
|
|
now = datetime(2026, 7, 2, 2, 0, tzinfo=UTC)
|
|
result = next_run_at(
|
|
"once",
|
|
{"run_at": "2026-07-02T01:00:00+00:00"},
|
|
"UTC",
|
|
now=now,
|
|
)
|
|
assert result is None
|
|
|
|
|
|
def test_next_run_at_for_once_normalizes_naive_run_at_to_utc():
|
|
now = datetime(2026, 7, 31, 0, 0, tzinfo=UTC)
|
|
result = next_run_at(
|
|
"once",
|
|
{"run_at": "2026-08-01T09:00:00"},
|
|
"Asia/Shanghai",
|
|
now=now,
|
|
)
|
|
assert result == datetime(2026, 8, 1, 1, 0, tzinfo=UTC)
|
|
assert result.utcoffset() == timedelta(0)
|
|
|
|
|
|
def test_next_run_at_for_once_normalizes_aware_run_at_to_utc():
|
|
now = datetime(2026, 7, 31, 0, 0, tzinfo=UTC)
|
|
result = next_run_at(
|
|
"once",
|
|
{"run_at": "2026-08-01T09:00:00+08:00"},
|
|
"UTC",
|
|
now=now,
|
|
)
|
|
assert result == datetime(2026, 8, 1, 1, 0, tzinfo=UTC)
|
|
assert result.utcoffset() == timedelta(0)
|
|
|
|
|
|
def test_next_run_at_for_cron_uses_timezone():
|
|
now = datetime(2026, 7, 1, 0, 30, tzinfo=UTC)
|
|
result = next_run_at(
|
|
"cron",
|
|
{"cron": "0 9 * * *"},
|
|
"Asia/Shanghai",
|
|
now=now,
|
|
)
|
|
assert result == datetime(2026, 7, 1, 1, 0, tzinfo=UTC)
|
|
|
|
|
|
def test_next_run_at_for_interval_adds_seconds_in_utc():
|
|
now = datetime(2026, 7, 1, 0, 0, tzinfo=UTC)
|
|
result = next_run_at(
|
|
"interval",
|
|
{"every_seconds": 90},
|
|
"UTC",
|
|
now=now,
|
|
)
|
|
assert result == datetime(2026, 7, 1, 0, 1, 30, tzinfo=UTC)
|
|
assert result.utcoffset() == timedelta(0)
|
|
|
|
|
|
def test_next_run_at_for_interval_ignores_timezone():
|
|
now = datetime(2026, 7, 1, 0, 0, tzinfo=UTC)
|
|
shanghai = next_run_at(
|
|
"interval",
|
|
{"every_seconds": 5400},
|
|
"Asia/Shanghai",
|
|
now=now,
|
|
)
|
|
utc = next_run_at(
|
|
"interval",
|
|
{"every_seconds": 5400},
|
|
"UTC",
|
|
now=now,
|
|
)
|
|
assert shanghai == utc == datetime(2026, 7, 1, 1, 30, tzinfo=UTC)
|
|
|
|
|
|
def test_next_run_at_for_interval_does_not_catch_up_from_a_stale_now():
|
|
# A late poller must schedule from the compute instant, not fill missed beats.
|
|
now = datetime(2026, 7, 1, 0, 10, tzinfo=UTC)
|
|
result = next_run_at(
|
|
"interval",
|
|
{"every_seconds": 60},
|
|
"UTC",
|
|
now=now,
|
|
)
|
|
assert result == datetime(2026, 7, 1, 0, 11, tzinfo=UTC)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"spec",
|
|
[
|
|
{},
|
|
{"every_seconds": "90"},
|
|
{"every_seconds": 90.0},
|
|
{"every_seconds": True},
|
|
{"every_seconds": 0},
|
|
{"every_seconds": -30},
|
|
],
|
|
)
|
|
def test_parse_interval_seconds_rejects_invalid_spec(spec):
|
|
with pytest.raises(ValueError, match="every_seconds"):
|
|
parse_interval_seconds(spec)
|