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

pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 8b401f28f03 Fix backfill params not overriding existing DAG run conf 
(#64939)
8b401f28f03 is described below

commit 8b401f28f03ce99dc304d924d67a8b04befb2e7b
Author: Shivam Rastogi <[email protected]>
AuthorDate: Tue Apr 21 06:11:09 2026 -0400

    Fix backfill params not overriding existing DAG run conf (#64939)
    
    * Fix backfill params not overriding existing DAG run conf
    
    When reprocessing an existing DAG run during backfill, the
    dag_run_conf was not being applied to the cleared run. This adds
    the dag_run_conf parameter to _handle_clear_run() and conditionally
    updates conf in the DagRun UPDATE statement.
    
    Also adds conf validation in _create_backfill() using
    dag.params.deep_merge().validate() to match the validation
    done in create_dagrun() for new runs, ensuring invalid conf
    is rejected before any runs are created or cleared.
    
    closes: #59043
    
    * UI: Add override params checkbox to backfill form
    
    Add an "Override parameters on existing runs" checkbox to the
    backfill form, unchecked by default. When unchecked, dag_run_conf
    is sent as null, preserving existing run params during reprocessing.
    When checked, the form values are sent and override existing conf.
    
    Also changes BackfillPostBody.dag_run_conf from dict={} to
    dict|None=None so the API can distinguish between "no conf
    provided" (null, preserve existing) and "empty conf" ({}, override).
    
    related: #59043
    
    * Address code review: pass null through and regenerate openapi artifacts
    
    * Address review: InvalidBackfillConf exception and partitioned path comment
    
    * UI: Disable max-lines in RunBackfillForm with justification
    
    * Apply suggestion from @uranusjr
    
    Co-authored-by: Tzu-ping Chung <[email protected]>
    
    * Apply suggestion from @uranusjr: chained .values()
    
    ---------
    
    Co-authored-by: Tzu-ping Chung <[email protected]>
---
 .../api_fastapi/core_api/datamodels/backfills.py   |   2 +-
 .../core_api/openapi/v2-rest-api-generated.yaml    |   7 +-
 .../core_api/routes/public/backfills.py            |   3 +
 airflow-core/src/airflow/models/backfill.py        |  29 +++-
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts |  14 +-
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |   4 +-
 .../ui/public/i18n/locales/en/components.json      |   1 +
 .../src/components/DagActions/RunBackfillForm.tsx  |  13 +-
 .../airflow/ui/src/queries/useCreateBackfill.ts    |   2 +-
 airflow-core/tests/unit/models/test_backfill.py    | 150 +++++++++++++++++++++
 .../src/airflowctl/api/datamodels/generated.py     |   2 +-
 11 files changed, 211 insertions(+), 16 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/backfills.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/backfills.py
index 87d2677028e..64ed8bb05e9 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/backfills.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/backfills.py
@@ -33,7 +33,7 @@ class BackfillPostBody(StrictBaseModel):
     from_date: datetime
     to_date: datetime
     run_backwards: bool = False
-    dag_run_conf: dict = {}
+    dag_run_conf: dict | None = None
     reprocess_behavior: ReprocessBehavior = ReprocessBehavior.NONE
     max_active_runs: int = 10
     run_on_latest_version: bool = True
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index dbd84221ac6..8f892d0c657 100644
--- 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++ 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -9281,10 +9281,11 @@ components:
           title: Run Backwards
           default: false
         dag_run_conf:
-          additionalProperties: true
-          type: object
+          anyOf:
+          - additionalProperties: true
+            type: object
+          - type: 'null'
           title: Dag Run Conf
-          default: {}
         reprocess_behavior:
           $ref: '#/components/schemas/ReprocessBehavior'
           default: none
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 a520d243e18..e9c28d4fba9 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
@@ -50,6 +50,7 @@ from airflow.models.backfill import (
     Backfill,
     BackfillDagRun,
     DagNonPeriodicScheduleException,
+    InvalidBackfillConf,
     InvalidBackfillDate,
     InvalidBackfillDirection,
     InvalidReprocessBehavior,
@@ -264,6 +265,7 @@ def create_backfill(
         InvalidBackfillDirection,
         DagNonPeriodicScheduleException,
         InvalidBackfillDate,
+        InvalidBackfillConf,
     ) as e:
         raise RequestValidationError(str(e))
 
@@ -316,5 +318,6 @@ def create_backfill_dry_run(
         InvalidBackfillDirection,
         DagNonPeriodicScheduleException,
         InvalidBackfillDate,
+        InvalidBackfillConf,
     ) as e:
         raise RequestValidationError(str(e))
diff --git a/airflow-core/src/airflow/models/backfill.py 
b/airflow-core/src/airflow/models/backfill.py
index 86dbe1713f7..febd22df461 100644
--- a/airflow-core/src/airflow/models/backfill.py
+++ b/airflow-core/src/airflow/models/backfill.py
@@ -107,6 +107,14 @@ class InvalidBackfillDate(AirflowException):
     """
 
 
+class InvalidBackfillConf(AirflowException):
+    """
+    Raised when the provided ``dag_run_conf`` fails validation against the 
DAG's params.
+
+    :meta private:
+    """
+
+
 class UnknownActiveBackfills(AirflowException):
     """
     Raised when the quantity of active backfills cannot be determined.
@@ -256,6 +264,7 @@ def _validate_backfill_params(
     from_date: datetime,
     to_date: datetime,
     reprocess_behavior: ReprocessBehavior | None,
+    dag_run_conf: dict | None = None,
 ) -> None:
     depends_on_past = any(x.depends_on_past for x in dag.tasks)
     if depends_on_past:
@@ -271,6 +280,11 @@ def _validate_backfill_params(
     current_time = timezone.utcnow()
     if from_date >= current_time and to_date >= current_time:
         raise InvalidBackfillDate("Backfill cannot be executed for future 
dates.")
+    if dag_run_conf is not None:
+        try:
+            dag.params.deep_merge(dag_run_conf).validate()
+        except ValueError as e:
+            raise InvalidBackfillConf(str(e)) from e
 
 
 def _do_dry_run(
@@ -367,6 +381,7 @@ def _create_backfill_dag_run_non_partitioned(
                     backfill_id=backfill_id,
                     sort_ordinal=backfill_sort_ordinal,
                     run_on_latest=run_on_latest_version,
+                    dag_run_conf=dag_run_conf,
                 )
             else:
                 session.add(
@@ -440,6 +455,10 @@ def _create_backfill_dag_run_partitioned(
     triggering_user_name: str | None,
     session: Session,
 ) -> None:
+    # Partitioned backfills don't currently reprocess existing runs — if a run 
exists
+    # for this partition, it's recorded as skipped via exception_reason rather 
than
+    # cleared and re-queued. As a result, this function never calls 
``_handle_clear_run``
+    # and therefore doesn't need to forward ``dag_run_conf`` for the reprocess 
path.
     stmt = _get_latest_dag_run_row_query(dag_id=dag.dag_id, info=info)
     dr = session.scalar(stmt)
     if dr:
@@ -516,6 +535,7 @@ def _handle_clear_run(
     backfill_id: int,
     sort_ordinal: int,
     run_on_latest: bool = False,
+    dag_run_conf: dict | None = None,
 ) -> None:
     """Clear the existing Dag run and update backfill metadata."""
     from sqlalchemy.sql import update
@@ -532,8 +552,8 @@ def _handle_clear_run(
         run_on_latest_version=run_on_latest,
     )
 
-    # Update backfill_id and run_type in DagRun table
-    session.execute(
+    # Update backfill_id, run_type, and optionally conf in DagRun table
+    stmt = (
         update(DagRun)
         .where(DagRun.logical_date == info.logical_date, DagRun.dag_id == 
dag.dag_id)
         .values(
@@ -542,6 +562,9 @@ def _handle_clear_run(
             triggered_by=DagRunTriggeredByType.BACKFILL,
         )
     )
+    if dag_run_conf is not None:
+        stmt = stmt.values(conf=dag_run_conf)
+    session.execute(stmt)
     session.add(
         BackfillDagRun(
             backfill_id=backfill_id,
@@ -600,7 +623,7 @@ def _create_backfill(
                 f"There can be only one running backfill per Dag."
             )
 
-        _validate_backfill_params(dag, reverse, from_date, to_date, 
reprocess_behavior)
+        _validate_backfill_params(dag, reverse, from_date, to_date, 
reprocess_behavior, dag_run_conf)
 
         br = Backfill(
             dag_id=dag_id,
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 3317a52593c..35589676fb4 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -445,10 +445,16 @@ export const $BackfillPostBody = {
             default: false
         },
         dag_run_conf: {
-            additionalProperties: true,
-            type: 'object',
-            title: 'Dag Run Conf',
-            default: {}
+            anyOf: [
+                {
+                    additionalProperties: true,
+                    type: 'object'
+                },
+                {
+                    type: 'null'
+                }
+            ],
+            title: 'Dag Run Conf'
         },
         reprocess_behavior: {
             '$ref': '#/components/schemas/ReprocessBehavior',
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index cfb5b37bebb..4b3efcaabeb 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -122,8 +122,8 @@ export type BackfillPostBody = {
     to_date: string;
     run_backwards?: boolean;
     dag_run_conf?: {
-        [key: string]: unknown;
-    };
+    [key: string]: unknown;
+} | null;
     reprocess_behavior?: ReprocessBehavior;
     max_active_runs?: number;
     run_on_latest_version?: boolean;
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
index 28ed7c7a15a..64a78bee04b 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
@@ -10,6 +10,7 @@
     "maxRuns": "Max Active Runs",
     "missingAndErroredRuns": "Missing and Errored Runs",
     "missingRuns": "Missing Runs",
+    "overrideExistingParams": "Override parameters on existing runs",
     "permissionDenied": "Dry Run Failed: User does not have permission to 
create backfills.",
     "reprocessBehavior": "Reprocess Behavior",
     "run": "Run Backfill",
diff --git 
a/airflow-core/src/airflow/ui/src/components/DagActions/RunBackfillForm.tsx 
b/airflow-core/src/airflow/ui/src/components/DagActions/RunBackfillForm.tsx
index 089c914707d..c1873b9d352 100644
--- a/airflow-core/src/airflow/ui/src/components/DagActions/RunBackfillForm.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DagActions/RunBackfillForm.tsx
@@ -1,3 +1,5 @@
+/* eslint-disable max-lines -- form aggregates date range, reprocess behavior, 
and param controls in a single UX flow */
+
 /*!
  * Licensed to the Apache Software Foundation (ASF) under one
  * or more contributor license agreements.  See the NOTICE file
@@ -50,6 +52,7 @@ const RunBackfillForm = ({ dag, onClose }: 
RunBackfillFormProps) => {
   const { t: translate } = useTranslation(["components", "common"]);
   const [errors, setErrors] = useState<{ conf?: string; date?: unknown }>({});
   const [unpause, setUnpause] = useState(true);
+  const [overrideParams, setOverrideParams] = useState(false);
   const [formError, setFormError] = useState(false);
   const initialParamsDict = useDagParams(dag.dag_id, true);
   const { conf } = useParamStore();
@@ -119,7 +122,7 @@ const RunBackfillForm = ({ dag, onClose }: 
RunBackfillFormProps) => {
     createBackfill({
       requestBody: {
         ...fdata,
-        dag_run_conf: JSON.parse(fdata.conf) as Record<string, unknown>,
+        dag_run_conf: overrideParams ? (JSON.parse(fdata.conf) as 
Record<string, unknown>) : null,
       },
     });
   };
@@ -250,6 +253,14 @@ const RunBackfillForm = ({ dag, onClose }: 
RunBackfillFormProps) => {
             <Spacer />
           </>
         ) : undefined}
+        <Checkbox
+          checked={overrideParams}
+          colorPalette="brand"
+          onChange={() => setOverrideParams(!overrideParams)}
+        >
+          {translate("backfill.overrideExistingParams")}
+        </Checkbox>
+        <Spacer />
         <ConfigForm
           control={control}
           errors={errors}
diff --git a/airflow-core/src/airflow/ui/src/queries/useCreateBackfill.ts 
b/airflow-core/src/airflow/ui/src/queries/useCreateBackfill.ts
index 673f7d5a82d..c99430e8cb6 100644
--- a/airflow-core/src/airflow/ui/src/queries/useCreateBackfill.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useCreateBackfill.ts
@@ -79,7 +79,7 @@ export const useCreateBackfill = ({ onSuccessConfirm }: { 
onSuccessConfirm: () =
     mutate({
       requestBody: {
         dag_id: dagId,
-        dag_run_conf: data.requestBody.dag_run_conf ?? {},
+        dag_run_conf: data.requestBody.dag_run_conf,
         from_date: formattedDataIntervalStart,
         max_active_runs: data.requestBody.max_active_runs,
         reprocess_behavior: data.requestBody.reprocess_behavior,
diff --git a/airflow-core/tests/unit/models/test_backfill.py 
b/airflow-core/tests/unit/models/test_backfill.py
index d3323aeab35..a8d7eb70d98 100644
--- a/airflow-core/tests/unit/models/test_backfill.py
+++ b/airflow-core/tests/unit/models/test_backfill.py
@@ -33,6 +33,7 @@ from airflow.models.backfill import (
     BackfillDagRun,
     BackfillDagRunExceptionReason,
     DagNonPeriodicScheduleException,
+    InvalidBackfillConf,
     InvalidBackfillDirection,
     InvalidReprocessBehavior,
     ReprocessBehavior,
@@ -389,6 +390,155 @@ def test_reprocess_behavior(reprocess_behavior, num_in_b, 
exc_reasons, dag_maker
     assert all(x.state == DagRunState.QUEUED for x in dag_runs_in_b)
 
 
[email protected](
+    "reprocess_behavior",
+    [ReprocessBehavior.FAILED, ReprocessBehavior.COMPLETED],
+)
+def test_backfill_conf_overrides_existing_dag_run(reprocess_behavior, 
dag_maker, session):
+    """When reprocessing an existing DagRun, the backfill's dag_run_conf 
should override the existing conf."""
+    with dag_maker(schedule="@daily") as dag:
+        PythonOperator(task_id="hi", python_callable=print)
+
+    existing_date = "2021-01-03"
+    dag_maker.create_dagrun(
+        run_id=f"scheduled_{existing_date}",
+        logical_date=timezone.parse(existing_date),
+        session=session,
+        state=DagRunState.FAILED,
+        conf={"old_key": "old_value"},
+    )
+    session.commit()
+
+    new_conf = {"new_key": "new_value"}
+    b = _create_backfill(
+        dag_id=dag.dag_id,
+        from_date=pendulum.parse("2021-01-01"),
+        to_date=pendulum.parse("2021-01-05"),
+        max_active_runs=10,
+        reverse=False,
+        triggering_user_name="pytest",
+        dag_run_conf=new_conf,
+        reprocess_behavior=reprocess_behavior,
+    )
+
+    session.expunge_all()
+
+    # The reprocessed existing run should have the new conf
+    reprocessed_dr = session.scalar(
+        select(DagRun).where(
+            DagRun.dag_id == dag.dag_id,
+            DagRun.logical_date == timezone.parse(existing_date),
+        )
+    )
+    assert reprocessed_dr.conf == new_conf
+
+    # New runs created by the backfill should also have the new conf
+    all_backfill_runs = session.scalars(
+        
select(DagRun).join(BackfillDagRun.dag_run).where(BackfillDagRun.backfill_id == 
b.id)
+    ).all()
+    assert all(x.conf == new_conf for x in all_backfill_runs)
+
+
+def test_backfill_none_conf_preserves_existing_dag_run_conf(dag_maker, 
session):
+    """When backfill dag_run_conf is None, existing DagRun conf should be 
preserved."""
+    with dag_maker(schedule="@daily") as dag:
+        PythonOperator(task_id="hi", python_callable=print)
+
+    existing_date = "2021-01-03"
+    original_conf = {"keep": "this"}
+    dag_maker.create_dagrun(
+        run_id=f"scheduled_{existing_date}",
+        logical_date=timezone.parse(existing_date),
+        session=session,
+        state=DagRunState.FAILED,
+        conf=original_conf,
+    )
+    session.commit()
+
+    _create_backfill(
+        dag_id=dag.dag_id,
+        from_date=pendulum.parse("2021-01-01"),
+        to_date=pendulum.parse("2021-01-05"),
+        max_active_runs=10,
+        reverse=False,
+        triggering_user_name="pytest",
+        dag_run_conf=None,
+        reprocess_behavior=ReprocessBehavior.FAILED,
+    )
+
+    session.expunge_all()
+
+    reprocessed_dr = session.scalar(
+        select(DagRun).where(
+            DagRun.dag_id == dag.dag_id,
+            DagRun.logical_date == timezone.parse(existing_date),
+        )
+    )
+    assert reprocessed_dr.conf == original_conf
+
+
+def test_backfill_empty_conf_overrides_existing_dag_run(dag_maker, session):
+    """When backfill dag_run_conf is {}, existing DagRun conf should be 
updated to {}."""
+    with dag_maker(schedule="@daily") as dag:
+        PythonOperator(task_id="hi", python_callable=print)
+
+    existing_date = "2021-01-03"
+    dag_maker.create_dagrun(
+        run_id=f"scheduled_{existing_date}",
+        logical_date=timezone.parse(existing_date),
+        session=session,
+        state=DagRunState.FAILED,
+        conf={"old_key": "old_value"},
+    )
+    session.commit()
+
+    _create_backfill(
+        dag_id=dag.dag_id,
+        from_date=pendulum.parse("2021-01-01"),
+        to_date=pendulum.parse("2021-01-05"),
+        max_active_runs=10,
+        reverse=False,
+        triggering_user_name="pytest",
+        dag_run_conf={},
+        reprocess_behavior=ReprocessBehavior.FAILED,
+    )
+
+    session.expunge_all()
+
+    reprocessed_dr = session.scalar(
+        select(DagRun).where(
+            DagRun.dag_id == dag.dag_id,
+            DagRun.logical_date == timezone.parse(existing_date),
+        )
+    )
+    assert reprocessed_dr.conf == {}
+
+
+def test_backfill_rejects_invalid_conf(dag_maker, session):
+    """Backfill with invalid conf should fail validation before creating any 
runs."""
+    from airflow.sdk import Param
+
+    with dag_maker(
+        schedule="@daily",
+        params={"validated_number": Param(1, type="integer", minimum=1, 
maximum=10)},
+    ) as dag:
+        PythonOperator(task_id="hi", python_callable=print)
+
+    with pytest.raises(InvalidBackfillConf, match="Invalid input for param 
validated_number"):
+        _create_backfill(
+            dag_id=dag.dag_id,
+            from_date=pendulum.parse("2021-01-01"),
+            to_date=pendulum.parse("2021-01-05"),
+            max_active_runs=10,
+            reverse=False,
+            triggering_user_name="pytest",
+            dag_run_conf={"validated_number": 99},
+        )
+
+    # No runs should have been created
+    assert session.scalar(select(DagRun).where(DagRun.dag_id == dag.dag_id)) 
is None
+
+
 def test_params_stored_correctly(dag_maker, session):
     with dag_maker(schedule="@daily") as dag:
         PythonOperator(task_id="hi", python_callable=print)
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py 
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index e10462fa553..50fdf66d40a 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -1132,7 +1132,7 @@ class BackfillPostBody(BaseModel):
     from_date: Annotated[datetime, Field(title="From Date")]
     to_date: Annotated[datetime, Field(title="To Date")]
     run_backwards: Annotated[bool | None, Field(title="Run Backwards")] = False
-    dag_run_conf: Annotated[dict[str, Any] | None, Field(title="Dag Run 
Conf")] = {}
+    dag_run_conf: Annotated[dict[str, Any] | None, Field(title="Dag Run 
Conf")] = None
     reprocess_behavior: ReprocessBehavior | None = "none"
     max_active_runs: Annotated[int | None, Field(title="Max Active Runs")] = 10
     run_on_latest_version: Annotated[bool | None, Field(title="Run On Latest 
Version")] = True

Reply via email to