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

ephraimbuddy 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 13b2969c783 Fix one bad callback request crashing the Dag processor 
and dropping the rest (#70990)
13b2969c783 is described below

commit 13b2969c783e2e30ee2290ac9902fc8858b8f12e
Author: Ephraim Anierobi <[email protected]>
AuthorDate: Fri Aug 14 09:51:22 2026 +0100

    Fix one bad callback request crashing the Dag processor and dropping the 
rest (#70990)
    
    * Fix one bad callback request crashing the Dag processor and dropping the 
rest
    
    A callback request that hits the documented race condition (the Dag or
    task was removed between scheduling the callback and parsing the file)
    raises out of _execute_callbacks, killing the parse subprocess. The
    remaining requests in the batch were already popped from the manager's
    queue, so on_failure_callback, on_retry_callback, and failure-email
    requests behind the bad one are lost permanently. Isolate each request
    so a failure is logged and the rest still execute, and route the
    last_ti task lookup through _get_dag_with_task so a removed task
    surfaces as the race-condition error instead of a raw TaskNotFound.
    
    * Serialize callback request once per loop iteration
    
    Avoids a second to_json() in the exception handler, where a
    serialization failure would mask the original error and abort the
    batch the handler exists to protect.
    
    * Address review: keep run conf out of logs and deliver Dag callbacks when 
only the task is gone
    
    The last_ti task lookup only enriches the callback context, so a task
    removed since the run should downgrade to the minimal context (as
    produce_dag_callback already does for an unrepresentable last_ti)
    instead of costing the user the callback. The failure log leaked
    user-supplied dag_run.conf at ERROR via the serialized request and
    carried no structured identifiers; it now excludes context_from_server
    and binds dag_id/run_id/ti_id so log filters can match the line.
    
    * fixup! Address review: keep run conf out of logs and deliver Dag 
callbacks when only the task is gone
---
 .../src/airflow/dag_processing/processor.py        |  87 +++++++++++-------
 .../tests/unit/dag_processing/test_processor.py    | 102 +++++++++++++++++++++
 2 files changed, 155 insertions(+), 34 deletions(-)

diff --git a/airflow-core/src/airflow/dag_processing/processor.py 
b/airflow-core/src/airflow/dag_processing/processor.py
index ca7539131f6..a44f9f37b9e 100644
--- a/airflow-core/src/airflow/dag_processing/processor.py
+++ b/airflow-core/src/airflow/dag_processing/processor.py
@@ -329,23 +329,33 @@ def _execute_callbacks(
 ) -> None:
     for request in callback_requests:
         if isinstance(request, (TaskCallbackRequest, EmailRequest)):
-            log.debug(
-                "Processing Callback Request",
-                request=request.to_json(),
-                ti_id=str(request.ti.id),
-            )
+            log_extra = {
+                "dag_id": request.ti.dag_id,
+                "run_id": request.ti.run_id,
+                "ti_id": str(request.ti.id),
+            }
         else:
-            log.debug("Processing Callback Request", request=request.to_json())
-        with BundleVersionLock(
-            bundle_name=request.bundle_name,
-            bundle_version=request.bundle_version,
-        ):
-            if isinstance(request, TaskCallbackRequest):
-                _execute_task_callbacks(dagbag, request, log)
-            elif isinstance(request, DagCallbackRequest):
-                _execute_dag_callbacks(dagbag, request, log)
-            elif isinstance(request, EmailRequest):
-                _execute_email_callbacks(dagbag, request, log)
+            log_extra = {"dag_id": request.dag_id, "run_id": request.run_id}
+        # context_from_server can carry user-supplied run conf, and the masker 
cannot
+        # redact inside an already-serialized string, so keep it out of log 
payloads.
+        request_json = request.to_json(exclude={"context_from_server"})
+        log.debug("Processing Callback Request", request=request_json, 
**log_extra)
+        # A failed request (e.g. the Dag or task was removed since the callback
+        # was scheduled) must not abort the remaining requests in this batch --
+        # they were already popped from the manager's queue and would be lost.
+        try:
+            with BundleVersionLock(
+                bundle_name=request.bundle_name,
+                bundle_version=request.bundle_version,
+            ):
+                if isinstance(request, TaskCallbackRequest):
+                    _execute_task_callbacks(dagbag, request, log)
+                elif isinstance(request, DagCallbackRequest):
+                    _execute_dag_callbacks(dagbag, request, log)
+                elif isinstance(request, EmailRequest):
+                    _execute_email_callbacks(dagbag, request, log)
+        except Exception:
+            log.exception("Failed to execute callback request", 
request=request_json, **log_extra)
 
 
 def _execute_dag_callbacks(dagbag: DagBag, request: DagCallbackRequest, log: 
FilteringBoundLogger) -> None:
@@ -360,25 +370,34 @@ def _execute_dag_callbacks(dagbag: DagBag, request: 
DagCallbackRequest, log: Fil
     callbacks = callbacks if isinstance(callbacks, list) else [callbacks]
     ctx_from_server = request.context_from_server
 
+    context: Context = {
+        "dag": dag,
+        "run_id": request.run_id,
+        "reason": request.msg,
+    }
     if ctx_from_server is not None and ctx_from_server.last_ti is not None:
-        task = dag.get_task(ctx_from_server.last_ti.task_id)
-
-        runtime_ti = RuntimeTaskInstance.model_construct(
-            **ctx_from_server.last_ti.model_dump(exclude_unset=True),
-            task=task,
-            _ti_context_from_server=TIRunContext.model_construct(
-                dag_run=ctx_from_server.dag_run,
-                max_tries=task.retries,
-            ),
-        )
-        context = runtime_ti.get_template_context()
-        context["reason"] = request.msg
-    else:
-        context: Context = {  # type: ignore[no-redef]
-            "dag": dag,
-            "run_id": request.run_id,
-            "reason": request.msg,
-        }
+        try:
+            task = dag.get_task(ctx_from_server.last_ti.task_id)
+        except TaskNotFound:
+            # The task only enriches the callback context; a task removed 
since the
+            # run must not cost the user the callback itself 
(produce_dag_callback
+            # makes the same call for an unrepresentable last_ti).
+            log.warning(
+                "Task from callback context no longer exists in the Dag; 
running callback with minimal context",
+                dag_id=request.dag_id,
+                task_id=ctx_from_server.last_ti.task_id,
+            )
+        else:
+            runtime_ti = RuntimeTaskInstance.model_construct(
+                **ctx_from_server.last_ti.model_dump(exclude_unset=True),
+                task=task,
+                _ti_context_from_server=TIRunContext.model_construct(
+                    dag_run=ctx_from_server.dag_run,
+                    max_tries=task.retries,
+                ),
+            )
+            context = runtime_ti.get_template_context()
+            context["reason"] = request.msg
 
     for callback in callbacks:
         log.info(
diff --git a/airflow-core/tests/unit/dag_processing/test_processor.py 
b/airflow-core/tests/unit/dag_processing/test_processor.py
index 776dec298f0..2ced95916f7 100644
--- a/airflow-core/tests/unit/dag_processing/test_processor.py
+++ b/airflow-core/tests/unit/dag_processing/test_processor.py
@@ -814,6 +814,42 @@ class TestExecuteCallbacks:
         mock_lock.return_value.__exit__.assert_called_once()
         mock_execute.assert_called_once_with(dagbag, callbacks[0], log)
 
+    def test_execute_callbacks_continues_after_failed_request(self, 
spy_agency):
+        """A request for a removed Dag must not abort the remaining requests 
in the batch."""
+        called = False
+
+        def on_failure(context):
+            nonlocal called
+            called = True
+
+        dag = DAG(dag_id="a", on_failure_callback=on_failure)
+
+        def fake_collect_dags(self, *args, **kwargs):
+            self.dags[dag.dag_id] = dag
+
+        spy_agency.spy_on(DagBag.collect_dags, call_fake=fake_collect_dags, 
owner=DagBag)
+        dagbag = DagBag()
+        dagbag.collect_dags()
+
+        def make_request(dag_id):
+            return DagCallbackRequest(
+                filepath="test.py",
+                dag_id=dag_id,
+                run_id="test_run",
+                bundle_name="testing",
+                bundle_version=None,
+                is_failure_callback=True,
+                msg="Message",
+            )
+
+        log = MagicMock(spec=FilteringBoundLogger)
+        _execute_callbacks(dagbag, [make_request("removed_dag"), 
make_request("a")], log)
+
+        assert called is True
+        log.exception.assert_called_once()
+        assert log.exception.call_args.kwargs["dag_id"] == "removed_dag"
+        assert "context_from_server" not in 
log.exception.call_args.kwargs["request"]
+
 
 class TestExecuteDagCallbacks:
     """Test the _execute_dag_callbacks function with context_from_server"""
@@ -892,6 +928,72 @@ class TestExecuteDagCallbacks:
         assert "ts" in context_received
         assert "params" in context_received
 
+    def 
test_execute_dag_callbacks_missing_last_ti_task_falls_back_to_minimal_context(self,
 spy_agency):
+        """A removed last_ti task must not drop the callback; it runs with the 
minimal context."""
+        called = False
+        context_received = None
+
+        def on_failure(context):
+            nonlocal called, context_received
+            called = True
+            context_received = context
+
+        with DAG(dag_id="test_dag", on_failure_callback=on_failure) as dag:
+            BaseOperator(task_id="test_task")
+
+        def fake_collect_dags(self, *args, **kwargs):
+            self.dags[dag.dag_id] = dag
+
+        spy_agency.spy_on(DagBag.collect_dags, call_fake=fake_collect_dags, 
owner=DagBag)
+        dagbag = DagBag()
+        dagbag.collect_dags()
+
+        current_time = timezone.utcnow()
+        dag_run_data = DRDataModel(
+            dag_id="test_dag",
+            run_id="test_run",
+            logical_date=current_time,
+            data_interval_start=current_time,
+            data_interval_end=current_time,
+            run_after=current_time,
+            start_date=current_time,
+            end_date=None,
+            run_type="manual",
+            state="running",
+            consumed_asset_events=[],
+            partition_key=None,
+        )
+        ti_data = TIDataModel(
+            id=uuid.uuid4(),
+            dag_id="test_dag",
+            task_id="removed_task",
+            run_id="test_run",
+            map_index=-1,
+            try_number=1,
+            dag_version_id=uuid.uuid4(),
+        )
+
+        request = DagCallbackRequest(
+            filepath="test.py",
+            dag_id="test_dag",
+            run_id="test_run",
+            bundle_name="testing",
+            bundle_version=None,
+            context_from_server=DagRunContext(dag_run=dag_run_data, 
last_ti=ti_data),
+            is_failure_callback=True,
+            msg="Test failure message",
+        )
+
+        _execute_dag_callbacks(dagbag, request, structlog.get_logger())
+
+        assert called is True
+        assert context_received is not None
+        assert context_received["dag"] is dag
+        assert context_received["run_id"] == "test_run"
+        assert context_received["reason"] == "Test failure message"
+        # The rich template context requires the task; the fallback has none 
of it
+        assert "ts" not in context_received
+
     def test_execute_dag_callbacks_without_context_from_server(self, 
spy_agency):
         """Test _execute_dag_callbacks falls back to simple context when 
context_from_server is None"""
         called = False

Reply via email to