flytekitplugins-awsemrserverless 1.16.26__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ """
2
+ .. currentmodule:: flytekitplugins.awsemrserverless
3
+
4
+ This plugin enables running Spark and Hive jobs on AWS EMR Serverless from
5
+ Flyte workflows. It exposes an async connector that handles the EMR
6
+ Serverless job lifecycle (submit, poll, cancel) and a task config type.
7
+
8
+ .. autosummary::
9
+ :template: custom.rst
10
+ :toctree: generated/
11
+
12
+ EMRServerless
13
+ EMRServerlessSparkJobDriver
14
+ EMRServerlessHiveJobDriver
15
+ EMRServerlessTask
16
+ EMRServerlessConnector
17
+ EMRServerlessJobMetadata
18
+ """
19
+
20
+ from flytekitplugins.awsemrserverless.connector import (
21
+ EMRServerlessConnector,
22
+ EMRServerlessJobMetadata,
23
+ )
24
+ from flytekitplugins.awsemrserverless.task import (
25
+ EMRServerless,
26
+ EMRServerlessHiveJobDriver,
27
+ EMRServerlessSparkJobDriver,
28
+ EMRServerlessTask,
29
+ )
@@ -0,0 +1,203 @@
1
+ """
2
+ EMR Serverless Pythonic-mode entrypoint script.
3
+
4
+ This file is the canonical source of the bootstrap script that EMR
5
+ Serverless workers execute as ``sparkSubmit.entryPoint`` for Pythonic
6
+ tasks (i.e. tasks that do not provide an explicit ``spark_job_driver``).
7
+
8
+ How it is delivered to EMR
9
+ --------------------------
10
+
11
+ The connector pod:
12
+
13
+ 1. reads this file from its own ``site-packages`` install;
14
+ 2. computes ``hashlib.sha256(content)[:12]`` and uploads (idempotently)
15
+ to ``s3://<bucket>/flyte/emr-serverless/entrypoint-<hash>.py``;
16
+ 3. passes that S3 URI as ``StartJobRun.jobDriver.sparkSubmit.entryPoint``.
17
+
18
+ EMR Serverless then downloads this script onto the Spark driver and
19
+ runs it with ``spark-submit``. ``sys.argv[1:]`` carries the actual
20
+ ``pyflyte-fast-execute`` (or ``pyflyte-execute``) invocation that
21
+ should run inside the worker, plus the fast-registration distribution
22
+ arguments.
23
+
24
+ Why a custom entrypoint at all
25
+ ------------------------------
26
+
27
+ EMR Serverless's API requires ``sparkSubmit.entryPoint`` to be a
28
+ single Python file URI -- there is no "container as entrypoint"
29
+ escape hatch (cf. SageMaker / Batch / ECS, which run the container
30
+ itself). We need a thin shim that:
31
+
32
+ * downloads the fast-registration tarball from Flyte's blob store,
33
+ * invokes ``pyflyte-fast-execute`` with the right resolver arguments,
34
+ * converts Flytekit's "exit-0-on-user-error" semantics into a non-zero
35
+ exit so EMR reports ``FAILED`` instead of ``SUCCESS``.
36
+
37
+ This file deliberately has only ``flytekit`` as a runtime dependency
38
+ (specifically ``flytekit.tools.fast_registration.download_distribution``)
39
+ because it runs *inside the EMR worker*, not the connector pod.
40
+
41
+ Editing this file
42
+ -----------------
43
+
44
+ Treat this file as part of the connector's *runtime contract* with
45
+ EMR workers, not as plugin internals:
46
+
47
+ * changes here propagate to every Pythonic-mode job on the next
48
+ connector deploy via the content hash in the S3 key;
49
+ * the corresponding unit tests live in ``tests/test_entrypoint.py``
50
+ and exercise this module both as imported Python and as the
51
+ spawned subprocess EMR sees;
52
+ * upstream alignment: this is the EMR analogue of
53
+ ``flytetools/flytekitplugins/databricks/entrypoint.py`` --
54
+ same shape, different transport (S3 instead of GitHub).
55
+ """
56
+
57
+ import os
58
+ import signal
59
+ import subprocess
60
+ import sys
61
+
62
+ from flytekit.tools.fast_registration import download_distribution
63
+
64
+
65
+ def _run_subprocess(cmd, env=None):
66
+ """Run ``cmd`` and forward SIGTERM, returning ``(returncode, stderr_text)``.
67
+
68
+ stdout streams through to the parent (Spark driver stdout); stderr is
69
+ captured so the caller can inspect it for Flytekit's user-error banner.
70
+ """
71
+ p = subprocess.Popen(cmd, env=env, stderr=subprocess.PIPE, stdout=None)
72
+ signal.signal(signal.SIGTERM, lambda s, f: p.send_signal(s))
73
+ _, stderr_bytes = p.communicate()
74
+ stderr_text = stderr_bytes.decode("utf-8", errors="replace") if stderr_bytes else ""
75
+ if stderr_text:
76
+ sys.stderr.write(stderr_text)
77
+ return p.returncode, stderr_text
78
+
79
+
80
+ def _exit_with_code(rc, stderr_text=""):
81
+ """Translate Flytekit subprocess exit semantics into EMR-correct exits.
82
+
83
+ Flytekit's ``pyflyte-execute`` catches user exceptions, writes the
84
+ error to the Flyte output blob, and exits ``0`` -- by design for
85
+ K8s-based agents where FlytePropeller reads the output.
86
+
87
+ In EMR Serverless the connector only polls EMR job state
88
+ (``SUCCESS`` / ``FAILED``) and cannot read the output blobs. If
89
+ ``pyflyte-execute`` exits ``0`` but the user function failed, EMR
90
+ reports ``SUCCESS`` and the connector wrongly reports ``SUCCEEDED``.
91
+
92
+ Detect this by scanning stderr for Flytekit's error banner. When
93
+ found, force a non-zero exit so Spark fails the driver and EMR
94
+ reports ``FAILED``.
95
+ """
96
+ if rc == 0 and "User Error Captured by Flyte" in stderr_text:
97
+ print(
98
+ "[flyte-entrypoint] pyflyte-execute exited 0 but stderr contains "
99
+ "a user error -- forcing non-zero exit so EMR reports FAILED",
100
+ file=sys.stderr,
101
+ )
102
+ sys.exit(1)
103
+ if rc != 0:
104
+ print(f"[flyte-entrypoint] Task process exited with code {rc}", file=sys.stderr)
105
+ sys.exit(rc)
106
+
107
+
108
+ def _parse_fast_execute_args(args):
109
+ """Split a ``pyflyte-fast-execute ...`` argv into its three pieces.
110
+
111
+ Returns ``(additional_distribution, dest_dir, task_cmd_start)``
112
+ where ``task_cmd_start`` is the index in ``args`` where the
113
+ underlying ``pyflyte-execute ...`` command begins.
114
+
115
+ Recognises the two-arg flag forms emitted by Flytekit:
116
+ ``--additional-distribution <s3://...>``
117
+ ``--dest-dir <path>``
118
+ and the optional ``--`` separator before the inner command.
119
+ """
120
+ additional_distribution = None
121
+ dest_dir = None
122
+ task_cmd_start = 0
123
+
124
+ i = 1
125
+ while i < len(args):
126
+ if args[i] == "--additional-distribution" and i + 1 < len(args):
127
+ additional_distribution = args[i + 1]
128
+ i += 2
129
+ elif args[i] == "--dest-dir" and i + 1 < len(args):
130
+ dest_dir = args[i + 1]
131
+ i += 2
132
+ elif args[i] == "--":
133
+ task_cmd_start = i + 1
134
+ break
135
+ else:
136
+ task_cmd_start = i
137
+ break
138
+
139
+ return additional_distribution, dest_dir, task_cmd_start
140
+
141
+
142
+ def _build_resolver_command(task_execute_cmd, additional_distribution, dest_dir):
143
+ """Inject the fast-registration distribution args before ``--resolver``.
144
+
145
+ ``pyflyte-execute`` resolves task callables via a resolver plugin
146
+ (default ``flytekit.core.python_auto_container.default_task_resolver``).
147
+ For fast-registered code, the resolver needs to know where the
148
+ extracted source tree lives, which we inject as
149
+ ``--dynamic-addl-distro`` / ``--dynamic-dest-dir`` immediately
150
+ before ``--resolver``.
151
+ """
152
+ cmd = []
153
+ for arg in task_execute_cmd:
154
+ if arg == "--resolver":
155
+ cmd.extend(
156
+ [
157
+ "--dynamic-addl-distro",
158
+ additional_distribution or "",
159
+ "--dynamic-dest-dir",
160
+ dest_dir or "",
161
+ ]
162
+ )
163
+ cmd.append(arg)
164
+ return cmd
165
+
166
+
167
+ def main():
168
+ args = sys.argv[1:]
169
+ if not args:
170
+ print("Usage: entrypoint.py pyflyte-fast-execute|pyflyte-execute ...", file=sys.stderr)
171
+ sys.exit(1)
172
+
173
+ if args[0] == "pyflyte-fast-execute":
174
+ additional_distribution, dest_dir, task_cmd_start = _parse_fast_execute_args(args)
175
+ task_execute_cmd = list(args[task_cmd_start:])
176
+
177
+ if additional_distribution:
178
+ if not dest_dir:
179
+ dest_dir = os.getcwd()
180
+ download_distribution(additional_distribution, dest_dir)
181
+
182
+ cmd = _build_resolver_command(task_execute_cmd, additional_distribution, dest_dir)
183
+
184
+ env = os.environ.copy()
185
+ if dest_dir:
186
+ resolved = os.path.realpath(os.path.expanduser(dest_dir))
187
+ env["PYTHONPATH"] = resolved + os.pathsep + env.get("PYTHONPATH", "")
188
+ rc, stderr_text = _run_subprocess(cmd, env)
189
+ _exit_with_code(rc, stderr_text)
190
+
191
+ elif args[0] == "pyflyte-execute":
192
+ env = os.environ.copy()
193
+ env.setdefault("PYTHONPATH", os.getcwd())
194
+ rc, stderr_text = _run_subprocess(args, env)
195
+ _exit_with_code(rc, stderr_text)
196
+
197
+ else:
198
+ print(f"Unrecognized command: {args}", file=sys.stderr)
199
+ sys.exit(1)
200
+
201
+
202
+ if __name__ == "__main__":
203
+ main()
@@ -0,0 +1,389 @@
1
+ """
2
+ Boto3 helper for EMR Serverless API operations.
3
+
4
+ Provides a thin async wrapper around the boto3 EMR Serverless client, with
5
+ application-lifecycle management used by the connector.
6
+
7
+ All outbound boto3 traffic is funneled through two private helpers so that
8
+ tests can mock the entire handler at a single boundary (matches the
9
+ flytekit-aws-sagemaker ``Boto3ConnectorMixin._call`` pattern):
10
+
11
+ * :py:meth:`EMRServerlessHandler._call` -- single-shot API calls
12
+ * :py:meth:`EMRServerlessHandler._paginate` -- paginated list operations
13
+ """
14
+
15
+ import asyncio
16
+ import logging
17
+ import time
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ import boto3
21
+ from botocore.config import Config as BotoConfig
22
+ from botocore.exceptions import ClientError
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _BOTO_RETRY_CONFIG = BotoConfig(retries={"max_attempts": 3, "mode": "adaptive"})
27
+
28
+ _APP_STARTED = "STARTED"
29
+ _APP_TERMINAL = {"TERMINATED"}
30
+ _APP_NEEDS_START = {"CREATED", "STOPPED"}
31
+ _APP_TRANSITIONING = {"CREATING", "STARTING"}
32
+
33
+ _JOB_TERMINAL = {"SUCCESS", "FAILED", "CANCELLED"}
34
+
35
+
36
+ class EMRServerlessHandler:
37
+ """
38
+ Async-friendly wrapper around the boto3 EMR Serverless client.
39
+
40
+ Retries are handled by botocore's built-in adaptive retry mode.
41
+ Blocking boto3 calls are offloaded to a thread-pool executor so they
42
+ do not block the asyncio event loop.
43
+ """
44
+
45
+ def __init__(self, region: Optional[str] = None):
46
+ self.region = region
47
+ self._client: Optional[Any] = None
48
+ logger.debug("EMRServerlessHandler initialized: region=%s", region or "(default)")
49
+
50
+ @property
51
+ def client(self) -> Any:
52
+ if self._client is None:
53
+ kwargs: Dict[str, Any] = {
54
+ "service_name": "emr-serverless",
55
+ "config": _BOTO_RETRY_CONFIG,
56
+ }
57
+ if self.region:
58
+ kwargs["region_name"] = self.region
59
+ self._client = boto3.client(**kwargs)
60
+ logger.debug(
61
+ "Boto3 EMR Serverless client created: region=%s",
62
+ self._client.meta.region_name,
63
+ )
64
+ return self._client
65
+
66
+ async def _call(self, method: str, **params: Any) -> Dict[str, Any]:
67
+ """Invoke a boto3 EMR Serverless client method in the thread-pool executor.
68
+
69
+ This is the single chokepoint for all non-paginated boto3 API calls
70
+ (``method`` is the boto3 client method name and ``params`` are keyword
71
+ arguments forwarded to the call in the camelCase form the AWS SDK
72
+ expects). Tests can mock the entire handler surface by patching just
73
+ this method. Returns the raw response dict from the boto3 call.
74
+ """
75
+ func = getattr(self.client, method)
76
+ loop = asyncio.get_event_loop()
77
+ return await loop.run_in_executor(None, lambda: func(**params))
78
+
79
+ async def _paginate(self, method: str, result_key: str, **params: Any) -> List[Dict[str, Any]]:
80
+ """Exhaust a boto3 paginator and return the concatenated items.
81
+
82
+ Used for list operations (e.g. ``list_applications``) that may span
83
+ multiple pages. ``method`` is the boto3 client method to paginate,
84
+ ``result_key`` is the key under which each page stores items
85
+ (e.g. ``"applications"`` for ``list_applications``), and ``params``
86
+ are keyword arguments forwarded to ``paginator.paginate()``. Tests
87
+ can mock this independently from ``_call``. Returns a flat list of
88
+ items across all pages.
89
+ """
90
+
91
+ def _collect() -> List[Dict[str, Any]]:
92
+ paginator = self.client.get_paginator(method)
93
+ items: List[Dict[str, Any]] = []
94
+ for page in paginator.paginate(**params):
95
+ items.extend(page.get(result_key, []))
96
+ return items
97
+
98
+ loop = asyncio.get_event_loop()
99
+ return await loop.run_in_executor(None, _collect)
100
+
101
+ # ------------------------------------------------------------------
102
+ # Application management
103
+ # ------------------------------------------------------------------
104
+
105
+ async def get_application(self, application_id: str) -> Dict[str, Any]:
106
+ logger.debug("GetApplication: applicationId=%s", application_id)
107
+ resp = await self._call("get_application", applicationId=application_id)
108
+ app = resp.get("application", {})
109
+ logger.debug(
110
+ "GetApplication response: id=%s, name=%s, state=%s",
111
+ app.get("applicationId"),
112
+ app.get("name"),
113
+ app.get("state"),
114
+ )
115
+ return app
116
+
117
+ async def find_application_by_name(self, name: str) -> Optional[str]:
118
+ """Find an active application ID by name. Returns None if not found."""
119
+ logger.info("Searching for application by name: '%s'", name)
120
+ apps = await self._paginate(
121
+ "list_applications",
122
+ result_key="applications",
123
+ states=["CREATED", "STARTED", "STOPPED"],
124
+ )
125
+ for app in apps:
126
+ if app.get("name") == name:
127
+ logger.info("Found application '%s' with ID: %s", name, app["id"])
128
+ return app["id"]
129
+ logger.info("Application '%s' not found after searching %d app(s)", name, len(apps))
130
+ return None
131
+
132
+ async def create_application(
133
+ self,
134
+ name: str,
135
+ release_label: str,
136
+ application_type: str,
137
+ initial_capacity: Optional[Dict[str, Any]] = None,
138
+ maximum_capacity: Optional[Dict[str, Any]] = None,
139
+ network_configuration: Optional[Dict[str, Any]] = None,
140
+ image_configuration: Optional[Dict[str, Any]] = None,
141
+ tags: Optional[Dict[str, str]] = None,
142
+ architecture: Optional[str] = None,
143
+ runtime_configuration: Optional[list] = None,
144
+ scheduler_configuration: Optional[Dict[str, Any]] = None,
145
+ auto_stop_config: Optional[Dict[str, Any]] = None,
146
+ ) -> str:
147
+ logger.info(
148
+ "CreateApplication: name=%s, release_label=%s, type=%s, architecture=%s",
149
+ name,
150
+ release_label,
151
+ application_type,
152
+ architecture,
153
+ )
154
+ params: Dict[str, Any] = {
155
+ "name": name,
156
+ "releaseLabel": release_label,
157
+ "type": application_type,
158
+ }
159
+ if initial_capacity:
160
+ params["initialCapacity"] = initial_capacity
161
+ logger.debug("CreateApplication: initialCapacity provided")
162
+ if maximum_capacity:
163
+ params["maximumCapacity"] = maximum_capacity
164
+ logger.debug("CreateApplication: maximumCapacity provided")
165
+ if network_configuration:
166
+ params["networkConfiguration"] = network_configuration
167
+ logger.debug("CreateApplication: networkConfiguration provided")
168
+ if image_configuration:
169
+ params["imageConfiguration"] = image_configuration
170
+ logger.debug("CreateApplication: imageConfiguration=%s", image_configuration)
171
+ if tags:
172
+ params["tags"] = tags
173
+ logger.debug("CreateApplication: tags=%s", tags)
174
+ if architecture:
175
+ params["architecture"] = architecture
176
+ if runtime_configuration:
177
+ params["runtimeConfiguration"] = runtime_configuration
178
+ logger.debug("CreateApplication: runtimeConfiguration provided")
179
+ if scheduler_configuration:
180
+ params["schedulerConfiguration"] = scheduler_configuration
181
+ logger.debug("CreateApplication: schedulerConfiguration=%s", scheduler_configuration)
182
+ if auto_stop_config:
183
+ params["autoStopConfiguration"] = auto_stop_config
184
+ logger.debug("CreateApplication: autoStopConfiguration=%s", auto_stop_config)
185
+
186
+ resp = await self._call("create_application", **params)
187
+ app_id = resp.get("applicationId", "")
188
+ logger.info("CreateApplication succeeded: applicationId=%s, arn=%s", app_id, resp.get("arn", ""))
189
+ return app_id
190
+
191
+ async def update_application(
192
+ self,
193
+ application_id: str,
194
+ image_configuration: Optional[Dict[str, Any]] = None,
195
+ maximum_capacity: Optional[Dict[str, Any]] = None,
196
+ auto_stop_config: Optional[Dict[str, Any]] = None,
197
+ runtime_configuration: Optional[list] = None,
198
+ scheduler_configuration: Optional[Dict[str, Any]] = None,
199
+ ) -> None:
200
+ """Update a mutable subset of application properties."""
201
+ params: Dict[str, Any] = {"applicationId": application_id}
202
+ update_fields = []
203
+
204
+ if image_configuration:
205
+ params["imageConfiguration"] = image_configuration
206
+ update_fields.append("imageConfiguration")
207
+ if maximum_capacity:
208
+ params["maximumCapacity"] = maximum_capacity
209
+ update_fields.append("maximumCapacity")
210
+ if auto_stop_config:
211
+ params["autoStopConfiguration"] = auto_stop_config
212
+ update_fields.append("autoStopConfiguration")
213
+ if runtime_configuration:
214
+ params["runtimeConfiguration"] = runtime_configuration
215
+ update_fields.append("runtimeConfiguration")
216
+ if scheduler_configuration:
217
+ params["schedulerConfiguration"] = scheduler_configuration
218
+ update_fields.append("schedulerConfiguration")
219
+
220
+ if len(params) <= 1:
221
+ logger.debug("UpdateApplication: no fields to update for %s, skipping", application_id)
222
+ return
223
+
224
+ logger.info("UpdateApplication: applicationId=%s, fields=%s", application_id, update_fields)
225
+ await self._call("update_application", **params)
226
+ logger.info("UpdateApplication succeeded for %s", application_id)
227
+
228
+ async def ensure_application_started(
229
+ self,
230
+ application_id: str,
231
+ timeout_seconds: int = 300,
232
+ poll_interval_seconds: int = 10,
233
+ ) -> None:
234
+ """Ensure the application reaches STARTED state, starting it if needed."""
235
+ logger.info(
236
+ "EnsureApplicationStarted: applicationId=%s, timeout=%ds",
237
+ application_id,
238
+ timeout_seconds,
239
+ )
240
+ start_time = time.monotonic()
241
+
242
+ while True:
243
+ app = await self.get_application(application_id)
244
+ state = app.get("state", "")
245
+
246
+ if state == _APP_STARTED:
247
+ elapsed = time.monotonic() - start_time
248
+ logger.info(
249
+ "Application %s is in STARTED state (waited %.1fs)",
250
+ application_id,
251
+ elapsed,
252
+ )
253
+ return
254
+
255
+ if state in _APP_TERMINAL:
256
+ logger.error(
257
+ "Application %s is in terminal state '%s', cannot be started",
258
+ application_id,
259
+ state,
260
+ )
261
+ raise RuntimeError(f"Application {application_id} is in terminal state '{state}' and cannot be started")
262
+
263
+ if state in _APP_NEEDS_START:
264
+ logger.info("Application %s is in state '%s', sending StartApplication request", application_id, state)
265
+ await self._call("start_application", applicationId=application_id)
266
+
267
+ elapsed = time.monotonic() - start_time
268
+ if elapsed >= timeout_seconds:
269
+ logger.error(
270
+ "Timed out after %.1fs waiting for application %s to start (last state: %s)",
271
+ elapsed,
272
+ application_id,
273
+ state,
274
+ )
275
+ raise TimeoutError(
276
+ f"Timed out after {timeout_seconds}s waiting for application {application_id} to start "
277
+ f"(last state: {state})"
278
+ )
279
+
280
+ logger.debug(
281
+ "Application %s state: %s, waiting %ds (elapsed: %.1fs / %ds)",
282
+ application_id,
283
+ state,
284
+ poll_interval_seconds,
285
+ elapsed,
286
+ timeout_seconds,
287
+ )
288
+ await asyncio.sleep(poll_interval_seconds)
289
+
290
+ # ------------------------------------------------------------------
291
+ # Job management
292
+ # ------------------------------------------------------------------
293
+
294
+ async def start_job_run(
295
+ self,
296
+ application_id: str,
297
+ execution_role_arn: str,
298
+ job_driver: Dict[str, Any],
299
+ configuration_overrides: Optional[Dict[str, Any]] = None,
300
+ tags: Optional[Dict[str, str]] = None,
301
+ execution_timeout_minutes: int = 60,
302
+ name: Optional[str] = None,
303
+ retry_policy: Optional[Dict[str, Any]] = None,
304
+ ) -> str:
305
+ logger.info(
306
+ "StartJobRun: applicationId=%s, name=%s, timeout=%dm",
307
+ application_id,
308
+ name,
309
+ execution_timeout_minutes,
310
+ )
311
+ params: Dict[str, Any] = {
312
+ "applicationId": application_id,
313
+ "executionRoleArn": execution_role_arn,
314
+ "jobDriver": job_driver,
315
+ "executionTimeoutMinutes": execution_timeout_minutes,
316
+ }
317
+ if configuration_overrides:
318
+ params["configurationOverrides"] = configuration_overrides
319
+ logger.debug("StartJobRun: configurationOverrides provided")
320
+ if tags:
321
+ params["tags"] = tags
322
+ if name:
323
+ params["name"] = name
324
+ if retry_policy:
325
+ params["retryPolicy"] = retry_policy
326
+ logger.debug("StartJobRun: retryPolicy=%s", retry_policy)
327
+
328
+ logger.debug("StartJobRun: jobDriver type=%s", list(job_driver.keys()))
329
+ resp = await self._call("start_job_run", **params)
330
+ job_run_id = resp.get("jobRunId", "")
331
+ logger.info(
332
+ "StartJobRun succeeded: jobRunId=%s, applicationId=%s, arn=%s",
333
+ job_run_id,
334
+ application_id,
335
+ resp.get("arn", ""),
336
+ )
337
+ return job_run_id
338
+
339
+ async def get_job_run(self, application_id: str, job_run_id: str) -> Dict[str, Any]:
340
+ logger.debug("GetJobRun: applicationId=%s, jobRunId=%s", application_id, job_run_id)
341
+ resp = await self._call(
342
+ "get_job_run",
343
+ applicationId=application_id,
344
+ jobRunId=job_run_id,
345
+ )
346
+ job = resp.get("jobRun", {})
347
+ logger.debug(
348
+ "GetJobRun response: jobRunId=%s, state=%s",
349
+ job.get("jobRunId"),
350
+ job.get("state"),
351
+ )
352
+ return job
353
+
354
+ async def cancel_job_run(self, application_id: str, job_run_id: str) -> None:
355
+ """Cancel a running job. Idempotent -- safe to call on completed jobs."""
356
+ logger.info("CancelJobRun: applicationId=%s, jobRunId=%s", application_id, job_run_id)
357
+ try:
358
+ job = await self.get_job_run(application_id, job_run_id)
359
+ current_state = job.get("state", "")
360
+ if current_state in _JOB_TERMINAL:
361
+ logger.info(
362
+ "CancelJobRun: job %s already in terminal state '%s', no action needed",
363
+ job_run_id,
364
+ current_state,
365
+ )
366
+ return
367
+ logger.info("CancelJobRun: job %s is in state '%s', sending cancel request", job_run_id, current_state)
368
+ await self._call(
369
+ "cancel_job_run",
370
+ applicationId=application_id,
371
+ jobRunId=job_run_id,
372
+ )
373
+ logger.info("CancelJobRun succeeded: jobRunId=%s", job_run_id)
374
+ except ClientError as e:
375
+ code = e.response.get("Error", {}).get("Code", "")
376
+ if code == "ResourceNotFoundException":
377
+ logger.warning(
378
+ "CancelJobRun: job %s not found (ResourceNotFoundException), may already be cleaned up",
379
+ job_run_id,
380
+ )
381
+ return
382
+ if code == "ValidationException" and "cannot be cancelled" in str(e).lower():
383
+ logger.info(
384
+ "CancelJobRun: job %s cannot be cancelled (ValidationException), likely already completed",
385
+ job_run_id,
386
+ )
387
+ return
388
+ logger.error("CancelJobRun failed for job %s: %s (code: %s)", job_run_id, e, code)
389
+ raise