mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-04 11:58:36 +00:00
fix(schedule): normalize a once schedule's next fire time to UTC
Ports #4607 onto the hexagonal path, where the same bug was reproduced: `next_after` returned ONCE's `run_at` with the task's declared offset still attached while the CRON branch returned UTC. That value is persisted into `scheduled_tasks.next_run_at`, and SQLAlchemy's SQLite dialect discards tzinfo on bind, so the stored instant was wrong by the whole offset -- a task declared in Asia/Shanghai fired eight hours late, and a negative offset fired early, slipping past the `min_once_delay_seconds` floor on the way. Postgres timestamptz normalizes on write, which is why only SQLite deployments were affected. `ensure_launchable` delegates to `next_after`, so both entry points are covered by the one conversion. The regression cases assert on `utcoffset()` rather than the instant, because the two are equal as instants either way -- it is the label that gets discarded on write. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
8ef0f002ba
commit
ee33b88893
@ -161,12 +161,21 @@ class ScheduleSpec:
|
|||||||
single occurrence is in the past). CRON is evaluated in this schedule's
|
single occurrence is in the past). CRON is evaluated in this schedule's
|
||||||
timezone and returned as UTC. A naive `now` is read as UTC
|
timezone and returned as UTC. A naive `now` is read as UTC
|
||||||
(schedules.py:32-33).
|
(schedules.py:32-33).
|
||||||
|
|
||||||
|
**Both branches return UTC** (#4607). `run_at` keeps the zone it was
|
||||||
|
declared in, so returning it unconverted handed the caller a non-UTC
|
||||||
|
offset while CRON handed back UTC. The value is persisted into
|
||||||
|
`scheduled_tasks.next_run_at`, and SQLAlchemy's SQLite dialect discards
|
||||||
|
tzinfo on bind -- so a once task declared in Asia/Shanghai fired eight
|
||||||
|
hours late, and a negative offset fired early, slipping past the
|
||||||
|
`min_once_delay_seconds` floor on the way. Postgres timestamptz
|
||||||
|
normalizes on write, which is why only SQLite deployments saw it.
|
||||||
"""
|
"""
|
||||||
if now.tzinfo is None:
|
if now.tzinfo is None:
|
||||||
now = now.replace(tzinfo=UTC)
|
now = now.replace(tzinfo=UTC)
|
||||||
|
|
||||||
if self.schedule_type is ScheduleType.ONCE:
|
if self.schedule_type is ScheduleType.ONCE:
|
||||||
return self.run_at if self.run_at > now else None
|
return self.run_at.astimezone(UTC) if self.run_at > now else None
|
||||||
|
|
||||||
zone = ZoneInfo(self.timezone)
|
zone = ZoneInfo(self.timezone)
|
||||||
next_local = croniter(self.cron, now.astimezone(zone)).get_next(datetime)
|
next_local = croniter(self.cron, now.astimezone(zone)).get_next(datetime)
|
||||||
|
|||||||
@ -231,6 +231,35 @@ class TestNextAfter:
|
|||||||
spec = cron_spec("0 9 * * *", "UTC")
|
spec = cron_spec("0 9 * * *", "UTC")
|
||||||
assert spec.next_after(NOW.replace(tzinfo=None)) == spec.next_after(NOW)
|
assert spec.next_after(NOW.replace(tzinfo=None)) == spec.next_after(NOW)
|
||||||
|
|
||||||
|
def test_once_in_a_non_utc_zone_is_returned_as_utc(self):
|
||||||
|
"""#4607: both branches must return UTC.
|
||||||
|
|
||||||
|
`run_at` keeps the zone it was declared in. Returning it unconverted
|
||||||
|
made ONCE disagree with CRON, and the value is persisted into a column
|
||||||
|
whose SQLite dialect discards tzinfo -- so the task fired a whole
|
||||||
|
offset late (+08:00 here). Comparing `utcoffset()` rather than the
|
||||||
|
instant is the point: the two are equal as instants either way, and it
|
||||||
|
is the *label* that gets thrown away on write.
|
||||||
|
"""
|
||||||
|
spec = ScheduleSpec.once_at(datetime(2026, 8, 2, 9, 0), "Asia/Shanghai") # noqa: DTZ001 -- naive local wall time is the subject
|
||||||
|
next_at = spec.next_after(NOW)
|
||||||
|
assert next_at.utcoffset() == timedelta(0)
|
||||||
|
assert next_at == datetime(2026, 8, 2, 1, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
def test_a_negative_offset_once_is_also_returned_as_utc(self):
|
||||||
|
"""The negative-offset half: this one fired *early*, which additionally
|
||||||
|
slipped past the `min_once_delay_seconds` floor."""
|
||||||
|
spec = ScheduleSpec.once_at(datetime(2026, 8, 2, 9, 0), "America/New_York") # noqa: DTZ001 -- naive local wall time is the subject
|
||||||
|
next_at = spec.next_after(NOW)
|
||||||
|
assert next_at.utcoffset() == timedelta(0)
|
||||||
|
assert next_at == datetime(2026, 8, 2, 13, 0, tzinfo=UTC) # EDT, UTC-4
|
||||||
|
|
||||||
|
def test_both_branches_agree_on_the_returned_offset(self):
|
||||||
|
"""The invariant behind the two cases above, stated once."""
|
||||||
|
once = ScheduleSpec.once_at(datetime(2026, 8, 2, 9, 0), "Asia/Shanghai") # noqa: DTZ001 -- naive local wall time is the subject
|
||||||
|
cron = cron_spec("0 9 * * *", "Asia/Shanghai")
|
||||||
|
assert once.next_after(NOW).utcoffset() == cron.next_after(NOW).utcoffset()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- C. ensure_launchable
|
# ---------------------------------------------------------------- C. ensure_launchable
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user