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

vincbeck 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 cae1728f127 Add EmrServerlessStopSessionOperator to Amazon provider 
(#74870)
cae1728f127 is described below

commit cae1728f1274e6a89bede3ad534b1ee1f8ea4995
Author: Vincent Gromakowski <[email protected]>
AuthorDate: Mon Oct 19 16:45:29 2026 +0200

    Add EmrServerlessStopSessionOperator to Amazon provider (#74870)
    
    Dags that start an EMR Serverless interactive session need a way to end
    it as soon as their work is done rather than relying on the session's
    idle timeout.
    
    Stopping follows the same rules as starting: completion waiting decides
    whether the task waits, deferrable mode only picks how it waits, and the
    triggerer polls with the task's region, SSL verification and botocore
    settings.
    
    Co-authored-by: vgkowski <[email protected]>
---
 .../amazon/docs/operators/emr/emr_serverless.rst   |  14 +++
 .../airflow/providers/amazon/aws/operators/emr.py  |  92 +++++++++++++++++
 .../airflow/providers/amazon/aws/triggers/emr.py   |  48 +++++++++
 .../amazon/aws/waiters/emr-serverless.json         |  19 ++++
 .../amazon/aws/example_emr_serverless_session.py   |  10 ++
 .../aws/operators/test_emr_serverless_session.py   | 115 +++++++++++++++++++--
 .../tests/unit/amazon/aws/triggers/test_emr.py     |  60 +++++++++++
 7 files changed, 347 insertions(+), 11 deletions(-)

diff --git a/providers/amazon/docs/operators/emr/emr_serverless.rst 
b/providers/amazon/docs/operators/emr/emr_serverless.rst
index 7a7a9d749e5..11070ac56dc 100644
--- a/providers/amazon/docs/operators/emr/emr_serverless.rst
+++ b/providers/amazon/docs/operators/emr/emr_serverless.rst
@@ -193,6 +193,20 @@ and anyone who can read the task's XCom can use it until 
it expires.
    :start-after: [START howto_operator_emr_serverless_get_session_endpoint]
    :end-before: [END howto_operator_emr_serverless_get_session_endpoint]
 
+.. _howto/operator:EmrServerlessStopSessionOperator:
+
+Stop an EMR Serverless interactive session
+==========================================
+
+To terminate an interactive session, use
+:class:`~airflow.providers.amazon.aws.operators.emr.EmrServerlessStopSessionOperator`.
+
+.. exampleinclude:: 
/../../amazon/tests/system/amazon/aws/example_emr_serverless_session.py
+   :language: python
+   :dedent: 4
+   :start-after: [START howto_operator_emr_serverless_stop_session]
+   :end-before: [END howto_operator_emr_serverless_stop_session]
+
 Reference
 ---------
 
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 d128ec5b7dd..5c2356db030 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/operators/emr.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/operators/emr.py
@@ -49,6 +49,7 @@ from airflow.providers.amazon.aws.triggers.emr import (
     EmrServerlessStartApplicationTrigger,
     EmrServerlessStartJobTrigger,
     EmrServerlessStopApplicationTrigger,
+    EmrServerlessStopSessionTrigger,
     EmrTerminateJobFlowTrigger,
 )
 from airflow.providers.amazon.aws.utils import validate_execute_complete_event
@@ -2080,3 +2081,94 @@ class 
EmrServerlessGetSessionEndpointOperator(AwsBaseOperator[EmrServerlessHook]
             "auth_token": auth_token,
             "auth_token_expires_at": response.get("authTokenExpiresAt"),
         }
+
+
+class EmrServerlessStopSessionOperator(AwsBaseOperator[EmrServerlessHook]):
+    """
+    Terminate an EMR Serverless interactive session.
+
+    .. seealso::
+        For more information on how to use this operator, take a look at the 
guide:
+        :ref:`howto/operator:EmrServerlessStopSessionOperator`
+
+    :param application_id: ID of the EMR Serverless application.
+    :param session_id: ID of the interactive session to terminate.
+    :param wait_for_completion: If True, wait for the session to terminate 
before returning.
+    :param aws_conn_id: The Airflow connection used for AWS credentials.
+        If this is ``None`` or empty then the default boto3 behaviour is used. 
If
+        running Airflow in a distributed manner and aws_conn_id is None or
+        empty, then default boto3 configuration would be used (and must be
+        maintained on each worker node).
+    :param region_name: AWS region_name. If not specified then the default 
boto3 behaviour is used.
+    :param verify: Whether or not to verify SSL certificates. See:
+        
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/core/session.html
+    :param waiter_max_attempts: Number of times the waiter should poll the 
session to check the state.
+    :param waiter_delay: Number of seconds between polling the state of the 
session.
+    :param deferrable: If True and ``wait_for_completion`` is enabled, the 
operator will wait
+        asynchronously for the session to terminate. This mode requires 
aiobotocore to be installed.
+        (default: False, but can be overridden in config file by setting 
default_deferrable to True)
+    """
+
+    aws_hook_class = EmrServerlessHook
+    template_fields: Sequence[str] = aws_template_fields(
+        "application_id",
+        "session_id",
+    )
+
+    def __init__(
+        self,
+        *,
+        application_id: str,
+        session_id: str,
+        wait_for_completion: bool = True,
+        waiter_delay: int = 10,
+        waiter_max_attempts: int = 60,
+        deferrable: bool = conf.getboolean("operators", "default_deferrable", 
fallback=False),
+        **kwargs,
+    ):
+        super().__init__(**kwargs)
+        self.application_id = application_id
+        self.session_id = session_id
+        self.wait_for_completion = wait_for_completion
+        self.waiter_delay = waiter_delay
+        self.waiter_max_attempts = waiter_max_attempts
+        self.deferrable = deferrable
+
+    def execute(self, context: Context) -> None:
+        self.hook._check_interactive_session_support()
+        self.log.info("Terminating EMR Serverless session %s", self.session_id)
+        self.hook.conn.terminate_session(applicationId=self.application_id, 
sessionId=self.session_id)
+
+        if self.wait_for_completion:
+            if self.deferrable:
+                self.defer(
+                    trigger=EmrServerlessStopSessionTrigger(
+                        application_id=self.application_id,
+                        session_id=self.session_id,
+                        waiter_delay=self.waiter_delay,
+                        waiter_max_attempts=self.waiter_max_attempts,
+                        aws_conn_id=self.aws_conn_id,
+                        region_name=self.region_name,
+                        verify=self.verify,
+                        botocore_config=self.botocore_config,
+                    ),
+                    timeout=timedelta(seconds=self.waiter_max_attempts * 
self.waiter_delay),
+                    method_name="execute_complete",
+                )
+            else:
+                wait(
+                    
waiter=self.hook.get_waiter("serverless_session_terminated"),
+                    waiter_delay=self.waiter_delay,
+                    waiter_max_attempts=self.waiter_max_attempts,
+                    args={"applicationId": self.application_id, "sessionId": 
self.session_id},
+                    failure_message="EMR Serverless session failed to 
terminate",
+                    status_message="EMR Serverless session status is",
+                    status_args=["session.state", "session.stateDetails"],
+                )
+
+    def execute_complete(self, context: Context, event: dict[str, Any] | None 
= None) -> None:
+        validated_event = validate_execute_complete_event(event)
+
+        if validated_event["status"] != "success":
+            raise RuntimeError(f"Error terminating EMR Serverless session: 
{validated_event}")
+        self.log.info("EMR Serverless session %s terminated", 
validated_event["session_id"])
diff --git a/providers/amazon/src/airflow/providers/amazon/aws/triggers/emr.py 
b/providers/amazon/src/airflow/providers/amazon/aws/triggers/emr.py
index 24fecaf8bce..387a315f11a 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/triggers/emr.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/triggers/emr.py
@@ -861,3 +861,51 @@ class EmrServerlessSessionTrigger(AwsBaseWaiterTrigger):
             verify=verify,
             botocore_config=botocore_config,
         )
+
+
+class EmrServerlessStopSessionTrigger(AwsBaseWaiterTrigger):
+    """
+    Poll an EMR Serverless interactive session until it is terminated.
+
+    :param application_id: The ID of the EMR Serverless application.
+    :param session_id: The ID of the interactive session being polled.
+    :param waiter_delay: polling period in seconds to check for the status
+    :param waiter_max_attempts: The maximum number of attempts to be made
+    :param aws_conn_id: Reference to AWS connection id
+    :param region_name: The AWS region where the resources to watch are.
+    :param verify: Whether or not to verify SSL certificates.
+        See: 
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/core/session.html
+    :param botocore_config: Configuration dictionary (key-values) for botocore 
client. See:
+        
https://botocore.amazonaws.com/v1/documentation/api/latest/reference/config.html
+    """
+
+    aws_hook_class = EmrServerlessHook
+
+    def __init__(
+        self,
+        *,
+        application_id: str,
+        session_id: str,
+        waiter_delay: int = 10,
+        waiter_max_attempts: int = 60,
+        aws_conn_id: str | None = "aws_default",
+        region_name: str | None = None,
+        verify: bool | str | None = None,
+        botocore_config: dict | None = None,
+    ) -> None:
+        super().__init__(
+            serialized_fields={"application_id": application_id, "session_id": 
session_id},
+            waiter_name="serverless_session_terminated",
+            waiter_args={"applicationId": application_id, "sessionId": 
session_id},
+            failure_message="EMR Serverless session failed to terminate",
+            status_message="EMR Serverless session status is",
+            status_queries=["session.state"],
+            return_key="session_id",
+            return_value=session_id,
+            waiter_delay=waiter_delay,
+            waiter_max_attempts=waiter_max_attempts,
+            aws_conn_id=aws_conn_id,
+            region_name=region_name,
+            verify=verify,
+            botocore_config=botocore_config,
+        )
diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/waiters/emr-serverless.json 
b/providers/amazon/src/airflow/providers/amazon/aws/waiters/emr-serverless.json
index ceaa231fc68..64ef8306519 100644
--- 
a/providers/amazon/src/airflow/providers/amazon/aws/waiters/emr-serverless.json
+++ 
b/providers/amazon/src/airflow/providers/amazon/aws/waiters/emr-serverless.json
@@ -189,6 +189,25 @@
                     "state": "failure"
                 }
             ]
