-
Notifications
You must be signed in to change notification settings - Fork 749
Description
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
ZoneInfoobjects 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 loggedObserved 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 PDTThe 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 secondsWith 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
- Bi-hourly
CronTriggerruns into infinite loop at daylight savings time boundary #980 — CronTrigger infinite loop at DST boundary - CronTrigger runs into infinite loop at daylight savings time boundary #1021 — CronTrigger infinite loop at DST (separate report)
CronTrigger.nextreturns a non-existing date on DST change #1059 — CronTrigger returns nonexistent dates during spring forward- Add fix for get_next_fire_time not advancing through fold with unfolded previous_fire_time #1094 — May be related (unable to verify from our environment)
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