Repository: apache/airflow

Issue:
Incorrect DAG scheduling after DST
**Apache Airflow version**: 1.10.9, 2.0.0dev

**Kubernetes version (if you are using kubernetes)** (use `kubectl version`):

**Environment**:

- **Cloud provider or hardware configuration**:
- **OS** (e.g. from /etc/os-release):
- **Kernel** (e.g. `uname -a`):
- **Install tools**:
- **Others**:
**What happened**:

If DAG's cron contains "non trivial" hours section, Scheduler will not schedule DAG correctly immediately after DST switch. In this case, "non-trivial" means either having an interval (`0 7-8 * * *`) or multiple values (`0 7,9 * * *`).

If DST occurs in morning (2-3 AM of March 8th), following following executions will occur:

```
0 7-8 * * *
"2020-03-07T07:00:00-08:00"
"2020-03-07T08:00:00-08:00" # DST switch after this run
"2020-03-08T08:00:00-07:00" # 8 AM instead of 7 AM

0 7,9 * * *

"2020-03-07T07:00:00-08:00"
"2020-03-07T09:00:00-08:00"
"2020-03-08T07:00:00-07:00" # DST switch after this run
"2020-03-08T08:00:00-07:00" # 8 AM instead of 7 AM
```

Cause for this is the method `is_fixed_time_schedule` in `dag.py`, which tests whether cron is "fixed" (i.e. "execute exactly at this time") or "relative" (i.e. "execute on each n hours"). Method relies on a quite crude test, it calculates two subsequent times and checks whether both hours and minutes of them are the same:

```python
        now = datetime.now()
        cron = croniter(self._schedule_interval, now)

        start = cron.get_next(datetime)
        cron_next = cron.get_next(datetime)

        if cron_next.minute == start.minute and cron_next.hour == start.hour:
            return True

        return False
```

This is not satisfied in case of above examples (it is executed at two different hours during each day, so hours in subsequent executions are never the same).

Based on this, method `following_schedule` in `dag.py` thinks that this DAG should be executed "on each n hours", and explicitly works around DST. It calculates the amount of time which needs to pass until next run, and adds that amount of time to the previous run, thus ignoring DST.

```python
            # We assume that DST transitions happen on the minute/hour
            if not self.is_fixed_time_schedule():
                # relative offset (eg. every 5 minutes)
                delta = cron.get_next(datetime) - naive
                following = dttm.in_timezone(self.timezone).add_timedelta(delta)
            else:
                # absolute (e.g. 3 AM)
                naive = cron.get_next(datetime)
                tz = pendulum.timezone(self.timezone.name)
                following = timezone.make_aware(naive, tz)
```

Note that only the first execution (or first few executions) will be affected. After them, the calculation will stabilize and will work correctly going forward.

This is describes the same issue as https://issues.apache.org/jira/browse/AIRFLOW-7039


**What you expected to happen**:

<!-- What do you think went wrong? -->

**How to reproduce it**:

A failing unit test which demonstrates the issue is pushed here:

https://github.com/gor-obr/airflow/commit/0d021a1304223711ba3ece980bcc1b18df4adcd0

**Anything else we need to know**:

The immediate issue can be fixed in several ways:

* Alter the logic of `is_fixed_time_schedule` so that it parses the cron string and checks whether it contains `/` in minute or hour sections.

* Extend the `Dag` class so that its constructor accepts an optional `bool` argument `is_fixed_time_schedule`. If this argument is provided, it will be used instead of the logic in `is_fixed_time_schedule` method. If argument is not provided, the behavior will continue to be as is now. This way user can work around the issue, while existing DAGs still working as they used to.

However, both of these fixes are a bit hacky in their nature and I'd say that they don't address the underlying issue.

Problem is in the fact that cron actually doesn't recognize "relative" schedules. In cron, all schedules are absolute, and say `0 */6 * * *` is just a syntactic  sugar for `0 0,6,12,18 * * *`. In cron these two schedules are equivalent, and in Airflow this syntactic sugar is overloaded with semantic meaning (first is assumed to be a "relative" schedule and second is assumed to be "fixed" schedule).

This is highlighted by the fact that Airflow uses croniter to calculate schedule, but then (in higher level of abstraction) works around this library and fiddles with calculated results (there is even a vague comment: ` # we don't want to rely on the transitions created by croniter as they are not always correct`).

Maybe the cleanest solution would be to accept that all cron schedules are in their nature absolute, and to extract the feature of relative scheduling from the cron. Leave cron to work with absolute schedules as it was designed to work, and introduce different syntax to allow for relative scheduling feature.

