This is an automated email from the ASF dual-hosted git repository.
o-nikolas 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 335fd71f58f Stop AWS Glue job run when a deferred task is cleared
(#73702)
335fd71f58f is described below
commit 335fd71f58f0cefff3e5c41b0191c751fdc256c5
Author: leon.jeon <[email protected]>
AuthorDate: Sat Nov 7 01:42:50 2026 +0900
Stop AWS Glue job run when a deferred task is cleared (#73702)
stop_job_run_on_kill was only honoured by the operator's synchronous
on_kill, which never runs for a deferred task, so clearing, failing or
succeeding a deferred Glue task left its job run going.
The trigger's on_kill fires on every user action and cannot tell a clear,
which re-runs the task, from mark failed or success. A cleared task's new
try would otherwise reconnect (durable) to the run that on_kill is
stopping and then fail on STOPPED. So when stop_job_run_on_kill is set,
the retry stops the previous run itself and waits for it to end before
submitting a new one: a STOPPING run still counts toward
MaxConcurrentRuns, so submitting earlier would fail.
---
.../src/airflow/providers/amazon/aws/exceptions.py | 4 +
.../airflow/providers/amazon/aws/operators/glue.py | 51 +++++++-
.../airflow/providers/amazon/aws/triggers/glue.py | 30 ++++-
.../tests/unit/amazon/aws/operators/test_glue.py | 141 ++++++++++++++++++++-
.../tests/unit/amazon/aws/triggers/test_glue.py | 41 ++++++
5 files changed, 262 insertions(+), 5 deletions(-)
diff --git a/providers/amazon/src/airflow/providers/amazon/aws/exceptions.py
b/providers/amazon/src/airflow/providers/amazon/aws/exceptions.py
index 7a59aa0fe2f..4c88b2e6a24 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/exceptions.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/exceptions.py
@@ -86,6 +86,10 @@ class GlueJobRunStoppedError(AirflowException):
"""Raised when a Glue job run finishes in a state that is not a real
success."""
+class GlueJobRunStopTimeoutError(AirflowException):
+ """Raised when a previous Glue job run does not reach a terminal state
after being stopped."""
+
+
class DataSyncTaskNotFoundError(AirflowException):
"""Raised when a DataSync task could not be identified or created for the
requested locations."""
diff --git
a/providers/amazon/src/airflow/providers/amazon/aws/operators/glue.py
b/providers/amazon/src/airflow/providers/amazon/aws/operators/glue.py
index 94e4d7b93cc..fcc63c788be 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/operators/glue.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/operators/glue.py
@@ -18,6 +18,7 @@
from __future__ import annotations
import os
+import time
import urllib.parse
import warnings
from collections.abc import Sequence
@@ -26,7 +27,7 @@ from typing import TYPE_CHECKING, Any, cast
from botocore.exceptions import BotoCoreError, ClientError
from airflow.exceptions import AirflowProviderDeprecationWarning
-from airflow.providers.amazon.aws.exceptions import GlueJobRunStoppedError
+from airflow.providers.amazon.aws.exceptions import GlueJobRunStoppedError,
GlueJobRunStopTimeoutError
from airflow.providers.amazon.aws.hooks.glue import GlueDataQualityHook,
GlueJobHook
from airflow.providers.amazon.aws.hooks.s3 import S3Hook
from airflow.providers.amazon.aws.links.glue import GlueJobRunDetailsLink
@@ -60,6 +61,8 @@ JOB_RUN_SUCCESS_STATES = ("SUCCEEDED",)
JOB_RUN_TERMINAL_STATES = (*JOB_RUN_SUCCESS_STATES, "STOPPED", "FAILED",
"TIMEOUT", "ERROR", "EXPIRED")
# Synthetic state for a run id Glue no longer knows about, so a retry submits
fresh rather than failing.
NOT_FOUND_STATE = "NOT_FOUND"
+# Upper bound on waiting for a previous run to leave STOPPING before a fresh
run is submitted.
+PREVIOUS_JOB_RUN_STOP_TIMEOUT_SECONDS = 600
class GlueJobOperator(ResumableJobMixin, AwsBaseOperator[GlueJobHook]):
@@ -94,7 +97,9 @@ class GlueJobOperator(ResumableJobMixin,
AwsBaseOperator[GlueJobHook]):
:param verbose: If True, Glue Job Run logs show in the Airflow Task Logs.
(default: False)
:param update_config: If True, Operator will update job configuration.
(default: False)
:param replace_script_file: If True, the script file will be replaced in
S3. (default: False)
- :param stop_job_run_on_kill: If True, Operator will stop the job run when
task is killed.
+ :param stop_job_run_on_kill: If True, Operator will stop the job run when
task is killed. In
+ deferrable mode this requires Airflow 3.3+, and with ``durable`` a
retried or cleared task stops
+ its previous run and waits for it to end before starting a new one,
instead of reconnecting to it.
:param sleep_before_return: time in seconds to wait before returning final
status. This is meaningful in case
of limiting concurrency, Glue needs 5-10 seconds to clean up resources.
Thus if status is returned immediately it might end up in case of more
than 1 concurrent run.
@@ -342,6 +347,7 @@ class GlueJobOperator(ResumableJobMixin,
AwsBaseOperator[GlueJobHook]):
region_name=self.region_name,
verify=self.verify,
botocore_config=self.botocore_config,
+ stop_job_run_on_kill=self.stop_job_run_on_kill,
),
method_name="execute_complete",
)
@@ -451,13 +457,26 @@ class GlueJobOperator(ResumableJobMixin,
AwsBaseOperator[GlueJobHook]):
def submit_job(self, context: Context) -> str:
"""Start a Glue job run and return its run id, or reconnect to one
this task already started."""
script_args = self._build_script_args(context)
+ if (
+ AIRFLOW_V_3_3_PLUS
+ and self.deferrable
+ and self.durable
+ and self.stop_job_run_on_kill
+ and context["ti"].try_number > 1
+ ):
+ # The trigger's on_kill stops the previous run on any user action,
and it cannot tell a
+ # clear (which re-runs the task) from mark failed/success.
Reattaching would wait on a
+ # run that on_kill is stopping, so stop it here and start fresh
instead.
+ previous_job_run_id = self._find_previous_job_run(context,
script_args[self.TASK_UUID_ARG])
+ if previous_job_run_id:
+ self._stop_job_run_and_wait(previous_job_run_id)
# Scan only when there's nothing else to go on: first attempt, no
store, or a store that
# never recorded this key. The store check itself is skipped on the
deferrable path, where
# nothing is ever written to it -- a store error there would otherwise
block the scan.
# Deferrable retries could reconnect via task_state_store too,
avoiding this scan entirely,
# if ResumableJobMixin exposed its reconnect decision apart from its
polling loop;
# tracked at https://github.com/apache/airflow/issues/71485.
- if (
+ elif (
self.durable
and (self.deferrable or not AIRFLOW_V_3_3_PLUS)
and context["ti"].try_number > 1
@@ -480,6 +499,32 @@ class GlueJobOperator(ResumableJobMixin,
AwsBaseOperator[GlueJobHook]):
self._set_job_run_id(context, glue_job_run["JobRunId"])
return glue_job_run["JobRunId"]
+ def _stop_job_run_and_wait(self, job_run_id: str) -> None:
+ """
+ Stop a job run and wait until it is terminal.
+
+ A STOPPING run still counts toward MaxConcurrentRuns, so submitting
before then would fail.
+ """
+ self.log.info(
+ "Stopping previous AWS Glue job %s run %s before starting a new
one.", self.job_name, job_run_id
+ )
+ response = self.hook.conn.batch_stop_job_run(JobName=self.job_name,
JobRunIds=[job_run_id])
+ if response.get("Errors"):
+ # The trigger's on_kill may already have stopped it, or it may
have just finished.
+ self.log.info("Stop request for run %s returned errors: %s",
job_run_id, response["Errors"])
+ deadline = time.monotonic() + PREVIOUS_JOB_RUN_STOP_TIMEOUT_SECONDS
+ while True:
+ state = self.hook.get_job_state(self.job_name, job_run_id)
+ if state in JOB_RUN_TERMINAL_STATES:
+ self.log.info("Previous AWS Glue job run %s reached %s.",
job_run_id, state)
+ return
+ if time.monotonic() >= deadline:
+ raise GlueJobRunStopTimeoutError(
+ f"Previous AWS Glue job {self.job_name} run {job_run_id}
is still {state} "
+ f"{PREVIOUS_JOB_RUN_STOP_TIMEOUT_SECONDS}s after being
stopped; not starting a new run."
+ )
+ time.sleep(self.job_poll_interval)
+
def get_job_status(self, external_id: JsonValue, context: Context) -> str:
"""Query the raw job run state; a run id Glue no longer knows about
degrades to NOT_FOUND."""
job_run_id = cast("str", external_id)
diff --git a/providers/amazon/src/airflow/providers/amazon/aws/triggers/glue.py
b/providers/amazon/src/airflow/providers/amazon/aws/triggers/glue.py
index 42c0e501dec..026626ff522 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/triggers/glue.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/triggers/glue.py
@@ -50,6 +50,8 @@ class GlueJobCompleteTrigger(AwsBaseWaiterTrigger):
if not specified.
:param verify: Whether or not to verify SSL certificates.
:param botocore_config: Configuration dictionary (key-values) for botocore
client.
+ :param stop_job_run_on_kill: If True, stop the Glue job run when the
deferred task is killed
+ (for example, cleared while running). Defaults to False.
"""
aws_hook_class = GlueJobHook
@@ -65,9 +67,15 @@ class GlueJobCompleteTrigger(AwsBaseWaiterTrigger):
region_name: str | None = None,
verify: bool | str | None = None,
botocore_config: dict | None = None,
+ stop_job_run_on_kill: bool = False,
):
super().__init__(
- serialized_fields={"job_name": job_name, "run_id": run_id,
"verbose": verbose},
+ serialized_fields={
+ "job_name": job_name,
+ "run_id": run_id,
+ "verbose": verbose,
+ "stop_job_run_on_kill": stop_job_run_on_kill,
+ },
waiter_name="job_complete",
waiter_args={"JobName": job_name, "RunId": run_id},
failure_message="AWS Glue job failed.",
@@ -85,6 +93,26 @@ class GlueJobCompleteTrigger(AwsBaseWaiterTrigger):
self.job_name = job_name
self.run_id = run_id
self.verbose = verbose
+ self.stop_job_run_on_kill = stop_job_run_on_kill
+
+ async def on_kill(self) -> None:
+ """
+ Stop the Glue job run when the user acts on the deferred task.
+
+ Requires Airflow 3.3+, where the triggerer calls
``BaseTrigger.on_kill()``; on older
+ versions this hook is inert. Released 3.3.x versions also call it when
the trigger is
+ reassigned to another triggerer (fixed in #73454), which stops a run
the new triggerer
+ is still watching.
+ """
+ if not self.stop_job_run_on_kill or not self.run_id:
+ return
+ self.log.info("Stopping AWS Glue job %s run %s.", self.job_name,
self.run_id)
+ async with await self.hook().get_async_conn() as client:
+ response = await client.batch_stop_job_run(JobName=self.job_name,
JobRunIds=[self.run_id])
+ if response.get("Errors"):
+ self.log.error(
+ "Failed to stop AWS Glue job %s run %s: %s", self.job_name,
self.run_id, response["Errors"]
+ )
async def run(self) -> AsyncIterator[TriggerEvent]:
if not self.verbose:
diff --git a/providers/amazon/tests/unit/amazon/aws/operators/test_glue.py
b/providers/amazon/tests/unit/amazon/aws/operators/test_glue.py
index 56c0f89a1a7..1e2da4a8c57 100644
--- a/providers/amazon/tests/unit/amazon/aws/operators/test_glue.py
+++ b/providers/amazon/tests/unit/amazon/aws/operators/test_glue.py
@@ -30,7 +30,7 @@ from moto import mock_aws
from airflow.exceptions import AirflowProviderDeprecationWarning
from airflow.models.dag import DAG
-from airflow.providers.amazon.aws.exceptions import GlueJobRunStoppedError
+from airflow.providers.amazon.aws.exceptions import GlueJobRunStoppedError,
GlueJobRunStopTimeoutError
from airflow.providers.amazon.aws.hooks.glue import GlueDataQualityHook,
GlueJobHook
from airflow.providers.amazon.aws.hooks.s3 import S3Hook
from airflow.providers.amazon.aws.links.glue import GlueJobRunDetailsLink
@@ -233,6 +233,28 @@ class TestGlueJobOperator:
assert serialized["verify"] is False
assert serialized["botocore_config"] == botocore_config
+ @mock.patch.object(GlueJobHook, "initialize_job")
+ @mock.patch.object(GlueJobHook, "get_conn")
+ def test_execute_deferrable_forwards_stop_job_run_on_kill_to_trigger(self,
_, mock_initialize_job):
+ """stop_job_run_on_kill must reach the trigger so a cleared deferred
task can stop the run."""
+ glue = GlueJobOperator(
+ durable=False,
+ task_id=TASK_ID,
+ job_name=JOB_NAME,
+ script_location="s3://folder/file",
+ s3_bucket="some_bucket",
+ iam_role_name="my_test_role",
+ deferrable=True,
+ stop_job_run_on_kill=True,
+ )
+ mock_initialize_job.return_value = {"JobRunState": "RUNNING",
"JobRunId": JOB_RUN_ID}
+
+ with pytest.raises(TaskDeferred) as defer:
+ glue.execute({"ti": mock.MagicMock(try_number=1)})
+
+ _, serialized = defer.value.trigger.serialize()
+ assert serialized["stop_job_run_on_kill"] is True
+
@mock.patch.object(GlueJobHook, "conn", new_callable=mock.PropertyMock)
@mock.patch.object(GlueJobHook, "initialize_job")
@mock.patch.object(GlueJobHook, "get_conn")
@@ -405,6 +427,123 @@ class TestGlueJobOperator:
mock_initialize_job.assert_not_called()
+ @staticmethod
+ def _make_retry_with_running_previous_run(glue: GlueJobOperator) ->
mock.MagicMock:
+ mock_ti = mock.MagicMock()
+ mock_ti.dag_id = DAG_ID
+ mock_ti.task_id = TASK_ID
+ mock_ti.run_id = "manual__2024-01-01T00:00:00+00:00"
+ mock_ti.map_index = -1
+ mock_ti.try_number = 2
+ mock_ti.xcom_pull.return_value = None
+ task_uuid =
f"{mock_ti.dag_id}:{mock_ti.task_id}:{mock_ti.run_id}:{mock_ti.map_index}"
+ glue.hook.conn = mock.MagicMock()
+ glue.hook.conn.get_job_runs.return_value = {
+ "JobRuns": [
+ {
+ "Id": JOB_RUN_ID,
+ "Arguments": {GlueJobOperator.TASK_UUID_ARG: task_uuid},
+ "JobRunState": "RUNNING",
+ }
+ ]
+ }
+ return mock_ti
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="the trigger's on_kill
only runs on Airflow 3.3+")
+ @pytest.mark.parametrize(
+ ("stop_response", "states_after_stop"),
+ [
+ pytest.param(
+ {"SuccessfulSubmissions": [{"JobRunId": JOB_RUN_ID}]},
+ ["STOPPING", "STOPPED"],
+ id="stopped_here",
+ ),
+ pytest.param(
+ {"Errors": [{"JobRunId": JOB_RUN_ID}]}, ["STOPPED"],
id="already_stopped_by_on_kill"
+ ),
+ ],
+ )
+ @mock.patch("airflow.providers.amazon.aws.operators.glue.time.sleep")
+ @mock.patch.object(GlueJobHook, "get_job_state")
+ @mock.patch.object(GlueJobHook, "initialize_job")
+ @mock.patch.object(GlueJobHook, "get_conn")
+ def
test_deferrable_retry_with_stop_job_run_on_kill_stops_previous_run_and_starts_fresh(
+ self,
+ mock_get_conn,
+ mock_initialize_job,
+ mock_get_job_state,
+ mock_sleep,
+ stop_response,
+ states_after_stop,
+ ):
+ glue = GlueJobOperator(
+ task_id=TASK_ID,
+ job_name=JOB_NAME,
+ script_location="s3://folder/file",
+ deferrable=True,
+ durable=True,
+ stop_job_run_on_kill=True,
+ )
+ mock_ti = self._make_retry_with_running_previous_run(glue)
+ glue.hook.conn.batch_stop_job_run.return_value = stop_response
+ mock_get_job_state.side_effect = states_after_stop
+ mock_initialize_job.return_value = {"JobRunState": "RUNNING",
"JobRunId": "22222"}
+
+ with pytest.raises(TaskDeferred) as defer:
+ glue.execute({"ti": mock_ti})
+
+
glue.hook.conn.batch_stop_job_run.assert_called_once_with(JobName=JOB_NAME,
JobRunIds=[JOB_RUN_ID])
+ assert mock_get_job_state.call_count == len(states_after_stop)
+ mock_initialize_job.assert_called_once()
+ assert defer.value.trigger.run_id == "22222"
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="the trigger's on_kill
only runs on Airflow 3.3+")
+
@mock.patch("airflow.providers.amazon.aws.operators.glue.PREVIOUS_JOB_RUN_STOP_TIMEOUT_SECONDS",
0)
+ @mock.patch.object(GlueJobHook, "get_job_state", return_value="STOPPING")
+ @mock.patch.object(GlueJobHook, "initialize_job")
+ @mock.patch.object(GlueJobHook, "get_conn")
+ def
test_deferrable_retry_with_stop_job_run_on_kill_raises_when_previous_run_does_not_stop(
+ self, mock_get_conn, mock_initialize_job, mock_get_job_state
+ ):
+ glue = GlueJobOperator(
+ task_id=TASK_ID,
+ job_name=JOB_NAME,
+ script_location="s3://folder/file",
+ deferrable=True,
+ stop_job_run_on_kill=True,
+ )
+ mock_ti = self._make_retry_with_running_previous_run(glue)
+ glue.hook.conn.batch_stop_job_run.return_value = {}
+
+ with pytest.raises(GlueJobRunStopTimeoutError, match="is still
STOPPING"):
+ glue.execute({"ti": mock_ti})
+
+ mock_initialize_job.assert_not_called()
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="the trigger's on_kill
only runs on Airflow 3.3+")
+ @mock.patch.object(GlueJobHook, "initialize_job")
+ @mock.patch.object(GlueJobHook, "get_conn")
+ def
test_deferrable_retry_with_stop_job_run_on_kill_without_durable_starts_fresh(
+ self, mock_get_conn, mock_initialize_job
+ ):
+ glue = GlueJobOperator(
+ task_id=TASK_ID,
+ job_name=JOB_NAME,
+ script_location="s3://folder/file",
+ deferrable=True,
+ durable=False,
+ stop_job_run_on_kill=True,
+ )
+ mock_ti = self._make_retry_with_running_previous_run(glue)
+ mock_initialize_job.return_value = {"JobRunState": "RUNNING",
"JobRunId": "22222"}
+
+ with pytest.raises(TaskDeferred) as defer:
+ glue.execute({"ti": mock_ti})
+
+ glue.hook.conn.batch_stop_job_run.assert_not_called()
+ assert GlueJobOperator.TASK_UUID_ARG not in
mock_initialize_job.call_args[0][0]
+ assert defer.value.trigger.run_id == "22222"
+
@pytest.mark.skipif(
not AIRFLOW_V_3_3_PLUS,
reason="task_state_store only exists as an execute() context key on
Airflow 3.3+",
diff --git a/providers/amazon/tests/unit/amazon/aws/triggers/test_glue.py
b/providers/amazon/tests/unit/amazon/aws/triggers/test_glue.py
index e4de3b7e994..add8dbb0b6e 100644
--- a/providers/amazon/tests/unit/amazon/aws/triggers/test_glue.py
+++ b/providers/amazon/tests/unit/amazon/aws/triggers/test_glue.py
@@ -108,6 +108,7 @@ class TestGlueJobTrigger:
"job_name": "job_name",
"run_id": "JobRunId",
"verbose": False,
+ "stop_job_run_on_kill": False,
"aws_conn_id": "aws_conn_id",
"waiter_max_attempts": 3,
"waiter_delay": 10,
@@ -357,6 +358,46 @@ class TestGlueJobTrigger:
log_output = mock_log_info.call_args[0][0]
assert "No new log from the Glue Job in /aws-glue/python-jobs/output"
in log_output
+ def test_serialization_includes_stop_job_run_on_kill(self):
+ trigger = GlueJobCompleteTrigger(job_name="job_name",
run_id="JobRunId", stop_job_run_on_kill=True)
+ _, kwargs = trigger.serialize()
+ assert kwargs["stop_job_run_on_kill"] is True
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ ("response", "expect_error_log"),
+ [
+ (
+ {"SuccessfulSubmissions": [{"JobName": "job_name", "JobRunId":
"JobRunId"}], "Errors": []},
+ False,
+ ),
+ (
+ {"SuccessfulSubmissions": [], "Errors": [{"JobName":
"job_name", "JobRunId": "JobRunId"}]},
+ True,
+ ),
+ ],
+ )
+ @mock.patch.object(GlueJobHook, "get_async_conn")
+ async def test_on_kill_stops_job_run_when_enabled(self, mock_glue_conn,
response, expect_error_log):
+ glue_client = AsyncMock()
+ glue_client.batch_stop_job_run = AsyncMock(return_value=response)
+ mock_glue_conn.return_value.__aenter__ =
AsyncMock(return_value=glue_client)
+ mock_glue_conn.return_value.__aexit__ = AsyncMock(return_value=False)
+ trigger = GlueJobCompleteTrigger(job_name="job_name",
run_id="JobRunId", stop_job_run_on_kill=True)
+
+ with mock.patch.object(trigger.log, "error") as mock_log_error:
+ await trigger.on_kill()
+
+
glue_client.batch_stop_job_run.assert_awaited_once_with(JobName="job_name",
JobRunIds=["JobRunId"])
+ assert mock_log_error.called is expect_error_log
+
+ @pytest.mark.asyncio
+ @mock.patch.object(GlueJobHook, "get_async_conn")
+ async def test_on_kill_does_not_stop_when_disabled(self, mock_glue_conn):
+ trigger = GlueJobCompleteTrigger(job_name="job_name",
run_id="JobRunId", stop_job_run_on_kill=False)
+ await trigger.on_kill()
+ mock_glue_conn.assert_not_called()
+
class TestGlueCatalogPartitionSensorTrigger:
@pytest.mark.asyncio