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

shahar1 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 bfe64b1991e Add AthenaSparkSensor (#72229)
bfe64b1991e is described below

commit bfe64b1991e0990c9d638cae094454ed6ff73be9
Author: SameerMesiah97 <[email protected]>
AuthorDate: Sat Oct 17 15:56:06 2026 +0100

    Add AthenaSparkSensor (#72229)
    
    Add a sensor for monitoring existing Athena Spark calculations.
    Include unit tests, documentation, and an example system test.
---
 .../amazon/docs/operators/athena/athena_spark.rst  |  25 ++-
 providers/amazon/provider.yaml                     |   1 +
 .../airflow/providers/amazon/aws/hooks/athena.py   |   7 +
 .../providers/amazon/aws/sensors/athena_spark.py   |  84 ++++++++++
 .../airflow/providers/amazon/get_provider_info.py  |   5 +-
 .../system/amazon/aws/example_athena_spark.py      |  25 ++-
 .../unit/amazon/aws/sensors/test_athena_spark.py   | 175 +++++++++++++++++++++
 7 files changed, 318 insertions(+), 4 deletions(-)

diff --git a/providers/amazon/docs/operators/athena/athena_spark.rst 
b/providers/amazon/docs/operators/athena/athena_spark.rst
index 83fe2427052..260675ba40b 100644
--- a/providers/amazon/docs/operators/athena/athena_spark.rst
+++ b/providers/amazon/docs/operators/athena/athena_spark.rst
@@ -15,8 +15,8 @@
    specific language governing permissions and limitations
    under the License.
 
-Athena Spark Operators
-======================
+Athena Spark Operators and Sensors
+==================================
 
 `Amazon Athena <https://aws.amazon.com/athena/>`__ supports Apache Spark 
calculations through session-based APIs.
 This page documents the provider support for submitting and monitoring those
@@ -53,6 +53,27 @@ this operator, please see the `Sample Dag 
<https://github.com/apache/airflow/blo
     :start-after: [START howto_operator_athena_spark]
     :end-before: [END howto_operator_athena_spark]
 
+
+Sensors
+-------
+
+Wait for an Athena Spark calculation
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Use 
:class:`~airflow.providers.amazon.aws.sensors.athena_spark.AthenaSparkSensor`
+to wait for an existing Athena Spark calculation execution to reach a terminal
+state.
+
+The sensor expects a calculation execution ID that was already created, for
+example by another Airflow task that submits a calculation without waiting,
+or by a system outside Airflow.
+
+.. exampleinclude:: 
/../../amazon/tests/system/amazon/aws/example_athena_spark.py
+    :language: python
+    :dedent: 4
+    :start-after: [START howto_sensor_athena_spark]
+    :end-before: [END howto_sensor_athena_spark]
+
 Reference
 ---------
 
diff --git a/providers/amazon/provider.yaml b/providers/amazon/provider.yaml
index 159724fea0f..a2536a08ee1 100644
--- a/providers/amazon/provider.yaml
+++ b/providers/amazon/provider.yaml
@@ -566,6 +566,7 @@ sensors:
   - integration-name: Amazon Athena
     python-modules:
       - airflow.providers.amazon.aws.sensors.athena
+      - airflow.providers.amazon.aws.sensors.athena_spark
   - integration-name: Amazon Web Services
     python-modules:
       - airflow.providers.amazon.aws.sensors.base_aws
diff --git a/providers/amazon/src/airflow/providers/amazon/aws/hooks/athena.py 
b/providers/amazon/src/airflow/providers/amazon/aws/hooks/athena.py
index f3bfbf6b6a9..7d8ab21fa82 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/hooks/athena.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/hooks/athena.py
@@ -87,6 +87,13 @@ class AthenaHook(AwsBaseHook):
         "CANCELLED",
     )
 
+    SPARK_INTERMEDIATE_STATES = (
+        "CREATING",
+        "CREATED",
+        "QUEUED",
+        "RUNNING",
+        "CANCELING",
+    )
     SPARK_FAILURE_STATES = (
         "FAILED",
         "CANCELED",
diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/sensors/athena_spark.py 
b/providers/amazon/src/airflow/providers/amazon/aws/sensors/athena_spark.py
new file mode 100644
index 00000000000..81a6d4be99a
--- /dev/null
+++ b/providers/amazon/src/airflow/providers/amazon/aws/sensors/athena_spark.py
@@ -0,0 +1,84 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import TYPE_CHECKING
+
+from airflow.providers.amazon.aws.hooks.athena import AthenaHook
+from airflow.providers.amazon.aws.sensors.base_aws import AwsBaseSensor
+from airflow.providers.amazon.aws.utils.mixins import aws_template_fields
+
+if TYPE_CHECKING:
+    from airflow.sdk import Context
+
+
+class AthenaSparkSensor(AwsBaseSensor[AthenaHook]):
+    """
+    Waits for an Amazon Athena Spark calculation to reach a terminal state.
+
+    :param calculation_execution_id: Athena Spark calculation execution ID. 
(templated)
+    :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). (templated)
+    :param region_name: AWS region_name. If not specified then the default 
boto3 behaviour is used. (templated)
+    """
+
+    aws_hook_class = AthenaHook
+
+    template_fields: Sequence[str] = 
aws_template_fields("calculation_execution_id")
+
+    def __init__(
+        self,
+        *,
+        calculation_execution_id: str,
+        aws_conn_id: str = "aws_default",
+        region_name: str | None = None,
+        **kwargs,
+    ) -> None:
+        super().__init__(aws_conn_id=aws_conn_id, region_name=region_name, 
**kwargs)
+        self.calculation_execution_id = calculation_execution_id
+
+    def poke(self, context: Context) -> bool:
+        state = self.hook.check_spark_calculation_status(
+            calculation_execution_id=self.calculation_execution_id,
+        )
+
+        self.log.info("Calculation %s state is: %s", 
self.calculation_execution_id, state)
+
+        if state in AthenaHook.SPARK_SUCCESS_STATES:
+            self.log.info("Athena Spark calculation %s completed.", 
self.calculation_execution_id)
+            return True
+
+        if state in AthenaHook.SPARK_FAILURE_STATES:
+            reason = self.hook.get_spark_calculation_state_change_reason(
+                calculation_execution_id=self.calculation_execution_id,
+            )
+            raise RuntimeError(
+                f"Athena Spark calculation {self.calculation_execution_id} 
failed with state {state}. "
+                f"Reason: {reason or 'Unknown'}"
+            )
+
+        if state in AthenaHook.SPARK_INTERMEDIATE_STATES:
+            return False
+
+        raise RuntimeError(
+            f"Unexpected Athena Spark calculation state for 
{self.calculation_execution_id}: {state!r}"
+        )
diff --git a/providers/amazon/src/airflow/providers/amazon/get_provider_info.py 
b/providers/amazon/src/airflow/providers/amazon/get_provider_info.py
index 45b899ed65e..0e816d7c0da 100644
--- a/providers/amazon/src/airflow/providers/amazon/get_provider_info.py
+++ b/providers/amazon/src/airflow/providers/amazon/get_provider_info.py
@@ -573,7 +573,10 @@ def get_provider_info():
         "sensors": [
             {
                 "integration-name": "Amazon Athena",
-                "python-modules": 
["airflow.providers.amazon.aws.sensors.athena"],
+                "python-modules": [
+                    "airflow.providers.amazon.aws.sensors.athena",
+                    "airflow.providers.amazon.aws.sensors.athena_spark",
+                ],
             },
             {
                 "integration-name": "Amazon Web Services",
diff --git a/providers/amazon/tests/system/amazon/aws/example_athena_spark.py 
b/providers/amazon/tests/system/amazon/aws/example_athena_spark.py
index a1e08050d0a..f479b45347a 100644
--- a/providers/amazon/tests/system/amazon/aws/example_athena_spark.py
+++ b/providers/amazon/tests/system/amazon/aws/example_athena_spark.py
@@ -14,7 +14,6 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-
 from __future__ import annotations
 
 from datetime import datetime
@@ -24,6 +23,7 @@ import boto3
 from airflow.providers.amazon.aws.hooks.athena import AthenaHook
 from airflow.providers.amazon.aws.operators.athena_spark import 
AthenaSparkOperator
 from airflow.providers.amazon.aws.operators.s3 import S3CreateBucketOperator, 
S3DeleteBucketOperator
+from airflow.providers.amazon.aws.sensors.athena_spark import AthenaSparkSensor
 
 from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS
 
@@ -96,6 +96,16 @@ def stop_athena_spark_session(session_id: str) -> None:
     client.terminate_session(SessionId=session_id)
 
 
+@task
+def start_athena_spark_calculation(session_id: str) -> str:
+    client = boto3.client("athena")
+    response = client.start_calculation_execution(
+        SessionId=session_id,
+        CodeBlock="print('hello from the athena spark sensor test')",
+    )
+    return response["CalculationExecutionId"]
+
+
 with DAG(
     dag_id=DAG_ID,
     schedule="@once",
@@ -125,6 +135,17 @@ with DAG(
     )
     # [END howto_operator_athena_spark]
 
+    calculation_execution_id = start_athena_spark_calculation(idle_session_id)
+
+    # [START howto_sensor_athena_spark]
+    await_spark_calculation = AthenaSparkSensor(
+        task_id="await_spark_calculation",
+        calculation_execution_id=calculation_execution_id,
+        poke_interval=30,
+        timeout=3600,
+    )
+    # [END howto_sensor_athena_spark]
+
     stop_session = stop_athena_spark_session(session_id)
 
     delete_bucket = S3DeleteBucketOperator(
@@ -143,6 +164,8 @@ with DAG(
         idle_session_id,
         # TEST BODY
         run_spark_calculation,
+        calculation_execution_id,
+        await_spark_calculation,
         # TEST TEARDOWN
         stop_session,
         delete_work_group(work_group),
diff --git 
a/providers/amazon/tests/unit/amazon/aws/sensors/test_athena_spark.py 
b/providers/amazon/tests/unit/amazon/aws/sensors/test_athena_spark.py
new file mode 100644
index 00000000000..a428255980d
--- /dev/null
+++ b/providers/amazon/tests/unit/amazon/aws/sensors/test_athena_spark.py
@@ -0,0 +1,175 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+from unittest import mock
+
+import pytest
+from moto import mock_aws
+
+from airflow.providers.amazon.aws.hooks.athena import AthenaHook
+from airflow.providers.amazon.aws.sensors.athena_spark import AthenaSparkSensor
+
+
[email protected]
+def mock_check_spark_calculation_status():
+    with mock.patch.object(
+        AthenaHook,
+        "check_spark_calculation_status",
+        autospec=True,
+    ) as m:
+        yield m
+
+
[email protected]
+def mock_get_spark_calculation_state_change_reason():
+    with mock.patch.object(
+        AthenaHook,
+        "get_spark_calculation_state_change_reason",
+        autospec=True,
+    ) as m:
+        yield m
+
+
+@mock_aws
+class TestAthenaSparkSensor:
+    def setup_method(self, _):
+        self.default_op_kwargs = {
+            "task_id": "test_athena_spark_sensor",
+            "calculation_execution_id": "abc",
+        }
+        self.sensor = AthenaSparkSensor(**self.default_op_kwargs, 
aws_conn_id=None)
+
+    def test_base_aws_op_attributes(self):
+        op = AthenaSparkSensor(**self.default_op_kwargs)
+        assert op.hook.aws_conn_id == "aws_default"
+        assert op.hook._region_name is None
+        assert op.hook._verify is None
+        assert op.hook._config is None
+        assert op.hook.log_query is True
+
+        op = AthenaSparkSensor(
+            **self.default_op_kwargs,
+            aws_conn_id="aws-test-custom-conn",
+            region_name="eu-west-1",
+            verify=False,
+            botocore_config={"read_timeout": 42},
+        )
+        assert op.hook.aws_conn_id == "aws-test-custom-conn"
+        assert op.hook._region_name == "eu-west-1"
+        assert op.hook._verify is False
+        assert op.hook._config is not None
+        assert op.hook._config.read_timeout == 42
+
+    def test_poke_success_state(self, mock_check_spark_calculation_status):
+        mock_check_spark_calculation_status.return_value = "COMPLETED"
+
+        assert self.sensor.poke({}) is True
+
+        mock_check_spark_calculation_status.assert_called_once_with(
+            mock.ANY,
+            calculation_execution_id="abc",
+        )
+
+    @pytest.mark.parametrize(
+        "state",
+        ["CREATING", "CREATED", "QUEUED", "RUNNING", "CANCELING"],
+    )
+    def test_poke_intermediate_states(
+        self,
+        state,
+        mock_check_spark_calculation_status,
+    ):
+        mock_check_spark_calculation_status.return_value = state
+
+        assert self.sensor.poke({}) is False
+
+        mock_check_spark_calculation_status.assert_called_once_with(
+            mock.ANY,
+            calculation_execution_id="abc",
+        )
+
+    @pytest.mark.parametrize(
+        ("state", "reason", "expected_reason"),
+        [
+            pytest.param(
+                "FAILED",
+                "Calculation failed",
+                "Calculation failed",
+                id="failed-with-reason",
+            ),
+            pytest.param(
+                "CANCELED",
+                None,
+                "Unknown",
+                id="canceled-without-reason",
+            ),
+        ],
+    )
+    def test_poke_failure_states(
+        self,
+        state,
+        reason,
+        expected_reason,
+        mock_check_spark_calculation_status,
+        mock_get_spark_calculation_state_change_reason,
+    ):
+        mock_check_spark_calculation_status.return_value = state
+        mock_get_spark_calculation_state_change_reason.return_value = reason
+
+        with pytest.raises(
+            RuntimeError,
+            match=rf"failed with state {state}\. Reason: {expected_reason}",
+        ):
+            self.sensor.poke({})
+
+        mock_check_spark_calculation_status.assert_called_once_with(
+            mock.ANY,
+            calculation_execution_id="abc",
+        )
+        mock_get_spark_calculation_state_change_reason.assert_called_once_with(
+            mock.ANY,
+            calculation_execution_id="abc",
+        )
+
+    @pytest.mark.parametrize(
+        "state",
+        [
+            pytest.param(None, id="missing-state"),
+            pytest.param("UNKNOWN", id="unexpected-state"),
+        ],
+    )
+    def test_poke_unexpected_states(
+        self,
+        state,
+        mock_check_spark_calculation_status,
+        mock_get_spark_calculation_state_change_reason,
+    ):
+        mock_check_spark_calculation_status.return_value = state
+
+        with pytest.raises(
+            RuntimeError,
+            match="Unexpected Athena Spark calculation state",
+        ):
+            self.sensor.poke({})
+
+        mock_check_spark_calculation_status.assert_called_once_with(
+            mock.ANY,
+            calculation_execution_id="abc",
+        )
+        mock_get_spark_calculation_state_change_reason.assert_not_called()

Reply via email to