Skip to content

[IntervalTrigger DST] scheduler wakeup loop computes incorrect sleep duration during DST spring forward with ZoneInfo timezone #1103

@ZuZuD

Description

@ZuZuD

Things to check first

  • I have checked that my issue does not already have a solution in the FAQ

  • I have searched the existing issues and didn't find my bug already reported there

  • I have checked that my bug is still present in the latest release

Version

3.10.4

What happened?

IntervalTrigger scheduler wakeup loop computes incorrect sleep duration during DST spring forward with ZoneInfo timezone

Summary

When BackgroundScheduler is configured with a ZoneInfo timezone (e.g. via tzlocal.get_localzone() in tzlocal 5.x), IntervalTrigger jobs with sub-minute intervals stop firing for exactly 1 hour during DST spring forward. The scheduler's _process_jobs() wakeup loop computes wait_seconds ≈ 3600 instead of ≈ 1 when the next fire time crosses the DST gap.

This caused job to stop for 1 hour during US DST spring forward on 2026-03-08.

Environment

  • APScheduler 3.10.4
  • tzlocal 5.3.1 (returns ZoneInfo objects instead of pytz)
  • Python 3.12
  • Timezone: America/Los_Angeles

Steps to reproduce

from zoneinfo import ZoneInfo
from apscheduler.schedulers.background import BackgroundScheduler

# Configure with ZoneInfo timezone (what tzlocal 5.x returns)
scheduler = BackgroundScheduler({"apscheduler.timezone": ZoneInfo("America/Los_Angeles")})
scheduler.start()

# Add a job that runs every 1 second
scheduler.add_job(my_func, 'interval', seconds=1)

# Observe behavior during DST spring forward (2:00 AM -> 3:00 AM):
# - Last successful run: 01:59:59 PST
# - Expected next run: 03:00:00 PDT (1 second later in UTC)
# - Actual next run: 04:00:00 PDT (1 hour later)
# - ~3600 "Run time of job was missed by 0:59:XX" warnings logged

Observed behavior

I0308 015959 Job executed successfully, next run at: 2026-03-08 03:00:00 PDT
W0308 040000 Run time of job "my_func" was missed by 0:59:59.123456
W0308 040000 Run time of job "my_func" was missed by 0:59:58.123456
W0308 040000 Run time of job "my_func" was missed by 0:59:57.123456
... (x3600 — one per missed second from 03:00:00 to 03:59:59)

The scheduler sleeps from 01:59:59 PST (09:59:59 UTC) until 04:00:00 PDT (11:00:00 UTC) — exactly 1 hour.

Expected behavior

BackgroundScheduler configured with a local ZoneInfo timezone should handle DST transitions correctly. The scheduler should wake up at 03:00:00 PDT (10:00:00 UTC) — only ~1 second after the last run — and continue firing every second. Users should be able to use get_localzone() (which returns ZoneInfo since tzlocal 5.x) without jobs silently stopping during DST transitions.

Analysis

The IntervalTrigger.get_next_fire_time() correctly computes the next fire time:

# interval.py:56
next_fire_time = previous_fire_time + self.interval
# 01:59:59 PST + 1s = 02:00:00 PST (nonexistent during spring forward)

# interval.py:68
return normalize(next_fire_time)
# normalize(02:00:00 PST) = 03:00:00 PDT (correct — same UTC instant)

The normalize() function (util.py:403) works correctly for individual fire times:

def normalize(dt):
    return datetime.fromtimestamp(dt.timestamp(), dt.tzinfo)
# Correctly maps 02:00:00 PST → 03:00:00 PDT

The bug is in the scheduler's _process_jobs() wakeup loop (base.py:1032-1033):

now = datetime.now(self.timezone)          # ~01:59:59 PST (ZoneInfo)
wait_seconds = min(max(timedelta_seconds(next_wakeup_time - now), 0), TIMEOUT_MAX)
# next_wakeup_time = 03:00:00 PDT (ZoneInfo)
# Expected: ~1 second (UTC difference: 10:00:00 - 09:59:59)
# Actual: ~3600 seconds

With pytz, both datetimes carried explicit fixed UTC offsets, and datetime.__sub__ correctly computed the UTC difference (~1 second). With ZoneInfo, the lazy offset resolution across the DST boundary in APScheduler's mixed pytz/ZoneInfo pipeline produces an incorrect result. APScheduler's internals import from pytz import timezone, utc (util.py:13) but self.timezone is now a ZoneInfo object from tzlocal 5.x, creating a mismatch.

The core issue is that the scheduler's wakeup loop should work correctly with any tzinfo-compliant timezone, including ZoneInfo. Since tzlocal 5.x returns ZoneInfo and APScheduler 3.9+ explicitly relaxed the pytz requirement, users reasonably expect get_localzone() to work.

Related issues

Those issues cover CronTrigger. This issue is about IntervalTrigger + the scheduler wakeup loop sleep duration computation.

Workaround

Configure the scheduler with UTC:

scheduler = BackgroundScheduler({"apscheduler.timezone": "UTC"})

This sidesteps the bug because UTC has no DST transitions, but it shouldn't be necessary — local timezones with DST should work correctly.

Affected versions

  • 3.10.4 (confirmed)
  • Likely all 3.9+ where ZoneInfo support was marked experimental (3.9.0 changelog: "No longer enforce pytz time zones (support for others is experimental)")
  • Not affected when using pytz timezones (tzlocal < 5.0)

How can we reproduce the bug?

