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 a564b789a67 fix: Patch ti note before state so that listeners can see 
it (#70247)
a564b789a67 is described below

commit a564b789a6779a8cda665f8ad44ed9c04ff8d808
Author: Kacper Muda <[email protected]>
AuthorDate: Wed Jul 22 19:18:36 2026 +0200

    fix: Patch ti note before state so that listeners can see it (#70247)
---
 .../core_api/routes/public/task_instances.py       | 73 +++++++++++-----------
 .../core_api/services/public/task_instances.py     | 32 +++++-----
 .../core_api/routes/public/test_dag_run.py         |  2 +-
 .../core_api/routes/public/test_task_instances.py  | 61 ++++++++++++++++++
 .../tests/unit/listeners/class_listener.py         | 11 +++-
 5 files changed, 122 insertions(+), 57 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py
index f72cd3801c6..7305e4e89be 100644
--- 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py
+++ 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py
@@ -1013,6 +1013,14 @@ def patch_task_group_instances(
     )
 
     response_tis = tis
+    # Apply "note" before "state" so listeners fired inside 
_patch_task_group_state() see the updated note.
+    if "note" in data:
+        _patch_task_instance_note(
+            task_instance_body=body,
+            tis=response_tis,
+            user=user,
+            update_mask=update_mask,
+        )
     if "new_state" in data:
         response_tis = _patch_task_group_state(
             group_id=group_id,
@@ -1022,13 +1030,6 @@ def patch_task_group_instances(
             data=data,
             session=session,
         )
-    if "note" in data:
-        _patch_task_instance_note(
-            task_instance_body=body,
-            tis=response_tis,
-            user=user,
-            update_mask=update_mask,
-        )
 
     response_tis = _reload_tis_with_rendered_fields(response_tis, session)
 
@@ -1209,36 +1210,34 @@ def patch_task_instance(
         dag_id, dag_run_id, task_id, dag_bag, body, session, map_index, 
update_mask
     )
 
-    for key, _ in data.items():
-        if key == "new_state":
-            # Create BulkTaskInstanceBody object with map_index field
-            bulk_ti_body = BulkTaskInstanceBody(
-                task_id=task_id,
-                map_index=map_index,
-                new_state=body.new_state,
-                note=body.note,
-                include_upstream=body.include_upstream,
-                include_downstream=body.include_downstream,
-                include_future=body.include_future,
-                include_past=body.include_past,
-            )
-
-            _patch_task_instance_state(
-                task_id=task_id,
-                dag_run_id=dag_run_id,
-                dag=dag,
-                task_instance_body=bulk_ti_body,
-                data=data,
-                session=session,
-            )
-
-        elif key == "note":
-            _patch_task_instance_note(
-                task_instance_body=body,
-                tis=tis,
-                user=user,
-                update_mask=update_mask,
-            )
+    # Apply "note" before "state" so listeners fired inside 
_patch_task_instance_state() see the updated note.
+    if "note" in data:
+        _patch_task_instance_note(
+            task_instance_body=body,
+            tis=tis,
+            user=user,
+            update_mask=update_mask,
+        )
+    if "new_state" in data:
+        # Create BulkTaskInstanceBody object with map_index field
+        bulk_ti_body = BulkTaskInstanceBody(
+            task_id=task_id,
+            map_index=map_index,
+            new_state=body.new_state,
+            note=body.note,
+            include_upstream=body.include_upstream,
+            include_downstream=body.include_downstream,
+            include_future=body.include_future,
+            include_past=body.include_past,
+        )
+        _patch_task_instance_state(
+            task_id=task_id,
+            dag_run_id=dag_run_id,
+            dag=dag,
+            task_instance_body=bulk_ti_body,
+            data=data,
+            session=session,
+        )
 
     return TaskInstanceCollectionResponse(
         task_instances=[
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/services/public/task_instances.py
 
b/airflow-core/src/airflow/api_fastapi/core_api/services/public/task_instances.py
index 0a1885f0e26..b00ba4effdb 100644
--- 
a/airflow-core/src/airflow/api_fastapi/core_api/services/public/task_instances.py
+++ 
b/airflow-core/src/airflow/api_fastapi/core_api/services/public/task_instances.py
@@ -468,22 +468,22 @@ class 
BulkTaskInstanceService(BulkService[BulkTaskInstanceBody]):
             update_mask=update_mask,
         )
 
-        for key, _ in data.items():
-            if key == "new_state":
-                _patch_task_instance_state(
-                    task_id=task_id,
-                    dag_run_id=dag_run_id,
-                    dag=dag,
-                    task_instance_body=entity,
-                    session=self.session,
-                    data=data,
-                )
-            elif key == "note":
-                _patch_task_instance_note(
-                    task_instance_body=entity,
-                    tis=tis,
-                    user=self.user,
-                )
+        # Apply "note" before "state" so listeners fired inside 
_patch_task_instance_state() see the updated note.
+        if "note" in data:
+            _patch_task_instance_note(
+                task_instance_body=entity,
+                tis=tis,
+                user=self.user,
+            )
+        if "new_state" in data:
+            _patch_task_instance_state(
+                task_id=task_id,
+                dag_run_id=dag_run_id,
+                dag=dag,
+                task_instance_body=entity,
+                session=self.session,
+                data=data,
+            )
 
         results.success.append(f"{dag_id}.{dag_run_id}.{task_id}[{map_index}]")
 
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 2c51cc50c4b..e531185795f 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
@@ -1612,7 +1612,7 @@ class TestPatchDagRun:
         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
+            assert listener.dag_run_has_dag_attr is True
 
     @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
     def test_patch_dag_run_listener_sees_note_when_note_and_state_both_patched(
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
index b1f8f0ed0da..ae3a8388fea 100644
--- 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
+++ 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
@@ -4674,6 +4674,22 @@ class TestPatchTaskInstance(TestTaskInstanceEndpoint):
         assert response2.json()["state"] == state
         assert listener.state == listener_state
 
+    def 
test_patch_task_instance_listener_sees_note_when_note_and_state_both_patched(
+        self, test_client, session, listener_manager
+    ):
+        from unit.listeners.class_listener import ClassBasedListener
+
+        self.create_task_instances(session)
+
+        listener = ClassBasedListener()
+        listener_manager(listener)
+        response = test_client.patch(
+            self.ENDPOINT_URL,
+            json={"new_state": "success", "note": "listener_note"},
+        )
+        assert response.status_code == 200
+        assert listener.ti_note_at_listener == "listener_note"
+
     
@mock.patch("airflow.serialization.definitions.dag.SerializedDAG.set_task_instance_state")
     def test_should_call_mocked_api(self, mock_set_ti_state, test_client, 
session):
         self.create_task_instances(session)
@@ -6976,6 +6992,35 @@ class TestBulkTaskInstances(TestTaskInstanceEndpoint):
         response = test_client.patch(self.ENDPOINT_URL, json={})
         assert response.status_code == 422
 
+    def test_bulk_update_listener_sees_note_when_note_and_state_both_patched(
+        self, test_client, session, listener_manager
+    ):
+        from unit.listeners.class_listener import ClassBasedListener
+
+        self.create_task_instances(session, task_instances=[{"state": 
State.RUNNING}])
+
+        listener = ClassBasedListener()
+        listener_manager(listener)
+        response = test_client.patch(
+            self.ENDPOINT_URL,
+            json={
+                "actions": [
+                    {
+                        "action": "update",
+                        "entities": [
+                            {
+                                "task_id": self.TASK_ID,
+                                "new_state": "success",
+                                "note": "listener_note",
+                            }
+                        ],
+                    }
+                ]
+            },
+        )
+        assert response.status_code == 200
+        assert listener.ti_note_at_listener == "listener_note"
+
 
 class TestPatchTaskGroup(TestTaskInstanceEndpoint):
     DAG_ID = "example_task_group"
@@ -7421,6 +7466,22 @@ class TestPatchTaskGroup(TestTaskInstanceEndpoint):
             assert ti.state == TaskInstanceState.FAILED
             _check_task_instance_note(session, ti.id, {"content": note_value, 
"user_id": "test"})
 
+    def 
test_patch_task_group_listener_sees_note_when_note_and_state_both_patched(
+        self, test_client, session, listener_manager
+    ):
+        from unit.listeners.class_listener import ClassBasedListener
+
+        self.create_task_instances(session, dag_id=self.DAG_ID)
+
+        listener = ClassBasedListener()
+        listener_manager(listener)
+        response = test_client.patch(
+            self.ENDPOINT_URL,
+            json={"new_state": "failed", "note": "listener_note"},
+        )
+        assert response.status_code == 200
+        assert listener.ti_note_at_listener == "listener_note"
+
 
 class TestPatchTaskGroupDryRun(TestTaskInstanceEndpoint):
     DAG_ID = "example_task_group"
diff --git a/airflow-core/tests/unit/listeners/class_listener.py 
b/airflow-core/tests/unit/listeners/class_listener.py
index 152f199cb80..75d7d73d4bd 100644
--- a/airflow-core/tests/unit/listeners/class_listener.py
+++ b/airflow-core/tests/unit/listeners/class_listener.py
@@ -27,8 +27,9 @@ class ClassBasedListener:
         self.stopped_component = None
         self.state = []
         self.dag_run_msg: str | None = None
-        self.dag_has_dag_attr: bool | None = None
+        self.dag_run_has_dag_attr: bool | None = None
         self.dag_run_note_at_listener: str | None = None
+        self.ti_note_at_listener: str | None = None
 
     @hookimpl
     def on_starting(self, component):
@@ -43,18 +44,22 @@ class ClassBasedListener:
     @hookimpl
     def on_task_instance_running(self, previous_state, task_instance):
         self.state.append(TaskInstanceState.RUNNING)
+        self.ti_note_at_listener = task_instance.note
 
     @hookimpl
     def on_task_instance_success(self, previous_state, task_instance):
         self.state.append(TaskInstanceState.SUCCESS)
+        self.ti_note_at_listener = task_instance.note
 
     @hookimpl
     def on_task_instance_failed(self, previous_state, task_instance, error: 
None | str | BaseException):
         self.state.append(TaskInstanceState.FAILED)
+        self.ti_note_at_listener = task_instance.note
 
     @hookimpl
     def on_task_instance_skipped(self, previous_state, task_instance):
         self.state.append(TaskInstanceState.SKIPPED)
+        self.ti_note_at_listener = task_instance.note
 
     @hookimpl
     def on_dag_run_running(self, dag_run, msg: str):
@@ -64,14 +69,14 @@ class ClassBasedListener:
     def on_dag_run_success(self, dag_run, msg: str):
         self.state.append(DagRunState.SUCCESS)
         self.dag_run_msg = msg
-        self.dag_has_dag_attr = dag_run.dag is not None
+        self.dag_run_has_dag_attr = dag_run.dag is not None
         self.dag_run_note_at_listener = dag_run.note
 
     @hookimpl
     def on_dag_run_failed(self, dag_run, msg: str):
         self.state.append(DagRunState.FAILED)
         self.dag_run_msg = msg
-        self.dag_has_dag_attr = dag_run.dag is not None
+        self.dag_run_has_dag_attr = dag_run.dag is not None
         self.dag_run_note_at_listener = dag_run.note
 
 

Reply via email to