flashruntime 0.3.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.
Files changed (95) hide show
  1. flashml_workloads/__init__.py +7 -0
  2. flashml_workloads/fedavg_driver.py +569 -0
  3. flashml_workloads/fedavg_weights.py +223 -0
  4. flashml_workloads/fedavg_worker.py +166 -0
  5. flashml_workloads/kmeans_driver.py +134 -0
  6. flashml_workloads/kmeans_shard.py +69 -0
  7. flashml_workloads/sgd_trainer.py +127 -0
  8. flashml_workloads/sharded_kmeans.py +323 -0
  9. flashml_workloads/sklearn_trial.py +89 -0
  10. flashruntime/__init__.py +125 -0
  11. flashruntime/artifacts/__init__.py +25 -0
  12. flashruntime/artifacts/store.py +228 -0
  13. flashruntime/backends/__init__.py +26 -0
  14. flashruntime/backends/base.py +63 -0
  15. flashruntime/backends/kuberay.py +465 -0
  16. flashruntime/checkpoint/__init__.py +20 -0
  17. flashruntime/checkpoint/catalog.py +198 -0
  18. flashruntime/checkpoint/local.py +109 -0
  19. flashruntime/checkpoint/store.py +86 -0
  20. flashruntime/integrations/__init__.py +5 -0
  21. flashruntime/integrations/huggingface.py +59 -0
  22. flashruntime/integrations/pytorch.py +52 -0
  23. flashruntime/integrations/sklearn.py +42 -0
  24. flashruntime/launchers/__init__.py +130 -0
  25. flashruntime/launchers/local.py +126 -0
  26. flashruntime/leases/__init__.py +27 -0
  27. flashruntime/leases/manager.py +365 -0
  28. flashruntime/leases/sqlite_store.py +169 -0
  29. flashruntime/leases/store.py +103 -0
  30. flashruntime/monitor/__init__.py +7 -0
  31. flashruntime/monitor/sampler.py +232 -0
  32. flashruntime/planner/__init__.py +56 -0
  33. flashruntime/planner/candidates.py +597 -0
  34. flashruntime/planner/catalog.py +129 -0
  35. flashruntime/planner/comm.py +95 -0
  36. flashruntime/planner/explain.py +109 -0
  37. flashruntime/planner/memory.py +166 -0
  38. flashruntime/planner/resolve.py +120 -0
  39. flashruntime/planner/selector.py +169 -0
  40. flashruntime/planner/timecost.py +81 -0
  41. flashruntime/profiling/__init__.py +113 -0
  42. flashruntime/protocol/__init__.py +18 -0
  43. flashruntime/protocol/plan_v1alpha1.py +320 -0
  44. flashruntime/protocol/v1alpha1.py +465 -0
  45. flashruntime/providers/__init__.py +138 -0
  46. flashruntime/py.typed +0 -0
  47. flashruntime/recipes/__init__.py +135 -0
  48. flashruntime/recipes/command.py +166 -0
  49. flashruntime/recovery/__init__.py +21 -0
  50. flashruntime/recovery/policy.py +170 -0
  51. flashruntime/recovery/signals.py +135 -0
  52. flashruntime/recovery/taxonomy.py +91 -0
  53. flashruntime/scheduler/__init__.py +170 -0
  54. flashruntime/sdk.py +402 -0
  55. flashruntime/service/__init__.py +3 -0
  56. flashruntime/service/app.py +391 -0
  57. flashruntime/service/auth.py +180 -0
  58. flashruntime/service/checkpoints.py +90 -0
  59. flashruntime/service/cli.py +167 -0
  60. flashruntime/service/dashboard.py +193 -0
  61. flashruntime/service/ledger.py +101 -0
  62. flashruntime/service/modea.py +821 -0
  63. flashruntime/strategies/__init__.py +156 -0
  64. flashruntime/strategies/command.py +56 -0
  65. flashruntime/torch/__init__.py +274 -0
  66. flashruntime/viewer/__init__.py +20 -0
  67. flashruntime/viewer/_docs/benchmarks.html +771 -0
  68. flashruntime/viewer/_docs/concepts/architecture.html +302 -0
  69. flashruntime/viewer/_docs/get-started.html +263 -0
  70. flashruntime/viewer/_docs/guides/federated-averaging.html +363 -0
  71. flashruntime/viewer/_docs/guides/huggingface.html +223 -0
  72. flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +271 -0
  73. flashruntime/viewer/_docs/guides/pytorch.html +313 -0
  74. flashruntime/viewer/_docs/guides/sklearn.html +232 -0
  75. flashruntime/viewer/_docs/index.html +251 -0
  76. flashruntime/viewer/_docs/reference/cli.html +254 -0
  77. flashruntime/viewer/_docs/reference/integrations.html +240 -0
  78. flashruntime/viewer/_docs/reference/sdk.html +341 -0
  79. flashruntime/viewer/_docs/reference/torch-helper.html +244 -0
  80. flashruntime/viewer/_docs/search-index.json +1 -0
  81. flashruntime/viewer/_docs/tutorials/convnet.html +571 -0
  82. flashruntime/viewer/_docs/tutorials/fault-tolerance.html +375 -0
  83. flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +278 -0
  84. flashruntime/viewer/flowmap.py +307 -0
  85. flashruntime/viewer/page.py +594 -0
  86. flashruntime/viewer/server.py +134 -0
  87. flashruntime/viewer/state.py +250 -0
  88. flashruntime/workloads/__init__.py +6 -0
  89. flashruntime/workloads/command.py +127 -0
  90. flashruntime-0.3.0.dist-info/METADATA +365 -0
  91. flashruntime-0.3.0.dist-info/RECORD +95 -0
  92. flashruntime-0.3.0.dist-info/WHEEL +5 -0
  93. flashruntime-0.3.0.dist-info/entry_points.txt +2 -0
  94. flashruntime-0.3.0.dist-info/licenses/LICENSE +202 -0
  95. flashruntime-0.3.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,465 @@