+        },
+        "serverless_session_terminated": {
+            "operation": "GetSession",
+            "delay": 10,
+            "maxAttempts": 60,
+            "acceptors": [
+                {
+                    "matcher": "path",
+                    "argument": "session.state",
+                    "expected": "TERMINATED",
+                    "state": "success"
+                },
+                {
+                    "matcher": "path",
+                    "argument": "session.state",
+                    "expected": "FAILED",
+                    "state": "failure"
+                }
+            ]
         }
     }
 }
diff --git 
a/providers/amazon/tests/system/amazon/aws/example_emr_serverless_session.py 
b/providers/amazon/tests/system/amazon/aws/example_emr_serverless_session.py
index b2b247df618..a24786a5cf4 100644
--- a/providers/amazon/tests/system/amazon/aws/example_emr_serverless_session.py
+++ b/providers/amazon/tests/system/amazon/aws/example_emr_serverless_session.py
@@ -24,6 +24,7 @@ from airflow.providers.amazon.aws.operators.emr import (
     EmrServerlessGetSessionEndpointOperator,
     EmrServerlessStartSessionOperator,
     EmrServerlessStopApplicationOperator,
+    EmrServerlessStopSessionOperator,
 )
 from airflow.providers.common.compat.sdk import DAG, TriggerRule, chain
 
