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

potiuk 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 c5d7f60fffe Fix EMR Serverless delete operator skipping deletion when 
deferrable (#73323)
c5d7f60fffe is described below

commit c5d7f60fffe7052b9def28b4ededb8a1573506b6
Author: Shikhar Goel <[email protected]>
AuthorDate: Sun Sep 20 19:48:49 2026 +0530

    Fix EMR Serverless delete operator skipping deletion when deferrable 
(#73323)
    
    * Fix EMR Serverless delete operator skipping deletion when deferrable
    
    With deferrable=True the parent stop step defers, so the delete call placed
    after super().execute() was never reached. The task then resumed in the
    delete operator's execute_complete with the stop trigger's success event,
    logged that the application was deleted and succeeded, silently leaving the
    application in place.
    
    closes: #72123
    
    * Reword super() comment and autospec the cancel_running_jobs mock
    
    Review nits: the comment in delete_stopped_application now records why
    super().execute_complete is used (this class overrides execute_complete
    for the delete trigger's event), and the cancel_running_jobs patch in the
    force_stop test uses autospec=True.
    
    Generated-by: Claude Opus 5
    
    ---------
    
    Co-authored-by: sgoel2be24-cyber 
<[email protected]>
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 .../airflow/providers/amazon/aws/operators/emr.py  | 19 +++++--
 .../amazon/aws/operators/test_emr_serverless.py    | 62 +++++++++++++++++++++-
 2 files changed, 76 insertions(+), 5 deletions(-)

diff --git a/providers/amazon/src/airflow/providers/amazon/aws/operators/emr.py 
b/providers/amazon/src/airflow/providers/amazon/aws/operators/emr.py
index aab67520149..20366b69487 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/operators/emr.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/operators/emr.py
@@ -1646,6 +1646,9 @@ class 
EmrServerlessStopApplicationOperator(AwsBaseOperator[EmrServerlessHook]):
     template_fields: Sequence[str] = aws_template_fields(
         "application_id",
     )
+    # Method the task resumes at once the application has stopped in 
deferrable mode.
+    # Subclasses can override it to run further steps after the stop.
+    stop_complete_method_name: str = "execute_complete"
 
     def __init__(
         self,
@@ -1710,7 +1713,7 @@ class 
EmrServerlessStopApplicationOperator(AwsBaseOperator[EmrServerlessHook]):
                     waiter_max_attempts=self.waiter_max_attempts,
                 ),
                 timeout=timedelta(seconds=self.waiter_max_attempts * 
self.waiter_delay),
-                method_name="execute_complete",
+                method_name=self.stop_complete_method_name,
             )
         if self.wait_for_completion:
             waiter = self.hook.get_waiter("serverless_app_stopped")
@@ -1740,7 +1743,7 @@ class 
EmrServerlessStopApplicationOperator(AwsBaseOperator[EmrServerlessHook]):
                 waiter_max_attempts=self.waiter_max_attempts,
             ),
             timeout=timedelta(seconds=self.waiter_max_attempts * 
self.waiter_delay),
-            method_name="execute_complete",
+            method_name=self.stop_complete_method_name,
         )
 
     def execute_complete(self, context: Context, event: dict[str, Any] | None 
= None) -> None:
@@ -1786,6 +1789,7 @@ class 
EmrServerlessDeleteApplicationOperator(EmrServerlessStopApplicationOperato
     template_fields: Sequence[str] = aws_template_fields(
         "application_id",
     )
+    stop_complete_method_name = "delete_stopped_application"
 
     def __init__(
         self,
@@ -1815,9 +1819,18 @@ class 
EmrServerlessDeleteApplicationOperator(EmrServerlessStopApplicationOperato
         self.wait_for_delete_completion = False if deferrable else 
wait_for_completion
 
     def execute(self, context: Context) -> None:
-        # super stops the app (or makes sure it's already stopped)
+        # super stops the app (or makes sure it's already stopped). In 
deferrable mode it defers
+        # instead of returning, and the task resumes in 
``delete_stopped_application``.
         super().execute(context)
+        self._delete_application()
+
+    def delete_stopped_application(self, context: Context, event: dict[str, 
Any] | None = None) -> None:
+        # super(): this class overrides execute_complete to handle the delete 
trigger's event,
+        # while the event here comes from the stop trigger.
+        super().execute_complete(context, event)
+        self._delete_application()
 
+    def _delete_application(self) -> None:
         self.log.info("Now deleting application: %s", self.application_id)
         response = 
self.hook.conn.delete_application(applicationId=self.application_id)
 
diff --git 
a/providers/amazon/tests/unit/amazon/aws/operators/test_emr_serverless.py 
b/providers/amazon/tests/unit/amazon/aws/operators/test_emr_serverless.py
index 75fcf1bce3d..96203a53f7e 100644
--- a/providers/amazon/tests/unit/amazon/aws/operators/test_emr_serverless.py
+++ b/providers/amazon/tests/unit/amazon/aws/operators/test_emr_serverless.py
@@ -30,6 +30,10 @@ from airflow.providers.amazon.aws.operators.emr import (
     EmrServerlessStartJobOperator,
     EmrServerlessStopApplicationOperator,
 )
+from airflow.providers.amazon.aws.triggers.emr import (
+    EmrServerlessDeleteApplicationTrigger,
+    EmrServerlessStopApplicationTrigger,
+)
 from airflow.providers.amazon.version_compat import NOTSET
 from airflow.providers.common.compat.sdk import AirflowException, TaskDeferred
 
@@ -1390,15 +1394,69 @@ class TestEmrServerlessDeleteOperator:
 
     @mock.patch.object(EmrServerlessHook, "conn")
     def test_delete_application_deferrable(self, mock_conn):
-        mock_conn.delete_application.return_value = {"ResponseMetadata": 
{"HTTPStatusCode": 200}}
+        operator = EmrServerlessDeleteApplicationOperator(
+            task_id=task_id,
+            application_id=application_id,
+            deferrable=True,
+        )
+        with pytest.raises(TaskDeferred) as defer:
+            operator.execute(None)
+
+        assert isinstance(defer.value.trigger, 
EmrServerlessStopApplicationTrigger)
+        assert defer.value.method_name == "delete_stopped_application"
+        
mock_conn.stop_application.assert_called_once_with(applicationId=application_id)
+        mock_conn.delete_application.assert_not_called()
 
+    @mock.patch.object(EmrServerlessHook, "cancel_running_jobs", autospec=True)
+    @mock.patch.object(EmrServerlessHook, "conn")
+    def test_delete_application_deferrable_with_force_stop(self, mock_conn, 
mock_cancel_running_jobs):
+        mock_cancel_running_jobs.return_value = 1
         operator = EmrServerlessDeleteApplicationOperator(
             task_id=task_id,
             application_id=application_id,
             deferrable=True,
+            force_stop=True,
         )
-        with pytest.raises(TaskDeferred):
+        with pytest.raises(TaskDeferred) as defer:
             operator.execute(None)
+        assert defer.value.method_name == "stop_application"
+
+        with pytest.raises(TaskDeferred) as defer:
+            operator.stop_application(None, {"status": "success", 
"application_id": application_id})
+
+        assert isinstance(defer.value.trigger, 
EmrServerlessStopApplicationTrigger)
+        assert defer.value.method_name == "delete_stopped_application"
+        mock_conn.delete_application.assert_not_called()
+
+    @mock.patch.object(EmrServerlessHook, "conn")
+    def test_delete_stopped_application_deferrable(self, mock_conn):
+        mock_conn.delete_application.return_value = {"ResponseMetadata": 
{"HTTPStatusCode": 200}}
+        operator = EmrServerlessDeleteApplicationOperator(
+            task_id=task_id,
+            application_id=application_id,
+            deferrable=True,
+        )
+
+        with pytest.raises(TaskDeferred) as defer:
+            operator.delete_stopped_application(None, {"status": "success", 
"application_id": application_id})
+
+        
mock_conn.delete_application.assert_called_once_with(applicationId=application_id)
+        assert isinstance(defer.value.trigger, 
EmrServerlessDeleteApplicationTrigger)
+        assert defer.value.method_name == "execute_complete"
+
+    @mock.patch.object(EmrServerlessHook, "conn")
+    def test_delete_stopped_application_stop_error(self, mock_conn):
+        operator = EmrServerlessDeleteApplicationOperator(
+            task_id=task_id,
+            application_id=application_id,
+            deferrable=True,
+        )
+        error_event = {"status": "error", "message": "Stop failed", 
"application_id": application_id}
+
+        with pytest.raises(AirflowException, match="Error stopping EMR 
Serverless application"):
+            operator.delete_stopped_application(None, error_event)
+
+        mock_conn.delete_application.assert_not_called()
 
     def test_execute_complete_error(self):
         operator = EmrServerlessDeleteApplicationOperator(

Reply via email to