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 b6d81b94ed9 Call listeners for running task instance when a Dag Run 
state is manually set (#69874)
b6d81b94ed9 is described below

commit b6d81b94ed9977f374e7651aa1ee23cb4217d139
Author: Kacper Muda <[email protected]>
AuthorDate: Wed Jul 22 18:42:53 2026 +0200

    Call listeners for running task instance when a Dag Run state is manually 
set (#69874)
    
    * Call listeners for running task instance when a Dag Run state is manually 
set
    
    * Address PR review
    
    # Conflicts:
    #       
airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
    
    * Remove two comment lines.
    
    * Add newsfragment
    
    * fix after rebase
---
 airflow-core/newsfragments/69874.bugfix.rst        |   1 +
 airflow-core/src/airflow/api/common/mark_tasks.py  |  44 ++++++--
 .../core_api/services/public/dag_run.py            |  11 +-
 .../tests/unit/api/common/test_mark_tasks.py       |   9 +-
 .../core_api/routes/public/test_dag_run.py         | 123 ++++++++++++++++++++-
 5 files changed, 167 insertions(+), 21 deletions(-)

diff --git a/airflow-core/newsfragments/69874.bugfix.rst 
b/airflow-core/newsfragments/69874.bugfix.rst
new file mode 100644
index 00000000000..ad0d87336aa
--- /dev/null
+++ b/airflow-core/newsfragments/69874.bugfix.rst
@@ -0,0 +1 @@
+Listeners registered via ``on_task_instance_success`` / 
``on_task_instance_failed`` are now called for non-teardown task instances that 
were running when a Dag Run state is manually set to a terminal state (e.g. via 
the API or UI).
diff --git a/airflow-core/src/airflow/api/common/mark_tasks.py 
b/airflow-core/src/airflow/api/common/mark_tasks.py
index 5274d8c706e..9a07e1c9b63 100644
--- a/airflow-core/src/airflow/api/common/mark_tasks.py
+++ b/airflow-core/src/airflow/api/common/mark_tasks.py
@@ -223,7 +223,7 @@ def _set_dag_run_terminal_state(
     ti_state: TaskInstanceState,
     commit: bool,
     session: SASession,
-) -> list[TaskInstance]:
+) -> tuple[list[TaskInstance], list[TaskInstance]]:
     """
     Set the dag run's state to the given terminal state.
 
@@ -237,11 +237,15 @@ def _set_dag_run_terminal_state(
     :param ti_state: state to set on running task instances
     :param commit: commit Dag and tasks to be altered to the database
     :param session: database session
-    :return: If commit is true, list of tasks that have been updated,
-             otherwise list of tasks that will be updated
+    :return: ``(all_updated_tis, killed_tis)`` where ``all_updated_tis`` is the
+        combined list of pending (now SKIPPED) and running (now ``ti_state``) 
TIs,
+        and ``killed_tis`` contains only the non-teardown TIs that were in an 
active
+        running state and were forcefully terminated (teardown TIs are 
intentionally
+        left running so they can finish their own cleanup, and must not 
receive a
+        terminal listener event here).
     """
     if not dag:
-        return []
+        return [], []
     if not run_id:
         raise ValueError(f"Invalid dag_run_id: {run_id}")
 
@@ -264,9 +268,10 @@ def _set_dag_run_terminal_state(
             )
         ).all()
     )
-
     # Do not kill teardown tasks
     task_ids_of_running_tis = {ti.task_id for ti in running_tis if not 
dag.task_dict[ti.task_id].is_teardown}
+    # Keep task instances that were killed with no cleanup capability (all 
running minus teardown ones).
+    killed_tis = [ti for ti in running_tis if ti.task_id in 
task_ids_of_running_tis]
 
     def _set_running_task(task: Operator) -> Operator:
         task.dag = dag
@@ -302,13 +307,14 @@ def _set_dag_run_terminal_state(
         if not any(dag.task_dict[ti.task_id].is_teardown for ti in 
(running_tis + pending_tis)):
             _set_dag_run_state(dag.dag_id, run_id, run_state, session)
 
-    return pending_normal_tis + set_state(
+    all_updated = pending_normal_tis + set_state(
         tasks=running_tasks,
         run_id=run_id,
         state=ti_state,
         commit=commit,
         session=session,
     )
+    return all_updated, killed_tis
 
 
 @provide_session
@@ -318,7 +324,7 @@ def set_dag_run_state_to_success(
     run_id: str | None = None,
     commit: bool = False,
     session: SASession = NEW_SESSION,
-) -> list[TaskInstance]:
+) -> tuple[list[TaskInstance], list[TaskInstance]]:
     """
     Set the dag run's state to success.
 
@@ -328,8 +334,15 @@ def set_dag_run_state_to_success(
     :param run_id: the run_id to start looking from
     :param commit: commit Dag and tasks to be altered to the database
     :param session: database session
-    :return: If commit is true, list of tasks that have been updated,
-             otherwise list of tasks that will be updated
+    :return: A tuple ``(all_updated_tis, killed_tis)``.
+        ``all_updated_tis`` is the combined list of task instances that were 
updated:
+        previously-running ones (now SUCCESS) and non-finished pending ones 
(now SKIPPED).
+        If ``commit`` is ``False``, this is the list of task instances *that 
would be* updated.
+        ``killed_tis`` contains only the non-teardown task instances that were 
in an active
+        running state (RUNNING, DEFERRED, UP_FOR_RESCHEDULE, AWAITING_INPUT) 
and were
+        forcefully terminated — teardown tasks are intentionally excluded 
because they are
+        left running to finish their own cleanup. Use ``killed_tis`` for 
firing terminal
+        listener hooks.
     :raises: ValueError if dag or logical_date is invalid
     """
     return _set_dag_run_terminal_state(
@@ -349,7 +362,7 @@ def set_dag_run_state_to_failed(
     run_id: str | None = None,
     commit: bool = False,
     session: SASession = NEW_SESSION,
-) -> list[TaskInstance]:
+) -> tuple[list[TaskInstance], list[TaskInstance]]:
     """
     Set the dag run's state to failed.
 
@@ -359,8 +372,15 @@ def set_dag_run_state_to_failed(
     :param run_id: the Dag run_id to start looking from
     :param commit: commit Dag and tasks to be altered to the database
     :param session: database session
-    :return: If commit is true, list of tasks that have been updated,
-             otherwise list of tasks that will be updated
+    :return: A tuple ``(all_updated_tis, killed_tis)``.
+        ``all_updated_tis`` is the combined list of task instances that were 
updated:
+        previously-running ones (now FAILED) and non-finished pending ones 
(now SKIPPED).
+        If ``commit`` is ``False``, this is the list of task instances *that 
would be* updated.
+        ``killed_tis`` contains only the non-teardown task instances that were 
in an active
+        running state (RUNNING, DEFERRED, UP_FOR_RESCHEDULE, AWAITING_INPUT) 
and were
+        forcefully terminated — teardown tasks are intentionally excluded 
because they are
+        left running to finish their own cleanup. Use ``killed_tis`` for 
firing terminal
+        listener hooks.
     """
     return _set_dag_run_terminal_state(
         dag=dag,
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/services/public/dag_run.py 
b/airflow-core/src/airflow/api_fastapi/core_api/services/public/dag_run.py
index 07181daa1c2..9f5e65501c0 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/services/public/dag_run.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/services/public/dag_run.py
@@ -57,6 +57,7 @@ from airflow.api_fastapi.core_api.datamodels.dag_run import (
 )
 from airflow.api_fastapi.core_api.datamodels.task_instances import 
NewTaskResponse
 from airflow.api_fastapi.core_api.services.public.common import BulkService
+from airflow.api_fastapi.core_api.services.public.task_instances import 
_emit_state_listener_hooks
 from airflow.listeners.listener import get_listener_manager
 from airflow.models.dagrun import DagRun, clear_partition_runs
 from airflow.models.taskinstance import TaskInstance
@@ -193,7 +194,10 @@ def patch_dag_run_state(
 ) -> None:
     """Set a Dag Run's state (success/queued/failed), firing the matching 
listener hooks."""
     if state == DagRunMutableStates.SUCCESS:
-        set_dag_run_state_to_success(dag=dag, run_id=dag_run.run_id, 
commit=True, session=session)
+        _, killed_tis = set_dag_run_state_to_success(
+            dag=dag, run_id=dag_run.run_id, commit=True, session=session
+        )
+        _emit_state_listener_hooks(killed_tis, TaskInstanceState.SUCCESS)
         try:
             if dag_run.dag is None:
                 dag_run.dag = dag
@@ -209,7 +213,10 @@ def patch_dag_run_state(
         # Not notifying on queued - only notifying on RUNNING, which happens 
in the scheduler.
         set_dag_run_state_to_queued(dag=dag, run_id=dag_run.run_id, 
commit=True, session=session)
     elif state == DagRunMutableStates.FAILED:
-        set_dag_run_state_to_failed(dag=dag, run_id=dag_run.run_id, 
commit=True, session=session)
+        _, killed_tis = set_dag_run_state_to_failed(
+            dag=dag, run_id=dag_run.run_id, commit=True, session=session
+        )
+        _emit_state_listener_hooks(killed_tis, TaskInstanceState.FAILED)
         try:
             if dag_run.dag is None:
                 dag_run.dag = dag
diff --git a/airflow-core/tests/unit/api/common/test_mark_tasks.py 
b/airflow-core/tests/unit/api/common/test_mark_tasks.py
index e1f5695ada8..b16582ea58e 100644
--- a/airflow-core/tests/unit/api/common/test_mark_tasks.py
+++ b/airflow-core/tests/unit/api/common/test_mark_tasks.py
@@ -50,9 +50,10 @@ def test_set_dag_run_state_to_failed(dag_maker: 
DagMaker[SerializedDAG]):
             ti.set_state(TaskInstanceState.RUNNING)
     dag_maker.session.flush()
 
-    updated_tis: list[TaskInstance] = set_dag_run_state_to_failed(
+    result: tuple[list[TaskInstance], list[TaskInstance]] = 
set_dag_run_state_to_failed(
         dag=dag, run_id=dr.run_id, commit=True, session=dag_maker.session
     )
+    updated_tis, _ = result
     assert len(updated_tis) == 2
     task_dict = {ti.task_id: ti for ti in updated_tis}
     assert task_dict["running"].state == TaskInstanceState.FAILED
@@ -82,9 +83,10 @@ def test_set_dag_run_state_to_success_unfinished_teardown(
     dag_maker.session.flush()
     assert dr.state == DagRunState.RUNNING
 
-    updated_tis: list[TaskInstance] = set_dag_run_state_to_success(
+    result: tuple[list[TaskInstance], list[TaskInstance]] = 
set_dag_run_state_to_success(
         dag=dag, run_id=dr.run_id, commit=True, session=dag_maker.session
     )
+    updated_tis, _ = result
     run = dag_maker.session.scalar(select(DagRun).filter_by(dag_id=dr.dag_id, 
run_id=dr.run_id))
     assert run is not None
     assert run.state != DagRunState.SUCCESS
@@ -111,9 +113,10 @@ def 
test_set_dag_run_state_to_success_keeps_finished_task_states(
     dag_maker.session.flush()
     dr.set_state(DagRunState.FAILED)
 
-    updated_tis: list[TaskInstance] = set_dag_run_state_to_success(
+    result: tuple[list[TaskInstance], list[TaskInstance]] = 
set_dag_run_state_to_success(
         dag=dag, run_id=dr.run_id, commit=True, session=dag_maker.session
     )
+    updated_tis, _ = result
     run = dag_maker.session.scalar(select(DagRun).filter_by(dag_id=dr.dag_id, 
run_id=dr.run_id))
     assert run is not None
     assert run.state == DagRunState.SUCCESS
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
index fb804e4c4c5..2c51cc50c4b 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
@@ -48,7 +48,7 @@ from airflow.timetables.interval import 
CronDataIntervalTimetable
 from airflow.timetables.simple import PartitionedAssetTimetable, 
PartitionedAtRuntime
 from airflow.timetables.trigger import CronPartitionTimetable
 from airflow.utils.session import provide_session
-from airflow.utils.state import DagRunState, State
+from airflow.utils.state import DagRunState, State, TaskInstanceState
 from airflow.utils.types import DagRunTriggeredByType, DagRunType
 
 from tests_common.test_utils.api_fastapi import _check_dag_run_note, 
_check_last_log
@@ -1594,7 +1594,7 @@ class TestPatchDagRun:
         assert body["detail"][0]["msg"] == "Input should be 'queued', 
'success' or 'failed'"
 
     @pytest.mark.parametrize(
-        ("state", "listener_state", "expected_msg"),
+        ("state", "expected_dagrun_state", "expected_msg"),
         [
             ("queued", [], None),
             ("success", [DagRunState.SUCCESS], "Dag Run's state was manually 
set to `success`."),
@@ -1603,13 +1603,13 @@ class TestPatchDagRun:
     )
     @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
     def test_patch_dag_run_notifies_listeners(
-        self, test_client, state, listener_state, expected_msg, 
listener_manager
+        self, test_client, state, expected_dagrun_state, expected_msg, 
listener_manager
     ):
         listener = ClassBasedListener()
         listener_manager(listener)
         response = 
test_client.patch(f"/dags/{DAG1_ID}/dagRuns/{DAG1_RUN1_ID}", json={"state": 
state})
         assert response.status_code == 200
-        assert listener.state == listener_state
+        assert listener.state == expected_dagrun_state
         if expected_msg is not None:
             assert listener.dag_run_msg == expected_msg
             assert listener.dag_has_dag_attr is True
@@ -1627,6 +1627,121 @@ class TestPatchDagRun:
         assert response.status_code == 200
         assert listener.dag_run_note_at_listener == "listener_note"
 
+    @pytest.mark.parametrize(
+        ("dag_run_state", "expected_ti_state"),
+        [
+            ("success", TaskInstanceState.SUCCESS),
+            ("failed", TaskInstanceState.FAILED),
+        ],
+    )
+    @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
+    def test_patch_dag_run_notifies_ti_listeners_for_running_tasks(
+        self,
+        test_client,
+        dag_maker,
+        session,
+        listener_manager,
+        dag_run_state,
+        expected_ti_state,
+    ):
+        with dag_maker(dag_id="test_ti_listeners", schedule=None, 
serialized=True):
+            EmptyOperator(task_id="t1")
+
+        dr = dag_maker.create_dagrun(state=DagRunState.RUNNING)
+        ti = session.scalar(
+            select(TaskInstance).where(
+                TaskInstance.dag_id == dr.dag_id,
+                TaskInstance.run_id == dr.run_id,
+                TaskInstance.task_id == "t1",
+            )
+        )
+        ti.state = TaskInstanceState.RUNNING
+        dag_maker.sync_dagbag_to_db()
+        session.commit()
+
+        listener = ClassBasedListener()
+        listener_manager(listener)
+
+        response = test_client.patch(
+            f"/dags/test_ti_listeners/dagRuns/{dr.run_id}", json={"state": 
dag_run_state}
+        )
+        assert response.status_code == 200
+        assert listener.state[0] is expected_ti_state
+        assert listener.state[1] is DagRunState(dag_run_state)
+        assert len(listener.state) == 2
+
+    @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
+    def test_patch_dag_run_does_not_notify_ti_listeners_for_non_running_tasks(
+        self,
+        test_client,
+        dag_maker,
+        session,
+        listener_manager,
+    ):
+        with dag_maker(dag_id="test_ti_listeners_queued", schedule=None, 
serialized=True):
+            EmptyOperator(task_id="t1")
+
+        dr = dag_maker.create_dagrun(state=DagRunState.RUNNING)
+        ti = session.scalar(
+            select(TaskInstance).where(
+                TaskInstance.dag_id == dr.dag_id,
+                TaskInstance.run_id == dr.run_id,
+                TaskInstance.task_id == "t1",
+            )
+        )
+        ti.state = TaskInstanceState.QUEUED
+        dag_maker.sync_dagbag_to_db()
+        session.commit()
+
+        listener = ClassBasedListener()
+        listener_manager(listener)
+
+        response = test_client.patch(
+            f"/dags/test_ti_listeners_queued/dagRuns/{dr.run_id}", 
json={"state": "success"}
+        )
+        assert response.status_code == 200
+        # Only the dagrun-level hook should have fired; no TI hooks for a 
non-running task.
+        # The list length check distinguishes "only dagrun fired" from "both 
fired".
+        assert listener.state == [DagRunState.SUCCESS]
+
+    @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
+    def 
test_patch_dag_run_does_not_notify_ti_listeners_for_running_teardown_tasks(
+        self,
+        test_client,
+        dag_maker,
+        session,
+        listener_manager,
+    ):
+        with dag_maker(dag_id="test_ti_listeners_teardown", schedule=None, 
serialized=True):
+            normal = EmptyOperator(task_id="normal")
+            teardown = 
EmptyOperator(task_id="teardown").as_teardown(setups=normal)
+            normal >> teardown
+
+        dr = dag_maker.create_dagrun(state=DagRunState.RUNNING)
+        for task_id in ("normal", "teardown"):
+            ti = session.scalar(
+                select(TaskInstance).where(
+                    TaskInstance.dag_id == dr.dag_id,
+                    TaskInstance.run_id == dr.run_id,
+                    TaskInstance.task_id == task_id,
+                )
+            )
+            ti.state = TaskInstanceState.RUNNING
+        dag_maker.sync_dagbag_to_db()
+        session.commit()
+
+        listener = ClassBasedListener()
+        listener_manager(listener)
+
+        response = test_client.patch(
+            f"/dags/test_ti_listeners_teardown/dagRuns/{dr.run_id}", 
json={"state": "success"}
+        )
+        assert response.status_code == 200
+        # Normal task was killed — its TI listener fires. Teardown task is 
intentionally skipped.
+        assert len(listener.state) == 2
+        assert listener.state[0] is TaskInstanceState.SUCCESS
+        assert listener.state[1] is DagRunState.SUCCESS
+
 
 class TestDeleteDagRun:
     def test_delete_dag_run(self, test_client, session):

Reply via email to