This is an automated email from the ASF dual-hosted git repository.

Lee-W 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 7b6512ceb87 [v3-3-test] Return 422 for empty backfill window and stop 
leaving orphan rows (#68883) (#69367)
7b6512ceb87 is described below

commit 7b6512ceb87aeff445ac19a9000c63b658084006
Author: Wei Lee <[email protected]>
AuthorDate: Sat Jul 4 18:23:48 2026 +0800

    [v3-3-test] Return 422 for empty backfill window and stop leaving orphan 
rows (#68883) (#69367)
---
 .../core_api/routes/public/backfills.py            |  2 +
 .../src/airflow/cli/commands/backfill_command.py   | 48 +++++------
 airflow-core/src/airflow/models/backfill.py        | 31 +++++---
 .../core_api/routes/public/test_backfills.py       | 58 +++++++++++++-
 .../unit/cli/commands/test_backfill_command.py     | 61 +++++++++++++-
 airflow-core/tests/unit/models/test_backfill.py    | 92 +++++++++++++++++++++-
 6 files changed, 255 insertions(+), 37 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/backfills.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/backfills.py
index 0dba6086e0a..70c353fb7a2 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/backfills.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/backfills.py
@@ -56,6 +56,7 @@ from airflow.models.backfill import (
     InvalidBackfillDateRange,
     InvalidBackfillDirection,
     InvalidReprocessBehavior,
+    NoBackfillRunsToCreate,
     _create_backfill,
     _do_dry_run,
 )
@@ -276,6 +277,7 @@ def create_backfill(
         InvalidBackfillDate,
         InvalidBackfillDateRange,
         InvalidBackfillConf,
+        NoBackfillRunsToCreate,
     ) as e:
         raise RequestValidationError(str(e))
 
diff --git a/airflow-core/src/airflow/cli/commands/backfill_command.py 
b/airflow-core/src/airflow/cli/commands/backfill_command.py
index d76d435a391..75ba6b03ce4 100644
--- a/airflow-core/src/airflow/cli/commands/backfill_command.py
+++ b/airflow-core/src/airflow/cli/commands/backfill_command.py
@@ -27,7 +27,7 @@ from airflow import settings
 from airflow.api_fastapi.common.dagbag import resolve_run_on_latest_version
 from airflow.cli.simple_table import AirflowConsole
 from airflow.exceptions import AirflowConfigException
-from airflow.models.backfill import ReprocessBehavior, _create_backfill, 
_do_dry_run
+from airflow.models.backfill import NoBackfillRunsToCreate, ReprocessBehavior, 
_create_backfill, _do_dry_run
 from airflow.utils import cli as cli_utils
 from airflow.utils.cli import sigint_handler
 from airflow.utils.platform import getuser
@@ -50,6 +50,13 @@ def create_backfill(args) -> None:
     else:
         reprocess_behavior = None
 
+    dag_run_conf = None
+    if args.dag_run_conf:
+        try:
+            dag_run_conf = json.loads(args.dag_run_conf)
+        except json.JSONDecodeError as e:
+            raise ValueError(f"Invalid JSON in --dag-run-conf: {e}")
+
     with create_session() as session:
         resolved_run_on_latest = resolve_run_on_latest_version(
             args.run_on_latest_version,
@@ -67,7 +74,7 @@ def create_backfill(args) -> None:
             to_date=args.to_date,
             max_active_runs=args.max_active_runs,
             reverse=args.run_backwards,
-            dag_run_conf=args.dag_run_conf,
+            dag_run_conf=dag_run_conf,
             reprocess_behavior=reprocess_behavior,
             run_on_latest_version=resolved_run_on_latest,
         )
@@ -79,7 +86,8 @@ def create_backfill(args) -> None:
                 from_date=args.from_date,
                 to_date=args.to_date,
                 reverse=args.run_backwards,
-                reprocess_behavior=args.reprocess_behavior,
+                reprocess_behavior=reprocess_behavior or 
ReprocessBehavior.NONE,
+                dag_run_conf=dag_run_conf,
                 session=session,
             )
         console.print("Runs to be attempted:")
@@ -97,22 +105,18 @@ def create_backfill(args) -> None:
         log.warning("Failed to get user name from os: %s, not setting the 
triggering user", e)
         user = None
 
-    # Parse dag_run_conf if provided
-    dag_run_conf = None
-    if args.dag_run_conf:
-        try:
-            dag_run_conf = json.loads(args.dag_run_conf)
-        except json.JSONDecodeError as e:
-            raise ValueError(f"Invalid JSON in --dag-run-conf: {e}")
-
-    _create_backfill(
-        dag_id=args.dag_id,
-        from_date=args.from_date,
-        to_date=args.to_date,
-        max_active_runs=args.max_active_runs,
-        reverse=args.run_backwards,
-        dag_run_conf=dag_run_conf,
-        triggering_user_name=user,
-        reprocess_behavior=reprocess_behavior,
-        run_on_latest_version=resolved_run_on_latest,
-    )
+    try:
+        _create_backfill(
+            dag_id=args.dag_id,
+            from_date=args.from_date,
+            to_date=args.to_date,
+            max_active_runs=args.max_active_runs,
+            reverse=args.run_backwards,
+            dag_run_conf=dag_run_conf,
+            triggering_user_name=user,
+            reprocess_behavior=reprocess_behavior,
+            run_on_latest_version=resolved_run_on_latest,
+        )
+    except NoBackfillRunsToCreate as e:
+        console.print(f"[yellow]Warning:[/yellow] {e}")
+        raise SystemExit(1)
diff --git a/airflow-core/src/airflow/models/backfill.py 
b/airflow-core/src/airflow/models/backfill.py
index 52019fa5d98..1f507de94bb 100644
--- a/airflow-core/src/airflow/models/backfill.py
+++ b/airflow-core/src/airflow/models/backfill.py
@@ -123,6 +123,17 @@ class InvalidBackfillConf(AirflowException):
     """
 
 
+class NoBackfillRunsToCreate(ValueError):
+    """
+    Raised when a backfill request yields no Dag runs for the given date range.
+
+    This happens when the from/to date range falls entirely outside the Dag's
+    scheduled intervals (e.g. the range predates the first partition boundary).
+
+    :meta private:
+    """
+
+
 class UnknownActiveBackfills(AirflowException):
     """
     Raised when the quantity of active backfills cannot be determined.
@@ -665,6 +676,17 @@ def _create_backfill(
             dag_run_conf,
         )
 
+        dagrun_info_list = _get_info_list(
+            from_date=from_date,
+            to_date=to_date,
+            reverse=reverse,
+            dag=dag,
+        )
+        if not dagrun_info_list:
+            raise NoBackfillRunsToCreate(
+                f"No runs to create for Dag {dag_id} in the range 
[{from_date}, {to_date}]"
+            )
+
         br = Backfill(
             dag_id=dag_id,
             from_date=from_date,
@@ -680,15 +702,6 @@ def _create_backfill(
 
         session.scalars(select(DagModel).where(DagModel.dag_id == 
dag_id)).one()
 
-        dagrun_info_list = _get_info_list(
-            from_date=from_date,
-            to_date=to_date,
-            reverse=reverse,
-            dag=dag,
-        )
-        if not dagrun_info_list:
-            raise RuntimeError(f"No runs to create for Dag {dag_id}")
-
         first_info = dagrun_info_list[0]
         if first_info.partition_key:
             _create_runs_partitioned(
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_backfills.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_backfills.py
index 42b48fc0c49..2380f73b9bf 100644
--- 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_backfills.py
+++ 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_backfills.py
@@ -27,7 +27,13 @@ from sqlalchemy import and_, func, select
 from airflow._shared.timezones import timezone
 from airflow.dag_processing.dagbag import DagBag
 from airflow.models import DagModel, DagRun
-from airflow.models.backfill import Backfill, BackfillDagRun, 
ReprocessBehavior, _create_backfill
+from airflow.models.backfill import (
+    Backfill,
+    BackfillDagRun,
+    NoBackfillRunsToCreate,
+    ReprocessBehavior,
+    _create_backfill,
+)
 from airflow.models.dag import DAG
 from airflow.models.dagbundle import DagBundleModel
 from airflow.providers.standard.operators.empty import EmptyOperator
@@ -965,6 +971,56 @@ class TestCreateBackfillPartitioned(TestBackfillEndpoint):
         response = test_client.post(url=url, json=data)
         assert response.status_code == 422
 
+    
@mock.patch("airflow.api_fastapi.core_api.routes.public.backfills._create_backfill",
 autospec=True)
+    def test_empty_window_create_returns_422(self, mock_create, session, 
dag_maker, test_client):
+        """POST /backfills with an empty window raises NoBackfillRunsToCreate 
→ 422."""
+        mock_create.side_effect = NoBackfillRunsToCreate(
+            "No runs to create for Dag TEST_PARTITIONED_DAG in the range [...]"
+        )
+        with dag_maker(
+            session=session,
+            dag_id="TEST_PARTITIONED_DAG",
+            schedule=CronPartitionTimetable("0 0 * * *", 
timezone="Asia/Taipei"),
+        ):
+            EmptyOperator(task_id="mytask")
+        session.commit()
+
+        data = {
+            "dag_id": "TEST_PARTITIONED_DAG",
+            "from_date": "2026-02-18T00:00:00+00:00",
+            "to_date": "2026-02-18T00:00:00+00:00",
+            "max_active_runs": 5,
+            "run_backwards": False,
+        }
+        response = test_client.post(url="/backfills", json=data)
+        assert response.status_code == 422
+
+    
@mock.patch("airflow.api_fastapi.core_api.routes.public.backfills._do_dry_run", 
autospec=True)
+    def test_empty_window_dry_run_returns_200_with_zero_entries(
+        self, mock_dry_run, session, dag_maker, test_client
+    ):
+        """POST /backfills/dry_run with an empty window returns 200 with 
total_entries == 0."""
+        mock_dry_run.return_value = iter([])
+        with dag_maker(
+            session=session,
+            dag_id="TEST_PARTITIONED_DAG",
+            schedule=CronPartitionTimetable("0 0 * * *", 
timezone="Asia/Taipei"),
+        ):
+            EmptyOperator(task_id="mytask")
+        session.commit()
+
+        data = {
+            "dag_id": "TEST_PARTITIONED_DAG",
+            "from_date": "2026-02-18T00:00:00+00:00",
+            "to_date": "2026-02-18T00:00:00+00:00",
+            "max_active_runs": 5,
+            "run_backwards": False,
+        }
+        response = test_client.post(url="/backfills/dry_run", json=data)
+        assert response.status_code == 200
+        assert response.json()["total_entries"] == 0
+        assert response.json()["backfills"] == []
+
 
 class TestCancelBackfill(TestBackfillEndpoint):
     def test_cancel_backfill(self, session, test_client):
diff --git a/airflow-core/tests/unit/cli/commands/test_backfill_command.py 
b/airflow-core/tests/unit/cli/commands/test_backfill_command.py
index c897fa20443..5d5743349f7 100644
--- a/airflow-core/tests/unit/cli/commands/test_backfill_command.py
+++ b/airflow-core/tests/unit/cli/commands/test_backfill_command.py
@@ -164,7 +164,8 @@ class TestCliBackfill:
             from_date=DEFAULT_DATE.replace(tzinfo=timezone.utc),
             to_date=DEFAULT_DATE.replace(tzinfo=timezone.utc),
             reverse=reverse,
-            reprocess_behavior="none",
+            reprocess_behavior=ReprocessBehavior.NONE,
+            dag_run_conf=None,
             session=mock.ANY,
         )
 
@@ -240,9 +241,10 @@ class TestCliBackfill:
         with pytest.raises(SystemExit):
             self.parser.parse_args(args)
 
+    @mock.patch("airflow.cli.commands.backfill_command.getuser", 
return_value="test_user")
     @mock.patch("airflow.cli.commands.backfill_command._create_backfill")
-    def test_backfill_with_empty_dag_run_conf(self, mock_create):
-        """Test that empty dag_run_conf is properly parsed."""
+    def test_backfill_with_empty_dag_run_conf(self, mock_create, mock_getuser):
+        """Test that empty dag_run_conf ({}) is parsed as an empty dict, not 
None."""
         args = [
             "backfill",
             "create",
@@ -265,6 +267,57 @@ class TestCliBackfill:
             reverse=False,
             dag_run_conf={},
             reprocess_behavior=None,
-            triggering_user_name="root",
+            triggering_user_name="test_user",
             run_on_latest_version=True,
         )
+
+    @mock.patch("airflow.cli.commands.backfill_command._do_dry_run", 
autospec=True)
+    def test_backfill_dry_run_passes_dag_run_conf(self, mock_dry_run):
+        """dry-run path forwards parsed dag_run_conf dict (not raw string) to 
_do_dry_run."""
+        mock_dry_run.return_value = iter([])
+        args = [
+            "backfill",
+            "create",
+            "--dag-id",
+            "example_bash_operator",
+            "--from-date",
+            DEFAULT_DATE.isoformat(),
+            "--to-date",
+            DEFAULT_DATE.isoformat(),
+            "--dry-run",
+            "--reprocess-behavior",
+            "failed",
+            "--dag-run-conf",
+            '{"key": "val"}',
+        ]
+        
airflow.cli.commands.backfill_command.create_backfill(self.parser.parse_args(args))
+
+        mock_dry_run.assert_called_once_with(
+            dag_id="example_bash_operator",
+            from_date=DEFAULT_DATE.replace(tzinfo=timezone.utc),
+            to_date=DEFAULT_DATE.replace(tzinfo=timezone.utc),
+            reverse=False,
+            reprocess_behavior=ReprocessBehavior.FAILED,
+            dag_run_conf={"key": "val"},
+            session=mock.ANY,
+        )
+
+    @mock.patch("airflow.cli.commands.backfill_command._create_backfill", 
autospec=True)
+    def test_backfill_empty_window_shows_friendly_message(self, mock_create):
+        """Empty-window backfill exits with code 1 and prints a message, no 
traceback."""
+        from airflow.models.backfill import NoBackfillRunsToCreate
+
+        mock_create.side_effect = NoBackfillRunsToCreate("No runs to create 
for Dag example_bash_operator")
+        args = [
+            "backfill",
+            "create",
+            "--dag-id",
+            "example_bash_operator",
+            "--from-date",
+            DEFAULT_DATE.isoformat(),
+            "--to-date",
+            DEFAULT_DATE.isoformat(),
+        ]
+        with pytest.raises(SystemExit) as exc_info:
+            
airflow.cli.commands.backfill_command.create_backfill(self.parser.parse_args(args))
+        assert exc_info.value.code == 1
diff --git a/airflow-core/tests/unit/models/test_backfill.py 
b/airflow-core/tests/unit/models/test_backfill.py
index 69a4ebd5dc2..ea621be84d6 100644
--- a/airflow-core/tests/unit/models/test_backfill.py
+++ b/airflow-core/tests/unit/models/test_backfill.py
@@ -20,10 +20,11 @@ from __future__ import annotations
 from contextlib import nullcontext
 from datetime import datetime, timedelta
 from typing import TYPE_CHECKING
+from unittest import mock
 
 import pendulum
 import pytest
-from sqlalchemy import select
+from sqlalchemy import func, select
 
 from airflow._shared.timezones import timezone
 from airflow.models import DagModel, DagRun, TaskInstance
@@ -37,6 +38,7 @@ from airflow.models.backfill import (
     InvalidBackfillDateRange,
     InvalidBackfillDirection,
     InvalidReprocessBehavior,
+    NoBackfillRunsToCreate,
     ReprocessBehavior,
     _create_backfill,
     _do_dry_run,
@@ -1384,6 +1386,94 @@ def 
test_partitioned_backfill_reprocess_failed(dag_maker, session):
     assert bdr.partition_key == info.partition_key
 
 
[email protected]("airflow.models.backfill._get_info_list", autospec=True, 
return_value=[])
+def 
test_create_backfill_empty_window_raises_no_runs_to_create(mock_get_info_list, 
dag_maker, session):
+    """_create_backfill raises NoBackfillRunsToCreate when _get_info_list 
returns an empty list."""
+    with dag_maker(schedule=CronPartitionTimetable("0 0 * * *", 
timezone="Asia/Taipei")) as dag:
+        PythonOperator(task_id="hi", python_callable=print)
+    session.commit()
+
+    with pytest.raises(NoBackfillRunsToCreate, match=dag.dag_id):
+        _create_backfill(
+            dag_id=dag.dag_id,
+            from_date=pendulum.parse("2026-02-18"),
+            to_date=pendulum.parse("2026-02-18"),
+            max_active_runs=2,
+            reverse=False,
+            triggering_user_name="pytest",
+            dag_run_conf={},
+        )
+
+
[email protected]("dag_run_conf", [None, {}])
[email protected]("airflow.models.backfill._get_info_list", autospec=True, 
return_value=[])
+def test_do_dry_run_empty_window_returns_empty_iterable(mock_get_info_list, 
dag_run_conf, dag_maker, session):
+    """_do_dry_run on an empty window yields nothing (does not raise), with or 
without dag_run_conf."""
+    with dag_maker(schedule=CronPartitionTimetable("0 0 * * *", 
timezone="Asia/Taipei")) as dag:
+        PythonOperator(task_id="hi", python_callable=print)
+    session.commit()
+
+    infos = list(
+        _do_dry_run(
+            dag_id=dag.dag_id,
+            from_date=pendulum.parse("2026-02-18"),
+            to_date=pendulum.parse("2026-02-18"),
+            reverse=False,
+            reprocess_behavior=ReprocessBehavior.NONE,
+            dag_run_conf=dag_run_conf,
+            session=session,
+        )
+    )
+    assert infos == []
+
+
+def test_create_backfill_real_empty_window_no_orphan(dag_maker, session):
+    """_create_backfill with a real empty window raises NoBackfillRunsToCreate 
without leaving an orphan.
+
+    Uses a weekly-Monday timetable; 2026-02-18 is a Wednesday so 
_get_info_list returns [] for real.
+    After the raise, no incomplete Backfill row must exist — proving #1 
(orphan fix) holds.
+    A second call for the same dag_id must succeed (not be blocked by 
AlreadyRunningBackfill).
+    """
+    with dag_maker(schedule=CronPartitionTimetable("0 0 * * 1", 
timezone="UTC")) as dag:
+        PythonOperator(task_id="hi", python_callable=print)
+    session.commit()
+
+    wednesday = pendulum.datetime(2026, 2, 18, tz="UTC")  # not a Monday
+
+    with pytest.raises(NoBackfillRunsToCreate, match=dag.dag_id):
+        _create_backfill(
+            dag_id=dag.dag_id,
+            from_date=wednesday,
+            to_date=wednesday,
+            max_active_runs=2,
+            reverse=False,
+            triggering_user_name="pytest",
+            dag_run_conf=None,
+        )
+
+    # No orphan: zero incomplete Backfill rows for this dag
+    orphan_count = session.scalar(
+        select(func.count()).where(
+            Backfill.dag_id == dag.dag_id,
+            Backfill.completed_at.is_(None),
+        )
+    )
+    assert orphan_count == 0, "An orphan Backfill row was left behind after 
NoBackfillRunsToCreate"
+
+    # A valid (Monday) window must not be blocked by AlreadyRunningBackfill
+    monday = pendulum.datetime(2026, 2, 23, tz="UTC")  # a Monday
+    br = _create_backfill(
+        dag_id=dag.dag_id,
+        from_date=monday,
+        to_date=monday,
+        max_active_runs=2,
+        reverse=False,
+        triggering_user_name="pytest",
+        dag_run_conf=None,
+    )
+    assert br is not None
+
+
 def test_handle_clear_run_preserves_partition_key(dag_maker, session):
     """BackfillDagRun created via the clear/reprocess path carries 
partition_key from info."""
 

Reply via email to