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

Reply via email to