@@ -77,6 +78,14 @@ with DAG(
     )
     # [END howto_operator_emr_serverless_get_session_endpoint]
 
+    # [START howto_operator_emr_serverless_stop_session]
+    stop_session = EmrServerlessStopSessionOperator(
+        task_id="stop_session",
+        application_id=application_id,
+        session_id=session_id,
+    )
+    # [END howto_operator_emr_serverless_stop_session]
+
     stop_app = EmrServerlessStopApplicationOperator(
         task_id="stop_app",
         application_id=application_id,
@@ -97,6 +106,7 @@ with DAG(
         # TEST BODY
         start_session,
         get_endpoint,
+        stop_session,
         # TEST TEARDOWN
         stop_app,
         delete_app,
diff --git 
a/providers/amazon/tests/unit/amazon/aws/operators/test_emr_serverless_session.py
 
b/providers/amazon/tests/unit/amazon/aws/operators/test_emr_serverless_session.py
index 50606e97ada..6e29371f6b1 100644
--- 
a/providers/amazon/tests/unit/amazon/aws/operators/test_emr_serverless_session.py
+++ 
b/providers/amazon/tests/unit/amazon/aws/operators/test_emr_serverless_session.py
@@ -25,8 +25,12 @@ from airflow.providers.amazon.aws.hooks.emr import 
EmrServerlessHook
 from airflow.providers.amazon.aws.operators.emr import (
     EmrServerlessGetSessionEndpointOperator,
     EmrServerlessStartSessionOperator,
+    EmrServerlessStopSessionOperator,
+)
+from airflow.providers.amazon.aws.triggers.emr import (
+    EmrServerlessSessionTrigger,
+    EmrServerlessStopSessionTrigger,
 )
-from airflow.providers.amazon.aws.triggers.emr import 
EmrServerlessSessionTrigger
 
 APP_ID = "app-123"
 SESSION_ID = "sess-abc"
@@ -37,6 +41,17 @@ BOTOCORE_CONFIG = {"retries": {"max_attempts": 7}}
 WAIT = "airflow.providers.amazon.aws.operators.emr.wait"
 
 
[email protected]
+def supported_botocore():
+    # The session operators gate on botocore >= 1.43.0. Pin a supported 
version so these
+    # tests exercise the boto calls regardless of the botocore installed in CI.
+    with mock.patch(
+        "airflow.providers.amazon.aws.hooks.emr.get_botocore_version",
+        return_value=(1, 43, 0),
+    ):
+        yield
+
+
 class TestEmrServerlessStartSessionOperator:
     @mock.patch(WAIT)
     @mock.patch.object(EmrServerlessHook, "get_waiter")
@@ -140,6 +155,7 @@ class TestEmrServerlessStartSessionOperator:
             op.execute_complete({}, {"status": "failure", "session_id": 
SESSION_ID})
 
 
[email protected]("supported_botocore")
 class TestEmrServerlessGetSessionEndpointOperator:
     RAW_RESPONSE = {
         "applicationId": APP_ID,
@@ -149,16 +165,6 @@ class TestEmrServerlessGetSessionEndpointOperator:
         "authTokenExpiresAt": None,
     }
 
-    @pytest.fixture(autouse=True)
-    def _supported_botocore(self):
-        # The operator gates on botocore >= 1.43.0. Pin a supported version so 
these
-        # tests exercise the boto call regardless of the botocore installed in 
CI.
-        with mock.patch(
-            "airflow.providers.amazon.aws.hooks.emr.get_botocore_version",
-            return_value=(1, 43, 0),
-        ):
-            yield
-
     @mock.patch.object(EmrServerlessHook, "conn")
     def test_returns_transformed_endpoint(self, mock_conn):
         mock_conn.get_session_endpoint.return_value = self.RAW_RESPONSE
@@ -195,3 +201,90 @@ class TestEmrServerlessGetSessionEndpointOperator:
             with pytest.raises(RuntimeError, match="botocore >= 1.43.0"):
                 op.execute({})
         mock_conn.get_session_endpoint.assert_not_called()
+
+
[email protected]("supported_botocore")
+class TestEmrServerlessStopSessionOperator:
+    @mock.patch(WAIT)
+    @mock.patch.object(EmrServerlessHook, "get_waiter")
+    @mock.patch.object(EmrServerlessHook, "conn")
+    def test_terminate_and_wait(self, mock_conn, get_waiter, wait_mock):
+        op = EmrServerlessStopSessionOperator(task_id="stop", 
application_id=APP_ID, session_id=SESSION_ID)
+        op.execute({})
+
+        
mock_conn.terminate_session.assert_called_once_with(applicationId=APP_ID, 
sessionId=SESSION_ID)
+        wait_mock.assert_called_once()
+        get_waiter.assert_called_once_with("serverless_session_terminated")
+
+    @mock.patch(WAIT)
+    @mock.patch.object(EmrServerlessHook, "get_waiter")
+    @mock.patch.object(EmrServerlessHook, "conn")
+    def test_terminate_no_wait(self, mock_conn, get_waiter, wait_mock):
+        op = EmrServerlessStopSessionOperator(
+            task_id="stop",
+            application_id=APP_ID,
+            session_id=SESSION_ID,
+            wait_for_completion=False,
+        )
+        op.execute({})
+        wait_mock.assert_not_called()
+
+    @mock.patch(WAIT)
+    @mock.patch.object(EmrServerlessHook, "get_waiter")
+    @mock.patch.object(EmrServerlessHook, "conn")
+    def test_deferrable_defers(self, mock_conn, get_waiter, wait_mock):
+        op = EmrServerlessStopSessionOperator(
+            task_id="stop",
+            application_id=APP_ID,
+            session_id=SESSION_ID,
+            deferrable=True,
+            region_name=REGION_NAME,
+            verify=VERIFY,
+            botocore_config=BOTOCORE_CONFIG,
+        )
+        with pytest.raises(TaskDeferred) as deferred:
+            op.execute({})
+
+        
mock_conn.terminate_session.assert_called_once_with(applicationId=APP_ID, 
sessionId=SESSION_ID)
+        trigger = deferred.value.trigger
+        assert isinstance(trigger, EmrServerlessStopSessionTrigger)
+        assert trigger.region_name == REGION_NAME
+        assert trigger.verify == VERIFY
+        assert trigger.botocore_config == BOTOCORE_CONFIG
+        wait_mock.assert_not_called()
+
+    @mock.patch(WAIT)
+    @mock.patch.object(EmrServerlessHook, "conn")
+    def test_wait_for_completion_false_does_not_defer(self, mock_conn, 
wait_mock):
+        op = EmrServerlessStopSessionOperator(
+            task_id="stop",
+            application_id=APP_ID,
+            session_id=SESSION_ID,
+            wait_for_completion=False,
+            deferrable=True,
+        )
+
+        op.execute({})
+
+        
mock_conn.terminate_session.assert_called_once_with(applicationId=APP_ID, 
sessionId=SESSION_ID)
+        wait_mock.assert_not_called()
+
+    @mock.patch.object(EmrServerlessHook, "conn")
+    def test_gates_on_old_botocore(self, mock_conn):
+        op = EmrServerlessStopSessionOperator(task_id="stop", 
application_id=APP_ID, session_id=SESSION_ID)
+        with mock.patch(
+            "airflow.providers.amazon.aws.hooks.emr.get_botocore_version",
+            return_value=(1, 41, 0),
+        ):
+            with pytest.raises(RuntimeError, match="botocore >= 1.43.0"):
+                op.execute({})
+        mock_conn.terminate_session.assert_not_called()
+
+    def test_execute_complete_success(self):
+        op = EmrServerlessStopSessionOperator(task_id="stop", 
application_id=APP_ID, session_id=SESSION_ID)
+        assert op.execute_complete({}, {"status": "success", "session_id": 
SESSION_ID}) is None
+
+    def test_execute_complete_failure_raises(self):
+        op = EmrServerlessStopSessionOperator(task_id="stop", 
application_id=APP_ID, session_id=SESSION_ID)
+        with pytest.raises(RuntimeError):
+            op.execute_complete({}, {"status": "failure", "session_id": 
SESSION_ID})
diff --git a/providers/amazon/tests/unit/amazon/aws/triggers/test_emr.py 
b/providers/amazon/tests/unit/amazon/aws/triggers/test_emr.py
index 0de41a78fb7..bb8f62a3ccb 100644
--- a/providers/amazon/tests/unit/amazon/aws/triggers/test_emr.py
+++ b/providers/amazon/tests/unit/amazon/aws/triggers/test_emr.py
@@ -35,6 +35,7 @@ from airflow.providers.amazon.aws.triggers.emr import (
     EmrServerlessStartApplicationTrigger,
     EmrServerlessStartJobTrigger,
     EmrServerlessStopApplicationTrigger,
+    EmrServerlessStopSessionTrigger,
     EmrStepSensorTrigger,
     EmrTerminateJobFlowTrigger,
 )
@@ -712,3 +713,62 @@ class TestEmrServerlessSessionTrigger:
             verify="/path/to/ca.pem",
             config={"retries": {"max_attempts": 7}},
         )
+
+
+class TestEmrServerlessStopSessionTrigger:
+    def test_serialization(self):
+        trigger = EmrServerlessStopSessionTrigger(
+            application_id="test_application_id",
+            session_id="test_session_id",
+            waiter_delay=10,
+            waiter_max_attempts=60,
+            aws_conn_id="aws_default",
+        )
+        classpath, kwargs = trigger.serialize()
+        assert classpath == 
"airflow.providers.amazon.aws.triggers.emr.EmrServerlessStopSessionTrigger"
+        assert kwargs == {
+            "application_id": "test_application_id",
+            "session_id": "test_session_id",
+            "waiter_delay": 10,
+            "waiter_max_attempts": 60,
+            "aws_conn_id": "aws_default",
+        }
+
+    def test_serialization_with_hook_configuration(self):
+        trigger = EmrServerlessStopSessionTrigger(
+            application_id="test_application_id",
+            session_id="test_session_id",
+            region_name="eu-west-1",
+            verify="/path/to/ca.pem",
+            botocore_config={"retries": {"max_attempts": 7}},
+        )
+
+        _, kwargs = trigger.serialize()
+
+        assert kwargs["region_name"] == "eu-west-1"
+        assert kwargs["verify"] == "/path/to/ca.pem"
+        assert kwargs["botocore_config"] == {"retries": {"max_attempts": 7}}
+
+    def test_hook_class(self):
+        assert EmrServerlessStopSessionTrigger.aws_hook_class is 
EmrServerlessHook
+
+    def test_hook_receives_configuration(self):
+        trigger = EmrServerlessStopSessionTrigger(
+            application_id="test_application_id",
+            session_id="test_session_id",
+            aws_conn_id="test_conn",
+            region_name="eu-west-1",
+            verify="/path/to/ca.pem",
+            botocore_config={"retries": {"max_attempts": 7}},
+        )
+
+        with mock.patch.object(EmrServerlessStopSessionTrigger, 
"aws_hook_class") as hook_class:
+            hook = trigger.hook()
+
+        assert hook is hook_class.return_value
+        hook_class.assert_called_once_with(
+            aws_conn_id="test_conn",
+            region_name="eu-west-1",
+            verify="/path/to/ca.pem",
+            config={"retries": {"max_attempts": 7}},
+        )

Reply via email to