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.
- flytekitplugins/awsemrserverless/__init__.py +29 -0
- flytekitplugins/awsemrserverless/_entrypoint.py +203 -0
- flytekitplugins/awsemrserverless/boto_handler.py +389 -0
- flytekitplugins/awsemrserverless/connector.py +825 -0
- flytekitplugins/awsemrserverless/task.py +402 -0
- flytekitplugins_awsemrserverless-1.16.26-py3.12-nspkg.pth +1 -0
- flytekitplugins_awsemrserverless-1.16.26.dist-info/METADATA +154 -0
- flytekitplugins_awsemrserverless-1.16.26.dist-info/RECORD +12 -0
- flytekitplugins_awsemrserverless-1.16.26.dist-info/WHEEL +5 -0
- flytekitplugins_awsemrserverless-1.16.26.dist-info/entry_points.txt +2 -0
- flytekitplugins_awsemrserverless-1.16.26.dist-info/namespace_packages.txt +1 -0
- flytekitplugins_awsemrserverless-1.16.26.dist-info/top_level.txt +1 -0
|
@@ -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
|