The obvious downside of this approach is that it would change the behavior of already existing DAGs. Some good news is that in most cases absolute and relative scheduling actually have the same result (they differ mostly in handling the DST).


Discussion:
Thanks for opening your first issue here! Be sure to follow the issue template!

Do you have any proposition for solutions or workarounds for this problem??
> Do you have any proposition for solutions or workarounds for this problem??

I added a broader context with some possible solutions in the description of the bug. As I don't have any experience with the project, probably someone from the community is better suited to evaluate the alternatives.

Hi we have run into this issue during last DST transition on 2020-11-01 as well, so will be nice if this can be addressed.
I'm wondering one of the solutions can be: if the `schedule_interval` is provided as a cron, then Airflow should respect that and not use relative delta at all? if `schedule_interval` is provided with a `datetime.timedelta` or relative timedelta object, only then the DAG uses a relative offset. What do you think?
Unfortunately as far as I'm aware, there is no systematic solution for this issue.

The best workaround I can suggest would be to extend `Dag` class and override the `is_fixed_time_schedule` to simply return `true`. This would solve the issue as the root cause is `is_fixed_time_schedule` incorrectly classifying a dag as "not fixed" time schedule. After this when defining dags simply instantiate the subclass instead of `Dag`. All dags that use overridden class should behave correctly.
👍 yes that's how I'm patching it, guess I'll just use that for now
I think this issue is covered by AIP 39 which allows you to set your own scheduling logic.
Yes. Note that the AIP-39 implementation by itself does not solve this problem—cron expression _by design_ does not work well with DST, and Airflow is alreay stretching the functionality the best it can. You need to implement a custom timetable using the API exposed by AIP-39 if you need more sophisticated handling of timezone-specific logic.
Closing as no action item on this issue.
@gor-obr / @bxnxiong I believe #30083 has a more durable fix. @eladkal / @uranusjr I think this issue actually should be reopened as even though cron schedules are brittle, `0 9 * * *` and `0 9,10 * * *` work differently right now (one is `fixed` and accounts for DST, the other is `interval` and does not) and I think this is technically a bug. 

