Skip to content

Hourly crontab skips execution during fall-back DST transition #10107

@basavakanaparthi-alation

Description

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions