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 d9b7eb0f8a4 Cancel the Redshift statement when a user kills the 
deferred task (#69676)
d9b7eb0f8a4 is described below

commit d9b7eb0f8a48c2059ef630786628ed01ba73741f
Author: Steve Ahn <[email protected]>
AuthorDate: Sat Aug 1 11:01:30 2026 -0700

    Cancel the Redshift statement when a user kills the deferred task (#69676)
    
    A deferred RedshiftDataOperator parks its query in the triggerer, so the
    operator's own on_kill no longer runs once the task is deferred. When a user
    marks that task failed, clears it, or marks it success, the trigger had no
    on_kill hook, so the Redshift statement kept running against the cluster or
    workgroup even though the operator already cancels the statement on kill in
    the non-deferred path. On Redshift Serverless a runaway statement keeps
    billing RPU-hours, and on a provisioned cluster it holds a WLM slot, until 
it
    finishes on its own.
    
    This adds on_kill to RedshiftDataTrigger to cancel the running statement 
when
    the user acts on the deferred task, matching the behaviour already shipped 
for
    the EMR, Dataproc, BigQuery, and Dataflow triggers. A cancel_on_kill flag on
    both the operator and the trigger lets users opt out.
---
 .../amazon/aws/operators/redshift_data.py          |  8 +++
 .../providers/amazon/aws/triggers/redshift_data.py | 31 +++++++++
 .../amazon/aws/operators/test_redshift_data.py     | 41 +++++++++++
 .../unit/amazon/aws/triggers/test_redshift_data.py | 81 ++++++++++++++++++++++
 4 files changed, 161 insertions(+)

diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/operators/redshift_data.py 
b/providers/amazon/src/airflow/providers/amazon/aws/operators/redshift_data.py
index fddc921fbdd..b81403be214 100644
--- 
a/providers/amazon/src/airflow/providers/amazon/aws/operators/redshift_data.py
+++ 
b/providers/amazon/src/airflow/providers/amazon/aws/operators/redshift_data.py
@@ -83,6 +83,9 @@ class RedshiftDataOperator(ResumableJobMixin, 
AwsBaseOperator[RedshiftDataHook])
     :param session_id: the session identifier of the query
     :param session_keep_alive_seconds: duration in seconds to keep the session 
alive after the query
         finishes. The maximum time a session can keep alive is 24 hours
+    :param cancel_on_kill: If True (default), cancel the running Redshift 
statement when the task is
+        killed. This applies both while the operator is running and, for a 
deferred task, while it
+        waits in the triggerer.
     :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
@@ -131,6 +134,7 @@ class RedshiftDataOperator(ResumableJobMixin, 
AwsBaseOperator[RedshiftDataHook])
         deferrable: bool = conf.getboolean("operators", "default_deferrable", 
fallback=False),
         session_id: str | None = None,
         session_keep_alive_seconds: int | None = None,
+        cancel_on_kill: bool = True,
         **kwargs,
     ) -> None:
         super().__init__(**kwargs)
@@ -152,6 +156,7 @@ class RedshiftDataOperator(ResumableJobMixin, 
AwsBaseOperator[RedshiftDataHook])
         self.deferrable = deferrable
         self.session_id = session_id
         self.session_keep_alive_seconds = session_keep_alive_seconds
+        self.cancel_on_kill = cancel_on_kill
         if self.deferrable and not self.wait_for_completion:
             self.log.warning(
                 "deferrable=True and wait_for_completion=False are set; 
deferrable will be "
@@ -202,6 +207,7 @@ class RedshiftDataOperator(ResumableJobMixin, 
AwsBaseOperator[RedshiftDataHook])
                         region_name=self.region_name,
                         verify=self.verify,
                         botocore_config=self.botocore_config,
+                        cancel_on_kill=self.cancel_on_kill,
                     ),
                     method_name="execute_complete",
                 )
@@ -256,6 +262,8 @@ class RedshiftDataOperator(ResumableJobMixin, 
AwsBaseOperator[RedshiftDataHook])
 
     def on_kill(self) -> None:
         """Cancel the submitted redshift query."""
+        if not self.cancel_on_kill:
+            return
         if hasattr(self, "statement_id"):
             self.log.info("Received a kill signal.")
             self.log.info("Stopping Query with statementId - %s", 
self.statement_id)
diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/triggers/redshift_data.py 
b/providers/amazon/src/airflow/providers/amazon/aws/triggers/redshift_data.py
index c011fe75104..29ac1aceb69 100644
--- 
a/providers/amazon/src/airflow/providers/amazon/aws/triggers/redshift_data.py
+++ 
b/providers/amazon/src/airflow/providers/amazon/aws/triggers/redshift_data.py
@@ -41,6 +41,9 @@ class RedshiftDataTrigger(BaseTrigger):
     :param poll_interval:  polling period in seconds to check for the status
     :param aws_conn_id: AWS connection ID for redshift
     :param region_name: aws region to use
+    :param cancel_on_kill: If True (default), cancel the running Redshift 
statement when the user
+        kills the deferred task (mark failed, clear, or mark success). 
Requires a version of
+        ``apache-airflow`` with ``BaseTrigger.on_kill()`` support; on older 
versions it is inert.
     """
 
     def __init__(
@@ -52,6 +55,7 @@ class RedshiftDataTrigger(BaseTrigger):
         region_name: str | None = None,
         verify: bool | str | None = None,
         botocore_config: dict | None = None,
+        cancel_on_kill: bool = True,
     ):
         super().__init__()
         self.statement_id = statement_id
@@ -62,6 +66,7 @@ class RedshiftDataTrigger(BaseTrigger):
         self.region_name = region_name
         self.verify = verify
         self.botocore_config = botocore_config
+        self.cancel_on_kill = cancel_on_kill
 
     def serialize(self) -> tuple[str, dict[str, Any]]:
         """Serialize RedshiftDataTrigger arguments and classpath."""
@@ -75,6 +80,7 @@ class RedshiftDataTrigger(BaseTrigger):
                 "region_name": self.region_name,
                 "verify": self.verify,
                 "botocore_config": self.botocore_config,
+                "cancel_on_kill": self.cancel_on_kill,
             },
         )
 
@@ -87,6 +93,31 @@ class RedshiftDataTrigger(BaseTrigger):
             config=self.botocore_config,
         )
 
+    async def on_kill(self) -> None:
+        """Cancel the running Redshift statement when the user kills the 
deferred task."""
+        # The triggerer invokes on_kill only on a user action (mark 
failed/success or clear), never on
+        # triggerer restart, redistribution, timeout, or normal completion, so 
cancelling here is safe.
+        if not self.cancel_on_kill or not self.statement_id:
+            return
+        self.log.info("Cancelling Redshift statement %s.", self.statement_id)
+        try:
+            async with await self.hook.get_async_conn() as client:
+                response = await client.cancel_statement(Id=self.statement_id)
+            # CancelStatement returns Status=False when Redshift declined the 
cancel (typically the
+            # statement already finished); surface that instead of claiming a 
successful cancel.
+            if response.get("Status"):
+                self.log.info("Redshift statement %s cancelled.", 
self.statement_id)
+            else:
+                self.log.warning(
+                    "Redshift declined to cancel statement %s; it may have 
already finished.",
+                    self.statement_id,
+                )
+        except Exception:
+            self.log.exception(
+                "Failed to cancel Redshift statement %s. The query may still 
be running.",
+                self.statement_id,
+            )
+
     async def run(self) -> AsyncIterator[TriggerEvent]:
         try:
             while True:
diff --git 
a/providers/amazon/tests/unit/amazon/aws/operators/test_redshift_data.py 
b/providers/amazon/tests/unit/amazon/aws/operators/test_redshift_data.py
index 6058fdd190b..1b33c7ab183 100644
--- a/providers/amazon/tests/unit/amazon/aws/operators/test_redshift_data.py
+++ b/providers/amazon/tests/unit/amazon/aws/operators/test_redshift_data.py
@@ -294,6 +294,23 @@ class TestRedshiftDataOperator:
             Id=STATEMENT_ID,
         )
 
+    
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.conn")
+    def test_on_kill_respects_cancel_on_kill_false(self, mock_conn):
+        mock_conn.execute_statement.return_value = {"Id": STATEMENT_ID, 
"SessionId": SESSION_ID}
+        operator = RedshiftDataOperator(
+            aws_conn_id=CONN_ID,
+            task_id=TASK_ID,
+            cluster_identifier="cluster_identifier",
+            sql=SQL,
+            database=DATABASE,
+            wait_for_completion=False,
+            cancel_on_kill=False,
+        )
+        mock_ti = mock.MagicMock(name="MockedTaskInstance")
+        operator.execute({"ti": mock_ti})
+        operator.on_kill()
+        mock_conn.cancel_statement.assert_not_called()
+
     
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.conn")
     def test_return_sql_result(self, mock_conn):
         expected_result = [{"Result": True}]
@@ -399,6 +416,30 @@ class TestRedshiftDataOperator:
             deferrable_operator.execute({"ti": mock_ti})
 
         assert isinstance(exc.value.trigger, RedshiftDataTrigger)
+        assert exc.value.trigger.cancel_on_kill is True
+
+    @mock.patch(
+        
"airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.check_query_is_finished",
+        return_value=False,
+    )
+    
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.execute_query")
+    def test_execute_defer_propagates_cancel_on_kill_false(self, 
mock_exec_query, check_query_is_finished):
+        """cancel_on_kill=False on the operator reaches the trigger, so the 
deferred kill is a no-op."""
+        operator = RedshiftDataOperator(
+            aws_conn_id=CONN_ID,
+            task_id=TASK_ID,
+            sql=SQL,
+            database=DATABASE,
+            cluster_identifier="cluster_identifier",
+            wait_for_completion=True,
+            poll_interval=5,
+            deferrable=True,
+            cancel_on_kill=False,
+        )
+        with pytest.raises(TaskDeferred) as exc:
+            operator.execute({"ti": mock.MagicMock(name="MockedTaskInstance")})
+
+        assert exc.value.trigger.cancel_on_kill is False
 
     def test_execute_complete_failure(self, deferrable_operator):
         """Tests that an AirflowException is raised in case of error event"""
diff --git 
a/providers/amazon/tests/unit/amazon/aws/triggers/test_redshift_data.py 
b/providers/amazon/tests/unit/amazon/aws/triggers/test_redshift_data.py
index aa10a8f5ae1..d8bbcaccd3d 100644
--- a/providers/amazon/tests/unit/amazon/aws/triggers/test_redshift_data.py
+++ b/providers/amazon/tests/unit/amazon/aws/triggers/test_redshift_data.py
@@ -24,6 +24,7 @@ import pytest
 from airflow.providers.amazon.aws.hooks.redshift_data import (
     ABORTED_STATE,
     FAILED_STATE,
+    RedshiftDataHook,
     RedshiftDataQueryAbortedError,
     RedshiftDataQueryFailedError,
 )
@@ -57,8 +58,21 @@ class TestRedshiftDataTrigger:
             "region_name": None,
             "botocore_config": None,
             "verify": None,
+            "cancel_on_kill": True,
         }
 
+    def test_redshift_data_trigger_serialization_cancel_on_kill_false(self):
+        """cancel_on_kill=False round-trips through serialization."""
+        trigger = RedshiftDataTrigger(
+            statement_id="uuid",
+            task_id=TEST_TASK_ID,
+            aws_conn_id=TEST_CONN_ID,
+            poll_interval=POLL_INTERVAL,
+            cancel_on_kill=False,
+        )
+        _, kwargs = trigger.serialize()
+        assert kwargs["cancel_on_kill"] is False
+
     @pytest.mark.asyncio
     @pytest.mark.parametrize(
         ("return_value", "response"),
@@ -151,3 +165,70 @@ class TestRedshiftDataTrigger:
         task = [i async for i in trigger.run()]
         assert len(task) == 1
         assert TriggerEvent(expected_response) in task
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("cancel_status", [True, False])
+    @mock.patch.object(RedshiftDataHook, "get_async_conn")
+    async def test_on_kill_cancels_the_statement(self, mock_get_async_conn, 
cancel_status):
+        """on_kill() issues CancelStatement and consumes its Status flag for 
both outcomes.
+
+        CancelStatement returns ``{"Status": bool}`` (False when Redshift 
declined, e.g. the
+        statement already finished); on_kill must read that shape without 
error either way.
+        """
+        mock_client = mock.AsyncMock()
+        mock_client.cancel_statement.return_value = {"Status": cancel_status}
+        mock_cm = mock.AsyncMock()
+        mock_cm.__aenter__.return_value = mock_client
+        mock_get_async_conn.return_value = mock_cm
+
+        trigger = RedshiftDataTrigger(
+            statement_id="uuid",
+            task_id=TEST_TASK_ID,
+            poll_interval=POLL_INTERVAL,
+            aws_conn_id=TEST_CONN_ID,
+        )
+        await trigger.on_kill()
+
+        mock_client.cancel_statement.assert_awaited_once_with(Id="uuid")
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        ("cancel_on_kill", "statement_id"),
+        [
+            pytest.param(False, "uuid", id="disabled"),
+            pytest.param(True, "", id="no-statement-id"),
+        ],
+    )
+    @mock.patch.object(RedshiftDataHook, "get_async_conn")
+    async def test_on_kill_does_not_cancel(self, mock_get_async_conn, 
cancel_on_kill, statement_id):
+        """on_kill() is a no-op (no connection opened) when disabled or 
without a statement_id."""
+        trigger = RedshiftDataTrigger(
+            statement_id=statement_id,
+            task_id=TEST_TASK_ID,
+            poll_interval=POLL_INTERVAL,
+            aws_conn_id=TEST_CONN_ID,
+            cancel_on_kill=cancel_on_kill,
+        )
+        await trigger.on_kill()
+
+        mock_get_async_conn.assert_not_called()
+
+    @pytest.mark.asyncio
+    @mock.patch.object(RedshiftDataHook, "get_async_conn")
+    async def test_on_kill_swallows_cancel_errors(self, mock_get_async_conn):
+        """on_kill() logs and swallows exceptions raised while cancelling."""
+        mock_client = mock.AsyncMock()
+        mock_client.cancel_statement.side_effect = Exception("AWS API error")
+        mock_cm = mock.AsyncMock()
+        mock_cm.__aenter__.return_value = mock_client
+        mock_get_async_conn.return_value = mock_cm
+
+        trigger = RedshiftDataTrigger(
+            statement_id="uuid",
+            task_id=TEST_TASK_ID,
+            poll_interval=POLL_INTERVAL,
+            aws_conn_id=TEST_CONN_ID,
+        )
+        await trigger.on_kill()
+
+        mock_client.cancel_statement.assert_awaited_once_with(Id="uuid")

Reply via email to