commoncompute 0.1.0__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.
- commoncompute/__init__.py +86 -0
- commoncompute/_generated_workloads.py +60 -0
- commoncompute/_transport.py +359 -0
- commoncompute/aws_batch.py +589 -0
- commoncompute/cli.py +474 -0
- commoncompute/client.py +591 -0
- commoncompute/compat/__init__.py +12 -0
- commoncompute/compat/openai.py +54 -0
- commoncompute/connect.py +140 -0
- commoncompute/errors.py +131 -0
- commoncompute/models.py +152 -0
- commoncompute/tasks.py +365 -0
- commoncompute-0.1.0.dist-info/METADATA +210 -0
- commoncompute-0.1.0.dist-info/RECORD +17 -0
- commoncompute-0.1.0.dist-info/WHEEL +4 -0
- commoncompute-0.1.0.dist-info/entry_points.txt +3 -0
- commoncompute-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"""boto3-shaped AWS Batch shim that talks to Common Compute.
|
|
2
|
+
|
|
3
|
+
Lets a codebase that calls ``boto3.client("batch")`` migrate to Common
|
|
4
|
+
Compute by changing one import line. The methods accept the same
|
|
5
|
+
keyword arguments and return dicts with the same key names that AWS
|
|
6
|
+
Batch returns, so downstream code that reads ``response["jobId"]``,
|
|
7
|
+
``response["jobs"][0]["status"]``, etc. keeps working.
|
|
8
|
+
|
|
9
|
+
Quick start::
|
|
10
|
+
|
|
11
|
+
# before
|
|
12
|
+
import boto3
|
|
13
|
+
batch = boto3.client("batch", region_name="us-east-1")
|
|
14
|
+
|
|
15
|
+
# after
|
|
16
|
+
from commoncompute.aws_batch import client
|
|
17
|
+
batch = client() # reads CC_API_KEY
|
|
18
|
+
|
|
19
|
+
resp = batch.submit_job(
|
|
20
|
+
jobName="nightly-embed",
|
|
21
|
+
jobQueue="ml-gpu", # routed by us, name is informational
|
|
22
|
+
jobDefinition="bge-large:1", # parsed as workload_id[:model_id]
|
|
23
|
+
containerOverrides={
|
|
24
|
+
"environment": [
|
|
25
|
+
{"name": "INPUT_PAYLOAD", "value": "{...json...}"},
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
)
|
|
29
|
+
print(resp["jobId"])
|
|
30
|
+
|
|
31
|
+
Mapping rules:
|
|
32
|
+
|
|
33
|
+
* ``jobDefinition`` is parsed as ``"<workload_id>[:<model_id>]"``. A
|
|
34
|
+
bare ``"bge-large"`` is treated as ``model_id`` only and the workload
|
|
35
|
+
is inferred (currently ``coreml_embed``).
|
|
36
|
+
* ``containerOverrides.environment`` is collected into a dict and passed
|
|
37
|
+
through as the job ``payload``. The well-known key ``INPUT_PAYLOAD``
|
|
38
|
+
may carry a JSON string that is parsed and used as-is.
|
|
39
|
+
* ``schedulingPriority`` (AWS) or ``shareIdentifier=='priority'`` maps
|
|
40
|
+
to Common Compute ``priority='priority'``; otherwise we default to
|
|
41
|
+
``'batch'`` (cheaper and matches the typical AWS Batch use case).
|
|
42
|
+
* AWS Batch state strings (``SUBMITTED``, ``PENDING``, ``RUNNABLE``,
|
|
43
|
+
``STARTING``, ``RUNNING``, ``SUCCEEDED``, ``FAILED``) are mapped from
|
|
44
|
+
Common Compute states (``queued``, ``leased``, ``running``,
|
|
45
|
+
``completed``, ``failed``, ``dead_letter``, ``cancelled``).
|
|
46
|
+
|
|
47
|
+
What this shim does NOT do (out of scope, by design):
|
|
48
|
+
|
|
49
|
+
* It does not call AWS. It does not require boto3 to be installed.
|
|
50
|
+
* It does not implement compute environments or job queues as
|
|
51
|
+
configurable resources — those are synthesised as defaults by the
|
|
52
|
+
CommonCompute backend.
|
|
53
|
+
|
|
54
|
+
For full boto3 wire-protocol parity (so existing scripts using
|
|
55
|
+
``boto3.client('batch', endpoint_url='https://aws.commoncompute.ai')``
|
|
56
|
+
work without any code change), see the AWS-shim Worker. It accepts
|
|
57
|
+
SigV4-signed JSON-1.1 traffic and supports RegisterJobDefinition,
|
|
58
|
+
SubmitJob (including arrayProperties), DescribeJobs, ListJobs,
|
|
59
|
+
TerminateJob, CancelJob, and the synthesised Describe* read-only set.
|
|
60
|
+
Issue access keys at https://commoncompute.ai/app/aws-keys.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
import json
|
|
66
|
+
import os
|
|
67
|
+
import re
|
|
68
|
+
import time
|
|
69
|
+
from datetime import datetime, timezone
|
|
70
|
+
from typing import Any, Iterable, Mapping, Optional
|
|
71
|
+
|
|
72
|
+
from .client import Client
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ─── State + priority translation ───────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
# Common Compute → AWS Batch terminal/transient state strings. Multiple
|
|
78
|
+
# CC states fold into a single AWS state on purpose — AWS Batch consumers
|
|
79
|
+
# only care about a handful of values.
|
|
80
|
+
_CC_TO_AWS_STATE = {
|
|
81
|
+
"queued": "SUBMITTED",
|
|
82
|
+
"leased": "STARTING",
|
|
83
|
+
"running": "RUNNING",
|
|
84
|
+
"completed": "SUCCEEDED",
|
|
85
|
+
"failed": "FAILED",
|
|
86
|
+
"dead_letter": "FAILED",
|
|
87
|
+
"cancelled": "FAILED",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _aws_state(cc_state: Optional[str]) -> str:
|
|
92
|
+
return _CC_TO_AWS_STATE.get(cc_state or "", "SUBMITTED")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _to_iso(ms: Optional[int]) -> Optional[str]:
|
|
96
|
+
if not ms:
|
|
97
|
+
return None
|
|
98
|
+
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ─── jobDefinition parsing ──────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
# Heuristic: anything containing one of these workload ids gets routed
|
|
104
|
+
# directly. Otherwise we default to ``coreml_embed`` (the most common
|
|
105
|
+
# batch workload migrated from AWS Batch + bge-* embeddings pipelines).
|
|
106
|
+
# Workload identity comes from the generated catalog mirror so this shim
|
|
107
|
+
# resolves jobDefinitions identically to the wire shim (apps/aws-shim) and the
|
|
108
|
+
# Node SDK. The previous hand-maintained set had drifted to 12 of the catalog's
|
|
109
|
+
# 24 workloads. Regenerate with:
|
|
110
|
+
# npx tsx packages/workloads/codegen/emit-sdk-workloads.ts
|
|
111
|
+
from ._generated_workloads import IMAGE_PATTERNS as _IMAGE_PATTERN_SOURCES
|
|
112
|
+
from ._generated_workloads import KNOWN_WORKLOAD_IDS as _KNOWN_WORKLOADS
|
|
113
|
+
|
|
114
|
+
_DEF_RE = re.compile(r"^([A-Za-z0-9_\-]+)(?::(\d+|\$LATEST))?$")
|
|
115
|
+
|
|
116
|
+
# Compile the generated (source, workload) patterns case-insensitively.
|
|
117
|
+
_IMAGE_PATTERNS: list[tuple[re.Pattern, str]] = [
|
|
118
|
+
(re.compile(src, re.I), workload) for src, workload in _IMAGE_PATTERN_SOURCES
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _infer_workload_from_image(image: str) -> Optional[str]:
|
|
123
|
+
for pattern, workload in _IMAGE_PATTERNS:
|
|
124
|
+
if pattern.search(image):
|
|
125
|
+
return workload
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parse_job_definition(job_definition: str) -> tuple[str, Optional[str]]:
|
|
130
|
+
"""Returns (workload_id, model_id) from a jobDefinition like
|
|
131
|
+
``"bge-large:1"`` or ``"coreml_embed:bge-large"`` or
|
|
132
|
+
``"my-pipeline-rev3"``."""
|
|
133
|
+
m = _DEF_RE.match(job_definition or "")
|
|
134
|
+
if m:
|
|
135
|
+
head, _rev = m.group(1), m.group(2)
|
|
136
|
+
if head in _KNOWN_WORKLOADS:
|
|
137
|
+
return head, None
|
|
138
|
+
return "coreml_embed", head
|
|
139
|
+
if ":" in job_definition:
|
|
140
|
+
left, right = job_definition.split(":", 1)
|
|
141
|
+
if left in _KNOWN_WORKLOADS:
|
|
142
|
+
return left, right
|
|
143
|
+
return "coreml_embed", job_definition or None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _payload_from_overrides(overrides: Optional[Mapping[str, Any]]) -> Any:
|
|
147
|
+
"""AWS Batch passes user data through environment variables. We
|
|
148
|
+
collect them into a dict; if INPUT_PAYLOAD is a JSON string we use
|
|
149
|
+
it directly so the user can carry arbitrary structure across the
|
|
150
|
+
boundary without inventing a new convention."""
|
|
151
|
+
if not overrides:
|
|
152
|
+
return None
|
|
153
|
+
env_list = overrides.get("environment") or []
|
|
154
|
+
env: dict[str, str] = {}
|
|
155
|
+
for item in env_list:
|
|
156
|
+
name = item.get("name")
|
|
157
|
+
value = item.get("value")
|
|
158
|
+
if isinstance(name, str) and isinstance(value, str):
|
|
159
|
+
env[name] = value
|
|
160
|
+
if "INPUT_PAYLOAD" in env:
|
|
161
|
+
raw = env.pop("INPUT_PAYLOAD")
|
|
162
|
+
try:
|
|
163
|
+
return json.loads(raw)
|
|
164
|
+
except json.JSONDecodeError:
|
|
165
|
+
return {"input": raw, "env": env}
|
|
166
|
+
return env if env else None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _priority_from_args(*, scheduling_priority: Any, share_identifier: Any) -> str:
|
|
170
|
+
if isinstance(share_identifier, str) and share_identifier.lower() == "priority":
|
|
171
|
+
return "priority"
|
|
172
|
+
if isinstance(scheduling_priority, (int, float)) and scheduling_priority >= 50:
|
|
173
|
+
return "priority"
|
|
174
|
+
return "batch"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ─── Public surface ─────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
class BatchClient:
|
|
180
|
+
"""boto3.client('batch')-shaped facade over a CommonCompute client.
|
|
181
|
+
|
|
182
|
+
Implements the AWS Batch methods that account for >95% of usage in
|
|
183
|
+
inference pipelines: submit_job, describe_jobs, list_jobs,
|
|
184
|
+
cancel_job, terminate_job. Any unimplemented method raises
|
|
185
|
+
``NotImplementedError`` with a link to the migration guide.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def __init__(
|
|
189
|
+
self,
|
|
190
|
+
cc: Optional[Client] = None,
|
|
191
|
+
*,
|
|
192
|
+
api_key: Optional[str] = None,
|
|
193
|
+
base_url: Optional[str] = None,
|
|
194
|
+
) -> None:
|
|
195
|
+
if cc is not None:
|
|
196
|
+
self._cc = cc
|
|
197
|
+
else:
|
|
198
|
+
kwargs: dict[str, Any] = {}
|
|
199
|
+
if api_key:
|
|
200
|
+
kwargs["api_key"] = api_key
|
|
201
|
+
if base_url:
|
|
202
|
+
kwargs["base_url"] = base_url
|
|
203
|
+
self._cc = Client(**kwargs)
|
|
204
|
+
# Process-local jobName cache so describe_jobs/list_jobs can echo
|
|
205
|
+
# back the original AWS-Batch jobName the customer chose. The
|
|
206
|
+
# CC backend doesn't store user-supplied job names today, so
|
|
207
|
+
# without this cache the only way to identify a job in describe
|
|
208
|
+
# output is by jobId. Hash-keyed by jobId.
|
|
209
|
+
self._job_names: dict[str, str] = {}
|
|
210
|
+
|
|
211
|
+
# ── submit_job ─────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
def submit_job(
|
|
214
|
+
self,
|
|
215
|
+
*,
|
|
216
|
+
jobName: str,
|
|
217
|
+
jobQueue: Optional[str] = None,
|
|
218
|
+
jobDefinition: str = "",
|
|
219
|
+
containerOverrides: Optional[Mapping[str, Any]] = None,
|
|
220
|
+
nodeOverrides: Optional[Mapping[str, Any]] = None,
|
|
221
|
+
retryStrategy: Optional[Mapping[str, Any]] = None,
|
|
222
|
+
timeout: Optional[Mapping[str, Any]] = None,
|
|
223
|
+
propagateTags: Optional[bool] = None,
|
|
224
|
+
tags: Optional[Mapping[str, str]] = None,
|
|
225
|
+
schedulingPriorityOverride: Any = None,
|
|
226
|
+
shareIdentifier: Any = None,
|
|
227
|
+
dependsOn: Optional[Iterable[Mapping[str, Any]]] = None,
|
|
228
|
+
arrayProperties: Optional[Mapping[str, Any]] = None,
|
|
229
|
+
parameters: Optional[Mapping[str, Any]] = None,
|
|
230
|
+
# AWS Batch accepts **kwargs we don't model — silently ignore
|
|
231
|
+
# for forward-compat with new boto3 fields.
|
|
232
|
+
**_extra: Any,
|
|
233
|
+
) -> dict[str, Any]:
|
|
234
|
+
if arrayProperties:
|
|
235
|
+
return self._submit_array_job(
|
|
236
|
+
jobName=jobName,
|
|
237
|
+
jobDefinition=jobDefinition,
|
|
238
|
+
containerOverrides=containerOverrides,
|
|
239
|
+
parameters=parameters,
|
|
240
|
+
arrayProperties=arrayProperties,
|
|
241
|
+
schedulingPriorityOverride=schedulingPriorityOverride,
|
|
242
|
+
shareIdentifier=shareIdentifier,
|
|
243
|
+
)
|
|
244
|
+
if dependsOn:
|
|
245
|
+
raise NotImplementedError(
|
|
246
|
+
"Job dependencies aren't supported by this shim. Sequence "
|
|
247
|
+
"submissions in your application code or open an issue with "
|
|
248
|
+
"your dependency pattern."
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
workload_id, model_id = _parse_job_definition(jobDefinition)
|
|
252
|
+
payload = _payload_from_overrides(containerOverrides)
|
|
253
|
+
priority = _priority_from_args(
|
|
254
|
+
scheduling_priority=schedulingPriorityOverride,
|
|
255
|
+
share_identifier=shareIdentifier,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# max_spend_usd from `timeout.attemptDurationSeconds` would be a
|
|
259
|
+
# very loose proxy; leave it unset so the user gets a quote-shape
|
|
260
|
+
# error and learns the correct field if they want a cap.
|
|
261
|
+
idempotency_key = jobName # AWS Batch dedupes on jobName per queue.
|
|
262
|
+
|
|
263
|
+
job = self._cc.jobs.submit(
|
|
264
|
+
workload_id=workload_id,
|
|
265
|
+
model_id=model_id,
|
|
266
|
+
priority=priority,
|
|
267
|
+
payload=payload,
|
|
268
|
+
idempotency_key=idempotency_key,
|
|
269
|
+
)
|
|
270
|
+
job_id = job.get("id") or ""
|
|
271
|
+
if job_id:
|
|
272
|
+
self._job_names[job_id] = jobName
|
|
273
|
+
return {
|
|
274
|
+
"jobName": jobName,
|
|
275
|
+
"jobId": job_id,
|
|
276
|
+
"jobArn": f"cc:job:{job_id}", # AWS callers expect an ARN-ish string
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
# ── array jobs ────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
def _submit_array_job(
|
|
282
|
+
self,
|
|
283
|
+
*,
|
|
284
|
+
jobName: str,
|
|
285
|
+
jobDefinition: str,
|
|
286
|
+
containerOverrides: Optional[Mapping[str, Any]],
|
|
287
|
+
parameters: Optional[Mapping[str, Any]],
|
|
288
|
+
arrayProperties: Mapping[str, Any],
|
|
289
|
+
schedulingPriorityOverride: Any,
|
|
290
|
+
shareIdentifier: Any,
|
|
291
|
+
) -> dict[str, Any]:
|
|
292
|
+
"""Translate AWS arrayProperties into a CommonCompute batch.
|
|
293
|
+
|
|
294
|
+
Item source priority:
|
|
295
|
+
1. ``parameters['array_items_json']`` — literal JSON array
|
|
296
|
+
2. ``parameters['array_input_uri']`` — JSONL/JSON file URI
|
|
297
|
+
3. fan-out: N copies, each with ``payload['array_index'] = i``
|
|
298
|
+
"""
|
|
299
|
+
size = int(arrayProperties.get("size") or 0)
|
|
300
|
+
if size <= 0:
|
|
301
|
+
raise ValueError("arrayProperties.size must be a positive integer")
|
|
302
|
+
workload_id, model_id = _parse_job_definition(jobDefinition)
|
|
303
|
+
priority = _priority_from_args(
|
|
304
|
+
scheduling_priority=schedulingPriorityOverride,
|
|
305
|
+
share_identifier=shareIdentifier,
|
|
306
|
+
)
|
|
307
|
+
env_payload = _payload_from_overrides(containerOverrides)
|
|
308
|
+
|
|
309
|
+
params = dict(parameters or {})
|
|
310
|
+
items: list[dict[str, Any]] = []
|
|
311
|
+
if "array_items_json" in params:
|
|
312
|
+
arr = json.loads(params["array_items_json"])
|
|
313
|
+
for i, raw in enumerate(arr[:size]):
|
|
314
|
+
if isinstance(raw, dict) and "payload" in raw:
|
|
315
|
+
items.append({"custom_id": raw.get("custom_id") or f"{jobName}-{i}", "payload": raw["payload"]})
|
|
316
|
+
else:
|
|
317
|
+
items.append({"custom_id": f"{jobName}-{i}", "payload": raw})
|
|
318
|
+
elif "array_input_uri" in params:
|
|
319
|
+
raise NotImplementedError(
|
|
320
|
+
"array_input_uri requires the wire-protocol shim at "
|
|
321
|
+
"aws.commoncompute.ai (the in-process shim can't fetch R2 URIs). "
|
|
322
|
+
"Set endpoint_url=https://aws.commoncompute.ai on boto3 to use it."
|
|
323
|
+
)
|
|
324
|
+
else:
|
|
325
|
+
base_payload = env_payload if isinstance(env_payload, dict) else {}
|
|
326
|
+
for i in range(size):
|
|
327
|
+
items.append({
|
|
328
|
+
"custom_id": f"{jobName}-{i}",
|
|
329
|
+
"payload": {**base_payload, "array_index": i},
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
resp = self._cc._transport.request( # type: ignore[attr-defined]
|
|
333
|
+
"POST", "/v1/batches",
|
|
334
|
+
json_body={
|
|
335
|
+
"name": jobName,
|
|
336
|
+
"workload_id": workload_id,
|
|
337
|
+
"model_id": model_id,
|
|
338
|
+
"priority": priority,
|
|
339
|
+
"items": items,
|
|
340
|
+
},
|
|
341
|
+
)
|
|
342
|
+
batch_id = resp.get("id") or ""
|
|
343
|
+
if batch_id:
|
|
344
|
+
self._job_names[batch_id] = jobName
|
|
345
|
+
return {
|
|
346
|
+
"jobName": jobName,
|
|
347
|
+
"jobId": batch_id,
|
|
348
|
+
"jobArn": f"arn:cc:batch:::job/{batch_id}",
|
|
349
|
+
"arrayProperties": {"size": resp.get("total", size), "statusSummary": {}},
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
# ── register_job_definition ───────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
def register_job_definition(
|
|
355
|
+
self,
|
|
356
|
+
*,
|
|
357
|
+
jobDefinitionName: str,
|
|
358
|
+
type: str = "container",
|
|
359
|
+
containerProperties: Optional[Mapping[str, Any]] = None,
|
|
360
|
+
parameters: Optional[Mapping[str, Any]] = None,
|
|
361
|
+
tags: Optional[Mapping[str, str]] = None,
|
|
362
|
+
**_extra: Any,
|
|
363
|
+
) -> dict[str, Any]:
|
|
364
|
+
"""In-process shim: persist a JobDefinition by recording the
|
|
365
|
+
resolved workload locally so subsequent ``submit_job`` calls
|
|
366
|
+
can reference it by name. The wire-protocol shim at
|
|
367
|
+
aws.commoncompute.ai persists this server-side; this shim
|
|
368
|
+
keeps a per-process registry only.
|
|
369
|
+
"""
|
|
370
|
+
if not hasattr(self, "_local_defs"):
|
|
371
|
+
self._local_defs: dict[str, dict[str, Any]] = {}
|
|
372
|
+
# Resolve a workload from the image hint or the cc-workload tag.
|
|
373
|
+
cc_workload = (tags or {}).get("cc-workload") or (parameters or {}).get("cc_workload")
|
|
374
|
+
if cc_workload and cc_workload in _KNOWN_WORKLOADS:
|
|
375
|
+
workload_id = cc_workload
|
|
376
|
+
else:
|
|
377
|
+
image = (containerProperties or {}).get("image", "") or ""
|
|
378
|
+
workload_id = _infer_workload_from_image(image)
|
|
379
|
+
if not workload_id:
|
|
380
|
+
raise ValueError(
|
|
381
|
+
f"Couldn't infer a CommonCompute workload from image={image!r}. "
|
|
382
|
+
"Set tags['cc-workload'] to one of: " + ", ".join(sorted(_KNOWN_WORKLOADS))
|
|
383
|
+
)
|
|
384
|
+
revision = 1 + sum(1 for d in self._local_defs.values() if d["name"] == jobDefinitionName)
|
|
385
|
+
arn = f"arn:cc:batch:::job-definition/{jobDefinitionName}:{revision}"
|
|
386
|
+
self._local_defs[arn] = {
|
|
387
|
+
"name": jobDefinitionName, "revision": revision, "workload_id": workload_id,
|
|
388
|
+
"model_id": (tags or {}).get("cc-model"), "containerProperties": containerProperties or {},
|
|
389
|
+
"parameters": parameters or {}, "tags": tags or {},
|
|
390
|
+
}
|
|
391
|
+
# Also key by name (latest) and name:rev for boto3 lookup parity.
|
|
392
|
+
self._local_defs[jobDefinitionName] = self._local_defs[arn]
|
|
393
|
+
self._local_defs[f"{jobDefinitionName}:{revision}"] = self._local_defs[arn]
|
|
394
|
+
return {
|
|
395
|
+
"jobDefinitionName": jobDefinitionName,
|
|
396
|
+
"jobDefinitionArn": arn,
|
|
397
|
+
"revision": revision,
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
# ── describe_jobs ──────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
def describe_jobs(self, *, jobs: Iterable[str], **_extra: Any) -> dict[str, Any]:
|
|
403
|
+
"""Mirror AWS describe_jobs — returns ``{"jobs": [...]}``.
|
|
404
|
+
|
|
405
|
+
Just-submitted jobs may not be queryable for ~100ms while the
|
|
406
|
+
underlying D1 row propagates across the worker's read replicas.
|
|
407
|
+
We absorb a single 404 by retrying once after 250ms, which is
|
|
408
|
+
enough to mask the eventual-consistency window in practice."""
|
|
409
|
+
out: list[dict[str, Any]] = []
|
|
410
|
+
for jid in jobs:
|
|
411
|
+
row = None
|
|
412
|
+
for attempt in range(2):
|
|
413
|
+
try:
|
|
414
|
+
row = self._cc.jobs.get(jid)
|
|
415
|
+
break
|
|
416
|
+
except Exception:
|
|
417
|
+
if attempt == 0:
|
|
418
|
+
time.sleep(0.25)
|
|
419
|
+
continue
|
|
420
|
+
if row is None:
|
|
421
|
+
continue
|
|
422
|
+
shape = _aws_job_shape(row)
|
|
423
|
+
# Patch in the original jobName from our process-local cache,
|
|
424
|
+
# so describe_jobs is round-trip-stable for in-process callers.
|
|
425
|
+
cached_name = self._job_names.get(jid)
|
|
426
|
+
if cached_name:
|
|
427
|
+
shape["jobName"] = cached_name
|
|
428
|
+
out.append(shape)
|
|
429
|
+
return {"jobs": out}
|
|
430
|
+
|
|
431
|
+
# ── list_jobs ──────────────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
def list_jobs(
|
|
434
|
+
self,
|
|
435
|
+
*,
|
|
436
|
+
jobQueue: Optional[str] = None,
|
|
437
|
+
arrayJobId: Optional[str] = None,
|
|
438
|
+
multiNodeJobId: Optional[str] = None,
|
|
439
|
+
jobStatus: Optional[str] = None,
|
|
440
|
+
maxResults: Optional[int] = None,
|
|
441
|
+
nextToken: Optional[str] = None,
|
|
442
|
+
filters: Any = None,
|
|
443
|
+
**_extra: Any,
|
|
444
|
+
) -> dict[str, Any]:
|
|
445
|
+
"""List jobs from /v1/me/submissions and translate each to AWS shape."""
|
|
446
|
+
rows = self._cc._transport.request("GET", "/v1/me/submissions").get("jobs", []) # type: ignore[attr-defined]
|
|
447
|
+
if maxResults:
|
|
448
|
+
rows = rows[: int(maxResults)]
|
|
449
|
+
target = jobStatus.upper() if isinstance(jobStatus, str) else None
|
|
450
|
+
summaries = []
|
|
451
|
+
for row in rows:
|
|
452
|
+
shape = _aws_job_shape(row)
|
|
453
|
+
if target and shape["status"] != target:
|
|
454
|
+
continue
|
|
455
|
+
summaries.append({
|
|
456
|
+
"jobId": shape["jobId"],
|
|
457
|
+
"jobName": shape["jobName"],
|
|
458
|
+
"createdAt": shape.get("createdAt"),
|
|
459
|
+
"startedAt": shape.get("startedAt"),
|
|
460
|
+
"stoppedAt": shape.get("stoppedAt"),
|
|
461
|
+
"status": shape["status"],
|
|
462
|
+
"statusReason": shape.get("statusReason"),
|
|
463
|
+
})
|
|
464
|
+
return {"jobSummaryList": summaries}
|
|
465
|
+
|
|
466
|
+
# ── cancel_job / terminate_job ─────────────────────────────────────
|
|
467
|
+
|
|
468
|
+
def cancel_job(self, *, jobId: str, reason: str = "", **_extra: Any) -> dict[str, Any]:
|
|
469
|
+
self._cc._transport.request("POST", f"/v1/jobs/{jobId}/cancel", json_body={"reason": reason}) # type: ignore[attr-defined]
|
|
470
|
+
return {}
|
|
471
|
+
|
|
472
|
+
def terminate_job(self, *, jobId: str, reason: str = "", **_extra: Any) -> dict[str, Any]:
|
|
473
|
+
# AWS distinguishes cancel (queued) vs terminate (running). On
|
|
474
|
+
# Common Compute the same endpoint handles both — the router
|
|
475
|
+
# picks the right path based on current state.
|
|
476
|
+
return self.cancel_job(jobId=jobId, reason=reason)
|
|
477
|
+
|
|
478
|
+
# ── Convenience: poll-to-completion ───────────────────────────────
|
|
479
|
+
|
|
480
|
+
def wait_until_done(
|
|
481
|
+
self,
|
|
482
|
+
*,
|
|
483
|
+
jobId: str,
|
|
484
|
+
timeout: float = 300.0,
|
|
485
|
+
poll_interval: float = 2.0,
|
|
486
|
+
) -> dict[str, Any]:
|
|
487
|
+
"""Block until job is SUCCEEDED or FAILED. Not part of boto3 API
|
|
488
|
+
but commonly needed at migration sites that previously used
|
|
489
|
+
``waiter = batch.get_waiter('jobs_complete')``."""
|
|
490
|
+
deadline = time.monotonic() + timeout
|
|
491
|
+
while True:
|
|
492
|
+
row = self._cc.jobs.get(jobId)
|
|
493
|
+
state = _aws_state(row.get("state"))
|
|
494
|
+
if state in ("SUCCEEDED", "FAILED"):
|
|
495
|
+
shape = _aws_job_shape(row)
|
|
496
|
+
cached_name = self._job_names.get(jobId)
|
|
497
|
+
if cached_name:
|
|
498
|
+
shape["jobName"] = cached_name
|
|
499
|
+
return shape
|
|
500
|
+
if time.monotonic() > deadline:
|
|
501
|
+
raise TimeoutError(f"Job {jobId} not done in {timeout}s (state={state})")
|
|
502
|
+
time.sleep(poll_interval)
|
|
503
|
+
|
|
504
|
+
# ── Result download (not in boto3 — commonly needed for CC) ────────
|
|
505
|
+
|
|
506
|
+
def download_result(self, *, jobId: str, dest: Optional[str] = None) -> bytes:
|
|
507
|
+
"""Download a completed job's result body. AWS Batch customers
|
|
508
|
+
typically read S3 themselves; on Common Compute the API streams
|
|
509
|
+
the result directly. Returns the raw bytes; if ``dest`` is given,
|
|
510
|
+
writes to that path and returns the bytes anyway.
|
|
511
|
+
|
|
512
|
+
Raises if the job isn't in a terminal state yet."""
|
|
513
|
+
body = self._cc._transport.request("GET", f"/v1/jobs/{jobId}/result")
|
|
514
|
+
# The transport may return parsed JSON or raw bytes depending on
|
|
515
|
+
# content-type — coerce to bytes for the file-write path.
|
|
516
|
+
if isinstance(body, (dict, list)):
|
|
517
|
+
data = json.dumps(body).encode("utf-8")
|
|
518
|
+
elif isinstance(body, str):
|
|
519
|
+
data = body.encode("utf-8")
|
|
520
|
+
elif isinstance(body, (bytes, bytearray)):
|
|
521
|
+
data = bytes(body)
|
|
522
|
+
else:
|
|
523
|
+
data = json.dumps(body).encode("utf-8")
|
|
524
|
+
if dest:
|
|
525
|
+
with open(dest, "wb") as f:
|
|
526
|
+
f.write(data)
|
|
527
|
+
return data
|
|
528
|
+
|
|
529
|
+
# ── Unimplemented stubs for legibility ────────────────────────────
|
|
530
|
+
|
|
531
|
+
def __getattr__(self, name: str) -> Any:
|
|
532
|
+
# Catch any boto3 method we haven't implemented and return a
|
|
533
|
+
# helpful error rather than AttributeError.
|
|
534
|
+
if name.startswith("_"):
|
|
535
|
+
raise AttributeError(name)
|
|
536
|
+
|
|
537
|
+
def _missing(*_a: Any, **_kw: Any) -> Any:
|
|
538
|
+
raise NotImplementedError(
|
|
539
|
+
f"BatchClient.{name} is not implemented by the Common Compute "
|
|
540
|
+
"AWS Batch shim. The shim covers submit_job, describe_jobs, "
|
|
541
|
+
"list_jobs, cancel_job, terminate_job, and wait_until_done — "
|
|
542
|
+
"AWS Batch concepts like compute environments and job queues "
|
|
543
|
+
"are obviated by the Common Compute architecture. See "
|
|
544
|
+
"https://commoncompute.ai/docs/migrate-from-aws-batch."
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
return _missing
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _aws_job_shape(cc_row: Mapping[str, Any]) -> dict[str, Any]:
|
|
551
|
+
"""Translate a Common Compute job row into the AWS Batch describe_jobs
|
|
552
|
+
shape that downstream code expects."""
|
|
553
|
+
state = cc_row.get("state")
|
|
554
|
+
return {
|
|
555
|
+
"jobId": cc_row.get("id"),
|
|
556
|
+
"jobName": (cc_row.get("metadata") or {}).get("jobName") or cc_row.get("type"),
|
|
557
|
+
"jobArn": f"cc:job:{cc_row.get('id', '')}",
|
|
558
|
+
"status": _aws_state(state),
|
|
559
|
+
"statusReason": cc_row.get("error") or None,
|
|
560
|
+
"createdAt": _to_iso(cc_row.get("created_at")),
|
|
561
|
+
"startedAt": _to_iso(cc_row.get("leased_at") or cc_row.get("started_at")),
|
|
562
|
+
"stoppedAt": _to_iso(cc_row.get("completed_at")),
|
|
563
|
+
"attempts": cc_row.get("attempts"),
|
|
564
|
+
"container": {
|
|
565
|
+
"exitCode": 0 if state == "completed" else None,
|
|
566
|
+
},
|
|
567
|
+
# Common Compute extension fields — not in AWS Batch but useful
|
|
568
|
+
# to callers who learn the shape and want extra signal.
|
|
569
|
+
"_cc_workload_id": cc_row.get("type"),
|
|
570
|
+
"_cc_device_id": cc_row.get("device_id"),
|
|
571
|
+
"_cc_result_uri": cc_row.get("result_uri"),
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def client(
|
|
576
|
+
*,
|
|
577
|
+
api_key: Optional[str] = None,
|
|
578
|
+
base_url: Optional[str] = None,
|
|
579
|
+
) -> BatchClient:
|
|
580
|
+
"""Construct a boto3-shaped client. Mirrors ``boto3.client('batch')``.
|
|
581
|
+
|
|
582
|
+
``region_name``, ``aws_access_key_id``, etc. are accepted by the
|
|
583
|
+
boto3 caller convention but ignored here (no AWS region applies).
|
|
584
|
+
Use ``api_key`` to override the ``CC_API_KEY`` env var.
|
|
585
|
+
"""
|
|
586
|
+
return BatchClient(api_key=api_key, base_url=base_url)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
__all__ = ["BatchClient", "client"]
|