1
+ """KubeRay execution backend: FlashRuntime JobSpec -> RayJob custom resource.
2
+
3
+ Responsibilities (and nothing more):
4
+ - validate a JobSpec against this deployment profile;
5
+ - render the RayJob manifest (`build_rayjob_manifest` is pure and unit-tested
6
+ without a cluster);
7
+ - create/read/delete the RayJob via the Kubernetes API;
8
+ - map RayJob + pod signals onto FlashRuntime `JobState` and `Event`s.
9
+
10
+ Ray task scheduling, pod lifecycle, and image building belong to Ray,
11
+ KubeRay, and the build pipeline respectively — never to this module.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import json
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime, timezone
20
+ from typing import Any, AsyncIterator
21
+
22
+ from flashruntime.backends.base import (
23
+ BackendExecution,
24
+ BackendStatus,
25
+ BackendUnavailableError,
26
+ SpecValidationError,
27
+ )
28
+ from flashruntime.protocol.v1alpha1 import (
29
+ LABEL_BACKEND,
30
+ LABEL_JOB_ID,
31
+ LABEL_PROFILE,
32
+ LABEL_RUNTIME_ID,
33
+ ArtifactRecord,
34
+ Event,
35
+ EventType,
36
+ JobRecord,
37
+ JobSpec,
38
+ JobState,
39
+ )
40
+
41
+ RAYJOB_GROUP = "ray.io"
42
+ RAYJOB_VERSION = "v1"
43
+ RAYJOB_PLURAL = "rayjobs"
44
+
45
+ SANDBOX_UNAVAILABLE_MSG = (
46
+ "Sandboxed execution is unavailable in the local profile. "
47
+ "Use a compatible ACK secure node pool."
48
+ )
49
+
50
+
51
+ @dataclass
52
+ class KubeRayBackendConfig:
53
+ """Deployment-side configuration. Everything cloud- or cluster-specific
54
+ (namespaces, node selectors, RuntimeClass names, secret names) lives here,
55
+ never in the public JobSpec."""
56
+
57
+ namespace: str = "flashml"
58
+ deployment_profile: str = "local" # "local" | "alibaba-ack"
59
+ ray_version: str = "2.46.0"
60
+ # Prepended to the JobSpec's repository:tag — "localhost:5001/" for the
61
+ # local Kind registry, the ACR endpoint + namespace on Alibaba. Keeps
62
+ # registry endpoints out of the public JobSpec.
63
+ image_registry_prefix: str = ""
64
+ # Node selection for standard workloads (the simulated compute workers
65
+ # locally; the standard node pool on ACK).
66
+ standard_node_selector: dict[str, str] = field(
67
+ default_factory=lambda: {"flashml.dev/compute": "true"}
68
+ )
69
+ # Secure pool wiring; empty means the profile has no sandbox tier.
70
+ sandbox_node_selector: dict[str, str] = field(default_factory=dict)
71
+ sandbox_runtime_class: str | None = None
72
+ sandbox_tolerations: list[dict[str, Any]] = field(default_factory=list)
73
+ # Plain env vars injected into head/worker containers (artifact endpoint,
74
+ # bucket, deployment profile, ...). Secret-backed vars go via env_secret.
75
+ extra_env: dict[str, str] = field(default_factory=dict)
76
+ env_secret: str | None = None # name of a Secret mounted via envFrom
77
+ ttl_seconds_after_finished: int = 600
78
+ active_deadline_seconds: int = 1800
79
+ image_pull_policy: str = "IfNotPresent"
80
+ head_memory: str = "1536Mi"
81
+ worker_memory: str = "1536Mi"
82
+ worker_cpu: str = "1"
83
+
84
+
85
+ def _workload_entrypoint(job: JobRecord) -> str:
86
+ """The container image owns the workload code; the entrypoint convention
87
+ is `python -m flashml_workloads.<type>`. Parameters travel as one JSON
88
+ env var so the entrypoint stays stable across workloads."""
89
+ workload = job.spec.spec.workload
90
+ return f"python -m flashml_workloads.{workload.type}"
91
+
92
+
93
+ def build_rayjob_manifest(job: JobRecord, cfg: KubeRayBackendConfig) -> dict[str, Any]:
94
+ """Pure translation of a FlashRuntime job to a RayJob manifest."""
95
+ spec = job.spec.spec
96
+ labels = {
97
+ LABEL_JOB_ID: job.job_id,
98
+ LABEL_RUNTIME_ID: job.runtime_execution_id or rayjob_name(job.job_id),
99
+ LABEL_BACKEND: "kuberay",
100
+ LABEL_PROFILE: cfg.deployment_profile,
101
+ }
102
+
103
+ env = [
104
+ {"name": "FLASHML_JOB_ID", "value": job.job_id},
105
+ {"name": "FLASHML_DEPLOYMENT_PROFILE", "value": cfg.deployment_profile},
106
+ {"name": "FLASHML_WORKLOAD_PARAMS", "value": json.dumps(spec.workload.parameters)},
107
+ {"name": "FLASHML_MAX_TASK_ATTEMPTS", "value": str(spec.retryPolicy.maxTaskAttempts)},
108
+ {"name": "FLASHML_ARTIFACT_PREFIX", "value": spec.artifacts.outputPrefix.replace(
109
+ "{job_id}", job.job_id
110
+ )},
111
+ ] + [{"name": k, "value": v} for k, v in sorted(cfg.extra_env.items())]
112
+
113
+ env_from = (
114
+ [{"secretRef": {"name": cfg.env_secret}}] if cfg.env_secret else []
115
+ )
116
+
117
+ if spec.isolation.tier == "sandboxed":
118
+ node_selector = dict(cfg.sandbox_node_selector)
119
+ runtime_class = cfg.sandbox_runtime_class
120
+ tolerations = list(cfg.sandbox_tolerations)
121
+ else:
122
+ node_selector = dict(cfg.standard_node_selector)
123
+ runtime_class = None
124
+ tolerations = []
125
+
126
+ # Downward-API identity so Ray tasks can report which Kubernetes node
127
+ # and pod they actually ran on (recovery evidence, node contributions).
128
+ downward_env = [
129
+ {"name": "K8S_NODE_NAME", "valueFrom": {"fieldRef": {"fieldPath": "spec.nodeName"}}},
130
+ {"name": "K8S_POD_NAME", "valueFrom": {"fieldRef": {"fieldPath": "metadata.name"}}},
131
+ ]
132
+
133
+ def pod_template(cpu: str, memory: str, is_head: bool) -> dict[str, Any]:
134
+ pod_spec: dict[str, Any] = {
135
+ "containers": [
136
+ {
137
+ "name": "ray-head" if is_head else "ray-worker",
138
+ "image": f"{cfg.image_registry_prefix}{spec.image.reference}",
139
+ "imagePullPolicy": cfg.image_pull_policy,
140
+ "env": env + downward_env,
141
+ **({"envFrom": env_from} if env_from else {}),
142
+ "resources": {
143
+ "requests": {"cpu": cpu, "memory": memory},
144
+ "limits": {"cpu": cpu, "memory": memory},
145
+ },
146
+ }
147
+ ],
148
+ }
149
+ if not is_head:
150
+ # Spread workers across nodes (preferred, not required) so the
151
+ # demo genuinely exercises multiple machines.
152
+ pod_spec["affinity"] = {
153
+ "podAntiAffinity": {
154
+ "preferredDuringSchedulingIgnoredDuringExecution": [
155
+ {
156
+ "weight": 100,
157
+ "podAffinityTerm": {
158
+ "topologyKey": "kubernetes.io/hostname",
159
+ "labelSelector": {
160
+ "matchLabels": {LABEL_JOB_ID: job.job_id}
161
+ },
162
+ },
163
+ }
164
+ ]
165
+ }
166
+ }
167
+ if node_selector:
168
+ pod_spec["nodeSelector"] = node_selector
169
+ if runtime_class:
170
+ pod_spec["runtimeClassName"] = runtime_class
171
+ if tolerations:
172
+ pod_spec["tolerations"] = tolerations
173
+ return {"metadata": {"labels": labels}, "spec": pod_spec}
174
+
175
+ return {
176
+ "apiVersion": f"{RAYJOB_GROUP}/{RAYJOB_VERSION}",
177
+ "kind": "RayJob",
178
+ "metadata": {
179
+ "name": job.runtime_execution_id or rayjob_name(job.job_id),
180
+ "namespace": cfg.namespace,
181
+ "labels": labels,
182
+ },
183
+ "spec": {
184
+ "entrypoint": _workload_entrypoint(job),
185
+ "shutdownAfterJobFinishes": True,
186
+ "ttlSecondsAfterFinished": cfg.ttl_seconds_after_finished,
187
+ "activeDeadlineSeconds": cfg.active_deadline_seconds,
188
+ "rayClusterSpec": {
189
+ "rayVersion": cfg.ray_version,
190
+ "headGroupSpec": {
191
+ # Head schedules no tasks (num-cpus: 0): compute happens
192
+ # only on the worker group, i.e. the simulated devices.
193
+ "rayStartParams": {"num-cpus": "0"},
194
+ "template": pod_template("1", cfg.head_memory, is_head=True),
195
+ },
196
+ "workerGroupSpecs": [
197
+ {
198
+ "groupName": "workers",
199
+ "replicas": spec.resources.maximumWorkers,
200
+ "minReplicas": spec.resources.minimumWorkers,
201
+ "maxReplicas": spec.resources.maximumWorkers,
202
+ "rayStartParams": {},
203
+ "template": pod_template(
204
+ cfg.worker_cpu, cfg.worker_memory, is_head=False
205
+ ),
206
+ }
207
+ ],
208
+ },
209
+ },
210
+ }
211
+
212
+
213
+ def rayjob_name(job_id: str) -> str:
214
+ return f"flashml-{job_id}"
215
+
216
+
217
+ def map_rayjob_status(status: dict[str, Any]) -> BackendStatus:
218
+ """Map RayJob .status onto FlashRuntime JobState.
219
+
220
+ KubeRay reports two axes: jobDeploymentStatus (cluster/provisioning) and
221
+ jobStatus (the Ray job itself). jobStatus wins once present.
222
+ """
223
+ deployment = status.get("jobDeploymentStatus", "")
224
+ ray_status = status.get("jobStatus", "")
225
+
226
+ if ray_status == "SUCCEEDED":
227
+ state = JobState.SUCCEEDED
228
+ elif ray_status == "FAILED" or deployment == "Failed":
229
+ state = JobState.FAILED
230
+ elif ray_status == "STOPPED":
231
+ state = JobState.CANCELLED
232
+ elif ray_status == "RUNNING":
233
+ state = JobState.RUNNING
234
+ elif ray_status == "PENDING":
235
+ state = JobState.SUBMITTED
236
+ elif deployment in ("Initializing", "Waiting", ""):
237
+ state = JobState.SUBMITTED
238
+ elif deployment in ("Running", "Complete"):
239
+ # Deployment running but Ray job not started/reported yet.
240
+ state = JobState.RUNNING if deployment == "Running" else JobState.SUCCEEDED
241
+ else:
242
+ state = JobState.SUBMITTED
243
+
244
+ reason = status.get("message", "") or f"deployment={deployment} job={ray_status}"
245
+ return BackendStatus(state=state, reason=reason, raw=dict(status))
246
+
247
+
248
+ class KubeRayExecutionBackend:
249
+ """ExecutionBackend implementation backed by the KubeRay operator.
250
+
251
+ Uses the official kubernetes client (sync) behind asyncio.to_thread —
252
+ call volume is tiny (submit/poll/logs), so thread offloading beats an
253
+ extra async client dependency.
254
+ """
255
+
256
+ name = "kuberay"
257
+
258
+ def __init__(self, cfg: KubeRayBackendConfig | None = None):
259
+ self.cfg = cfg or KubeRayBackendConfig()
260
+ self._api = None # lazily constructed CustomObjectsApi
261
+ self._core = None # lazily constructed CoreV1Api
262
+
263
+ # -- kubernetes client plumbing ----------------------------------------
264
+
265
+ def _ensure_clients(self):
266
+ if self._api is None:
267
+ from kubernetes import client, config as kube_config
268
+
269
+ try:
270
+ kube_config.load_incluster_config()
271
+ except Exception:
272
+ try:
273
+ kube_config.load_kube_config()
274
+ except Exception as exc: # pragma: no cover
275
+ raise BackendUnavailableError(
276
+ f"no Kubernetes configuration available: {exc}"
277
+ ) from exc
278
+ self._api = client.CustomObjectsApi()
279
+ self._core = client.CoreV1Api()
280
+ return self._api, self._core
281
+
282
+ # -- ExecutionBackend --------------------------------------------------
283
+
284
+ async def validate(self, spec: JobSpec) -> None:
285
+ if spec.spec.execution.backend != "ray":
286
+ raise SpecValidationError(
287
+ f"backend '{spec.spec.execution.backend}' is not supported; "
288
+ "supported backends: ray"
289
+ )
290
+ if spec.spec.isolation.tier == "sandboxed" and not self.cfg.sandbox_node_selector:
291
+ if spec.spec.isolation.allowFallback:
292
+ return # falls back to standard tier at submit time
293
+ raise SpecValidationError(SANDBOX_UNAVAILABLE_MSG)
294
+
295
+ async def submit(self, job: JobRecord) -> BackendExecution:
296
+ api, _ = self._ensure_clients()
297
+ name = rayjob_name(job.job_id)
298
+ job.runtime_execution_id = name
299
+ manifest = build_rayjob_manifest(job, self.cfg)
300
+
301
+ def _create():
302
+ return api.create_namespaced_custom_object(
303
+ RAYJOB_GROUP, RAYJOB_VERSION, self.cfg.namespace, RAYJOB_PLURAL, manifest
304
+ )
305
+
306
+ await asyncio.to_thread(_create)
307
+ return BackendExecution(
308
+ execution_id=name,
309
+ backend=self.name,
310
+ submitted_at=datetime.now(timezone.utc),
311
+ details={"namespace": self.cfg.namespace, "manifest": manifest},
312
+ )
313
+
314
+ async def get_status(self, execution_id: str) -> BackendStatus:
315
+ api, _ = self._ensure_clients()
316
+
317
+ def _get():
318
+ return api.get_namespaced_custom_object(
319
+ RAYJOB_GROUP, RAYJOB_VERSION, self.cfg.namespace, RAYJOB_PLURAL, execution_id
320
+ )
321
+
322
+ obj = await asyncio.to_thread(_get)
323
+ return map_rayjob_status(obj.get("status", {}) or {})
324
+
325
+ async def stream_events(self, execution_id: str) -> AsyncIterator[Event]:
326
+ """Poll RayJob status + pod events and yield normalized FlashRuntime
327
+ events until the job reaches a terminal state. Every event carries its
328
+ raw source so recovery evidence stays honest."""
329
+ job_id = execution_id.removeprefix("flashml-")
330
+ seen_pods: dict[str, str] = {} # pod name -> phase
331
+ seen_event_uids: set[str] = set()
332
+ emitted_cluster_starting = False
333
+ last_state: JobState | None = None
334
+
335
+ while True:
336
+ status = await self.get_status(execution_id)
337
+ if not emitted_cluster_starting:
338
+ emitted_cluster_starting = True
339
+ yield Event(
340
+ job_id=job_id,
341
+ type=EventType.RAY_CLUSTER_STARTING,
342
+ source="kuberay.rayjob",
343
+ message="RayJob accepted by KubeRay; ephemeral Ray cluster starting",
344
+ data={"execution_id": execution_id},
345
+ )
346
+
347
+ # Pod lifecycle -> worker lost/replaced events.
348
+ async for ev in self._pod_events(execution_id, job_id, seen_pods, seen_event_uids):
349
+ yield ev
350
+
351
+ if status.state != last_state:
352
+ last_state = status.state
353
+ if status.state == JobState.SUCCEEDED:
354
+ yield Event(
355
+ job_id=job_id,
356
+ type=EventType.JOB_SUCCEEDED,
357
+ source="kuberay.rayjob",
358
+ message=status.reason,
359
+ data=status.raw,
360
+ )
361
+ return
362
+ if status.state == JobState.FAILED:
363
+ yield Event(
364
+ job_id=job_id,
365
+ type=EventType.JOB_FAILED,
366
+ source="kuberay.rayjob",
367
+ message=status.reason,
368
+ data=status.raw,
369
+ )
370
+ return
371
+ elif status.state.terminal:
372
+ return
373
+ await asyncio.sleep(2)
374
+
375
+ async def _pod_events(
376
+ self,
377
+ execution_id: str,
378
+ job_id: str,
379
+ seen_pods: dict[str, str],
380
+ seen_event_uids: set[str],
381
+ ) -> AsyncIterator[Event]:
382
+ _, core = self._ensure_clients()
383
+ selector = f"{LABEL_JOB_ID}={job_id}"
384
+
385
+ def _list():
386
+ return core.list_namespaced_pod(self.cfg.namespace, label_selector=selector)
387
+
388
+ pods = await asyncio.to_thread(_list)
389
+ current = {}
390
+ for pod in pods.items:
391
+ name = pod.metadata.name
392
+ phase = pod.status.phase or "Unknown"
393
+ deleting = pod.metadata.deletion_timestamp is not None
394
+ current[name] = phase
395
+ is_worker = "workers" in name
396
+ if name not in seen_pods:
397
+ if is_worker and seen_pods:
398
+ # A worker appearing after startup means KubeRay replaced
399
+ # a lost one (group replicas are fixed in the POC).
400
+ prior_workers = [p for p in seen_pods if "workers" in p]
401
+ if any(p not in current for p in prior_workers):
402
+ yield Event(
403
+ job_id=job_id,
404
+ type=EventType.RAY_WORKER_REPLACED,
405
+ source="kubernetes.pod",
406
+ message=f"replacement Ray worker pod {name} created",
407
+ data={"pod": name, "phase": phase},
408
+ )
409
+ elif deleting and seen_pods.get(name) != "Deleting" and is_worker:
410
+ current[name] = "Deleting"
411
+ yield Event(
412
+ job_id=job_id,
413
+ type=EventType.RAY_WORKER_LOST,
414
+ source="kubernetes.pod",
415
+ message=f"Ray worker pod {name} is terminating",
416
+ data={"pod": name},
417
+ )
418
+ for name in list(seen_pods):
419
+ if name not in current and "workers" in name:
420
+ if seen_pods[name] != "Deleting":
421
+ yield Event(
422
+ job_id=job_id,
423
+ type=EventType.RAY_WORKER_LOST,
424
+ source="kubernetes.pod",
425
+ message=f"Ray worker pod {name} disappeared",
426
+ data={"pod": name},
427
+ )
428
+ seen_pods.clear()
429
+ seen_pods.update(current)
430
+
431
+ async def get_logs(self, execution_id: str) -> str:
432
+ """Logs of the RayJob submitter pod (driver output)."""
433
+ _, core = self._ensure_clients()
434
+
435
+ def _logs() -> str:
436
+ pods = core.list_namespaced_pod(
437
+ self.cfg.namespace, label_selector=f"job-name={execution_id}"
438
+ )
439
+ chunks = []
440
+ for pod in pods.items:
441
+ try:
442
+ chunks.append(
443
+ core.read_namespaced_pod_log(pod.metadata.name, self.cfg.namespace)
444
+ )
445
+ except Exception as exc: # pod may be pending
446
+ chunks.append(f"[no logs from {pod.metadata.name}: {exc}]")
447
+ return "\n".join(chunks)
448
+
449
+ return await asyncio.to_thread(_logs)
450
+
451
+ async def cancel(self, execution_id: str) -> None:
452
+ api, _ = self._ensure_clients()
453
+
454
+ def _delete():
455
+ api.delete_namespaced_custom_object(
456
+ RAYJOB_GROUP, RAYJOB_VERSION, self.cfg.namespace, RAYJOB_PLURAL, execution_id
457
+ )
458
+
459
+ await asyncio.to_thread(_delete)
460
+
461
+ async def collect_artifacts(self, execution_id: str) -> list[ArtifactRecord]:
462
+ """Artifact records are written by the workload itself through the
463
+ ArtifactStore; the runtime service collects them from the store by
464
+ prefix (see flashruntime.artifacts). The backend has nothing to add."""
465
+ return []
@@ -0,0 +1,20 @@
1
+ """Checkpoint manifests and compatibility-aware selection.
2
+
3
+ A checkpoint is a manifest (job, attempt, step, world size, part hashes,
4
+ validation status), not just a path. Validity is by construction — parts
5
+ upload first, the manifest is written last after every hash verifies — so a
6
+ partial checkpoint can never be selected. Recovery restores only from
7
+ verified, topology-compatible manifests.
8
+
9
+ from flashruntime.checkpoint import CheckpointCatalog
10
+
11
+ catalog = CheckpointCatalog(on_event=ledger.append)
12
+ catalog.register_part(job, attempt, step, part) # after upload
13
+ manifest = catalog.commit(job_id=..., expected_parts=[...], ...)
14
+ catalog.latest_valid(job_id, world_size=4) # recovery's input
15
+ catalog.lost_work(job_id, failed_at_step=470) # → 70
16
+ """
17
+
18
+ from flashruntime.checkpoint.catalog import CheckpointCatalog, CheckpointError
19
+
20
+ __all__ = ["CheckpointCatalog", "CheckpointError"]
@@ -0,0 +1,198 @@
1
+ """The checkpoint catalog: validity by construction, selection by policy.
2
+
3
+ The core rule (evaluation §H): **parts first, manifest last**. Workers
4
+ upload shard files and register each one (key + sha256 + size). Only when a
5
+ manifest's *expected* part set exactly matches the *registered* parts —
6
+ every key present, every hash equal — does `commit()` store the manifest
7
+ with `hash_verified` status. Until that moment the checkpoint does not
8
+ exist: a crash mid-upload leaves orphan parts, never a selectable
9
+ checkpoint. There is no code path that can mark a partial checkpoint valid,
10
+ which is stronger than any check.
11
+
12
+ Validation ladder: `hash_verified` (cheap, automatic) → `restore_verified`
13
+ (a real load succeeded — recovery prefers these) → `invalid` (quarantined;
14
+ never selectable again).
15
+
16
+ Selection: `latest_valid()` returns the newest-by-step manifest that is
17
+ (a) verified, (b) compatible with the requested world size (its own, or one
18
+ listed in `compatible_world_sizes` — resharding territory), and (c) not
19
+ quarantined. Recovery restores from that or reports honest lost work.
20
+
21
+ Storage note: this catalog is deliberately in-memory + event-emitting; the
22
+ FlashRuntime service persists it by replaying the same events into its
23
+ ledger. The blob bytes themselves live in the artifact store — the catalog
24
+ holds *metadata about* checkpoints, never checkpoint data.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import uuid
30
+ from datetime import datetime, timezone
31
+ from typing import Callable
32
+
33
+ from flashruntime.protocol.v1alpha1 import (
34
+ CheckpointManifest,
35
+ CheckpointPart,
36
+ CheckpointValidation,
37
+ Event,
38
+ EventType,
39
+ )
40
+
41
+ EventSink = Callable[[Event], None]
42
+
43
+
44
+ class CheckpointError(Exception):
45
+ """Refused catalog operation (mismatched parts, unknown manifest, ...)."""
46
+
47
+
48
+ class CheckpointCatalog:
49
+ def __init__(self, on_event: EventSink | None = None) -> None:
50
+ self._on_event = on_event
51
+ self._manifests: dict[str, CheckpointManifest] = {}
52
+ # (job_id, attempt_id, step) -> {part_key: (sha256, size_bytes)}
53
+ self._registered: dict[tuple[str, str, int], dict[str, tuple[str, int]]] = {}
54
+
55
+ # -- upload side --------------------------------------------------------
56
+
57
+ def register_part(
58
+ self, job_id: str, attempt_id: str, step: int, part: CheckpointPart
59
+ ) -> None:
60
+ """Record one uploaded shard file (call after the bytes are durably
61
+ in the artifact store, with the hash the store reported)."""
62
+ bucket = self._registered.setdefault((job_id, attempt_id, step), {})
63
+ bucket[part.key] = (part.sha256, part.size_bytes)
64
+
65
+ def commit(
66
+ self,
67
+ *,
68
+ job_id: str,
69
+ attempt_id: str,
70
+ step: int,
71
+ expected_parts: list[CheckpointPart],
72
+ storage_prefix: str,
73
+ world_size: int = 1,
74
+ compatible_world_sizes: list[int] | None = None,
75
+ framework: str = "",
76
+ strategy_family: str = "",
77
+ checkpoint_duration_s: float | None = None,
78
+ ) -> CheckpointManifest:
79
+ """Write the manifest — the two-phase-commit point.
80
+
81
+ Verifies every expected part against what was registered; any
82
+ missing key or hash mismatch raises (and emits CHECKPOINT_REJECTED),
83
+ leaving no manifest behind.
84
+ """
85
+ registered = self._registered.get((job_id, attempt_id, step), {})
86
+ problems: list[str] = []
87
+ for part in expected_parts:
88
+ got = registered.get(part.key)
89
+ if got is None:
90
+ problems.append(f"missing part {part.key}")
91
+ elif got[0] != part.sha256:
92
+ problems.append(f"hash mismatch for {part.key}")
93
+ if not expected_parts:
94
+ problems.append("manifest with zero parts")
95
+ if problems:
96
+ self._emit(
97
+ EventType.CHECKPOINT_REJECTED,
98
+ job_id,
99
+ detail=f"step {step}: " + "; ".join(problems),
100
+ )
101
+ raise CheckpointError("; ".join(problems))
102
+
103
+ manifest = CheckpointManifest(
104
+ manifest_id=f"ck-{uuid.uuid4().hex[:12]}",
105
+ job_id=job_id,
106
+ attempt_id=attempt_id,
107
+ step=step,
108
+ framework=framework,
109
+ strategy_family=strategy_family,
110
+ world_size=world_size,
111
+ compatible_world_sizes=compatible_world_sizes or [world_size],
112
+ storage_prefix=storage_prefix,
113
+ parts=expected_parts,
114
+ validation=CheckpointValidation.HASH_VERIFIED,
115
+ )
116
+ if checkpoint_duration_s is not None:
117
+ manifest.checkpoint_duration_s = checkpoint_duration_s
118
+ self._manifests[manifest.manifest_id] = manifest
119
+ self._emit(
120
+ EventType.CHECKPOINT_MANIFEST_COMMITTED,
121
+ job_id,
122
+ detail=f"step {step}, {len(expected_parts)} part(s), world_size {world_size}",
123
+ )
124
+ return manifest
125
+
126
+ # -- validation ladder --------------------------------------------------
127
+
128
+ def mark_restore_verified(self, manifest_id: str) -> None:
129
+ """Upgrade after a real, successful load (e.g. the profiling stage's
130
+ save/restore cycle, or a completed recovery)."""
131
+ manifest = self._require(manifest_id)
132
+ manifest.validation = CheckpointValidation.RESTORE_VERIFIED
133
+ self._emit(EventType.CHECKPOINT_RESTORE_VERIFIED, manifest.job_id, detail=f"step {manifest.step}")
134
+
135
+ def quarantine(self, manifest_id: str, reason: str) -> None:
136
+ """Mark invalid (e.g. restore failed, producer node untrusted). A
137
+ quarantined manifest is never selectable again."""
138
+ manifest = self._require(manifest_id)
139
+ manifest.validation = CheckpointValidation.INVALID
140
+ self._emit(EventType.CHECKPOINT_REJECTED, manifest.job_id, detail=f"quarantined step {manifest.step}: {reason}")
141
+
142
+ # -- selection ----------------------------------------------------------
143
+
144
+ def latest_valid(
145
+ self,
146
+ job_id: str,
147
+ world_size: int | None = None,
148
+ require_restore_verified: bool = False,
149
+ ) -> CheckpointManifest | None:
150
+ """Newest verified, topology-compatible manifest — recovery's input.
151
+ Ties on step break toward restore-verified, then newest creation."""
152
+ acceptable = (
153
+ (CheckpointValidation.RESTORE_VERIFIED,)
154
+ if require_restore_verified
155
+ else (CheckpointValidation.HASH_VERIFIED, CheckpointValidation.RESTORE_VERIFIED)
156
+ )
157
+ candidates = [
158
+ m
159
+ for m in self._manifests.values()
160
+ if m.job_id == job_id
161
+ and m.validation in acceptable
162
+ and (world_size is None or world_size in m.compatible_world_sizes)
163
+ ]
164
+ if not candidates:
165
+ return None
166
+ return max(
167
+ candidates,
168
+ key=lambda m: (m.step, m.validation == CheckpointValidation.RESTORE_VERIFIED, m.created),
169
+ )
170
+
171
+ def lost_work(self, job_id: str, failed_at_step: int, world_size: int | None = None) -> int | None:
172
+ """Steps lost if recovery happens now — the recovery-economics
173
+ number ("killed at 470, resume from 400 ⇒ 70 lost")."""
174
+ manifest = self.latest_valid(job_id, world_size)
175
+ if manifest is None:
176
+ return None
177
+ return max(0, failed_at_step - manifest.step)
178
+
179
+ # -- internals ----------------------------------------------------------
180
+
181
+ def _require(self, manifest_id: str) -> CheckpointManifest:
182
+ manifest = self._manifests.get(manifest_id)
183
+ if manifest is None:
184
+ raise CheckpointError(f"unknown manifest {manifest_id}")
185
+ return manifest
186
+
187
+ def _emit(self, event_type: EventType, job_id: str, detail: str) -> None:
188
+ if self._on_event is None:
189
+ return
190
+ self._on_event(
191
+ Event(
192
+ type=event_type,
193
+ job_id=job_id,
194
+ source="flashruntime.checkpoint",
195
+ message=detail,
196
+ timestamp=datetime.now(timezone.utc),
197
+ )
198
+ )