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 bfe56db36f1 Add on_kill support to SageMakerProcessingOperator (#76413)
bfe56db36f1 is described below
commit bfe56db36f189726c5dcf9468071dbc8ad41f34a
Author: Amogh Desai <[email protected]>
AuthorDate: Mon Nov 9 08:11:13 2026 +0530
Add on_kill support to SageMakerProcessingOperator (#76413)
---
.../providers/amazon/aws/hooks/sagemaker.py | 38 +++++--
.../providers/amazon/aws/operators/sagemaker.py | 36 +++++-
.../providers/amazon/aws/triggers/sagemaker.py | 31 ++++--
.../tests/unit/amazon/aws/hooks/test_sagemaker.py | 36 ++++++
.../aws/operators/test_sagemaker_processing.py | 124 +++++++++++++++++++++
.../aws/operators/test_sagemaker_training.py | 14 +--
.../unit/amazon/aws/triggers/test_sagemaker.py | 19 +++-
7 files changed, 266 insertions(+), 32 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 5f70eaf8f4a..d8f97dda223 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/hooks/sagemaker.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/hooks/sagemaker.py
@@ -562,25 +562,47 @@ class SageMakerHook(AwsBaseHook):
)
return response
- def stop_training_job(self, training_job_name: str) -> None:
+ def _stop_job(self, api_name: str, name_kwarg: str, job_kind: str,
job_name: str) -> None:
"""
- Stop a running SageMaker training job.
-
- .. seealso::
- - :external+boto3:py:meth:`SageMaker.Client.stop_training_job`
+ Call a SageMaker ``stop_*`` API, tolerating a job that is no longer
running.
- :param training_job_name: name of the training job to stop.
+ :param api_name: name of the boto3 client method to call.
+ :param name_kwarg: name of the job-name keyword that method takes.
+ :param job_kind: job kind as it should read in the log message.
+ :param job_name: name of the job to stop.
"""
try:
- self.conn.stop_training_job(TrainingJobName=training_job_name)
+ getattr(self.conn, api_name)(**{name_kwarg: 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)
+ self.log.warning("Could not stop %s job %s: %s", job_kind,
job_name, ce)
return
raise
+ 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.
+ """
+ self._stop_job("stop_training_job", "TrainingJobName", "training",
training_job_name)
+
+ def stop_processing_job(self, processing_job_name: str) -> None:
+ """
+ Stop a running SageMaker processing job.
+
+ .. seealso::
+ - :external+boto3:py:meth:`SageMaker.Client.stop_processing_job`
+
+ :param processing_job_name: name of the processing job to stop.
+ """
+ self._stop_job("stop_processing_job", "ProcessingJobName",
"processing", processing_job_name)
+
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 badaeae08e2..4be55c65846 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/operators/sagemaker.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/operators/sagemaker.py
@@ -272,6 +272,12 @@ class SageMakerProcessingOperator(SageMakerBaseOperator):
{"output_name": "evaluation", "file_name": "metrics.json",
"result_name": "EvaluationReport"}
]
# Downstream:
ti.xcom_pull("this_task")["OutputFiles"]["EvaluationReport"]["mse"]
+ :param cancel_on_kill: If True, the processing 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. It does not cover a
deferral that expires on
+ its own ``timeout``: the Triggerer kills the trigger without calling
``on_kill``, and no
+ event reaches the task, so nothing runs to stop the job.
:return: Dict with a ``Processing`` key (the job description) and, when
``output_files_to_xcom``
is configured, an ``OutputFiles`` key containing the parsed JSON
contents of the configured
output files (keyed by their ``result_name``).
@@ -291,6 +297,7 @@ class SageMakerProcessingOperator(SageMakerBaseOperator):
action_if_job_exists: str = "timestamp",
deferrable: bool = conf.getboolean("operators", "default_deferrable",
fallback=False),
output_files_to_xcom: list[dict] | None = None,
+ cancel_on_kill: bool = False,
**kwargs,
):
super().__init__(config=config, **kwargs)
@@ -307,7 +314,9 @@ class SageMakerProcessingOperator(SageMakerBaseOperator):
self.max_attempts = max_attempts or 60
self.max_ingestion_time = max_ingestion_time
self.deferrable = deferrable
+ self.cancel_on_kill = cancel_on_kill
self.serialized_job: dict
+ self._processing_job_name: str | None = None
def _create_integer_fields(self) -> None:
"""Set fields which should be cast to integers."""
@@ -338,6 +347,9 @@ class SageMakerProcessingOperator(SageMakerBaseOperator):
self.hook.describe_processing_job,
)
+ # Recorded before the submit call so a kill in that window still has a
name to stop.
+ self._processing_job_name = self.config["ProcessingJobName"]
+
if self.deferrable and not self.wait_for_completion:
self.log.warning(
"Setting deferrable to True does not have effect when
wait_for_completion is set to False."
@@ -383,6 +395,7 @@ class SageMakerProcessingOperator(SageMakerBaseOperator):
region_name=self.region_name,
verify=self.verify,
botocore_config=self.botocore_config,
+ cancel_on_kill=self.cancel_on_kill,
),
method_name="execute_complete",
)
@@ -396,6 +409,11 @@ class SageMakerProcessingOperator(SageMakerBaseOperator):
validated_event = validate_execute_complete_event(event)
if validated_event["status"] != "success":
+ # A "timeout" event means the waiter gave up while the job was
still running; "failed"
+ # and "stopped" are already terminal.
+ if self.cancel_on_kill and validated_event["status"] == "timeout":
+ self.log.info("Stopping SageMaker processing job %s.",
validated_event["job_name"])
+ self.hook.stop_processing_job(validated_event["job_name"])
raise AirflowException(f"Error while running job:
{validated_event}")
self.log.info("SageMaker job %s completed.",
validated_event["job_name"])
@@ -405,6 +423,12 @@ class SageMakerProcessingOperator(SageMakerBaseOperator):
result.update(self._read_output_files())
return result
+ def on_kill(self) -> None:
+ """Stop the SageMaker processing job if ``cancel_on_kill`` is set."""
+ if self.cancel_on_kill and self._processing_job_name:
+ self.log.info("Stopping SageMaker processing job %s.",
self._processing_job_name)
+ self.hook.stop_processing_job(self._processing_job_name)
+
def _read_output_files(self) -> dict:
"""Read configured output files from S3 after job completion and
return them for XCom."""
if not self.output_files_to_xcom:
@@ -1223,7 +1247,7 @@ 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.
+ :param cancel_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.
@@ -1242,7 +1266,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,
+ cancel_on_kill: bool = False,
**kwargs,
):
super().__init__(config=config, **kwargs)
@@ -1260,7 +1284,7 @@ class SageMakerTrainingOperator(SageMakerBaseOperator):
Provided value: '{action_if_job_exists}'."
)
self.deferrable = deferrable
- self.stop_training_job_on_kill = stop_training_job_on_kill
+ self.cancel_on_kill = cancel_on_kill
self.serialized_training_data: dict
self._training_job_name: str | None = None
@@ -1362,7 +1386,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,
+ cancel_on_kill=self.cancel_on_kill,
),
method_name="execute_complete",
)
@@ -1383,8 +1407,8 @@ class SageMakerTrainingOperator(SageMakerBaseOperator):
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:
+ """Stop the SageMaker training job if ``cancel_on_kill`` is set."""
+ if self.cancel_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)
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 e69cb2dfcbb..8dcef92a58a 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/triggers/sagemaker.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/triggers/sagemaker.py
@@ -22,7 +22,7 @@ import warnings
from collections import Counter
from collections.abc import AsyncIterator
from enum import IntEnum
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, ClassVar
from asgiref.sync import sync_to_async
from botocore.exceptions import WaiterError
@@ -54,9 +54,10 @@ 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 cancel_on_kill: If True, stop the job when the user marks the
deferred task failed,
+ clears it, or marks it succeeded. Only the Training and Processing job
types can be stopped;
+ it is a no-op for the others. 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.
"""
@@ -123,17 +124,29 @@ class SageMakerTrigger(AwsBaseWaiterTrigger):
config=self.botocore_config,
)
+ # Spelled via the methods themselves so mypy rejects a name that does not
exist, but stored as
+ # names so the call still resolves on the hook instance.
+ _STOP_METHODS_BY_JOB_TYPE: ClassVar[dict[str, str]] = {
+ "training": SageMakerHook.stop_training_job.__name__,
+ "processing": SageMakerHook.stop_processing_job.__name__,
+ }
+
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":
+ """Stop the job when the user acts on the deferred task."""
+ if not self.cancel_on_kill:
+ return
+ # Absent job types are a no-op, not a call against another type's stop
API.
+ stop_method = self._STOP_METHODS_BY_JOB_TYPE.get(self.job_type.lower())
+ if stop_method is None:
return
- self.log.info("Stopping SageMaker training job %s.", self.job_name)
+ self.log.info("Stopping SageMaker %s job %s.", self.job_type,
self.job_name)
hook: SageMakerHook = self.hook() # type: ignore[assignment]
try:
- await sync_to_async(hook.stop_training_job)(self.job_name)
+ await sync_to_async(getattr(hook, stop_method))(self.job_name)
except Exception:
self.log.exception(
- "Failed to stop SageMaker training job %s. The job may still
be running.",
+ "Failed to stop SageMaker %s job %s. The job may still be
running.",
+ self.job_type,
self.job_name,
)
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 c97f71e13e6..9319a8ff173 100644
--- a/providers/amazon/tests/unit/amazon/aws/hooks/test_sagemaker.py
+++ b/providers/amazon/tests/unit/amazon/aws/hooks/test_sagemaker.py
@@ -732,6 +732,42 @@ 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_processing_job(self, mock_conn):
+ hook = SageMakerHook(aws_conn_id="aws_default")
+
+ hook.stop_processing_job(processing_job_name="job_name")
+
+
mock_conn().stop_processing_job.assert_called_once_with(ProcessingJobName="job_name")
+
+ @patch("airflow.providers.amazon.aws.hooks.sagemaker.SageMakerHook.conn",
new_callable=mock.PropertyMock)
+ def test_stop_processing_job_swallows_validation_exception(self,
mock_conn):
+ mock_conn().stop_processing_job.side_effect = ClientError(
+ error_response={"Error": {"Code": "ValidationException",
"Message": "already Completed"}},
+ operation_name="StopProcessingJob",
+ )
+
+ hook = SageMakerHook(aws_conn_id="aws_default")
+
+ hook.stop_processing_job(processing_job_name="job_name")
+
+
mock_conn().stop_processing_job.assert_called_once_with(ProcessingJobName="job_name")
+
+ @patch("airflow.providers.amazon.aws.hooks.sagemaker.SageMakerHook.conn",
new_callable=mock.PropertyMock)
+ def test_stop_processing_job_raises_other_client_errors(self, mock_conn):
+ error = ClientError(
+ error_response={"Error": {"Code": "AccessDeniedException"}},
+ operation_name="StopProcessingJob",
+ )
+ mock_conn().stop_processing_job.side_effect = error
+
+ hook = SageMakerHook(aws_conn_id="aws_default")
+
+ with pytest.raises(ClientError) as raised_exception:
+ hook.stop_processing_job(processing_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_training_job(self, mock_conn):
hook = SageMakerHook(aws_conn_id="aws_default")
diff --git
a/providers/amazon/tests/unit/amazon/aws/operators/test_sagemaker_processing.py
b/providers/amazon/tests/unit/amazon/aws/operators/test_sagemaker_processing.py
index 6ce11d7a669..ab164c3a86f 100644
---
a/providers/amazon/tests/unit/amazon/aws/operators/test_sagemaker_processing.py
+++
b/providers/amazon/tests/unit/amazon/aws/operators/test_sagemaker_processing.py
@@ -112,6 +112,130 @@ class TestSageMakerProcessingOperator:
task_id="test_sagemaker_operator", wait_for_completion=True,
check_interval=5, deferrable=True
)
+ @mock.patch.object(SageMakerHook, "stop_processing_job")
+ def test_on_kill_does_nothing_by_default(self, mock_stop):
+ op = SageMakerProcessingOperator(**self.processing_config_kwargs,
config=CREATE_PROCESSING_PARAMS)
+ op._processing_job_name = "job_name"
+
+ op.on_kill()
+
+ mock_stop.assert_not_called()
+
+ @mock.patch.object(SageMakerHook, "stop_processing_job")
+ def test_on_kill_does_nothing_before_the_job_is_submitted(self, mock_stop):
+ op = SageMakerProcessingOperator(
+ **self.processing_config_kwargs,
+ config=CREATE_PROCESSING_PARAMS,
+ cancel_on_kill=True,
+ )
+
+ op.on_kill()
+
+ mock_stop.assert_not_called()
+
+ @mock.patch.object(SageMakerHook, "stop_processing_job")
+ @mock.patch.object(SageMakerHook, "describe_processing_job")
+ @mock.patch.object(SageMakerHook, "count_processing_jobs_by_name",
return_value=0)
+ @mock.patch.object(SageMakerHook, "create_processing_job")
+ @mock.patch.object(sagemaker, "serialize", return_value="")
+ def test_on_kill_stops_a_job_killed_during_submit(self, _, mock_create,
__, mock_desc, mock_stop):
+ # Only holds while the name is recorded before the submit call.
+ mock_desc.side_effect = [ClientError({"Error": {"Code":
"ValidationException"}}, "op"), None]
+ op = SageMakerProcessingOperator(
+ task_id="test_sagemaker_operator",
+ config=CREATE_PROCESSING_PARAMS,
+ wait_for_completion=True,
+ check_interval=5,
+ cancel_on_kill=True,
+ )
+ mock_create.side_effect = lambda *args, **kwargs: (
+ op.on_kill(),
+ {"ProcessingJobArn": "test_arn", "ResponseMetadata":
{"HTTPStatusCode": 200}},
+ )[1]
+
+ op.execute(None)
+
+ mock_stop.assert_called_once_with("job_name")
+
+ @mock.patch.object(SageMakerHook, "stop_processing_job")
+ @mock.patch.object(SageMakerHook, "describe_processing_job")
+ @mock.patch.object(SageMakerHook, "count_processing_jobs_by_name",
return_value=0)
+ @mock.patch.object(
+ SageMakerHook,
+ "create_processing_job",
+ return_value={"ProcessingJobArn": "test_arn", "ResponseMetadata":
{"HTTPStatusCode": 200}},
+ )
+ @mock.patch.object(sagemaker, "serialize", return_value="")
+ def test_on_kill_stops_the_renamed_job(self, _, mock_create, __,
mock_desc, mock_stop):
+ # The name is rewritten with a timestamp suffix when one already
exists; on_kill has to stop
+ # the name that was actually submitted, not the one in the config.
+ mock_desc.side_effect = [None, ClientError({"Error": {"Code":
"ValidationException"}}, "op"), None]
+ op = SageMakerProcessingOperator(
+ **self.processing_config_kwargs,
+ config=CREATE_PROCESSING_PARAMS,
+ cancel_on_kill=True,
+ )
+
+ op.execute(None)
+ op.on_kill()
+
+ submitted_name = op.config["ProcessingJobName"]
+ assert submitted_name != "job_name"
+ mock_stop.assert_called_once_with(submitted_name)
+
+ @pytest.mark.parametrize(
+ ("status", "stops"),
+ [("timeout", True), ("failed", False), ("stopped", False)],
+ )
+ @mock.patch.object(SageMakerHook, "stop_processing_job")
+ def test_execute_complete_stops_a_job_left_running_by_a_timeout(self,
mock_stop, status, stops):
+ op = SageMakerProcessingOperator(
+ **self.processing_config_kwargs,
+ config=CREATE_PROCESSING_PARAMS,
+ cancel_on_kill=True,
+ )
+
+ with pytest.raises(AirflowException):
+ op.execute_complete(None, {"status": status, "job_name":
"job_name"})
+
+ if stops:
+ mock_stop.assert_called_once_with("job_name")
+ else:
+ mock_stop.assert_not_called()
+
+ @mock.patch.object(SageMakerHook, "stop_processing_job")
+ def test_execute_complete_leaves_the_job_alone_when_the_flag_is_off(self,
mock_stop):
+ op = SageMakerProcessingOperator(**self.processing_config_kwargs,
config=CREATE_PROCESSING_PARAMS)
+
+ with pytest.raises(AirflowException):
+ op.execute_complete(None, {"status": "timeout", "job_name":
"job_name"})
+
+ mock_stop.assert_not_called()
+
+ @pytest.mark.parametrize("cancel_on_kill", [True, False])
+ @mock.patch.object(SageMakerHook, "describe_processing_job")
+ @mock.patch.object(SageMakerHook, "count_processing_jobs_by_name",
return_value=0)
+ @mock.patch.object(
+ SageMakerHook,
+ "create_processing_job",
+ return_value={"ProcessingJobArn": "test_arn", "ResponseMetadata":
{"HTTPStatusCode": 200}},
+ )
+ def test_deferred_trigger_receives_stop_on_kill(self, mock_create, __,
mock_desc, cancel_on_kill):
+ mock_desc.side_effect = [
+ ClientError({"Error": {"Code": "ValidationException"}}, "op"),
+ {"ProcessingJobStatus": "InProgress"},
+ ]
+ op = SageMakerProcessingOperator(
+ **self.defer_processing_config_kwargs,
+ config=CREATE_PROCESSING_PARAMS,
+ cancel_on_kill=cancel_on_kill,
+ )
+
+ with pytest.raises(TaskDeferred) as exc:
+ op.execute(None)
+
+ assert exc.value.trigger.cancel_on_kill is cancel_on_kill
+
@mock.patch.object(SageMakerHook, "describe_processing_job")
@mock.patch.object(SageMakerHook, "count_processing_jobs_by_name",
return_value=0)
@mock.patch.object(
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 badd8e798e5..0e309b03c12 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
@@ -126,7 +126,7 @@ class TestSageMakerTrainingOperator:
@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.cancel_on_kill = True
self.sagemaker.on_kill()
@@ -146,7 +146,7 @@ class TestSageMakerTrainingOperator:
check_interval=5,
check_if_job_exists=False,
print_log=False,
- stop_training_job_on_kill=True,
+ cancel_on_kill=True,
)
mock_training.side_effect = lambda *args, **kwargs: (
sagemaker_op.on_kill(),
@@ -169,7 +169,7 @@ class TestSageMakerTrainingOperator:
"TrainingJobArn": "test_arn",
"ResponseMetadata": {"HTTPStatusCode": 200},
}
- self.sagemaker.stop_training_job_on_kill = True
+ self.sagemaker.cancel_on_kill = True
self.sagemaker.execute(None)
self.sagemaker.on_kill()
@@ -308,11 +308,11 @@ 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])
+ @pytest.mark.parametrize("cancel_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
+ self, mock_training, mock_describe_training_job, cancel_on_kill
):
mock_training.return_value = {
"TrainingJobArn": "test_arn",
@@ -325,13 +325,13 @@ class TestSageMakerTrainingOperator:
check_if_job_exists=False,
print_log=False,
deferrable=True,
- stop_training_job_on_kill=stop_training_job_on_kill,
+ cancel_on_kill=cancel_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
+ assert exc.value.trigger.cancel_on_kill is cancel_on_kill
@mock.patch.object(
SageMakerHook,
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 b540741763a..dbd5a8ec4bc 100644
--- a/providers/amazon/tests/unit/amazon/aws/triggers/test_sagemaker.py
+++ b/providers/amazon/tests/unit/amazon/aws/triggers/test_sagemaker.py
@@ -95,8 +95,22 @@ class TestSagemakerTrigger:
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):
+ @pytest.mark.parametrize(
+ ("job_type", "stop_method"),
+ [("Training", "stop_training_job"), ("Processing",
"stop_processing_job")],
+ )
+ async def test_on_kill_stops_the_job_for_its_type(self, job_type,
stop_method):
+ 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()
+
+ getattr(mock_hook, stop_method).assert_called_once_with(JOB_NAME)
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("job_type", ["Transform", "Tuning", "Endpoint"])
+ async def test_on_kill_skips_job_types_without_stop_support(self,
job_type):
trigger = SageMakerTrigger(job_name=JOB_NAME, job_type=job_type,
cancel_on_kill=True)
mock_hook = mock.MagicMock(spec=SageMakerHook)
@@ -104,6 +118,7 @@ class TestSagemakerTrigger:
await trigger.on_kill()
mock_hook.stop_training_job.assert_not_called()
+ mock_hook.stop_processing_job.assert_not_called()
@pytest.mark.asyncio
async def test_on_kill_swallows_stop_training_job_error(self):