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

amoghrajesh 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 fdeff70fc0b Add on_kill support to SageMakerTrainingOperator (#76033)
fdeff70fc0b is described below

commit fdeff70fc0b879cd1a45c2f0bf65cb55a7856750
Author: Amogh Desai <[email protected]>
AuthorDate: Tue Nov 3 18:43:37 2026 +0530

    Add on_kill support to SageMakerTrainingOperator (#76033)
---
 .../providers/amazon/aws/hooks/sagemaker.py        | 19 +++++
 .../providers/amazon/aws/operators/sagemaker.py    | 19 ++++-
 .../providers/amazon/aws/triggers/sagemaker.py     | 26 ++++++-
 .../tests/unit/amazon/aws/hooks/test_sagemaker.py  | 59 +++++++++++++++
 .../aws/operators/test_sagemaker_training.py       | 87 ++++++++++++++++++++++
 .../unit/amazon/aws/triggers/test_sagemaker.py     | 55 ++++++++++++++
 6 files changed, 263 insertions(+), 2 deletions(-)

diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/hooks/sagemaker.py 
b/providers/amazon/src/airflow/providers/amazon/aws/hooks/sagemaker.py
index f071ff844c9..5f70eaf8f4a 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/hooks/sagemaker.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/hooks/sagemaker.py
@@ -562,6 +562,25 @@ class SageMakerHook(AwsBaseHook):
             )
         return response
 
+    def stop_training_job(self, training_job_name: str) -> None:
+        """
+        Stop a running SageMaker training job.
+
+        .. seealso::
+            - :external+boto3:py:meth:`SageMaker.Client.stop_training_job`
+
+        :param training_job_name: name of the training job to stop.
+        """
+        try:
+            self.conn.stop_training_job(TrainingJobName=training_job_name)
+        except ClientError as ce:
+            # SageMaker only accepts a stop for a job in InProgress; a 
ValidationException here means
+            # the job reached a terminal state (or never existed) before the 
stop landed.
+            if ce.response["Error"]["Code"] == "ValidationException":
+                self.log.warning("Could not stop training job %s: %s", 
training_job_name, ce)
+                return
+            raise
+
     def describe_training_job(self, name: str):
         """
         Get the training job info associated with the name.
diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/operators/sagemaker.py 
b/providers/amazon/src/airflow/providers/amazon/aws/operators/sagemaker.py
index 661c58fed14..badaeae08e2 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/operators/sagemaker.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/operators/sagemaker.py
@@ -1223,6 +1223,10 @@ class SageMakerTrainingOperator(SageMakerBaseOperator):
         This is only relevant if check_if_job_exists is True.
     :param deferrable: Run operator in the deferrable mode. This is only 
effective if wait_for_completion is
         set to True.
+    :param stop_training_job_on_kill: If True, the training job is stopped 
when the task is killed.
+        Without it the job keeps running, and billing, after the task is gone. 
A deferred task is
+        stopped from the Triggerer, which needs Airflow 3.3 or later; below 
that the flag only
+        applies while the task holds a worker slot.
     :return Dict: Returns The ARN of the training job created in Amazon 
SageMaker.
     """
 
@@ -1238,6 +1242,7 @@ class SageMakerTrainingOperator(SageMakerBaseOperator):
         check_if_job_exists: bool = True,
         action_if_job_exists: str = "timestamp",
         deferrable: bool = conf.getboolean("operators", "default_deferrable", 
fallback=False),
+        stop_training_job_on_kill: bool = False,
         **kwargs,
     ):
         super().__init__(config=config, **kwargs)
@@ -1255,7 +1260,9 @@ class SageMakerTrainingOperator(SageMakerBaseOperator):
                 Provided value: '{action_if_job_exists}'."
             )
         self.deferrable = deferrable
+        self.stop_training_job_on_kill = stop_training_job_on_kill
         self.serialized_training_data: dict
+        self._training_job_name: str | None = None
 
     def expand_role(self) -> None:
         """Expand an IAM role name into an ARN."""
@@ -1281,7 +1288,10 @@ class SageMakerTrainingOperator(SageMakerBaseOperator):
                 self.hook.describe_training_job,
             )
 
