Skip to content

Commit d1e5f1a

Browse files
Fix Calendar view hanging for Dags with high-frequency cron schedules (apache#71263)
The planned-runs computation for cron timetables iterates croniter until the year boundary with no cap, while the generic-timetable path stops at MAX_PLANNED_RUNS. For "*/5 * * * *" that is ~105k iterations (~2s) per calendar request, ~520k (~7s) for a minutely cron, and ~31M (minutes of CPU) for a seconds-resolution cron - enough for any user with Dag read access to pin an API server worker just by opening the Calendar tab. Apply the same MAX_PLANNED_RUNS cap the generic path has used since the endpoint was added.
1 parent 956b7f8 commit d1e5f1a

2 files changed

Lines changed: 44 additions & 1 deletion

File tree

airflow-core/src/airflow/api_fastapi/core_api/services/ui/calendar.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from __future__ import annotations
1818

1919
import collections
20+
import itertools
2021
from collections.abc import Iterator, Sequence
2122
from datetime import datetime
2223
from typing import Literal, cast
@@ -221,7 +222,10 @@ def _calculate_cron_planned_runs(
221222
ret_type=datetime,
222223
)
223224

224-
for dt in dates_iter:
225+
# Cap the iteration like _calculate_timetable_planned_runs does; a high-frequency
226+
# expression (e.g. "* * * * *", or a seconds-resolution cron) would otherwise take
227+
# hundreds of thousands of steps before hitting the year boundary.
228+
for dt in itertools.islice(dates_iter, self.MAX_PLANNED_RUNS):
225229
if dt is None or dt.year != year:
226230
break
227231
if dag.end_date and dt > dag.end_date:

airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_calendar.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from sqlalchemy.orm import Session
2525

2626
from airflow._shared.timezones import timezone
27+
from airflow.api_fastapi.core_api.services.ui.calendar import CalendarService
2728
from airflow.models.deadline import Deadline
2829
from airflow.providers.standard.operators.empty import EmptyOperator
2930
from airflow.sdk import CronPartitionTimetable
@@ -326,6 +327,44 @@ def test_hourly_calendar_partitioned(self, test_client, query_params, result):
326327
assert body == result
327328

328329

330+
class TestCalendarPlannedRunsCap:
331+
"""A high-frequency cron must stop at MAX_PLANNED_RUNS instead of iterating to the year boundary."""
332+
333+
DAG_NAME = "test_minutely_dag"
334+
335+
@pytest.fixture(autouse=True)
336+
@provide_session
337+
def setup_dag_runs(self, dag_maker, *, session: Session = NEW_SESSION) -> None:
338+
clear_db_runs()
339+
clear_db_dags()
340+
with dag_maker(
341+
self.DAG_NAME,
342+
schedule="* * * * *",
343+
start_date=datetime(2025, 6, 1),
344+
catchup=False,
345+
serialized=True,
346+
session=session,
347+
):
348+
EmptyOperator(task_id="test_task1")
349+
dag_maker.create_dagrun(
350+
run_id="run_1",
351+
state=DagRunState.SUCCESS,
352+
logical_date=datetime(2025, 6, 1),
353+
)
354+
dag_maker.sync_dagbag_to_db()
355+
session.commit()
356+
357+
def teardown_method(self) -> None:
358+
clear_db_runs()
359+
clear_db_dags()
360+
361+
def test_planned_runs_capped_for_high_frequency_cron(self, test_client):
362+
response = test_client.get(f"/calendar/{self.DAG_NAME}")
363+
assert response.status_code == 200
364+
planned = [r for r in response.json()["dag_runs"] if r["state"] == "planned"]
365+
assert sum(r["count"] for r in planned) == CalendarService.MAX_PLANNED_RUNS
366+
367+
329368
_CALLBACK_PATH = "tests.unit.api_fastapi.core_api.routes.ui.test_calendar._noop_callback"
330369

331370

0 commit comments

Comments
 (0)