mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 05:06:59 +00:00
fix(scheduler): prevent fixed-hour cron tasks from firing twice on DST fall-back (#6066)
* fix(scheduler): prevent fixed-hour cron tasks from firing twice on DST fall-back * fix(scheduler): restrict fixed-time DST skip to single-time cron and add edge tests --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
b37f769b67
commit
73fdbcfdf4
@ -327,6 +327,13 @@ This release closes that milestone with **181 merged pull requests**.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **scheduler:** Fixed-hour cron tasks no longer fire twice on the daylight-saving
|
||||
fall-back day. `croniter` returns both occurrences of an ambiguous wall-clock
|
||||
hour (the first with `fold=0`, the second with `fold=1`). For tasks where
|
||||
neither minute nor hour contains a wildcard, the second occurrence is skipped
|
||||
to preserve once-per-day semantics (Vixie cron contract), while wildcard
|
||||
schedules (such as `0 * * * *`) still run in both occurrences of the repeated
|
||||
hour. (issue #6052, [#6066])
|
||||
- **persistence:** A SQLite `checkpointer.connection_string` written as a
|
||||
`file:` URI now fails at startup instead of silently writing somewhere else.
|
||||
LangGraph's SQLite checkpointer and Store open connection strings without
|
||||
@ -6259,5 +6266,6 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
||||
[#5987]: https://github.com/bytedance/deer-flow/pull/5987
|
||||
[#5991]: https://github.com/bytedance/deer-flow/pull/5991
|
||||
[#6015]: https://github.com/bytedance/deer-flow/pull/6015
|
||||
[#6066]: https://github.com/bytedance/deer-flow/pull/6066
|
||||
[#6069]: https://github.com/bytedance/deer-flow/pull/6069
|
||||
|
||||
|
||||
@ -332,6 +332,10 @@
|
||||
|
||||
### 修复
|
||||
|
||||
- **调度器:** 固定小时的 cron 任务在夏令时回退(DST fall-back)当天不再重复运行两次。
|
||||
`croniter` 会返回模糊本地时间的两个实例(首个为 `fold=0`,第二个为 `fold=1`)。
|
||||
对于分和时字段不包含通配符的固定任务,现在会跳过第二个重复实例(`fold=1`),保持每天只运行一次
|
||||
的契约(Vixie cron 规范),同时通配符计划(如 `0 * * * *`)仍会在重复的小时内每小时正常触发。(Issue #6052, [#6066])
|
||||
- **持久化:** SQLite `checkpointer.connection_string` 若写成 `file:` URI,现在会在
|
||||
启动时报错,而不是悄悄写到别处。LangGraph 的 SQLite checkpointer 与 Store 打开
|
||||
连接串时未传 `uri=True`,SQLite 会把 URI 当作字面文件名:
|
||||
@ -5310,4 +5314,5 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
||||
[#5987]: https://github.com/bytedance/deer-flow/pull/5987
|
||||
[#5991]: https://github.com/bytedance/deer-flow/pull/5991
|
||||
[#6015]: https://github.com/bytedance/deer-flow/pull/6015
|
||||
[#6066]: https://github.com/bytedance/deer-flow/pull/6066
|
||||
[#6069]: https://github.com/bytedance/deer-flow/pull/6069
|
||||
|
||||
@ -30,6 +30,26 @@ def parse_interval_seconds(schedule_spec: dict[str, object]) -> int:
|
||||
return raw
|
||||
|
||||
|
||||
def _is_fixed_time_cron(cron_expr: str) -> bool:
|
||||
"""Return whether a cron expression specifies a single fixed minute and hour.
|
||||
|
||||
Expressions with wildcards ('*'), steps ('/'), ranges ('-'), or lists (',')
|
||||
in either the minute or hour field (e.g. '0 0-23 * * *' or '*/15 2 * * *')
|
||||
fire multiple times and should preserve all occurrences across repeated hours.
|
||||
"""
|
||||
parts = cron_expr.strip().split()
|
||||
if len(parts) >= 2:
|
||||
minute, hour = parts[0], parts[1]
|
||||
special = ("*", "/", "-", ",")
|
||||
return not any(ch in minute or ch in hour for ch in special)
|
||||
return False
|
||||
|
||||
|
||||
def _is_ambiguous_datetime(dt: datetime) -> bool:
|
||||
"""Return whether a local datetime is ambiguous (e.g. during a DST fall-back)."""
|
||||
return dt.replace(fold=0).utcoffset() != dt.replace(fold=1).utcoffset()
|
||||
|
||||
|
||||
def next_run_at(
|
||||
schedule_type: str,
|
||||
schedule_spec: dict[str, object],
|
||||
@ -60,11 +80,24 @@ def next_run_at(
|
||||
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)
|
||||
it = croniter(cron_expr, local_now)
|
||||
next_local = it.get_next(datetime)
|
||||
if next_local.tzinfo is None:
|
||||
next_local = next_local.replace(tzinfo=zone)
|
||||
return next_local.astimezone(UTC)
|
||||
|
||||
# During a daylight-saving fall-back (e.g. 03:00 -> 02:00), an ambiguous
|
||||
# wall-clock hour repeats twice. croniter returns both occurrences (the
|
||||
# first with fold=0, the second with fold=1). For fixed-time tasks
|
||||
# (where neither minute nor hour contains '*'), running twice on the
|
||||
# same calendar day violates the cron contract (Vixie cron / POSIX
|
||||
# behavior). Skip the second occurrence (fold=1) to preserve once-per-day
|
||||
# semantics while letting wildcard schedules (e.g. '0 * * * *') fire each hour.
|
||||
if next_local.fold == 1 and _is_fixed_time_cron(cron_expr) and _is_ambiguous_datetime(next_local):
|
||||
next_local = it.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)
|
||||
|
||||
@ -74,6 +74,85 @@ def test_next_run_at_for_cron_uses_timezone():
|
||||
assert result == datetime(2026, 7, 1, 1, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_next_run_at_for_cron_skips_duplicate_fixed_hour_run_on_dst_fallback():
|
||||
"""A fixed-hour task runs once per day on DST fall-back (Vixie cron contract)."""
|
||||
# Europe/Berlin fall-back: 2026-10-25 03:00 CEST turns back to 02:00 CET.
|
||||
# 02:30 is ambiguous and occurs twice.
|
||||
now = datetime(2026, 10, 24, 12, 0, tzinfo=UTC)
|
||||
first = next_run_at("cron", {"cron": "30 2 * * *"}, "Europe/Berlin", now=now)
|
||||
assert first == datetime(2026, 10, 25, 0, 30, tzinfo=UTC) # 02:30 CEST (+02:00)
|
||||
|
||||
second = next_run_at("cron", {"cron": "30 2 * * *"}, "Europe/Berlin", now=first)
|
||||
assert second == datetime(2026, 10, 26, 1, 30, tzinfo=UTC) # 02:30 CET (+01:00) next day
|
||||
|
||||
# America/New_York fall-back: 2026-11-01 02:00 EDT turns back to 01:00 EST.
|
||||
# 01:30 is ambiguous and occurs twice.
|
||||
ny_now = datetime(2026, 10, 31, 12, 0, tzinfo=UTC)
|
||||
ny_first = next_run_at("cron", {"cron": "30 1 * * *"}, "America/New_York", now=ny_now)
|
||||
assert ny_first == datetime(2026, 11, 1, 5, 30, tzinfo=UTC) # 01:30 EDT (-04:00)
|
||||
|
||||
ny_second = next_run_at("cron", {"cron": "30 1 * * *"}, "America/New_York", now=ny_first)
|
||||
assert ny_second == datetime(2026, 11, 2, 6, 30, tzinfo=UTC) # 01:30 EST (-05:00) next day
|
||||
|
||||
# Australia/Sydney fall-back (Southern Hemisphere): 2027-04-04 03:00 AEDT (+11:00) turns back to 02:00 AEST (+10:00).
|
||||
# 02:30 is ambiguous and occurs twice.
|
||||
syd_now = datetime(2027, 4, 3, 12, 0, tzinfo=UTC)
|
||||
syd_first = next_run_at("cron", {"cron": "30 2 * * *"}, "Australia/Sydney", now=syd_now)
|
||||
assert syd_first == datetime(2027, 4, 3, 15, 30, tzinfo=UTC) # 02:30 AEDT (+11:00)
|
||||
|
||||
syd_second = next_run_at("cron", {"cron": "30 2 * * *"}, "Australia/Sydney", now=syd_first)
|
||||
assert syd_second == datetime(2027, 4, 4, 16, 30, tzinfo=UTC) # 02:30 AEST (+10:00) next day
|
||||
|
||||
|
||||
def test_next_run_at_for_cron_spring_forward_does_not_skip_day():
|
||||
"""Europe/Berlin spring-forward: 2027-03-28 02:00 CET jumps to 03:00 CEST.
|
||||
02:30 does not exist and resolves to 03:00 with fold=0; must not be skipped."""
|
||||
b_now = datetime(2027, 3, 27, 13, 0, tzinfo=UTC)
|
||||
first = next_run_at("cron", {"cron": "30 2 * * *"}, "Europe/Berlin", now=b_now)
|
||||
assert first == datetime(2027, 3, 28, 1, 0, tzinfo=UTC) # 03:00 CEST (+02:00)
|
||||
|
||||
second = next_run_at("cron", {"cron": "30 2 * * *"}, "Europe/Berlin", now=first)
|
||||
assert second == datetime(2027, 3, 29, 0, 30, tzinfo=UTC) # 02:30 CEST next day
|
||||
|
||||
|
||||
def test_next_run_at_for_cron_wildcards_fire_in_repeated_dst_hour():
|
||||
"""Wildcard schedules (e.g. hourly) fire in both occurrences of the repeated hour."""
|
||||
now = datetime(2026, 10, 24, 23, 0, tzinfo=UTC)
|
||||
# 02:00 CEST (+02:00) -> 00:00 UTC
|
||||
dt1 = next_run_at("cron", {"cron": "0 * * * *"}, "Europe/Berlin", now=now)
|
||||
assert dt1 == datetime(2026, 10, 25, 0, 0, tzinfo=UTC)
|
||||
|
||||
# 02:00 CET (+01:00) -> 01:00 UTC
|
||||
dt2 = next_run_at("cron", {"cron": "0 * * * *"}, "Europe/Berlin", now=dt1)
|
||||
assert dt2 == datetime(2026, 10, 25, 1, 0, tzinfo=UTC)
|
||||
|
||||
# 03:00 CET (+01:00) -> 02:00 UTC
|
||||
dt3 = next_run_at("cron", {"cron": "0 * * * *"}, "Europe/Berlin", now=dt2)
|
||||
assert dt3 == datetime(2026, 10, 25, 2, 0, tzinfo=UTC)
|
||||
|
||||
# Step-minute schedule (*/15 2 * * *) fires in both occurrences of the repeated hour
|
||||
now_step = datetime(2026, 10, 24, 23, 45, tzinfo=UTC)
|
||||
step_first = next_run_at("cron", {"cron": "*/15 2 * * *"}, "Europe/Berlin", now=now_step)
|
||||
assert step_first == datetime(2026, 10, 25, 0, 0, tzinfo=UTC) # 02:00 CEST
|
||||
|
||||
# Hour range schedule (0 0-23 * * *) is semantically hourly and fires in both occurrences
|
||||
range_dt1 = next_run_at("cron", {"cron": "0 0-23 * * *"}, "Europe/Berlin", now=now)
|
||||
assert range_dt1 == datetime(2026, 10, 25, 0, 0, tzinfo=UTC) # 02:00 CEST (+02:00)
|
||||
|
||||
range_dt2 = next_run_at("cron", {"cron": "0 0-23 * * *"}, "Europe/Berlin", now=range_dt1)
|
||||
assert range_dt2 == datetime(2026, 10, 25, 1, 0, tzinfo=UTC) # 02:00 CET (+01:00)
|
||||
|
||||
range_dt3 = next_run_at("cron", {"cron": "0 0-23 * * *"}, "Europe/Berlin", now=range_dt2)
|
||||
assert range_dt3 == datetime(2026, 10, 25, 2, 0, tzinfo=UTC) # 03:00 CET (+01:00)
|
||||
|
||||
# Hour list schedule (0 1,2,3 * * *) also fires in both occurrences of hour 2
|
||||
list_dt1 = next_run_at("cron", {"cron": "0 1,2,3 * * *"}, "Europe/Berlin", now=now)
|
||||
assert list_dt1 == datetime(2026, 10, 25, 0, 0, tzinfo=UTC) # 02:00 CEST
|
||||
|
||||
list_dt2 = next_run_at("cron", {"cron": "0 1,2,3 * * *"}, "Europe/Berlin", now=list_dt1)
|
||||
assert list_dt2 == datetime(2026, 10, 25, 1, 0, tzinfo=UTC) # 02:00 CET
|
||||
|
||||
|
||||
def test_next_run_at_for_interval_adds_seconds_in_utc():
|
||||
now = datetime(2026, 7, 1, 0, 0, tzinfo=UTC)
|
||||
result = next_run_at(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user