-        self.log.info("Creating SageMaker training job %s.", 
self.config["TrainingJobName"])
+        # Recorded before the submit call so a kill in that window still has a 
name to stop.
+        self._training_job_name = self.config["TrainingJobName"]
+
+        self.log.info("Creating SageMaker training job %s.", 
self._training_job_name)
 
         if self.deferrable and not self.wait_for_completion:
             self.log.warning(
@@ -1352,6 +1362,7 @@ class SageMakerTrainingOperator(SageMakerBaseOperator):
                     region_name=self.region_name,
                     verify=self.verify,
                     botocore_config=self.botocore_config,
+                    cancel_on_kill=self.stop_training_job_on_kill,
                 ),
                 method_name="execute_complete",
             )
@@ -1371,6 +1382,12 @@ class SageMakerTrainingOperator(SageMakerBaseOperator):
         self.serialized_training_data = 
serialize(self.hook.describe_training_job(job_name))
         return {"Training": self.serialized_training_data}
 
+    def on_kill(self) -> None:
+        """Stop the SageMaker training job if ``stop_training_job_on_kill`` is 
set."""
+        if self.stop_training_job_on_kill and self._training_job_name:
+            self.log.info("Stopping SageMaker training job %s.", 
self._training_job_name)
+            self.hook.stop_training_job(self._training_job_name)
+
     def get_openlineage_facets_on_complete(self, task_instance) -> 
OperatorLineage:
         """Return OpenLineage data gathered from SageMaker's API response 
saved by training job."""
         from airflow.providers.openlineage.extractors import OperatorLineage
diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/triggers/sagemaker.py 
b/providers/amazon/src/airflow/providers/amazon/aws/triggers/sagemaker.py
index cc4725e8257..e69cb2dfcbb 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/triggers/sagemaker.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/triggers/sagemaker.py
@@ -24,6 +24,7 @@ from collections.abc import AsyncIterator
 from enum import IntEnum
 from typing import TYPE_CHECKING
 
+from asgiref.sync import sync_to_async
 from botocore.exceptions import WaiterError
 
 from airflow.exceptions import AirflowProviderDeprecationWarning
@@ -53,6 +54,9 @@ class SageMakerTrigger(AwsBaseWaiterTrigger):
     :param region_name: The AWS region where the job is running. Used to build 
the hook.
     :param verify: Whether or not to verify SSL certificates. Used to build 
the hook.
     :param botocore_config: Configuration dictionary for the botocore client. 
Used to build the hook.
+    :param cancel_on_kill: If True, stop the training job when the user marks 
the deferred task failed,
+        clears it, or marks it succeeded. Only applies when ``job_type`` is 
``Training``. Requires
+        ``apache-airflow`` with ``BaseTrigger.on_kill()`` support (3.3 and 
later); below that it is inert.
     :param poke_interval: (deprecated) use ``waiter_delay`` instead.
     :param max_attempts: (deprecated) use ``waiter_max_attempts`` instead.
     """
@@ -67,6 +71,7 @@ class SageMakerTrigger(AwsBaseWaiterTrigger):
         region_name: str | None = None,
         verify: bool | str | None = None,
         botocore_config: dict | None = None,
+        cancel_on_kill: bool = False,
         poke_interval: int | None = None,
         max_attempts: int | None = None,
     ):
@@ -88,8 +93,13 @@ class SageMakerTrigger(AwsBaseWaiterTrigger):
             waiter_max_attempts = max_attempts
         self.job_name = job_name
         self.job_type = job_type
+        self.cancel_on_kill = cancel_on_kill
         super().__init__(
-            serialized_fields={"job_name": job_name, "job_type": job_type},
+            serialized_fields={
+                "job_name": job_name,
+                "job_type": job_type,
+                "cancel_on_kill": cancel_on_kill,
+            },
             waiter_name=self._get_job_type_waiter(job_type),
             waiter_args={self._get_waiter_arg_name(job_type): job_name},
             failure_message=f"Error while waiting for {job_type} job",
@@ -113,6 +123,20 @@ class SageMakerTrigger(AwsBaseWaiterTrigger):
             config=self.botocore_config,
         )
 
+    async def on_kill(self) -> None:
+        """Stop the training job when the user acts on the deferred task."""
+        if not self.cancel_on_kill or self.job_type.lower() != "training":
+            return
+        self.log.info("Stopping SageMaker training job %s.", self.job_name)
+        hook: SageMakerHook = self.hook()  # type: ignore[assignment]
+        try:
+            await sync_to_async(hook.stop_training_job)(self.job_name)
+        except Exception:
+            self.log.exception(
+                "Failed to stop SageMaker training job %s. The job may still 
be running.",
+                self.job_name,
+            )
+
     def _event_from_exception(self, error: AirflowException) -> TriggerEvent:
 
         if isinstance(error, WaiterNoCredentialsError):
diff --git a/providers/amazon/tests/unit/amazon/aws/hooks/test_sagemaker.py 
b/providers/amazon/tests/unit/amazon/aws/hooks/test_sagemaker.py
index 4b10fcff20d..c97f71e13e6 100644
--- a/providers/amazon/tests/unit/amazon/aws/hooks/test_sagemaker.py
+++ b/providers/amazon/tests/unit/amazon/aws/hooks/test_sagemaker.py
@@ -17,6 +17,7 @@
 # under the License.
 from __future__ import annotations
 
+import re
 import time
 from datetime import UTC, datetime
 from unittest import mock
@@ -37,6 +38,8 @@ from airflow.providers.amazon.aws.hooks.sagemaker import (
 )
 from airflow.providers.common.compat.sdk import AirflowException
 
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS
+
 role = "arn:aws:iam:role/test-role"
 
 path = "local/data"
@@ -729,6 +732,62 @@ class TestSageMakerHook:
             # Value contains the value associated with the key in Name
             assert transformed_param["Value"] == 
params_dict[transformed_param["Name"]]
 
+    @patch("airflow.providers.amazon.aws.hooks.sagemaker.SageMakerHook.conn", 
new_callable=mock.PropertyMock)
+    def test_stop_training_job(self, mock_conn):
+        hook = SageMakerHook(aws_conn_id="aws_default")
+
+        hook.stop_training_job(training_job_name="job_name")
+
+        
mock_conn().stop_training_job.assert_called_once_with(TrainingJobName="job_name")
+
+    @patch("airflow.providers.amazon.aws.hooks.sagemaker.SageMakerHook.conn", 
new_callable=mock.PropertyMock)
+    def test_stop_training_job_swallows_validation_exception(self, mock_conn):
+        mock_conn().stop_training_job.side_effect = ClientError(
+            error_response={"Error": {"Code": "ValidationException", 
"Message": "already Completed"}},
+            operation_name="StopTrainingJob",
+        )
+
+        hook = SageMakerHook(aws_conn_id="aws_default")
+
+        hook.stop_training_job(training_job_name="job_name")
+
+        
mock_conn().stop_training_job.assert_called_once_with(TrainingJobName="job_name")
+
+    @pytest.mark.skipif(
+        not AIRFLOW_V_3_1_PLUS,
+        reason="caplog only supports structured assertions under structlog, 
from Airflow 3.1+",
+    )
+    @patch("airflow.providers.amazon.aws.hooks.sagemaker.SageMakerHook.conn", 
new_callable=mock.PropertyMock)
+    def test_stop_training_job_warns_when_the_job_is_not_running(self, 
mock_conn, caplog):
+        mock_conn().stop_training_job.side_effect = ClientError(
+            error_response={"Error": {"Code": "ValidationException", 
"Message": "already Completed"}},
+            operation_name="StopTrainingJob",
+        )
+
+        hook = SageMakerHook(aws_conn_id="aws_default")
+
+        hook.stop_training_job(training_job_name="job_name")
+
+        assert {
+            "event": re.compile(r"Could not stop training job job_name"),
+            "log_level": "warning",
+        } in caplog
+
+    @patch("airflow.providers.amazon.aws.hooks.sagemaker.SageMakerHook.conn", 
new_callable=mock.PropertyMock)
+    def test_stop_training_job_raises_other_client_errors(self, mock_conn):
+        error = ClientError(
+            error_response={"Error": {"Code": "AccessDeniedException"}},
+            operation_name="StopTrainingJob",
+        )
+        mock_conn().stop_training_job.side_effect = error
+
+        hook = SageMakerHook(aws_conn_id="aws_default")
+
+        with pytest.raises(ClientError) as raised_exception:
+            hook.stop_training_job(training_job_name="job_name")
+
+        assert raised_exception.value == error
+
     @patch("airflow.providers.amazon.aws.hooks.sagemaker.SageMakerHook.conn", 
new_callable=mock.PropertyMock)
     def test_stop_pipeline_returns_status(self, mock_conn):
         mock_conn().describe_pipeline_execution.return_value = 
{"PipelineExecutionStatus": "Stopping"}
diff --git 
a/providers/amazon/tests/unit/amazon/aws/operators/test_sagemaker_training.py 
b/providers/amazon/tests/unit/amazon/aws/operators/test_sagemaker_training.py
index abd1eccb848..badd8e798e5 100644
--- 
a/providers/amazon/tests/unit/amazon/aws/operators/test_sagemaker_training.py
+++ 
b/providers/amazon/tests/unit/amazon/aws/operators/test_sagemaker_training.py
@@ -116,6 +116,68 @@ class TestSageMakerTrainingOperator:
         a = []
         a.sort()
 
+    @mock.patch.object(SageMakerHook, "stop_training_job")
+    def test_on_kill_does_nothing_by_default(self, mock_stop):
+        self.sagemaker._training_job_name = "job_name"
+
+        self.sagemaker.on_kill()
+
+        mock_stop.assert_not_called()
+
+    @mock.patch.object(SageMakerHook, "stop_training_job")
+    def test_on_kill_does_nothing_before_the_job_is_submitted(self, mock_stop):
+        self.sagemaker.stop_training_job_on_kill = True
+
+        self.sagemaker.on_kill()
+
+        mock_stop.assert_not_called()
+
+    @mock.patch.object(SageMakerHook, "stop_training_job")
+    @mock.patch.object(SageMakerHook, "describe_training_job")
+    @mock.patch.object(SageMakerHook, "create_training_job")
+    @mock.patch.object(sagemaker, "serialize", return_value="")
+    def test_on_kill_stops_a_job_killed_during_submit(self, _, mock_training, 
mock_desc, mock_stop):
+        # A kill landing while create_training_job is in flight must still 
find a name to stop, which
+        # only holds while the name is recorded before the submit call.
+        sagemaker_op = SageMakerTrainingOperator(
+            task_id="test_sagemaker_operator",
+            config=CREATE_TRAINING_PARAMS.copy(),
+            wait_for_completion=True,
+            check_interval=5,
+            check_if_job_exists=False,
+            print_log=False,
+            stop_training_job_on_kill=True,
+        )
+        mock_training.side_effect = lambda *args, **kwargs: (
+            sagemaker_op.on_kill(),
+            {"TrainingJobArn": "test_arn", "ResponseMetadata": 
{"HTTPStatusCode": 200}},
+        )[1]
+
+        sagemaker_op.execute(None)
+
+        mock_stop.assert_called_once_with("job_name")
+
+    @mock.patch.object(SageMakerHook, "stop_training_job")
+    @mock.patch.object(SageMakerHook, "describe_training_job")
+    @mock.patch.object(SageMakerHook, "create_training_job")
+    @mock.patch.object(sagemaker, "serialize", return_value="")
+    def test_on_kill_stops_the_renamed_job(self, _, mock_training, mock_desc, 
mock_stop):
+        # The job name is rewritten with a timestamp suffix when one already 
exists; on_kill has to
+        # stop the name that was actually submitted.
+        mock_desc.side_effect = [None, ClientError({"Error": {"Code": 
"ValidationException"}}, "op"), None]
+        mock_training.return_value = {
+            "TrainingJobArn": "test_arn",
+            "ResponseMetadata": {"HTTPStatusCode": 200},
+        }
+        self.sagemaker.stop_training_job_on_kill = True
+
+        self.sagemaker.execute(None)
+        self.sagemaker.on_kill()
+
+        submitted_name = self.sagemaker.config["TrainingJobName"]
+        assert submitted_name != "job_name"
+        mock_stop.assert_called_once_with(submitted_name)
+
     @mock.patch.object(SageMakerHook, "describe_training_job")
     @mock.patch.object(SageMakerHook, "create_training_job")
     def test_execute_with_failure(self, mock_training, mock_desc):
@@ -246,6 +308,31 @@ class TestSageMakerTrainingOperator:
         assert exc.value.trigger.verify == VERIFY
         assert exc.value.trigger.botocore_config == BOTOCORE_CONFIG
 
+    @pytest.mark.parametrize("stop_training_job_on_kill", [True, False])
+    @mock.patch.object(SageMakerHook, "describe_training_job")
+    @mock.patch.object(SageMakerHook, "create_training_job")
+    def test_deferred_trigger_receives_stop_on_kill(
+        self, mock_training, mock_describe_training_job, 
stop_training_job_on_kill
+    ):
+        mock_training.return_value = {
+            "TrainingJobArn": "test_arn",
+            "ResponseMetadata": {"HTTPStatusCode": 200},
+        }
+        mock_describe_training_job.return_value = {"TrainingJobStatus": 
"InProgress"}
+        sagemaker = SageMakerTrainingOperator(
+            task_id="test_sagemaker_operator",
+            config=CREATE_TRAINING_PARAMS.copy(),
+            check_if_job_exists=False,
+            print_log=False,
+            deferrable=True,
+            stop_training_job_on_kill=stop_training_job_on_kill,
+        )
+
+        with pytest.raises(TaskDeferred) as exc:
+            sagemaker.execute(context=None)
+
+        assert exc.value.trigger.cancel_on_kill is stop_training_job_on_kill
+
     @mock.patch.object(
         SageMakerHook,
         "describe_training_job",
diff --git a/providers/amazon/tests/unit/amazon/aws/triggers/test_sagemaker.py 
b/providers/amazon/tests/unit/amazon/aws/triggers/test_sagemaker.py
index 2dde688b16b..b540741763a 100644
--- a/providers/amazon/tests/unit/amazon/aws/triggers/test_sagemaker.py
+++ b/providers/amazon/tests/unit/amazon/aws/triggers/test_sagemaker.py
@@ -60,6 +60,61 @@ class TestSagemakerTrigger:
         assert args["waiter_max_attempts"] == WAITER_MAX_ATTEMPTS
         assert args["aws_conn_id"] == AWS_CONN_ID
         assert args["region_name"] == REGION_NAME
+        assert args["cancel_on_kill"] is False
+
+    @pytest.mark.parametrize("cancel_on_kill", [True, False])
+    def test_sagemaker_trigger_serializes_cancel_on_kill(self, cancel_on_kill):
+        trigger = SageMakerTrigger(
+            job_name=JOB_NAME,
+            job_type=JOB_TYPE,
+            cancel_on_kill=cancel_on_kill,
+        )
+
+        _, args = trigger.serialize()
+
+        assert args["cancel_on_kill"] is cancel_on_kill
+
+    @pytest.mark.asyncio
+    async def test_on_kill_stops_the_training_job(self):
+        trigger = SageMakerTrigger(job_name=JOB_NAME, job_type=JOB_TYPE, 
cancel_on_kill=True)
+        mock_hook = mock.MagicMock(spec=SageMakerHook)
+
+        with mock.patch.object(trigger, "hook", return_value=mock_hook):
+            await trigger.on_kill()
+
+        mock_hook.stop_training_job.assert_called_once_with(JOB_NAME)
+
+    @pytest.mark.asyncio
+    async def test_on_kill_does_nothing_when_not_enabled(self):
+        trigger = SageMakerTrigger(job_name=JOB_NAME, job_type=JOB_TYPE, 
cancel_on_kill=False)
+        mock_hook = mock.MagicMock(spec=SageMakerHook)
+
+        with mock.patch.object(trigger, "hook", return_value=mock_hook):
+            await trigger.on_kill()
+
+        mock_hook.stop_training_job.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("job_type", ["Transform", "Processing", "Tuning", 
"Endpoint"])
+    async def test_on_kill_only_stops_training_jobs(self, job_type):
+        trigger = SageMakerTrigger(job_name=JOB_NAME, job_type=job_type, 
cancel_on_kill=True)
+        mock_hook = mock.MagicMock(spec=SageMakerHook)
+
+        with mock.patch.object(trigger, "hook", return_value=mock_hook):
+            await trigger.on_kill()
+
+        mock_hook.stop_training_job.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_on_kill_swallows_stop_training_job_error(self):
+        trigger = SageMakerTrigger(job_name=JOB_NAME, job_type=JOB_TYPE, 
cancel_on_kill=True)
+        mock_hook = mock.MagicMock(spec=SageMakerHook)
+        mock_hook.stop_training_job.side_effect = RuntimeError("boom")
+
+        with mock.patch.object(trigger, "hook", return_value=mock_hook):
+            await trigger.on_kill()
+
+        mock_hook.stop_training_job.assert_called_once_with(JOB_NAME)
 
     @pytest.mark.parametrize(
         ("deprecated_kwarg", "canonical_attr", "value"),

Reply via email to