Resolving patch:
diff --git a/airflow/models/dag.py b/airflow/models/dag.py
--- a/airflow/models/dag.py
+++ b/airflow/models/dag.py
@@ -848,16 +848,35 @@ def date_range(
         return [info.logical_date for info in it]
 
     def is_fixed_time_schedule(self):
+        """Figures out if the schedule has a fixed time (e.g. 3 AM every day).
+
+        Detection is done by "peeking" the next two cron trigger time; if the
+        two times have the same minute and hour value, the schedule is fixed,
+        and we *don't* need to perform the DST fix.
+
+        This assumes DST happens on whole minute changes (e.g. 12:59 -> 12:00).
+
+        Do not try to understand what this actually means. It is old logic that
+        should not be used anywhere.
+        """
         warnings.warn(
             "`DAG.is_fixed_time_schedule()` is deprecated.",
             category=RemovedInAirflow3Warning,
             stacklevel=2,
         )
-        try:
-            return not self.timetable._should_fix_dst
-        except AttributeError:
+
+        from airflow.timetables._cron import CronMixin
+
+        if not isinstance(self.timetable, CronMixin):
             return True
 
+        from croniter import croniter
+
+        cron = croniter(self.timetable._expression)
+        next_a = cron.get_next(datetime.datetime)
+        next_b = cron.get_next(datetime.datetime)
+        return next_b.minute == next_a.minute and next_b.hour == next_a.hour
+
     def following_schedule(self, dttm):
         """
         Calculate the following schedule for this dag in UTC.
diff --git a/airflow/timetables/_cron.py b/airflow/timetables/_cron.py
--- a/airflow/timetables/_cron.py
+++ b/airflow/timetables/_cron.py
@@ -17,7 +17,6 @@
 from __future__ import annotations
 
 import datetime
-from functools import cached_property
 from typing import TYPE_CHECKING, Any
 
 from cron_descriptor import CasingTypeEnum, ExpressionDescriptor, FormatException, MissingFieldException
@@ -32,21 +31,32 @@
     from pendulum import DateTime
 
 
-def _is_schedule_fixed(expression: str) -> bool:
-    """Figures out if the schedule has a fixed time (e.g. 3 AM every day).
+def _covers_every_hour(cron: croniter) -> bool:
+    """Check whether the given cron runs at least once an hour.
 
-    :return: True if the schedule has a fixed time, False if not.
+    This indicates whether we need to implement a workaround for (what I call)
+    the "fold hour problem". Folding happens when a region switches time
+    backwards, usually as a part of ending a DST period, causing a block of time
+    to occur twice in the wall clock. This is indicated by the ``fold`` flag on
+    datetime.
 
-    Detection is done by "peeking" the next two cron trigger time; if the
-    two times have the same minute and hour value, the schedule is fixed,
-    and we *don't* need to perform the DST fix.
+    As an example, Switzerland in 2023 ended DST on 3am (wall clock time, UTC+2)
+    by dialing back the clock to 2am (UTC+1). So for (say) ``30 * * * *``, if
+    the last run was 2:30am (UTC+2), the next needs to be 2:30am (UTC+1, folded)
+    instead of 3:30am.
 
-    This assumes DST happens on whole minute changes (e.g. 12:59 -> 12:00).
+    While this technically happens for all runs (in such a timezone), we only
+    really care about runs that happen at least once an hour, and can
+    provide a somewhat reasonable rationale to skip the fold hour for things
+    such as ``*/2`` (every two hour). So we try to *minially* peak into croniter
+    internals to work around the issue.
+
+    The check is simple since croniter internally normalizes things to ``*``.
+    More edge cases can be added later as needed.
+
+    See also: https://github.com/kiorky/croniter/issues/56.
     """
-    cron = croniter(expression)
-    next_a = cron.get_next(datetime.datetime)
-    next_b = cron.get_next(datetime.datetime)
-    return next_b.minute == next_a.minute and next_b.hour == next_a.hour
+    return cron.expanded[1] == ["*"]
 
 
 class CronMixin:
@@ -91,18 +101,12 @@ def validate(self) -> None:
         except (CroniterBadCronError, CroniterBadDateError) as e:
             raise AirflowTimetableInvalid(str(e))
 
-    @cached_property
-    def _should_fix_dst(self) -> bool:
-        # This is lazy so instantiating a schedule does not immediately raise
-        # an exception. Validity is checked with validate() during DAG-bagging.
-        return not _is_schedule_fixed(self._expression)
-
     def _get_next(self, current: DateTime) -> DateTime:
         """Get the first schedule after specified time, with DST fixed."""
         naive = make_naive(current, self._timezone)
         cron = croniter(self._expression, start_time=naive)
         scheduled = cron.get_next(datetime.datetime)
-        if not self._should_fix_dst:
+        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)
@@ -112,7 +116,7 @@ def _get_prev(self, current: DateTime) -> DateTime:
         naive = make_naive(current, self._timezone)
         cron = croniter(self._expression, start_time=naive)
         scheduled = cron.get_prev(datetime.datetime)
-        if not self._should_fix_dst:
+        if not _covers_every_hour(cron):
             return convert_to_utc(make_aware(scheduled, self._timezone))
         delta = naive - scheduled
         return convert_to_utc(current.in_timezone(self._timezone) - delta)


Test patch:
diff --git a/tests/timetables/test_interval_timetable.py b/tests/timetables/test_interval_timetable.py
--- a/tests/timetables/test_interval_timetable.py
+++ b/tests/timetables/test_interval_timetable.py
@@ -253,3 +253,308 @@ def test_cron_next_dagrun_info_alignment(last_data_interval: DataInterval, expec
         restriction=TimeRestriction(None, None, True),
     )
     assert info == expected_info
