This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
commit f9fc6143fa5ed382d6750286cf643cb2436bd6e7
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Thu Oct 29 13:23:45 2026 +0000
[v3-4-test] Fix hourly cron schedules running an hour off across a DST
change (#75847) (#75953)
Co-authored-by: Wei Lee <[email protected]>
---
airflow-core/src/airflow/timetables/_cron.py | 69 ++++-
.../tests/unit/timetables/test_cron_mixin.py | 279 ++++++++++++++++++++-
2 files changed, 343 insertions(+), 5 deletions(-)
diff --git a/airflow-core/src/airflow/timetables/_cron.py
b/airflow-core/src/airflow/timetables/_cron.py
index 245cc3fbc39..ac0438e7a0c 100644
--- a/airflow-core/src/airflow/timetables/_cron.py
+++ b/airflow-core/src/airflow/timetables/_cron.py
@@ -31,6 +31,27 @@ if TYPE_CHECKING:
from pendulum.tz.timezone import FixedTimezone, Timezone
+# How far the fold search looks back for a fold twin: tzdata 2020-2030 has no
DST shift above 2 hours
+# (Antarctica/Troll); Casey's 3 hours was a change of standard time, not DST.
+_MAX_DST_SHIFT = datetime.timedelta(hours=2)
+
+
+def _localize_all_folds(naive: datetime.datetime, timezone: Timezone |
FixedTimezone) -> list[DateTime]:
+ """
+ Return every UTC instant a naive wall-clock time maps to, earliest first.
+
+ A wall-clock time inside a fall-back fold occurs twice, but ``make_aware``
only returns the later one.
+ A time skipped by a spring-forward gap never occurs, so it maps to nothing.
+ """
+ first = timezone.convert(naive.replace(fold=0))
+ second = timezone.convert(naive.replace(fold=1))
+ if first.replace(tzinfo=None) != naive:
+ return []
+ if first.utcoffset() != second.utcoffset():
+ return [convert_to_utc(first), convert_to_utc(second)]
+ return [convert_to_utc(first)]
+
+
def _covers_every_hour(cron: croniter) -> bool:
"""
Check whether the given cron runs at least once an hour.
@@ -153,6 +174,34 @@ class CronMixin:
"""
return convert_to_utc(make_aware(dt.replace(tzinfo=None),
self._timezone))
+ def _localized_ticks_within_limit(self, naive: datetime.datetime, *,
before: bool) -> list[DateTime]:
+ """Localize every cron tick within ``_MAX_DST_SHIFT`` on one side of
``naive``, ``naive`` included."""
+ nudge = datetime.timedelta(microseconds=1)
+ cron = croniter(self._expression, start_time=naive + nudge if before
else naive - nudge)
+ step = cron.get_prev if before else cron.get_next
+ localized: list[DateTime] = []
+ tick = step(datetime.datetime)
+ while abs(tick - naive) <= _MAX_DST_SHIFT:
+ localized += _localize_all_folds(tick, self._timezone)
+ tick = step(datetime.datetime)
+ return localized
+
+ def _localize_if_unambiguous(
+ self, naive: datetime.datetime, scheduled: datetime.datetime
+ ) -> DateTime | None:
+ """
+ Localize ``scheduled`` when neither it nor ``naive`` is ambiguous,
else return ``None``.
+
+ croniter yields ticks in wall-clock order, so none lies between
``naive`` and ``scheduled``;
+ a fold or gap between them holds no tick, and localizing an
unambiguous wall-clock time is
+ exact. An earlier tick can only cross ``current`` through a fold at
``naive`` itself, and a
+ fold or gap at ``scheduled`` has two or no instants; those cases are
left to the fold search.
+ """
+ if len(_localize_all_folds(naive, self._timezone)) != 1:
+ return None
+ localized = _localize_all_folds(scheduled, self._timezone)
+ return localized[0] if len(localized) == 1 else None
+
def _get_next(self, current: DateTime) -> DateTime:
"""Get the first schedule after specified time, with DST fixed."""
naive = make_naive(current, self._timezone)
@@ -162,8 +211,16 @@ class CronMixin:
assert isinstance(scheduled, datetime.datetime)
if not _covers_every_hour(cron):
return convert_to_utc(make_aware(scheduled, self._timezone))
- delta = scheduled - naive
- return convert_to_utc(current.in_timezone(self._timezone) + delta)
+
+ if (exact := self._localize_if_unambiguous(naive, scheduled)) is not
None:
+ return exact
+ # Ticks within ``_MAX_DST_SHIFT`` before ``naive`` may have a later
fold occurrence after ``current``.
+ candidates = [c for c in self._localized_ticks_within_limit(naive,
before=True) if c > current]
+ # croniter is strictly increasing on naive wall clock and DST gaps are
finite, so a
+ # candidate strictly after ``current`` is reached in finitely many
steps.
+ while not (forward := [c for c in _localize_all_folds(scheduled,
self._timezone) if c > current]):
+ scheduled = cron.get_next(datetime.datetime)
+ return min(candidates + forward)
def _get_prev(self, current: DateTime) -> DateTime:
"""Get the first schedule strictly before specified time, with DST
fixed."""
@@ -181,8 +238,12 @@ class CronMixin:
scheduled = cron.get_prev(datetime.datetime)
prev = convert_to_utc(make_aware(scheduled, self._timezone))
return prev
- delta = naive - scheduled
- return convert_to_utc(current.in_timezone(self._timezone) - delta)
+ if (exact := self._localize_if_unambiguous(naive, scheduled)) is not
None:
+ return exact
+ candidates = [c for c in self._localized_ticks_within_limit(naive,
before=False) if c < current]
+ while not (backward := [c for c in _localize_all_folds(scheduled,
self._timezone) if c < current]):
+ scheduled = cron.get_prev(datetime.datetime)
+ return max(candidates + backward)
def _align_to_next(self, current: DateTime) -> DateTime:
"""
diff --git a/airflow-core/tests/unit/timetables/test_cron_mixin.py
b/airflow-core/tests/unit/timetables/test_cron_mixin.py
index d8d5ff44df3..2f694b084d7 100644
--- a/airflow-core/tests/unit/timetables/test_cron_mixin.py
+++ b/airflow-core/tests/unit/timetables/test_cron_mixin.py
@@ -16,7 +16,12 @@
# under the License.
from __future__ import annotations
-from airflow.timetables._cron import CronMixin
+import datetime
+
+import pendulum
+import pytest
+
+from airflow.timetables._cron import CronMixin, _localize_all_folds
SAMPLE_TZ = "UTC"
@@ -39,3 +44,275 @@ def test_dom_and_dow_conflict():
assert "(or)" in desc
assert "Every minute, on day 1 of the month" in desc
assert "Every minute, only on Monday" in desc
+
+
[email protected](
+ ("timezone", "friday_last_tick", "monday_first_tick"),
+ [
+ pytest.param(
+ "Europe/Berlin",
+ pendulum.datetime(2026, 10, 23, 21, 0, tz="UTC"), # Fri 23:00 CEST
+ pendulum.datetime(2026, 10, 25, 23, 0, tz="UTC"), # Mon 00:00 CET
+ id="berlin-fall-back",
+ ),
+ pytest.param(
+ "America/Chicago",
+ pendulum.datetime(2026, 10, 31, 4, 0, tz="UTC"), # Fri 2026-10-30
23:00 CDT
+ pendulum.datetime(2026, 11, 2, 6, 0, tz="UTC"), # Mon 00:00 CST
+ id="chicago-fall-back",
+ ),
+ pytest.param(
+ "Australia/Sydney",
+ pendulum.datetime(2026, 4, 3, 12, 0, tz="UTC"), # Fri 23:00 AEDT
+ pendulum.datetime(2026, 4, 5, 14, 0, tz="UTC"), # Mon 00:00 AEST
+ id="sydney-fall-back",
+ ),
+ pytest.param(
+ "Europe/Berlin",
+ pendulum.datetime(2027, 3, 26, 22, 0, tz="UTC"), # Fri 23:00 CET
+ pendulum.datetime(2027, 3, 28, 22, 0, tz="UTC"), # Mon 00:00 CEST
+ id="berlin-spring-forward",
+ ),
+ ],
+)
+def test_weekday_hourly_across_weekend_dst(
+ timezone: str, friday_last_tick: pendulum.DateTime, monday_first_tick:
pendulum.DateTime
+):
+ mixin = CronMixin("0 * * * 1-5", timezone)
+
+ assert mixin._get_next(friday_last_tick) == monday_first_tick
+ assert mixin._get_prev(monday_first_tick) == friday_last_tick
+
+
[email protected](
+ ("timezone", "cron", "current", "expected"),
+ [
+ pytest.param(
+ "America/Havana",
+ "0 * * * 0",
+ pendulum.datetime(2026, 10, 26, 3, 0, tz="UTC"),
+ # Sun 00:00 -04:00, first of the folded hour
+ pendulum.datetime(2026, 11, 1, 4, 0, tz="UTC"),
+ id="havana-target-in-fold-hour",
+ ),
+ pytest.param(
+ "Europe/Berlin",
+ "0 * * * *",
+ pendulum.datetime(2026, 10, 25, 0, 0, tz="UTC"),
+ pendulum.datetime(2026, 10, 25, 1, 0, tz="UTC"),
+ id="berlin-fold-first-step",
+ ),
+ pytest.param(
+ "Europe/Berlin",
+ "0 * * * *",
+ pendulum.datetime(2026, 10, 25, 1, 0, tz="UTC"),
+ pendulum.datetime(2026, 10, 25, 2, 0, tz="UTC"),
+ id="berlin-fold-second-step",
+ ),
+ pytest.param(
+ "America/Havana",
+ "0 * * * 0",
+ pendulum.datetime(2027, 3, 13, 4, 0, tz="UTC"),
+ # Sun 00:00 is skipped by the spring-forward gap, so the next tick
is the following hour
+ pendulum.datetime(2027, 3, 14, 5, 0, tz="UTC"),
+ id="havana-next-target-in-gap",
+ ),
+ pytest.param(
+ "America/Godthab",
+ "0 * * * 6",
+ pendulum.datetime(2026, 10, 25, 0, 0, tz="UTC"),
+ # Sat 23:00 -02:00 is followed by its fold twin Sat 23:00 -03:00
+ pendulum.datetime(2026, 10, 25, 1, 0, tz="UTC"),
+ id="godthab-next-fold-twin",
+ ),
+ pytest.param(
+ "America/Santiago",
+ "0 * * * 6",
+ pendulum.datetime(2026, 4, 5, 2, 0, tz="UTC"),
+ # Sat 23:00 -03:00 is followed by its fold twin Sat 23:00 -04:00
+ pendulum.datetime(2026, 4, 5, 3, 0, tz="UTC"),
+ id="santiago-next-fold-twin",
+ ),
+ pytest.param(
+ "America/Godthab",
+ "0,30 * * * 6",
+ pendulum.datetime(2026, 10, 25, 0, 30, tz="UTC"),
+ # Sat 23:00 -02:00 (second pass of the fold hour) comes before the
twin Sat 23:30 -02:00
+ pendulum.datetime(2026, 10, 25, 1, 0, tz="UTC"),
+ id="godthab-next-half-hour-fold",
+ ),
+ pytest.param(
+ "Australia/Lord_Howe",
+ "0,10 * * * 0",
+ pendulum.datetime(2026, 4, 4, 14, 10, tz="UTC"),
+ # Sun 02:00 +10:30, first tick after the 30 minute fall-back
+ pendulum.datetime(2026, 4, 4, 15, 30, tz="UTC"),
+ id="lord-howe-next-half-hour-dst",
+ ),
+ pytest.param(
+ "Australia/Lord_Howe",
+ "0,45 * * * 0",
+ pendulum.datetime(2026, 4, 4, 14, 10, tz="UTC"),
+ # Sun 01:45 +11:00, the first pass of the folded 01:30-02:00 range
+ pendulum.datetime(2026, 4, 4, 14, 45, tz="UTC"),
+ id="lord-howe-next-tick-in-fold",
+ ),
+ pytest.param(
+ "Antarctica/Troll",
+ "0,10 * * * 0",
+ pendulum.datetime(2026, 10, 25, 0, 15, tz="UTC"),
+ # Sun 01:00 +01:00 after the two hour fall-back, not the later
twin of Sun 02:00 +02:00
+ pendulum.datetime(2026, 10, 25, 1, 0, tz="UTC"),
+ id="troll-next-two-hour-fold",
+ ),
+ pytest.param(
+ "America/Havana",
+ "0 * 1 3,11 *",
+ pendulum.datetime(2026, 3, 2, 4, 0, tz="UTC"),
+ # Sun 00:00 -04:00, the earlier of the two midnights; both ends of
the span are -05:00
+ pendulum.datetime(2026, 11, 1, 4, 0, tz="UTC"),
+ id="havana-next-sparse-cron-target-in-fold",
+ ),
+ pytest.param(
+ "America/Godthab",
+ "0 * * * 0",
+ pendulum.datetime(2026, 10, 25, 0, 30, tz="UTC"),
+ # current is Sat 23:30 -01:00, the first pass of the fall-back
fold; only Sunday has ticks,
+ # so the answer is Sun 00:00 -02:00, not the second pass of
current's own hour
+ pendulum.datetime(2026, 10, 25, 2, 0, tz="UTC"),
+ id="godthab-next-day-boundary-fold",
+ ),
+ ],
+)
+def test_get_next_across_dst(
+ timezone: str, cron: str, current: pendulum.DateTime, expected:
pendulum.DateTime
+):
+ assert CronMixin(cron, timezone)._get_next(current) == expected
+
+
[email protected](
+ ("timezone", "cron", "current", "expected"),
+ [
+ pytest.param(
+ "America/Godthab",
+ "0 * * * 6",
+ pendulum.datetime(2026, 4, 4, 1, 0, tz="UTC"),
+ # Sat 22:00 -02:00; Sat 23:00 is skipped
+ pendulum.datetime(2026, 3, 29, 0, 0, tz="UTC"),
+ id="godthab-target-in-gap",
+ ),
+ pytest.param(
+ "Europe/Berlin",
+ "0 * * * *",
+ pendulum.datetime(2026, 10, 25, 1, 0, tz="UTC"),
+ pendulum.datetime(2026, 10, 25, 0, 0, tz="UTC"),
+ id="berlin-fold-first-step",
+ ),
+ pytest.param(
+ "Europe/Berlin",
+ "0 * * * *",
+ pendulum.datetime(2026, 10, 25, 2, 0, tz="UTC"),
+ pendulum.datetime(2026, 10, 25, 1, 0, tz="UTC"),
+ id="berlin-fold-second-step",
+ ),
+ pytest.param(
+ "America/Havana",
+ "0 * * * 0",
+ pendulum.datetime(2026, 11, 1, 5, 0, tz="UTC"),
+ # Sun 00:00 -04:00, the earlier of the two midnights
+ pendulum.datetime(2026, 11, 1, 4, 0, tz="UTC"),
+ id="havana-prev-fold-twin",
+ ),
+ pytest.param(
+ "America/Havana",
+ "0,30 * * * 0",
+ pendulum.datetime(2026, 11, 1, 5, 0, tz="UTC"),
+ # Sun 00:30 -04:00 comes after Sun 00:00 -04:00 but before the
later Sun 00:00 -05:00
+ pendulum.datetime(2026, 11, 1, 4, 30, tz="UTC"),
+ id="havana-prev-half-hour-fold",
+ ),
+ pytest.param(
+ "America/Havana",
+ "0 * * * 0",
+ pendulum.datetime(2026, 3, 8, 5, 0, tz="UTC"),
+ # Sat 23:00 is one wall-clock hour back but not a tick; the
previous Sun 00:00 is a week earlier
+ pendulum.datetime(2026, 3, 2, 4, 0, tz="UTC"),
+ id="havana-prev-gap-at-one-hour",
+ ),
+ pytest.param(
+ "Australia/Lord_Howe",
+ "0,10 * * * 0",
+ pendulum.datetime(2026, 10, 3, 15, 30, tz="UTC"),
+ # Sat 01:40 +10:30; Sun 02:10 is skipped by the 30 minute gap
+ pendulum.datetime(2026, 10, 3, 14, 40, tz="UTC"),
+ id="lord-howe-prev-target-in-half-hour-gap",
+ ),
+ pytest.param(
+ "America/Havana",
+ "0 * 1 3,11 *",
+ pendulum.datetime(2026, 11, 1, 5, 0, tz="UTC"),
+ # Sun 00:00 -04:00, the first pass of current's own wall time;
both ends are -05:00
+ pendulum.datetime(2026, 11, 1, 4, 0, tz="UTC"),
+ id="havana-prev-sparse-cron-fold-twin",
+ ),
+ pytest.param(
+ "America/Havana",
+ "0,30 * * * 6",
+ pendulum.datetime(2026, 11, 1, 5, 0, tz="UTC"),
+ # current is Sun 00:00 -05:00, the second pass of the fall-back
fold; only Saturday has ticks,
+ # so the answer is Sat 23:30 -04:00
+ pendulum.datetime(2026, 11, 1, 3, 30, tz="UTC"),
+ id="havana-prev-day-boundary-fold",
+ ),
+ ],
+)
+def test_get_prev_across_dst(
+ timezone: str, cron: str, current: pendulum.DateTime, expected:
pendulum.DateTime
+):
+ assert CronMixin(cron, timezone)._get_prev(current) == expected
+
+
[email protected](
+ ("timezone", "naive", "expected"),
+ [
+ pytest.param(
+ pendulum.timezone("UTC"),
+ datetime.datetime(2026, 6, 1, 12, 0),
+ [pendulum.datetime(2026, 6, 1, 12, 0, tz="UTC")],
+ id="ordinary-time",
+ ),
+ pytest.param(
+ pendulum.timezone("America/Havana"),
+ datetime.datetime(2026, 11, 1, 0, 0),
+ [pendulum.datetime(2026, 11, 1, 4, 0, tz="UTC"),
pendulum.datetime(2026, 11, 1, 5, 0, tz="UTC")],
+ id="havana-fold",
+ ),
+ pytest.param(
+ pendulum.timezone("Europe/London"),
+ datetime.datetime(2026, 10, 25, 1, 30),
+ [
+ pendulum.datetime(2026, 10, 25, 0, 30, tz="UTC"),
+ pendulum.datetime(2026, 10, 25, 1, 30, tz="UTC"),
+ ],
+ id="london-fold",
+ ),
+ pytest.param(
+ pendulum.timezone("America/Godthab"),
+ datetime.datetime(2026, 3, 28, 23, 0),
+ [],
+ id="godthab-spring-forward-gap",
+ ),
+ pytest.param(
+ pendulum.fixed_timezone(7200),
+ datetime.datetime(2026, 6, 1, 12, 0),
+ [pendulum.datetime(2026, 6, 1, 10, 0, tz="UTC")],
+ id="fixed-timezone",
+ ),
+ ],
+)
+def test_localize_all_folds(
+ timezone: pendulum.Timezone | pendulum.FixedTimezone,
+ naive: datetime.datetime,
+ expected: list[pendulum.DateTime],
+):
+ assert _localize_all_folds(naive, timezone) == expected