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/_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)