bladekit 4.5.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.
- bladekit/__init__.py +219 -0
- bladekit/_app.py +3241 -0
- bladekit/_banner.py +640 -0
- bladekit/_batch.py +150 -0
- bladekit/_bucket.py +211 -0
- bladekit/_build.py +337 -0
- bladekit/_bundle.py +459 -0
- bladekit/_client.py +1152 -0
- bladekit/_cls.py +574 -0
- bladekit/_constants.py +121 -0
- bladekit/_cron.py +88 -0
- bladekit/_dict.py +174 -0
- bladekit/_env.py +98 -0
- bladekit/_image.py +577 -0
- bladekit/_loader.py +79 -0
- bladekit/_map.py +555 -0
- bladekit/_preempt.py +94 -0
- bladekit/_queue.py +137 -0
- bladekit/_render.py +683 -0
- bladekit/_runner_api.py +148 -0
- bladekit/_sandbox.py +1051 -0
- bladekit/_secret.py +52 -0
- bladekit/_serialize.py +88 -0
- bladekit/_serve.py +188 -0
- bladekit/_volume.py +232 -0
- bladekit/_webhook.py +90 -0
- bladekit/cli.py +2282 -0
- bladekit/config.py +181 -0
- bladekit/exceptions.py +544 -0
- bladekit-4.5.0.dist-info/METADATA +373 -0
- bladekit-4.5.0.dist-info/RECORD +33 -0
- bladekit-4.5.0.dist-info/WHEEL +4 -0
- bladekit-4.5.0.dist-info/entry_points.txt +3 -0
bladekit/_map.py
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
"""Function.map() / .starmap() — batch fan-out over the GPU fleet.
|
|
2
|
+
|
|
3
|
+
Implements the client side of the map protocol (map-protocol.md):
|
|
4
|
+
|
|
5
|
+
1. Create a map invocation (POST /v1/maps) — state=open.
|
|
6
|
+
2. Push inputs in batches of <= 1000 (POST /v1/maps/{id}/inputs) — streaming,
|
|
7
|
+
never loads all items into memory at once.
|
|
8
|
+
3. Finalize (POST /v1/maps/{id}/finalize) — state=finalized.
|
|
9
|
+
4. Stream results as a generator (GET /v1/maps/{id}/results, paginated by
|
|
10
|
+
cursor) — yields values in order=index by default, optionally order=arrival.
|
|
11
|
+
|
|
12
|
+
Key design decisions (from the contract):
|
|
13
|
+
- return_exceptions=False (default): the first failed input raises immediately
|
|
14
|
+
and stops the generator; the batch continues server-side.
|
|
15
|
+
- return_exceptions=True: exceptions are yielded in-place; the generator never
|
|
16
|
+
raises.
|
|
17
|
+
- Reconnection: if the generator is interrupted and restarted, passing the
|
|
18
|
+
cursor from the previous run resumes from the last seen result. The CLI's
|
|
19
|
+
--detach/resume pattern uses this mechanism.
|
|
20
|
+
- Input chunks are <= 1000 per POST (conventions.md §6 limits).
|
|
21
|
+
- Results are paged with limit <= 200 (conventions.md §6).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import time
|
|
27
|
+
from typing import Any, Generator, Iterable, List, Optional, Tuple
|
|
28
|
+
|
|
29
|
+
from . import exceptions as exc
|
|
30
|
+
from ._constants import (
|
|
31
|
+
MAP_INPUT_BATCH_SIZE,
|
|
32
|
+
MAP_RESULTS_PAGE_LIMIT,
|
|
33
|
+
MAP_POLL_INTERVAL,
|
|
34
|
+
MAP_POLL_MAX_INTERVAL,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _make_input_payload(args: tuple, kwargs: dict) -> Tuple[dict, Optional[bytes]]:
|
|
39
|
+
"""Serialize a single (args, kwargs) pair into a map input args payload."""
|
|
40
|
+
from ._serialize import dumps_args, make_payload_in
|
|
41
|
+
|
|
42
|
+
raw = dumps_args(args, kwargs)
|
|
43
|
+
payload_in, object_bytes = make_payload_in(raw)
|
|
44
|
+
return payload_in, object_bytes
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MapHandle:
|
|
48
|
+
"""Handle for an in-flight or completed map invocation.
|
|
49
|
+
|
|
50
|
+
Returned by Function.map() and Function.starmap() when called with
|
|
51
|
+
``detach=True``, or by ``blade map resume <id>``. The generator is lazy:
|
|
52
|
+
no network calls until iteration begins.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
map_id: The server-assigned map invocation ID (``map_<ULID>``).
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, client: Any, map_id: str) -> None:
|
|
59
|
+
self._client = client
|
|
60
|
+
self.map_id = map_id
|
|
61
|
+
|
|
62
|
+
def __repr__(self) -> str:
|
|
63
|
+
return f"MapHandle(map_id={self.map_id!r})"
|
|
64
|
+
|
|
65
|
+
def results(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
order: str = "index",
|
|
69
|
+
return_exceptions: bool = False,
|
|
70
|
+
cursor: Optional[str] = None,
|
|
71
|
+
) -> Generator[Any, None, None]:
|
|
72
|
+
"""Yield results, resumable from ``cursor``.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
order: ``"index"`` (default, like ``list(map(f, xs))``) or
|
|
76
|
+
``"arrival"`` (throughput-max order).
|
|
77
|
+
return_exceptions: if False (default) the first failed input raises
|
|
78
|
+
immediately; if True exceptions are yielded in-place.
|
|
79
|
+
cursor: opaque resume cursor (from a previous interrupted iteration).
|
|
80
|
+
"""
|
|
81
|
+
yield from _stream_results(
|
|
82
|
+
self._client,
|
|
83
|
+
self.map_id,
|
|
84
|
+
order=order,
|
|
85
|
+
return_exceptions=return_exceptions,
|
|
86
|
+
cursor=cursor,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def cancel(self) -> None:
|
|
90
|
+
"""Request cancellation of the batch (MAP_NOT_CANCELLABLE if terminal)."""
|
|
91
|
+
self._client.cancel_map(self.map_id)
|
|
92
|
+
|
|
93
|
+
def status(self) -> dict:
|
|
94
|
+
"""Return the current map invocation object from the server."""
|
|
95
|
+
return self._client.get_map(self.map_id)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _create_map_batch(
|
|
99
|
+
client: Any,
|
|
100
|
+
*,
|
|
101
|
+
function_ref: dict,
|
|
102
|
+
bundle_id: str,
|
|
103
|
+
resource_spec: dict,
|
|
104
|
+
runtime: dict,
|
|
105
|
+
image: Optional[str],
|
|
106
|
+
image_build_id: Optional[str],
|
|
107
|
+
app_name: Optional[str],
|
|
108
|
+
inputs: Iterable[Tuple[tuple, dict]],
|
|
109
|
+
retries: Optional[dict],
|
|
110
|
+
input_timeout_seconds: Optional[int],
|
|
111
|
+
priority: Optional[int],
|
|
112
|
+
namespace: Optional[str],
|
|
113
|
+
max_concurrency: Optional[int],
|
|
114
|
+
gpu_class: str,
|
|
115
|
+
best_effort: bool,
|
|
116
|
+
max_interruptions: Optional[int],
|
|
117
|
+
) -> str:
|
|
118
|
+
"""Create a map invocation, push inputs, finalize; return its ``map_id``.
|
|
119
|
+
|
|
120
|
+
Shared by :func:`run_map` (which then streams results) and
|
|
121
|
+
:func:`spawn_map` (which returns a handle without blocking). Inputs are
|
|
122
|
+
pushed in chunks of up to 1000 per request so a 1M-item iterable never sits
|
|
123
|
+
in memory. On any error during push the batch is best-effort cancelled so the
|
|
124
|
+
server does not keep workers for an abandoned batch.
|
|
125
|
+
"""
|
|
126
|
+
# 1. Create the map invocation.
|
|
127
|
+
body: dict[str, Any] = {
|
|
128
|
+
"function": function_ref,
|
|
129
|
+
"bundle_id": bundle_id,
|
|
130
|
+
"resources": resource_spec,
|
|
131
|
+
"runtime": runtime,
|
|
132
|
+
}
|
|
133
|
+
if image is not None:
|
|
134
|
+
body["image"] = image
|
|
135
|
+
if image_build_id is not None:
|
|
136
|
+
body["image_build_id"] = image_build_id
|
|
137
|
+
if app_name:
|
|
138
|
+
body["app_name"] = app_name
|
|
139
|
+
if retries is not None:
|
|
140
|
+
body["retries"] = retries
|
|
141
|
+
if input_timeout_seconds is not None:
|
|
142
|
+
body["input_timeout_seconds"] = input_timeout_seconds
|
|
143
|
+
if priority is not None:
|
|
144
|
+
body["priority"] = priority
|
|
145
|
+
if namespace is not None:
|
|
146
|
+
body["namespace"] = namespace
|
|
147
|
+
if max_concurrency is not None:
|
|
148
|
+
body["max_concurrency"] = max_concurrency
|
|
149
|
+
# capacity class + preemption budget (omitted when default so a default
|
|
150
|
+
# map body is byte-for-byte unchanged; m8-spot.md §13.6).
|
|
151
|
+
if gpu_class != "spot":
|
|
152
|
+
body["gpu_class"] = gpu_class
|
|
153
|
+
if best_effort:
|
|
154
|
+
body["best_effort"] = True
|
|
155
|
+
if max_interruptions is not None:
|
|
156
|
+
body["max_interruptions"] = max_interruptions
|
|
157
|
+
|
|
158
|
+
map_resp = client.create_map(body)
|
|
159
|
+
map_id = map_resp["map_id"]
|
|
160
|
+
|
|
161
|
+
# Emit any creation warnings (GPU_QUOTA_REACHED, GPU_TYPE_UNAVAILABLE).
|
|
162
|
+
_emit_map_warnings(map_resp)
|
|
163
|
+
|
|
164
|
+
# 2. Push inputs in streaming chunks.
|
|
165
|
+
try:
|
|
166
|
+
_push_inputs(client, map_id, inputs)
|
|
167
|
+
except Exception:
|
|
168
|
+
# Best-effort cancel so the server doesn't keep workers for an
|
|
169
|
+
# abandoned batch.
|
|
170
|
+
try:
|
|
171
|
+
client.cancel_map(map_id)
|
|
172
|
+
except exc.FabricError:
|
|
173
|
+
pass
|
|
174
|
+
raise
|
|
175
|
+
|
|
176
|
+
# 3. Finalize.
|
|
177
|
+
client.finalize_map(map_id)
|
|
178
|
+
return map_id
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def run_map(
|
|
182
|
+
client: Any,
|
|
183
|
+
*,
|
|
184
|
+
function_ref: dict,
|
|
185
|
+
bundle_id: str,
|
|
186
|
+
resource_spec: dict,
|
|
187
|
+
runtime: dict,
|
|
188
|
+
image: Optional[str] = None,
|
|
189
|
+
image_build_id: Optional[str] = None,
|
|
190
|
+
app_name: Optional[str] = None,
|
|
191
|
+
inputs: Iterable[Tuple[tuple, dict]],
|
|
192
|
+
order: str = "index",
|
|
193
|
+
return_exceptions: bool = False,
|
|
194
|
+
retries: Optional[dict] = None,
|
|
195
|
+
input_timeout_seconds: Optional[int] = None,
|
|
196
|
+
priority: Optional[int] = None,
|
|
197
|
+
namespace: Optional[str] = None,
|
|
198
|
+
max_concurrency: Optional[int] = None,
|
|
199
|
+
gpu_class: str = "spot",
|
|
200
|
+
best_effort: bool = False,
|
|
201
|
+
max_interruptions: Optional[int] = None,
|
|
202
|
+
) -> Generator[Any, None, None]:
|
|
203
|
+
"""Create a map batch, push inputs, finalize, stream results.
|
|
204
|
+
|
|
205
|
+
This is the implementation backing ``Function.map()`` / ``Function.starmap()``.
|
|
206
|
+
|
|
207
|
+
The function is a generator: it creates the batch server-side, then yields
|
|
208
|
+
results as they become available. Inputs are pushed in chunks of up to 1000
|
|
209
|
+
per request so that 1M-item iterables never sit in memory.
|
|
210
|
+
"""
|
|
211
|
+
map_id = _create_map_batch(
|
|
212
|
+
client,
|
|
213
|
+
function_ref=function_ref,
|
|
214
|
+
bundle_id=bundle_id,
|
|
215
|
+
resource_spec=resource_spec,
|
|
216
|
+
runtime=runtime,
|
|
217
|
+
image=image,
|
|
218
|
+
image_build_id=image_build_id,
|
|
219
|
+
app_name=app_name,
|
|
220
|
+
inputs=inputs,
|
|
221
|
+
retries=retries,
|
|
222
|
+
input_timeout_seconds=input_timeout_seconds,
|
|
223
|
+
priority=priority,
|
|
224
|
+
namespace=namespace,
|
|
225
|
+
max_concurrency=max_concurrency,
|
|
226
|
+
gpu_class=gpu_class,
|
|
227
|
+
best_effort=best_effort,
|
|
228
|
+
max_interruptions=max_interruptions,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Stream results.
|
|
232
|
+
yield from _stream_results(
|
|
233
|
+
client,
|
|
234
|
+
map_id,
|
|
235
|
+
order=order,
|
|
236
|
+
return_exceptions=return_exceptions,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def spawn_map(
|
|
241
|
+
client: Any,
|
|
242
|
+
*,
|
|
243
|
+
function_ref: dict,
|
|
244
|
+
bundle_id: str,
|
|
245
|
+
resource_spec: dict,
|
|
246
|
+
runtime: dict,
|
|
247
|
+
image: Optional[str] = None,
|
|
248
|
+
image_build_id: Optional[str] = None,
|
|
249
|
+
app_name: Optional[str] = None,
|
|
250
|
+
inputs: Iterable[Tuple[tuple, dict]],
|
|
251
|
+
retries: Optional[dict] = None,
|
|
252
|
+
input_timeout_seconds: Optional[int] = None,
|
|
253
|
+
priority: Optional[int] = None,
|
|
254
|
+
namespace: Optional[str] = None,
|
|
255
|
+
max_concurrency: Optional[int] = None,
|
|
256
|
+
gpu_class: str = "spot",
|
|
257
|
+
best_effort: bool = False,
|
|
258
|
+
max_interruptions: Optional[int] = None,
|
|
259
|
+
) -> "MapHandle":
|
|
260
|
+
"""Fire an async fan-out and return a :class:`MapHandle` WITHOUT blocking.
|
|
261
|
+
|
|
262
|
+
This is the implementation backing ``Function.spawn_map()`` /
|
|
263
|
+
``Function.spawn_starmap()``. It creates the batch, pushes all inputs, and
|
|
264
|
+
finalizes it server-side — then returns immediately. The batch is persisted
|
|
265
|
+
server-side, so results can be collected later (even from another process,
|
|
266
|
+
via ``blade map resume <map_id>``) by iterating ``handle.results()``.
|
|
267
|
+
"""
|
|
268
|
+
map_id = _create_map_batch(
|
|
269
|
+
client,
|
|
270
|
+
function_ref=function_ref,
|
|
271
|
+
bundle_id=bundle_id,
|
|
272
|
+
resource_spec=resource_spec,
|
|
273
|
+
runtime=runtime,
|
|
274
|
+
image=image,
|
|
275
|
+
image_build_id=image_build_id,
|
|
276
|
+
app_name=app_name,
|
|
277
|
+
inputs=inputs,
|
|
278
|
+
retries=retries,
|
|
279
|
+
input_timeout_seconds=input_timeout_seconds,
|
|
280
|
+
priority=priority,
|
|
281
|
+
namespace=namespace,
|
|
282
|
+
max_concurrency=max_concurrency,
|
|
283
|
+
gpu_class=gpu_class,
|
|
284
|
+
best_effort=best_effort,
|
|
285
|
+
max_interruptions=max_interruptions,
|
|
286
|
+
)
|
|
287
|
+
return MapHandle(client, map_id)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _push_inputs(
|
|
291
|
+
client: Any,
|
|
292
|
+
map_id: str,
|
|
293
|
+
inputs: Iterable[Tuple[tuple, dict]],
|
|
294
|
+
) -> None:
|
|
295
|
+
"""Push inputs to the server in batches of MAP_INPUT_BATCH_SIZE."""
|
|
296
|
+
batch: List[dict] = []
|
|
297
|
+
idx = 0
|
|
298
|
+
|
|
299
|
+
for args, kwargs in inputs:
|
|
300
|
+
payload_in, object_bytes = _make_input_payload(args, kwargs)
|
|
301
|
+
if object_bytes is not None:
|
|
302
|
+
# Large payload: upload to object store first.
|
|
303
|
+
decl = client.create_payload(payload_in["size_bytes"], payload_in["sha256"])
|
|
304
|
+
client.upload_to_presigned(
|
|
305
|
+
decl["put_url"], object_bytes, "application/octet-stream"
|
|
306
|
+
)
|
|
307
|
+
payload_in["payload_id"] = decl["payload_id"]
|
|
308
|
+
|
|
309
|
+
# client_input_id for idempotent push (conventions.md §14.1):
|
|
310
|
+
# a unique string per input so a retry of a failed POST batch
|
|
311
|
+
# does not produce duplicate inputs.
|
|
312
|
+
batch.append({
|
|
313
|
+
"args": payload_in,
|
|
314
|
+
"client_input_id": f"sdk-{map_id}-{idx}",
|
|
315
|
+
})
|
|
316
|
+
idx += 1
|
|
317
|
+
|
|
318
|
+
if len(batch) >= MAP_INPUT_BATCH_SIZE:
|
|
319
|
+
client.push_map_inputs(map_id, batch)
|
|
320
|
+
batch = []
|
|
321
|
+
|
|
322
|
+
# Flush the last partial batch.
|
|
323
|
+
if batch:
|
|
324
|
+
client.push_map_inputs(map_id, batch)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
_MAP_TERMINAL_STATES = frozenset({"completed", "cancelled"})
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _stream_results(
|
|
331
|
+
client: Any,
|
|
332
|
+
map_id: str,
|
|
333
|
+
*,
|
|
334
|
+
order: str = "index",
|
|
335
|
+
return_exceptions: bool = False,
|
|
336
|
+
cursor: Optional[str] = None,
|
|
337
|
+
) -> Generator[Any, None, None]:
|
|
338
|
+
"""Poll GET /v1/maps/{id}/results pages until the batch is done.
|
|
339
|
+
|
|
340
|
+
Termination signal (openapi.yaml MapResultPage):
|
|
341
|
+
- ``next_cursor`` is the *only* contractual termination signal:
|
|
342
|
+
``null`` ⟺ all currently-available results have been read AND the
|
|
343
|
+
batch is terminal (map_state in {completed, cancelled}).
|
|
344
|
+
- ``map_state`` is read to confirm the batch is terminal when
|
|
345
|
+
``next_cursor`` is null (defensive belt-and-suspenders check).
|
|
346
|
+
|
|
347
|
+
Uses exponential backoff between empty non-terminal pages (the server may
|
|
348
|
+
not have results yet if workers are still starting up).
|
|
349
|
+
"""
|
|
350
|
+
poll_interval = MAP_POLL_INTERVAL
|
|
351
|
+
|
|
352
|
+
while True:
|
|
353
|
+
page = client.get_map_results(
|
|
354
|
+
map_id,
|
|
355
|
+
order=order,
|
|
356
|
+
return_exceptions=return_exceptions,
|
|
357
|
+
cursor=cursor,
|
|
358
|
+
limit=MAP_RESULTS_PAGE_LIMIT,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
items = page.get("items") or []
|
|
362
|
+
next_cursor = page.get("next_cursor")
|
|
363
|
+
map_state = page.get("map_state", "")
|
|
364
|
+
|
|
365
|
+
for item in items:
|
|
366
|
+
outcome = item.get("outcome")
|
|
367
|
+
if outcome == "succeeded":
|
|
368
|
+
yield _decode_result(client, item)
|
|
369
|
+
poll_interval = MAP_POLL_INTERVAL # reset after progress
|
|
370
|
+
elif outcome in ("exception", "error"):
|
|
371
|
+
if return_exceptions:
|
|
372
|
+
yield _decode_exception_or_error(client, item)
|
|
373
|
+
poll_interval = MAP_POLL_INTERVAL
|
|
374
|
+
else:
|
|
375
|
+
# Raise the exception and stop the generator.
|
|
376
|
+
# Attach next_cursor so callers can resume (S1: openapi.yaml:2026-2030).
|
|
377
|
+
error = _build_map_error(client, item, map_id)
|
|
378
|
+
if next_cursor is not None:
|
|
379
|
+
# error may be an arbitrary reconstructed user exception,
|
|
380
|
+
# so attach details dynamically (only FabricError defines it).
|
|
381
|
+
err_any: Any = error
|
|
382
|
+
if getattr(err_any, "details", None) is None:
|
|
383
|
+
err_any.details = {}
|
|
384
|
+
err_any.details["resume_cursor"] = next_cursor
|
|
385
|
+
raise error
|
|
386
|
+
else:
|
|
387
|
+
# Unknown outcome: treat as an error to be safe.
|
|
388
|
+
if return_exceptions:
|
|
389
|
+
yield exc.RunError(
|
|
390
|
+
f"unexpected map result outcome: {outcome!r}",
|
|
391
|
+
code="INTERNAL",
|
|
392
|
+
)
|
|
393
|
+
else:
|
|
394
|
+
raise exc.RunError(
|
|
395
|
+
f"unexpected map result outcome: {outcome!r}",
|
|
396
|
+
code="INTERNAL",
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
# Advance cursor with whatever the server returned (opaque, persistable).
|
|
400
|
+
if next_cursor is not None:
|
|
401
|
+
cursor = next_cursor
|
|
402
|
+
|
|
403
|
+
# Contractual termination: next_cursor=null ⟺ drained AND terminal.
|
|
404
|
+
# (openapi.yaml:5209-5213: "null ⟺ all results read AND batch terminal")
|
|
405
|
+
if next_cursor is None and map_state in _MAP_TERMINAL_STATES:
|
|
406
|
+
return
|
|
407
|
+
|
|
408
|
+
# next_cursor=null but map not yet terminal (e.g. finalized, not completed):
|
|
409
|
+
# keep current cursor and back off.
|
|
410
|
+
if not items:
|
|
411
|
+
time.sleep(poll_interval)
|
|
412
|
+
poll_interval = min(poll_interval * 1.5, MAP_POLL_MAX_INTERVAL)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _decode_result(client: Any, item: dict) -> Any:
|
|
416
|
+
"""Deserialize a succeeded map result."""
|
|
417
|
+
from ._serialize import decode_payload_out, loads
|
|
418
|
+
|
|
419
|
+
result = item.get("result")
|
|
420
|
+
if result is None:
|
|
421
|
+
return None
|
|
422
|
+
raw = decode_payload_out(result, fetch=client.download_presigned)
|
|
423
|
+
return loads(raw)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _decode_exception_or_error(client: Any, item: dict) -> BaseException:
|
|
427
|
+
"""Return the exception object for a failed input (return_exceptions=True)."""
|
|
428
|
+
outcome = item.get("outcome")
|
|
429
|
+
if outcome == "exception":
|
|
430
|
+
exc_info = item.get("exception")
|
|
431
|
+
if exc_info:
|
|
432
|
+
return _build_user_exception(client, exc_info)
|
|
433
|
+
# outcome == "error": infra error (RETRIES_EXHAUSTED, INPUT_TIMEOUT, PREEMPTED)
|
|
434
|
+
error = item.get("error") or {}
|
|
435
|
+
code = error.get("code", "INTERNAL")
|
|
436
|
+
message = error.get("message", code)
|
|
437
|
+
hint = _map_error_hint(code, error.get("details") or {})
|
|
438
|
+
if hint:
|
|
439
|
+
message = f"{message}\n{hint}"
|
|
440
|
+
return exc.RunError(message, code=code, details=error.get("details") or {})
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _build_map_error(client: Any, item: dict, map_id: str) -> BaseException:
|
|
444
|
+
"""Build the exception to raise when return_exceptions=False."""
|
|
445
|
+
outcome = item.get("outcome")
|
|
446
|
+
if outcome == "exception":
|
|
447
|
+
exc_info = item.get("exception")
|
|
448
|
+
if exc_info:
|
|
449
|
+
return _build_user_exception(client, exc_info)
|
|
450
|
+
# Infra error.
|
|
451
|
+
error = item.get("error") or {}
|
|
452
|
+
code = error.get("code", "INTERNAL")
|
|
453
|
+
message = error.get("message", code)
|
|
454
|
+
hint = _map_error_hint(code, error.get("details") or {})
|
|
455
|
+
if hint:
|
|
456
|
+
message = f"{message}\n{hint}"
|
|
457
|
+
return exc.RunError(
|
|
458
|
+
message, code=code, run_id=map_id, details=error.get("details") or {}
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _build_user_exception(client: Any, info: dict) -> BaseException:
|
|
463
|
+
"""Reconstruct (or wrap) a remote user exception."""
|
|
464
|
+
from ._serialize import decode_payload_out, loads
|
|
465
|
+
|
|
466
|
+
tb = info.get("traceback_str", "")
|
|
467
|
+
pickled = info.get("pickled")
|
|
468
|
+
if pickled:
|
|
469
|
+
try:
|
|
470
|
+
raw = decode_payload_out(pickled, fetch=client.download_presigned)
|
|
471
|
+
original = loads(raw)
|
|
472
|
+
if isinstance(original, BaseException):
|
|
473
|
+
original._forge_remote_traceback = tb # type: ignore[attr-defined]
|
|
474
|
+
note = f"\n\n--- remote traceback ---\n{tb}"
|
|
475
|
+
if hasattr(original, "add_note"):
|
|
476
|
+
try:
|
|
477
|
+
original.add_note(note)
|
|
478
|
+
except Exception:
|
|
479
|
+
pass
|
|
480
|
+
return original
|
|
481
|
+
except Exception:
|
|
482
|
+
pass
|
|
483
|
+
|
|
484
|
+
exc_type = info.get("type", "Exception")
|
|
485
|
+
message = info.get("message", "")
|
|
486
|
+
return exc.RemoteFunctionError(exc_type, message, tb)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _emit_map_warnings(resp: dict) -> None:
|
|
490
|
+
"""Print structured creation warnings to stderr."""
|
|
491
|
+
warnings = resp.get("warnings")
|
|
492
|
+
if not warnings:
|
|
493
|
+
return
|
|
494
|
+
try:
|
|
495
|
+
from ._render import err_console
|
|
496
|
+
except Exception:
|
|
497
|
+
return
|
|
498
|
+
for w in warnings:
|
|
499
|
+
code = w.get("code", "WARNING")
|
|
500
|
+
message = w.get("message", "")
|
|
501
|
+
err_console.print(f"[yellow]warning[/yellow] [bold]{code}[/bold]: {message}")
|
|
502
|
+
details = w.get("details") or {}
|
|
503
|
+
if code == "GPU_QUOTA_REACHED":
|
|
504
|
+
ns = details.get("namespace", "")
|
|
505
|
+
gpu_type = details.get("gpu_type", "")
|
|
506
|
+
limit = details.get("limit", "?")
|
|
507
|
+
in_use = details.get("in_use", "?")
|
|
508
|
+
err_console.print(
|
|
509
|
+
f" namespace {ns!r} has used {in_use}/{limit} GPUs of type "
|
|
510
|
+
f"{gpu_type!r}; batch will queue."
|
|
511
|
+
)
|
|
512
|
+
elif code == "ORG_GPU_QUOTA_REACHED":
|
|
513
|
+
in_use = details.get("in_use", "?")
|
|
514
|
+
limit = details.get("limit", "?")
|
|
515
|
+
gpu_type = details.get("gpu_type", "*")
|
|
516
|
+
scope = "GPUs" if gpu_type == "*" else f"GPUs of type {gpu_type!r}"
|
|
517
|
+
err_console.print(
|
|
518
|
+
f" organization is using {in_use}/{limit} {scope}; "
|
|
519
|
+
"the batch advances as usage drops below the limit."
|
|
520
|
+
)
|
|
521
|
+
elif code == "GPU_TYPE_UNAVAILABLE":
|
|
522
|
+
known = details.get("known_types") or []
|
|
523
|
+
if known:
|
|
524
|
+
err_console.print(
|
|
525
|
+
f" [yellow]available GPU types:[/yellow] {', '.join(known)}"
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _map_error_hint(code: str, details: dict) -> str:
|
|
530
|
+
"""Return a short actionable hint for map-specific error codes."""
|
|
531
|
+
if code == "RETRIES_EXHAUSTED":
|
|
532
|
+
last = details.get("last_error_code", "")
|
|
533
|
+
base = "The input exhausted all retry attempts."
|
|
534
|
+
if last:
|
|
535
|
+
base += f" Last error: {last}."
|
|
536
|
+
base += " Check your function for transient failures, or increase retries=."
|
|
537
|
+
return base
|
|
538
|
+
if code == "INPUT_TIMEOUT":
|
|
539
|
+
return (
|
|
540
|
+
"An input exceeded its per-input timeout. Increase input_timeout_seconds= "
|
|
541
|
+
"on the map() call, or optimize the function for speed."
|
|
542
|
+
)
|
|
543
|
+
if code == "PREEMPTED":
|
|
544
|
+
return (
|
|
545
|
+
"A worker was preempted by a higher-priority run. "
|
|
546
|
+
"The input will be automatically re-queued."
|
|
547
|
+
)
|
|
548
|
+
if code == "GPU_QUOTA_EXCEEDED":
|
|
549
|
+
ns = details.get("namespace", "")
|
|
550
|
+
gpu_type = details.get("gpu_type", "")
|
|
551
|
+
return (
|
|
552
|
+
f"Namespace {ns!r} has exceeded its GPU quota for {gpu_type!r}. "
|
|
553
|
+
"Wait for quota to free up, or ask an admin to raise the limit."
|
|
554
|
+
)
|
|
555
|
+
return ""
|
bladekit/_preempt.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""``@app.on_preempt`` / ``@bladekit.on_preempt`` hook + checkpoint helpers.
|
|
2
|
+
|
|
3
|
+
Phase A formalises the soft-preemption checkpoint hook that runner-protocol.md
|
|
4
|
+
§15.2 left as "le hook checkpoint quand il existera". The SDK exposes:
|
|
5
|
+
|
|
6
|
+
- a decorator — ``@app.on_preempt`` (or its alias ``@bladekit.on_preempt``) — that
|
|
7
|
+
registers ONE zero-arg callback to run when the container is being preempted
|
|
8
|
+
(SIGTERM + ``FABRIC_PREEMPT_GRACE_SECONDS``, runner-protocol.md §15.2.1);
|
|
9
|
+
- ``bladekit.save_checkpoint(obj, path)`` / ``bladekit.load_checkpoint(path,
|
|
10
|
+
default=None)`` — the idempotent-resume primitives, writing/reading a
|
|
11
|
+
cloudpickle blob on a mounted Volume (the Volume owns durability).
|
|
12
|
+
|
|
13
|
+
Hook discovery is the SAME mechanism as ``@bladekit.enter`` (``_cls.py``): the
|
|
14
|
+
decorator marks the callable with the dunder attribute below, and the runner
|
|
15
|
+
scans for it (the bundle module for a free function, the live instance for an
|
|
16
|
+
``@app.cls`` method). That attribute is THE SDK↔runner contract — see also
|
|
17
|
+
``forge_runner.preempt``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import tempfile
|
|
24
|
+
from typing import Any, Callable, Optional
|
|
25
|
+
|
|
26
|
+
# SDK↔runner contract (runner-protocol.md §15.2.1): the decorator marks the
|
|
27
|
+
# callback with this attribute; the runner discovers it the same way it finds
|
|
28
|
+
# ``@bladekit.enter`` hooks (``__forge_enter__``). A method on an ``@app.cls`` class
|
|
29
|
+
# is found on the live instance; a free function is found in the bundle module.
|
|
30
|
+
FORGE_ON_PREEMPT_ATTR = "__forge_on_preempt__"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def on_preempt(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
34
|
+
"""Register ``fn`` to run when the container is being preempted.
|
|
35
|
+
|
|
36
|
+
Usable as ``@bladekit.on_preempt`` on a free function or on an ``@app.cls``
|
|
37
|
+
method (``self`` is supplied — the checkpoint can read model/optimizer state
|
|
38
|
+
off the live instance), and re-exported as ``@app.on_preempt``. The callback
|
|
39
|
+
takes no argument (besides ``self`` for a method) and is executed
|
|
40
|
+
best-effort exactly once on the SIGTERM grace window, BEFORE the process
|
|
41
|
+
exits (runner-protocol.md §15.2.1). The last decorated callback wins
|
|
42
|
+
(deterministic); the hook decides nothing about re-queue/retry — the control
|
|
43
|
+
plane stays the only judge.
|
|
44
|
+
"""
|
|
45
|
+
fn.__forge_on_preempt__ = True # type: ignore[attr-defined]
|
|
46
|
+
return fn
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def save_checkpoint(obj: Any, path: str) -> None:
|
|
50
|
+
"""Serialize ``obj`` (cloudpickle) and write it atomically to ``path``.
|
|
51
|
+
|
|
52
|
+
``path`` should point at a file on a mounted Volume: the Volume — not
|
|
53
|
+
this helper — owns durability. For a read-write volume the upperdir persists
|
|
54
|
+
for the run; ``volume.commit()`` (runner-protocol.md §11) persists it across
|
|
55
|
+
runs. The write is atomic (tmp file + ``os.replace``) so a preemption mid-
|
|
56
|
+
write never leaves a torn checkpoint that ``load_checkpoint`` would choke on.
|
|
57
|
+
"""
|
|
58
|
+
from ._serialize import dumps
|
|
59
|
+
|
|
60
|
+
raw = dumps(obj)
|
|
61
|
+
directory = os.path.dirname(os.path.abspath(path)) or "."
|
|
62
|
+
os.makedirs(directory, exist_ok=True)
|
|
63
|
+
# Write to a temp file in the SAME directory (os.replace is only atomic
|
|
64
|
+
# within a filesystem) then rename over the target.
|
|
65
|
+
fd, tmp = tempfile.mkstemp(prefix=".forge-ckpt-", dir=directory)
|
|
66
|
+
try:
|
|
67
|
+
with os.fdopen(fd, "wb") as f:
|
|
68
|
+
f.write(raw)
|
|
69
|
+
f.flush()
|
|
70
|
+
os.fsync(f.fileno())
|
|
71
|
+
os.replace(tmp, path)
|
|
72
|
+
except BaseException:
|
|
73
|
+
# Best-effort cleanup of the temp file on any failure.
|
|
74
|
+
try:
|
|
75
|
+
os.unlink(tmp)
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
78
|
+
raise
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_checkpoint(path: str, default: Optional[Any] = None) -> Any:
|
|
82
|
+
"""Read+deserialize the checkpoint at ``path``, else return ``default``.
|
|
83
|
+
|
|
84
|
+
The idempotent-resume primitive: call it at function start. A missing file
|
|
85
|
+
(no checkpoint yet — the first attempt) returns ``default`` instead of
|
|
86
|
+
raising, so resume code reads "load the last checkpoint or start fresh".
|
|
87
|
+
"""
|
|
88
|
+
from ._serialize import loads
|
|
89
|
+
|
|
90
|
+
if not os.path.exists(path):
|
|
91
|
+
return default
|
|
92
|
+
with open(path, "rb") as f:
|
|
93
|
+
raw = f.read()
|
|
94
|
+
return loads(raw)
|