Description
Hourly crontab schedules skip one execution during fall-back DST transitions when the same local hour occurs twice.
During fall-back (e.g., when clocks go from 1:59 AM back to 1:00 AM), an hourly task that ran at the first 1:00 AM will not run at the second 1:00 AM, even though a full hour has elapsed in real time.
Celery Version
- Celery: 5.6.2
- Python: 3.11+
Reproduction
from datetime import datetime
from zoneinfo import ZoneInfo
from celery.schedules import crontab
# Fall-back Nov 1, 2026 US/Pacific:
# 8:00 UTC = 1:00 AM PDT (before clocks fall back)
# 9:00 UTC = 1:00 AM PST (after clocks fall back)
schedule = crontab(minute='0', hour='*')
schedule.tz = ZoneInfo('US/Pacific')
schedule.nowfun = lambda: datetime(2026, 11, 1, 9, 0) # 9 UTC = 1 AM PST
result = schedule.is_due(datetime(2026, 11, 1, 8, 0)) # 8 UTC = 1 AM PDT (last run)
print(f'is_due: {result.is_due}') # False - BUG: should be True
Expected: is_due=True (one hour has passed in real time)
Actual: is_due=False (task skips execution)
Root Cause
In crontab.remaining_estimate(), when comparing last_run_at to the current time, the method doesn't account for the UTC offset change during fall-back transitions. Since both times map to the same local hour (1 AM), Celery incorrectly determines the task already ran.
Impact
Any hourly scheduled task will miss one execution per year during the fall-back DST transition in timezones that observe DST. This affects:
0 * * * * (top of hour)
5 * * * * (any minute, every hour)
- Any
hour='*' crontab schedule
Suggested Fix
Detect when the UTC offset has decreased by 1 hour (fall-back) and adjust last_run_at accordingly before comparison:
def remaining_estimate(self, last_run_at, ffwd=ffwd):
def _adjust_time(t):
return self.to_local(self.maybe_make_aware(t))
utc_now = self.now()
local_now = _adjust_time(utc_now)
local_hour_ago = _adjust_time(utc_now - timedelta(hours=1))
offset_now = local_now.utcoffset()
offset_hour_ago = local_hour_ago.utcoffset()
if offset_now is not None and offset_hour_ago is not None:
time_fell_back = (offset_now - offset_hour_ago).total_seconds() == -3600.0
cron_is_hourly = getattr(self, "_orig_hour", None) == "*"
if time_fell_back and cron_is_hourly and (utc_now > last_run_at):
last_run_at = last_run_at - timedelta(hours=1)
return _old_remaining_estimate(self, last_run_at, ffwd)
Related Issues
Happy to submit a PR with the fix and tests if this approach is acceptable.
Description
Hourly crontab schedules skip one execution during fall-back DST transitions when the same local hour occurs twice.
During fall-back (e.g., when clocks go from 1:59 AM back to 1:00 AM), an hourly task that ran at the first 1:00 AM will not run at the second 1:00 AM, even though a full hour has elapsed in real time.
Celery Version
Reproduction
Expected:
is_due=True(one hour has passed in real time)Actual:
is_due=False(task skips execution)Root Cause
In
crontab.remaining_estimate(), when comparinglast_run_atto the current time, the method doesn't account for the UTC offset change during fall-back transitions. Since both times map to the same local hour (1 AM), Celery incorrectly determines the task already ran.Impact
Any hourly scheduled task will miss one execution per year during the fall-back DST transition in timezones that observe DST. This affects:
0 * * * *(top of hour)5 * * * *(any minute, every hour)hour='*'crontab scheduleSuggested Fix
Detect when the UTC offset has decreased by 1 hour (fall-back) and adjust
last_run_ataccordingly before comparison:Related Issues
Happy to submit a PR with the fix and tests if this approach is acceptable.