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:
rayhpeng 2026-08-03 12:03:22 +08:00
parent 8ef0f002ba
commit ee33b88893
2 changed files with 39 additions and 1 deletions

View File

@ -161,12 +161,21 @@ class ScheduleSpec:
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
(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:
now = now.replace(tzinfo=UTC)
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)
next_local = croniter(self.cron, now.astimezone(zone)).get_next(datetime)

View File

@ -231,6 +231,35 @@ class TestNextAfter:
spec = cron_spec("0 9 * * *", "UTC")
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