+
+
+class TestCronIntervalDst:
+    """Test cron interval timetable can correctly enter a DST boundary.
+
+    Zurich (Switzerland) is chosen since it is +1/+2 DST, making it a bit easier
+    to get my head around the mental timezone conversion.
+
+    In 2023, DST entered on 26th Mar, 2am local clocks (1am UTC) were turned
+    forward to 3am. DST exited on 29th Oct, 3am local clocks (1am UTC) were
+    turned backward to 2am (making the 2:XX hour fold).
+    """
+
+    def test_entering_exact(self) -> None:
+        timetable = CronDataIntervalTimetable("0 3 * * *", timezone="Europe/Zurich")
+        restriction = TimeRestriction(
+            earliest=pendulum.datetime(2023, 3, 24, tz=TIMEZONE),
+            latest=None,
+            catchup=True,
+        )
+
+        # Last run before DST. Interval starts and ends on 2am UTC (local time is +1).
+        next_info = timetable.next_dagrun_info(last_automated_data_interval=None, restriction=restriction)
+        assert next_info and next_info.data_interval == DataInterval(
+            pendulum.datetime(2023, 3, 24, 2, tz=TIMEZONE),
+            pendulum.datetime(2023, 3, 25, 2, tz=TIMEZONE),
+        )
+
+        # Crossing the DST switch. Interval starts on 2am UTC (local time +1)
+        # but ends on 1am UTC (local time is +2).
+        next_info = timetable.next_dagrun_info(
+            last_automated_data_interval=next_info.data_interval,
+            restriction=restriction,
+        )
+        assert next_info and next_info.data_interval == DataInterval(
+            pendulum.datetime(2023, 3, 25, 2, tz=TIMEZONE),
+            pendulum.datetime(2023, 3, 26, 1, tz=TIMEZONE),
+        )
+
+        # In DST. Interval starts and ends on 1am UTC (local time is +2).
+        next_info = timetable.next_dagrun_info(
+            last_automated_data_interval=next_info.data_interval,
+            restriction=restriction,
+        )
+        assert next_info and next_info.data_interval == DataInterval(
+            pendulum.datetime(2023, 3, 26, 1, tz=TIMEZONE),
+            pendulum.datetime(2023, 3, 27, 1, tz=TIMEZONE),
+        )
+
+    def test_entering_skip(self) -> None:
+        timetable = CronDataIntervalTimetable("0 2 * * *", timezone="Europe/Zurich")
+        restriction = TimeRestriction(
+            earliest=pendulum.datetime(2023, 3, 24, tz=TIMEZONE),
+            latest=None,
+            catchup=True,
+        )
+
+        # Last run before DST. Interval starts and ends on 1am UTC (local time is +1).
+        next_info = timetable.next_dagrun_info(last_automated_data_interval=None, restriction=restriction)
+        assert next_info and next_info.data_interval == DataInterval(
+            pendulum.datetime(2023, 3, 24, 1, tz=TIMEZONE),
+            pendulum.datetime(2023, 3, 25, 1, tz=TIMEZONE),
+        )
+
+        # Crossing the DST switch. Interval starts on 1am UTC (local time +1)
+        # and ends on 1am UTC (local time is +2) since the 2am wall clock time
+        # does not logically exist due to entering DST.
+        next_info = timetable.next_dagrun_info(
+            last_automated_data_interval=next_info.data_interval,
+            restriction=restriction,
+        )
+        assert next_info and next_info.data_interval == DataInterval(
+            pendulum.datetime(2023, 3, 25, 1, tz=TIMEZONE),
+            pendulum.datetime(2023, 3, 26, 1, tz=TIMEZONE),
+        )
+
+        # In DST. Interval starts on 1am UTC (local time is +2 but 2am local
+        # time is not possible) and ends on 0am UTC.
+        next_info = timetable.next_dagrun_info(
+            last_automated_data_interval=next_info.data_interval,
+            restriction=restriction,
+        )
+        assert next_info and next_info.data_interval == DataInterval(
+            pendulum.datetime(2023, 3, 26, 1, tz=TIMEZONE),
+            pendulum.datetime(2023, 3, 27, 0, tz=TIMEZONE),
+        )
+
+    def test_exiting_exact(self) -> None:
+        timetable = CronDataIntervalTimetable("0 3 * * *", timezone="Europe/Zurich")
+        restriction = TimeRestriction(
+            earliest=pendulum.datetime(2023, 10, 27, tz=TIMEZONE),
+            latest=None,
+            catchup=True,
+        )
+
+        # Last run in DST. Interval starts and ends on 1am UTC (local time is +2).
+        next_info = timetable.next_dagrun_info(last_automated_data_interval=None, restriction=restriction)
+        assert next_info and next_info.data_interval == DataInterval(
+            pendulum.datetime(2023, 10, 27, 1, tz=TIMEZONE),
+            pendulum.datetime(2023, 10, 28, 1, tz=TIMEZONE),
+        )
+
+        # Crossing the DST switch. Interval starts on 1am UTC (local time +2)
+        # and ends on 2am UTC (local time +1).
+        next_info = timetable.next_dagrun_info(
+            last_automated_data_interval=next_info.data_interval,
+            restriction=restriction,
+        )
+        assert next_info and next_info.data_interval == DataInterval(
+            pendulum.datetime(2023, 10, 28, 1, tz=TIMEZONE),
+            pendulum.datetime(2023, 10, 29, 2, tz=TIMEZONE),
+        )
+
+        # Out of DST. Interval starts and ends on 2am UTC (local time is +1).
+        next_info = timetable.next_dagrun_info(
+            last_automated_data_interval=next_info.data_interval,
+            restriction=restriction,
+        )
+        assert next_info and next_info.data_interval == DataInterval(
+            pendulum.datetime(2023, 10, 29, 2, tz=TIMEZONE),
+            pendulum.datetime(2023, 10, 30, 2, tz=TIMEZONE),
+        )
+
+    def test_exiting_fold(self) -> None:
+        timetable = CronDataIntervalTimetable("0 2 * * *", timezone="Europe/Zurich")
+        restriction = TimeRestriction(
+            earliest=pendulum.datetime(2023, 10, 27, tz=TIMEZONE),
+            latest=None,
+            catchup=True,
+        )
+
+        # Last run before folding. Interval starts and ends on 0am UTC (local
+        # time is +2).
+        next_info = timetable.next_dagrun_info(last_automated_data_interval=None, restriction=restriction)
+        assert next_info and next_info.data_interval == DataInterval(
+            pendulum.datetime(2023, 10, 27, 0, tz=TIMEZONE),
+            pendulum.datetime(2023, 10, 28, 0, tz=TIMEZONE),
+        )
+
+        # Account for folding. Interval starts on 0am UTC (local time +2) and
+        # ends on 1am UTC (local time +1). There are two 2am local times on the
+        # 29th due to folding. We end on the second one (fold=1. There's no
+        # logical reason here; this is simply what Airflow has been doing since
+        # a long time ago, and there's no point breaking it.
+        next_info = timetable.next_dagrun_info(
+            last_automated_data_interval=next_info.data_interval,
+            restriction=restriction,
+        )
+        assert next_info and next_info.data_interval == DataInterval(
+            pendulum.datetime(2023, 10, 28, 0, tz=TIMEZONE),
+            pendulum.datetime(2023, 10, 29, 1, tz=TIMEZONE),
+        )
+
+        # Stepping out of DST. Interval starts from the folded 2am local time
+        # (1am UTC out of DST) since that is when the previous interval ended.
+        # It ends at 1am UTC (local time is +1) normally.
+        next_info = timetable.next_dagrun_info(
+            last_automated_data_interval=next_info.data_interval,
+            restriction=restriction,
+        )
+        assert next_info and next_info.data_interval == DataInterval(
+            pendulum.datetime(2023, 10, 29, 1, tz=TIMEZONE),
+            pendulum.datetime(2023, 10, 30, 1, tz=TIMEZONE),
+        )
+
+
+class TestCronIntervalDstNonTrivial:
+    """These tests are similar to TestCronIntervalDst but with a different cron.
+
+    The original test cases are from apache/airflow#7999. In 2020 at Los Angeles,
+    DST started on 8th Mar; 10am UTC was turned from 2am UTC-8 to 3am UTC-7.
+    """
+
+    def test_7_to_8_entering(self):
+        timetable = CronDataIntervalTimetable("0 7-8 * * *", timezone="America/Los_Angeles")
+        restriction = TimeRestriction(
+            earliest=pendulum.datetime(2020, 3, 7, tz=TIMEZONE),
+            latest=None,
+            catchup=True,
+        )
+
+        # Triggers as expected before the interval touches the DST transition.
+        next_info = timetable.next_dagrun_info(
+            last_automated_data_interval=None,
+            restriction=restriction,
+        )
+        assert next_info and next_info.data_interval == DataInterval(
+            pendulum.datetime(2020, 3, 7, 7 + 8, tz=TIMEZONE),
+            pendulum.datetime(2020, 3, 7, 8 + 8, tz=TIMEZONE),
+        )
+
+        # This interval ends an hour early since it includes the DST switch!
+        next_info = timetable.next_dagrun_info(
+            last_automated_data_interval=next_info.data_interval,
+            restriction=restriction,
+        )
+        assert next_info and next_info.data_interval == DataInterval(
+            pendulum.datetime(2020, 3, 7, 8 + 8, tz=TIMEZONE),
+            pendulum.datetime(2020, 3, 8, 7 + 7, tz=TIMEZONE),
+        )
+
+        # We're fully into DST so the interval is as expected.
+        next_info = timetable.next_dagrun_info(
+            last_automated_data_interval=next_info.data_interval,
+            restriction=restriction,
+        )
+        assert next_info and next_info.data_interval == DataInterval(
+            pendulum.datetime(2020, 3, 8, 7 + 7, tz=TIMEZONE),
+            pendulum.datetime(2020, 3, 8, 8 + 7, tz=TIMEZONE),
+        )
+
+    def test_7_and_9_entering(self):
+        timetable = CronDataIntervalTimetable("0 7,9 * * *", timezone="America/Los_Angeles")
+        restriction = TimeRestriction(
+            earliest=pendulum.datetime(2020, 3, 7, tz=TIMEZONE),
+            latest=None,
+            catchup=True,
+        )
+
+        # Triggers as expected before the interval touches the DST transition.
+        next_info = timetable.next_dagrun_info(
+            last_automated_data_interval=None,
+            restriction=restriction,
+        )
+        assert next_info and next_info.data_interval == DataInterval(
+            pendulum.datetime(2020, 3, 7, 7 + 8, tz=TIMEZONE),
+            pendulum.datetime(2020, 3, 7, 9 + 8, tz=TIMEZONE),
+        )
+
+        # This interval ends an hour early since it includes the DST switch!
+        next_info = timetable.next_dagrun_info(
+            last_automated_data_interval=next_info.data_interval,
+            restriction=restriction,
+        )
+        assert next_info and next_info.data_interval == DataInterval(
+            pendulum.datetime(2020, 3, 7, 9 + 8, tz=TIMEZONE),
+            pendulum.datetime(2020, 3, 8, 7 + 7, tz=TIMEZONE),
+        )
+
+        # We're fully into DST so the interval is as expected.
+        next_info = timetable.next_dagrun_info(
+            last_automated_data_interval=next_info.data_interval,
+            restriction=restriction,
+        )
+        assert next_info and next_info.data_interval == DataInterval(
+            pendulum.datetime(2020, 3, 8, 7 + 7, tz=TIMEZONE),
+            pendulum.datetime(2020, 3, 8, 9 + 7, tz=TIMEZONE),
+        )
+
+
+def test_fold_scheduling():
+    timetable = CronDataIntervalTimetable("*/30 * * * *", timezone="Europe/Zurich")
+    restriction = TimeRestriction(
+        earliest=pendulum.datetime(2023, 10, 28, 23, 30, tz=TIMEZONE),  # Locally 1:30 (DST).
+        latest=None,
+        catchup=True,
+    )
+
+    # Still in DST, acting normally.
+    next_info = timetable.next_dagrun_info(
+        last_automated_data_interval=None,
+        restriction=restriction,
+    )
+    assert next_info and next_info.data_interval == DataInterval(
+        pendulum.datetime(2023, 10, 28, 23, 30, tz=TIMEZONE),
+        pendulum.datetime(2023, 10, 29, 0, 0, tz=TIMEZONE),  # Locally 2am (DST).
+    )
+    next_info = timetable.next_dagrun_info(
+        last_automated_data_interval=next_info.data_interval,
+        restriction=restriction,
+    )
+    assert next_info and next_info.data_interval == DataInterval(
+        pendulum.datetime(2023, 10, 29, 0, 0, tz=TIMEZONE),
+        pendulum.datetime(2023, 10, 29, 0, 30, tz=TIMEZONE),  # Locally 2:30 (DST).
+    )
+
+    # Crossing into fold.
+    next_info = timetable.next_dagrun_info(
+        last_automated_data_interval=next_info.data_interval,
+        restriction=restriction,
+    )
+    assert next_info and next_info.data_interval == DataInterval(
+        pendulum.datetime(2023, 10, 29, 0, 30, tz=TIMEZONE),
+        pendulum.datetime(2023, 10, 29, 1, 0, tz=TIMEZONE),  # Locally 2am (fold, not DST).
+    )
+
+    # In the "fold zone".
+    next_info = timetable.next_dagrun_info(
+        last_automated_data_interval=next_info.data_interval,
+        restriction=restriction,
+    )
+    assert next_info and next_info.data_interval == DataInterval(
+        pendulum.datetime(2023, 10, 29, 1, 0, tz=TIMEZONE),
+        pendulum.datetime(2023, 10, 29, 1, 30, tz=TIMEZONE),  # Locally 2am (fold, not DST).
+    )
+
+    # Stepping out of fold.
+    next_info = timetable.next_dagrun_info(
+        last_automated_data_interval=next_info.data_interval,
+        restriction=restriction,
+    )
+    assert next_info and next_info.data_interval == DataInterval(
+        pendulum.datetime(2023, 10, 29, 1, 30, tz=TIMEZONE),
+        pendulum.datetime(2023, 10, 29, 2, 0, tz=TIMEZONE),  # Locally 3am (not DST).
+    )