This is an automated email from the ASF dual-hosted git repository.
uranusjr pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-3-test by this push:
new 261fc1c9661 [v3-3-test] Fix drifting data intervals for monthly/yearly
schedules with catchup disabled (#69143) (#69189)
261fc1c9661 is described below
commit 261fc1c9661d046647b57f299818bf4d4c31d807
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Jul 1 14:30:05 2026 +0800
[v3-3-test] Fix drifting data intervals for monthly/yearly schedules with
catchup disabled (#69143) (#69189)
Co-authored-by: Shahar Epstein <[email protected]>
---
airflow-core/newsfragments/69143.bugfix.rst | 1 +
airflow-core/src/airflow/timetables/interval.py | 26 +++++--
.../unit/timetables/test_interval_timetable.py | 84 ++++++++++++++++++++++
3 files changed, 107 insertions(+), 4 deletions(-)
diff --git a/airflow-core/newsfragments/69143.bugfix.rst
b/airflow-core/newsfragments/69143.bugfix.rst
new file mode 100644
index 00000000000..f6a2b7880d9
--- /dev/null
+++ b/airflow-core/newsfragments/69143.bugfix.rst
@@ -0,0 +1 @@
+Fix drifting, mis-aligned data intervals for monthly and yearly
(``relativedelta``) schedules when ``catchup`` is disabled. The first interval
is now anchored to the Dag's ``start_date`` instead of a fixed 30-day/365-day
grid, matching the boundaries produced with ``catchup=True``.
diff --git a/airflow-core/src/airflow/timetables/interval.py
b/airflow-core/src/airflow/timetables/interval.py
index 5064ee9be50..30a7f5033fa 100644
--- a/airflow-core/src/airflow/timetables/interval.py
+++ b/airflow-core/src/airflow/timetables/interval.py
@@ -224,8 +224,26 @@ class DeltaDataIntervalTimetable(DeltaMixin,
_DataIntervalTimetable):
+ delta.seconds
)
- def _round(self, dt: DateTime) -> DateTime:
- """Round the given time to the nearest interval."""
+ def _round(self, dt: DateTime, anchor: DateTime) -> DateTime:
+ """
+ Floor ``dt`` to the latest schedule boundary at or before it.
+
+ Months/years have no fixed second count, so the epoch-grid rounding
+ used for fixed deltas would drift. For them we anchor on ``anchor``
+ (the start_date) and advance one period at a time, so boundaries match
+ the catchup=True grid -- relativedelta day-clamping is path-dependent
+ (e.g. Jan 31 -> Feb 28 -> Mar 28), so a multiplied jump would land
+ elsewhere. Fixed deltas keep the historical epoch rounding and ignore
+ ``anchor``.
+
+ ``anchor`` must be at or before ``dt``; otherwise the forward stepping
+ cannot reach ``dt`` and the result is meaningless.
+ """
+ if isinstance(self._delta, relativedelta) and (self._delta.months or
self._delta.years):
+ boundary = anchor
+ while self._get_next(boundary) <= dt:
+ boundary = self._get_next(boundary)
+ return boundary
if isinstance(self._delta, datetime.timedelta):
delta_in_seconds = self._delta.total_seconds()
else:
@@ -243,8 +261,8 @@ class DeltaDataIntervalTimetable(DeltaMixin,
_DataIntervalTimetable):
This is slightly different from the cron version at terminal values.
"""
- round_current_time = self._round(coerce_datetime(utcnow()))
- new_start = self._get_prev(round_current_time)
+ now = coerce_datetime(utcnow())
+ new_start = self._get_prev(self._round(now, earliest or now))
if earliest is None:
return new_start
return max(new_start, earliest)
diff --git a/airflow-core/tests/unit/timetables/test_interval_timetable.py
b/airflow-core/tests/unit/timetables/test_interval_timetable.py
index d8ad62131a8..dbe27c6784f 100644
--- a/airflow-core/tests/unit/timetables/test_interval_timetable.py
+++ b/airflow-core/tests/unit/timetables/test_interval_timetable.py
@@ -43,6 +43,8 @@ OLD_INTERVAL = DataInterval(start=YESTERDAY, end=CURRENT_TIME)
HOURLY_CRON_TIMETABLE = CronDataIntervalTimetable("@hourly", utc)
HOURLY_TIMEDELTA_TIMETABLE =
DeltaDataIntervalTimetable(datetime.timedelta(hours=1))
HOURLY_RELATIVEDELTA_TIMETABLE =
DeltaDataIntervalTimetable(dateutil.relativedelta.relativedelta(hours=1))
+MONTHLY_RELATIVEDELTA_TIMETABLE =
DeltaDataIntervalTimetable(dateutil.relativedelta.relativedelta(months=1))
+YEARLY_RELATIVEDELTA_TIMETABLE =
DeltaDataIntervalTimetable(dateutil.relativedelta.relativedelta(years=1))
CRON_TIMETABLE = CronDataIntervalTimetable("30 16 * * *", utc)
DELTA_FROM_MIDNIGHT = datetime.timedelta(minutes=30, hours=16)
@@ -139,6 +141,88 @@ def test_no_catchup_next_info_starts_at_current_time(
assert next_info == DagRunInfo.interval(start=expected_start,
end=CURRENT_TIME)
[email protected](
+ "last_automated_data_interval",
+ [
+ pytest.param(None, id="first-run"),
+ pytest.param(
+ DataInterval(
+ pendulum.DateTime(2020, 1, 1, tzinfo=utc),
+ pendulum.DateTime(2020, 2, 1, tzinfo=utc),
+ ),
+ id="subsequent",
+ ),
+ ],
+)
[email protected](
+ ("timetable", "start_date", "expected_start", "expected_end"),
+ [
+ pytest.param(
+ MONTHLY_RELATIVEDELTA_TIMETABLE,
+ pendulum.DateTime(2025, 1, 15, tzinfo=utc),
+ pendulum.DateTime(2026, 5, 15, tzinfo=utc),
+ pendulum.DateTime(2026, 6, 15, tzinfo=utc),
+ id="monthly",
+ ),
+ pytest.param(
+ YEARLY_RELATIVEDELTA_TIMETABLE,
+ pendulum.DateTime(2020, 3, 10, tzinfo=utc),
+ pendulum.DateTime(2025, 3, 10, tzinfo=utc),
+ pendulum.DateTime(2026, 3, 10, tzinfo=utc),
+ id="yearly",
+ ),
+ ],
+)
+@time_machine.travel(pendulum.DateTime(2026, 6, 29, tzinfo=utc))
+def test_no_catchup_calendar_delta_aligns_to_start_date(
+ timetable: Timetable,
+ start_date: pendulum.DateTime,
+ expected_start: pendulum.DateTime,
+ expected_end: pendulum.DateTime,
+ last_automated_data_interval: DataInterval | None,
+) -> None:
+ """``catchup=False`` with a relativedelta in months/years must stay aligned
+ to ``start_date`` and not drift onto a fixed 30-day/365-day epoch grid."""
+ next_info = timetable.next_dagrun_info(
+ last_automated_data_interval=last_automated_data_interval,
+ restriction=TimeRestriction(earliest=start_date, latest=None,
catchup=False),
+ )
+ assert next_info == DagRunInfo.interval(start=expected_start,
end=expected_end)
+
+
+@time_machine.travel(pendulum.DateTime(2026, 6, 29, 12, tzinfo=utc))
+def test_no_catchup_calendar_delta_without_start_date_ends_now() -> None:
+ """With no ``start_date`` to anchor on, the interval simply ends at now."""
+ next_info = MONTHLY_RELATIVEDELTA_TIMETABLE.next_dagrun_info(
+ last_automated_data_interval=None,
+ restriction=TimeRestriction(earliest=None, latest=None, catchup=False),
+ )
+ assert next_info == DagRunInfo.interval(
+ start=pendulum.DateTime(2026, 5, 29, 12, tzinfo=utc),
+ end=pendulum.DateTime(2026, 6, 29, 12, tzinfo=utc),
+ )
+
+
+@time_machine.travel(pendulum.DateTime(2026, 6, 29, tzinfo=utc))
+def test_no_catchup_calendar_delta_uses_one_period_at_a_time_clamping() ->
None:
+ """Boundaries advance one relativedelta period at a time, so day-clamping
is
+ path-dependent: Jan 31 + 1 month clamps to Feb 28, and because each step
starts
+ from the previous boundary (not from Jan 31), the 28 then sticks --
+ Feb 28 -> Mar 28 -> ... -> May 28 -> Jun 28. A single multiplied jump off
+ ``start_date`` would instead re-clamp from the 31st and land elsewhere
+ (Jan 31 + 5 months -> Jun 30)."""
+ next_info = MONTHLY_RELATIVEDELTA_TIMETABLE.next_dagrun_info(
+ last_automated_data_interval=None,
+ restriction=TimeRestriction(
+ earliest=pendulum.DateTime(2025, 1, 31, tzinfo=utc), latest=None,
catchup=False
+ ),
+ )
+ assert next_info == DagRunInfo.interval(
+ start=pendulum.DateTime(2026, 5, 28, tzinfo=utc),
+ end=pendulum.DateTime(2026, 6, 28, tzinfo=utc),
+ )
+
+
@pytest.mark.parametrize(
"timetable",
[