fix(scheduler): normalize once-schedule next_run_at to UTC (#4607)

The once branch of next_run_at returned the run time with the task's
local timezone offset attached, while the cron branch normalizes to 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 a
non-UTC timezone fires shifted by the whole offset (e.g. 8 hours late
for Asia/Shanghai, hours early for negative offsets - also bypassing the
min_once_delay_seconds guard). Postgres timestamptz normalizes on write,
which is why only SQLite deployments are affected.

Align the once branch with the cron branch by converting to UTC before
returning.
This commit is contained in:
Baldwinzc 2026-07-31 17:27:51 +08:00 committed by GitHub
parent 80848837b7
commit 486b51eb51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 29 additions and 1 deletions

View File

@ -41,6 +41,10 @@ def next_run_at(
# 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":

View File

@ -1,4 +1,4 @@
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
import pytest
@ -38,6 +38,30 @@ def test_next_run_at_for_once_returns_none_after_fire_time():
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(