#!/usr/bin/env python3
"""
Minimal reproduction of the APScheduler DST bug.
Simulates the scheduler's main loop across a DST spring-forward boundary,
using the exact functions from APScheduler 3.10.4.
"""
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
TZ = ZoneInfo("US/Pacific")  # scheduler3task.py:60 → get_localzone()
UTC = ZoneInfo("UTC")
# DST transition: 2026-03-08 02:00 AM PST → 03:00 AM PDT
DST_TRANSITION_UTC = datetime(2026, 3, 8, 10, 0, 0, tzinfo=UTC).timestamp()
# --- Copied from APScheduler 3.10.4 ---
def timedelta_seconds(delta):  # util.py:190-191
    return delta.days * 24 * 60 * 60 + delta.seconds + delta.microseconds / 1000000.0
def normalize(dt):  # util.py:403-404
    return datetime.fromtimestamp(dt.timestamp(), dt.tzinfo)
def get_next_fire_time(previous_fire_time, interval):  # interval.py:54-68
    next_fire_time = previous_fire_time + interval  # line 56
    return normalize(next_fire_time)  # line 68
def compute_wait_seconds(next_wakeup_time, now):  # base.py:1032-1033
    return max(timedelta_seconds(next_wakeup_time - now), 0)
# --- Scheduler main loop simulation ---
# Simulates blocking.py:27-32 _main_loop:
#   while running:
#       self._event.wait(wait_seconds)   ← sleep for wait_seconds real time
#       wait_seconds = self._process_jobs()
#
# _process_jobs (base.py:941-1037):
#   1. Fire due jobs
#   2. For each job: next_run_time = get_next_fire_time(last_run_time, now)
#   3. next_wakeup_time = min(all next_run_times).astimezone(self.timezone)
#   4. now = datetime.now(self.timezone)
#   5. wait_seconds = timedelta_seconds(next_wakeup_time - now)
interval = timedelta(seconds=1)
# Start 5 seconds before DST
simulated_utc = DST_TRANSITION_UTC - 5
print(f"DST transition at: {datetime.fromtimestamp(DST_TRANSITION_UTC, TZ)} "
      f"({datetime.fromtimestamp(DST_TRANSITION_UTC, UTC)} UTC)")
print()
print(f"{'cycle':>5}  {'now (local)':>30}  {'next_run (local)':>30}  "
      f"{'wait':>8}  {'real':>6}  {'note'}")
print("-" * 105)
for cycle in range(20):
    # datetime.now(self.timezone) — base.py:955,1032
    now = datetime.fromtimestamp(simulated_utc, TZ)
    # interval.py:56,68 — compute next fire time
    next_rt = get_next_fire_time(now, interval)
    # base.py:1018 — .astimezone(self.timezone)
    next_wakeup = next_rt.astimezone(TZ)
    # base.py:1033 — compute wait
    wait = compute_wait_seconds(next_wakeup, now)
    real = next_rt.timestamp() - now.timestamp()
    note = ""
    if abs(wait - real) > 2:
        note = f"BUG: scheduler sleeps {wait/3600:.1f}h instead of {real:.0f}s"
    print(f"{cycle:>5}  {str(now):>30}  {str(next_rt):>30}  "
          f"{wait:>8.1f}  {real:>6.1f}  {note}")
    # Simulate Event.wait(wait_seconds) — scheduler advances by `wait` real seconds
    simulated_utc += wait

Execution

DST transition at: 2026-03-08 03:00:00-07:00 (2026-03-08 10:00:00+00:00 UTC)

cycle                     now (local)                next_run (local)      wait    real  note
---------------------------------------------------------------------------------------------------------
    0       2026-03-08 01:59:55-08:00       2026-03-08 01:59:56-08:00       1.0     1.0  
    1       2026-03-08 01:59:56-08:00       2026-03-08 01:59:57-08:00       1.0     1.0  
    2       2026-03-08 01:59:57-08:00       2026-03-08 01:59:58-08:00       1.0     1.0  
    3       2026-03-08 01:59:58-08:00       2026-03-08 01:59:59-08:00       1.0     1.0  
    4       2026-03-08 01:59:59-08:00       2026-03-08 03:00:00-07:00    3601.0     1.0  BUG: scheduler sleeps 1.0h instead of 1s
    5       2026-03-08 04:00:00-07:00       2026-03-08 04:00:01-07:00       1.0     1.0  
    6       2026-03-08 04:00:01-07:00       2026-03-08 04:00:02-07:00       1.0     1.0  
    7       2026-03-08 04:00:02-07:00       2026-03-08 04:00:03-07:00       1.0     1.0  
    8       2026-03-08 04:00:03-07:00       2026-03-08 04:00:04-07:00       1.0     1.0  
    9       2026-03-08 04:00:04-07:00       2026-03-08 04:00:05-07:00       1.0     1.0  
   10       2026-03-08 04:00:05-07:00       2026-03-08 04:00:06-07:00       1.0     1.0  
   11       2026-03-08 04:00:06-07:00       2026-03-08 04:00:07-07:00       1.0     1.0  
   12       2026-03-08 04:00:07-07:00       2026-03-08 04:00:08-07:00       1.0     1.0  
   13       2026-03-08 04:00:08-07:00       2026-03-08 04:00:09-07:00       1.0     1.0  
   14       2026-03-08 04:00:09-07:00       2026-03-08 04:00:10-07:00       1.0     1.0  
   15       2026-03-08 04:00:10-07:00       2026-03-08 04:00:11-07:00       1.0     1.0  
   16       2026-03-08 04:00:11-07:00       2026-03-08 04:00:12-07:00       1.0     1.0  
   17       2026-03-08 04:00:12-07:00       2026-03-08 04:00:13-07:00       1.0     1.0  
   18       2026-03-08 04:00:13-07:00       2026-03-08 04:00:14-07:00       1.0     1.0  
   19       2026-03-08 04:00:14-07:00       2026-03-08 04:00:15-07:00       1.0     1.0  

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions