Repository: apache/airflow

Issue:
Data Interval wrong when manually triggering with a specific logical date
### Apache Airflow version

2.2.5

### What happened

When I use the date picker in the “Trigger DAG w/ config” page to choose a specific logical date for some reason on a scheduled daily DAG the Data Interval Start (circled in red) is 2 days before the logical date (circled in blue), instead of the same as the logical date. And the Data Interval End is one day before the logical date. So the interval is the correct length, but on wrong days.

![Screen Shot 2022-05-11 at 5 14 10 PM](https://user-images.githubusercontent.com/45696489/168159891-b080273b-4b22-4ef8-a2ae-98327a503f9f.png)

I encountered this with a DAG with a daily schedule which typically runs at 09:30 UTC. I am testing this in a dev environment (with catchup off) and trying to trigger a run for 2022-05-09 09:30:00. I would expect the data interval to start at that same time and the data interval end to be 1 day after.

It has nothing to do with the previous run since that was way back on 2022-04-26

### What you think should happen instead

The data interval start date should be the same as the logical date (if it is a custom logical date)

### How to reproduce

I made a sample DAG as shown below:
```python
import pendulum
from airflow.models import DAG
from airflow.operators.python import PythonOperator


def sample(data_interval_start, data_interval_end):
    return "data_interval_start: {}, data_interval_end: {}".format(str(data_interval_start), str(data_interval_end))


args = {
    'start_date': pendulum.datetime(2022, 3, 10, 9, 30)
}
with DAG(
    dag_id='sample_data_interval_issue',
    default_args=args,
    schedule_interval='30 9 * * *'  # 09:30 UTC
) as sample_data_interval_issue:
    task = PythonOperator(
        task_id='sample',
        python_callable=sample
    )

```

I then start it to start a scheduled DAG run (`2022-05-11, 09:30:00 UTC`), and the `data_interval_start` is the same as I expect, `2022-05-11T09:30:00+00:00`.

However, when I went to "Trigger DAG w/ config" page and in the date chooser choose `2022-05-09 09:30:00+00:00`, and then triggered that. It shows the run datetime is `2022-05-09, 09:30:00 UTC`, but the `data_interval_start` is incorrectly set to `2022-05-08T09:30:00+00:00`, 2 days before the date I choose.

### Operating System

Debian GNU/Linux 11 (bullseye)

### Versions of Apache Airflow Providers

N/A

### Deployment

Astronomer

### Deployment details

_No response_

### Anything else

_No response_

### Are you willing to submit PR?

- [X] Yes I am willing to submit a PR!

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)



Discussion:

Resolving patch:
diff --git a/airflow/timetables/_cron.py b/airflow/timetables/_cron.py
--- a/airflow/timetables/_cron.py
+++ b/airflow/timetables/_cron.py
@@ -116,7 +116,7 @@ def _get_prev(self, current: DateTime) -> DateTime:
         delta = naive - scheduled
         return convert_to_utc(current.in_timezone(self._timezone) - delta)
 
-    def _align(self, current: DateTime) -> DateTime:
+    def _align_to_next(self, current: DateTime) -> DateTime:
         """Get the next scheduled time.
 
         This is ``current + interval``, unless ``current`` falls right on the
@@ -126,3 +126,14 @@ def _align(self, current: DateTime) -> DateTime:
         if self._get_prev(next_time) != current:
             return next_time
         return current
+
+    def _align_to_prev(self, current: DateTime) -> DateTime:
+        """Get the prev scheduled time.
+
+        This is ``current - interval``, unless ``current`` falls right on the
+        interval boundary, when ``current`` is returned.
+        """
+        prev_time = self._get_prev(current)
+        if self._get_next(prev_time) != current:
+            return prev_time
+        return current
diff --git a/airflow/timetables/interval.py b/airflow/timetables/interval.py
--- a/airflow/timetables/interval.py
+++ b/airflow/timetables/interval.py
@@ -37,8 +37,16 @@ class _DataIntervalTimetable(Timetable):
     instance), and schedule a DagRun at the end of each interval.
     """
 
-    def _align(self, current: DateTime) -> DateTime:
-        """Align given time to the scheduled.
+    def _skip_to_latest(self, earliest: Optional[DateTime]) -> DateTime:
+        """Bound the earliest time a run can be scheduled.
+
+        This is called when ``catchup=False``. See docstring of subclasses for
+        exact skipping behaviour of a schedule.
+        """
+        raise NotImplementedError()
+
+    def _align_to_next(self, current: DateTime) -> DateTime:
+        """Align given time to the next scheduled time.
 
         For fixed schedules (e.g. every midnight); this finds the next time that
         aligns to the declared time, if the given time does not align. If the
@@ -46,6 +54,19 @@ def _align(self, current: DateTime) -> DateTime:
         """
         raise NotImplementedError()
 
+    def _align_to_prev(self, current: DateTime) -> DateTime:
+        """Align given time to the previous scheduled time.
+
+        For fixed schedules (e.g. every midnight); this finds the prev time that
+        aligns to the declared time, if the given time does not align. If the
+        schedule is not fixed (e.g. every hour), the given time is returned.
+
+        It is not enough to use ``_get_prev(_align_to_next())``, since when a
+        DAG's schedule changes, this alternative would make the first scheduling
+        after the schedule change remain the same.
+        """
+        raise NotImplementedError()
+
     def _get_next(self, current: DateTime) -> DateTime:
         """Get the first schedule after the current time."""
         raise NotImplementedError()
@@ -54,14 +75,6 @@ def _get_prev(self, current: DateTime) -> DateTime:
         """Get the last schedule before the current time."""
         raise NotImplementedError()
 
-    def _skip_to_latest(self, earliest: Optional[DateTime]) -> DateTime:
-        """Bound the earliest time a run can be scheduled.
-
-        This is called when ``catchup=False``. See docstring of subclasses for
-        exact skipping behaviour of a schedule.
-        """
-        raise NotImplementedError()
-
     def next_dagrun_info(
         self,
         *,
@@ -72,7 +85,7 @@ def next_dagrun_info(
         if not restriction.catchup:
             earliest = self._skip_to_latest(earliest)
         elif earliest is not None:
-            earliest = self._align(earliest)
+            earliest = self._align_to_next(earliest)
         if last_automated_data_interval is None:
             # First run; schedule the run at the first available time matching
             # the schedule, and retrospectively create a data interval for it.
@@ -80,13 +93,15 @@ def next_dagrun_info(
                 return None
             start = earliest
         else:  # There's a previous run.
+            # Alignment is needed when DAG has new schedule interval.
+            align_last_data_interval_end = self._align_to_prev(last_automated_data_interval.end)
             if earliest is not None:
                 # Catchup is False or DAG has new start date in the future.
                 # Make sure we get the later one.
-                start = max(last_automated_data_interval.end, earliest)
+                start = max(align_last_data_interval_end, earliest)
             else:
                 # Data interval starts from the end of the previous interval.
-                start = last_automated_data_interval.end
+                start = align_last_data_interval_end
         if restriction.latest is not None and start > restriction.latest:
             return None
         end = self._get_next(start)
@@ -138,13 +153,13 @@ def _skip_to_latest(self, earliest: Optional[DateTime]) -> DateTime:
             raise AssertionError("next schedule shouldn't be earlier")
         if earliest is None:
             return new_start
-        return max(new_start, self._align(earliest))
+        return max(new_start, self._align_to_next(earliest))
 
     def infer_manual_data_interval(self, *, run_after: DateTime) -> DataInterval:
         # Get the last complete period before run_after, e.g. if a DAG run is
         # scheduled at each midnight, the data interval of a manually triggered
         # run at 1am 25th is between 0am 24th and 0am 25th.
-        end = self._get_prev(self._align(run_after))
+        end = self._align_to_prev(run_after)
         return DataInterval(start=self._get_prev(end), end=end)
 
 
@@ -202,7 +217,10 @@ def _get_next(self, current: DateTime) -> DateTime:
     def _get_prev(self, current: DateTime) -> DateTime:
         return convert_to_utc(current - self._delta)
 
-    def _align(self, current: DateTime) -> DateTime:
+    def _align_to_next(self, current: DateTime) -> DateTime:
+        return current
+
+    def _align_to_prev(self, current: DateTime) -> DateTime:
         return current
 
     def _skip_to_latest(self, earliest: Optional[DateTime]) -> DateTime:
diff --git a/airflow/timetables/trigger.py b/airflow/timetables/trigger.py
--- a/airflow/timetables/trigger.py
+++ b/airflow/timetables/trigger.py
@@ -86,15 +86,15 @@ def next_dagrun_info(
             if last_automated_data_interval is None:
                 if restriction.earliest is None:
                     return None
-                next_start_time = self._align(restriction.earliest)
+                next_start_time = self._align_to_next(restriction.earliest)
             else:
                 next_start_time = self._get_next(last_automated_data_interval.end)
         else:
             current_time = DateTime.utcnow()
             if restriction.earliest is not None and current_time < restriction.earliest:
-                next_start_time = self._align(restriction.earliest)
+                next_start_time = self._align_to_next(restriction.earliest)
             else:
-                next_start_time = self._align(current_time)
+                next_start_time = self._align_to_next(current_time)
         if restriction.latest is not None and restriction.latest < next_start_time:
             return None
         return DagRunInfo.interval(next_start_time - self._interval, next_start_time)


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
@@ -37,6 +37,7 @@
 
 CURRENT_TIME = pendulum.DateTime(2021, 9, 7, tzinfo=TIMEZONE)
 YESTERDAY = CURRENT_TIME - datetime.timedelta(days=1)
+OLD_INTERVAL = DataInterval(start=YESTERDAY, end=CURRENT_TIME)
 
 HOURLY_CRON_TIMETABLE = CronDataIntervalTimetable("@hourly", TIMEZONE)
 HOURLY_TIMEDELTA_TIMETABLE = DeltaDataIntervalTimetable(datetime.timedelta(hours=1))
@@ -63,6 +64,29 @@ def test_no_catchup_first_starts_at_current_time(
     assert next_info == DagRunInfo.interval(start=expected_start, end=CURRENT_TIME + DELTA_FROM_MIDNIGHT)
 
 
+@pytest.mark.parametrize(
+    "earliest",
+    [pytest.param(None, id="none"), pytest.param(START_DATE, id="start_date")],
+)
+@pytest.mark.parametrize(
+    "catchup",
+    [pytest.param(True, id="catchup_true"), pytest.param(False, id="catchup_false")],
+)
+@freezegun.freeze_time(CURRENT_TIME)
+def test_new_schedule_interval_next_info_starts_at_new_time(
+    earliest: Optional[pendulum.DateTime],
+    catchup: bool,
+) -> None:
+    """First run after DAG has new schedule interval."""
+    next_info = CRON_TIMETABLE.next_dagrun_info(
+        last_automated_data_interval=OLD_INTERVAL,
+        restriction=TimeRestriction(earliest=earliest, latest=None, catchup=catchup),
+    )
+    expected_start = YESTERDAY + datetime.timedelta(hours=16, minutes=30)
+    expected_end = CURRENT_TIME + datetime.timedelta(hours=16, minutes=30)
+    assert next_info == DagRunInfo.interval(start=expected_start, end=expected_end)
+
+
 @pytest.mark.parametrize(
     "timetable",
     [
@@ -159,3 +183,72 @@ def test_validate_failure(timetable: Timetable, error_message: str) -> None:
 def test_cron_interval_timezone_from_string():
     timetable = CronDataIntervalTimetable("@hourly", "UTC")
     assert timetable.serialize()['timezone'] == 'UTC'
+
+
+@pytest.mark.parametrize(
+    "trigger_at, expected_interval",
+    [
+        # Arbitrary trigger time.
+        pytest.param(
+            pendulum.DateTime(2022, 8, 8, 1, tzinfo=TIMEZONE),
+            DataInterval(
+                pendulum.DateTime(2022, 8, 7, tzinfo=TIMEZONE),
+                pendulum.DateTime(2022, 8, 8, tzinfo=TIMEZONE),
+            ),
+            id="adhoc",
+        ),
+        # Trigger time falls exactly on interval boundary.
+        pytest.param(
+            pendulum.DateTime(2022, 8, 8, tzinfo=TIMEZONE),
+            DataInterval(
+                pendulum.DateTime(2022, 8, 7, tzinfo=TIMEZONE),
+                pendulum.DateTime(2022, 8, 8, tzinfo=TIMEZONE),
+            ),
+            id="exact",
+        ),
+    ],
+)
+def test_cron_infer_manual_data_interval_alignment(
+    trigger_at: pendulum.DateTime,
+    expected_interval: DataInterval,
+) -> None:
+    timetable = CronDataIntervalTimetable("@daily", TIMEZONE)
+    assert timetable.infer_manual_data_interval(run_after=trigger_at) == expected_interval
+
+
+@pytest.mark.parametrize(
+    "last_data_interval, expected_info",
+    [
+        pytest.param(
+            DataInterval(
+                pendulum.DateTime(2022, 8, 7, tzinfo=TIMEZONE),
+                pendulum.DateTime(2022, 8, 8, tzinfo=TIMEZONE),
+            ),
+            DagRunInfo.interval(
+                pendulum.DateTime(2022, 8, 8, tzinfo=TIMEZONE),
+                pendulum.DateTime(2022, 8, 9, tzinfo=TIMEZONE),
+            ),
+            id="exact",
+        ),
+        pytest.param(
+            # Previous data interval does not align with the current timetable.
+            # This is possible if the user edits a DAG with existing runs.
+            DataInterval(
+                pendulum.DateTime(2022, 8, 7, 1, tzinfo=TIMEZONE),
+                pendulum.DateTime(2022, 8, 8, 1, tzinfo=TIMEZONE),
+            ),
+            DagRunInfo.interval(
+                pendulum.DateTime(2022, 8, 8, tzinfo=TIMEZONE),
+                pendulum.DateTime(2022, 8, 9, tzinfo=TIMEZONE),
+            ),
+            id="changed",
+        ),
+    ],
+)
+def test_cron_next_dagrun_info_alignment(last_data_interval: DataInterval, expected_info: DagRunInfo):
+    timetable = CronDataIntervalTimetable("@daily", TIMEZONE)
+    info = timetable.next_dagrun_info(
+        last_automated_data_interval=last_data_interval,
+        restriction=TimeRestriction(None, None, True),
+    )
+    assert info == expected_info