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.
- flashml_workloads/__init__.py +7 -0
- flashml_workloads/fedavg_driver.py +569 -0
- flashml_workloads/fedavg_weights.py +223 -0
- flashml_workloads/fedavg_worker.py +166 -0
- flashml_workloads/kmeans_driver.py +134 -0
- flashml_workloads/kmeans_shard.py +69 -0
- flashml_workloads/sgd_trainer.py +127 -0
- flashml_workloads/sharded_kmeans.py +323 -0
- flashml_workloads/sklearn_trial.py +89 -0
- flashruntime/__init__.py +125 -0
- flashruntime/artifacts/__init__.py +25 -0
- flashruntime/artifacts/store.py +228 -0
- flashruntime/backends/__init__.py +26 -0
- flashruntime/backends/base.py +63 -0
- flashruntime/backends/kuberay.py +465 -0
- flashruntime/checkpoint/__init__.py +20 -0
- flashruntime/checkpoint/catalog.py +198 -0
- flashruntime/checkpoint/local.py +109 -0
- flashruntime/checkpoint/store.py +86 -0
- flashruntime/integrations/__init__.py +5 -0
- flashruntime/integrations/huggingface.py +59 -0
- flashruntime/integrations/pytorch.py +52 -0
- flashruntime/integrations/sklearn.py +42 -0
- flashruntime/launchers/__init__.py +130 -0
- flashruntime/launchers/local.py +126 -0
- flashruntime/leases/__init__.py +27 -0
- flashruntime/leases/manager.py +365 -0
- flashruntime/leases/sqlite_store.py +169 -0
- flashruntime/leases/store.py +103 -0
- flashruntime/monitor/__init__.py +7 -0
- flashruntime/monitor/sampler.py +232 -0
- flashruntime/planner/__init__.py +56 -0
- flashruntime/planner/candidates.py +597 -0
- flashruntime/planner/catalog.py +129 -0
- flashruntime/planner/comm.py +95 -0
- flashruntime/planner/explain.py +109 -0
- flashruntime/planner/memory.py +166 -0
- flashruntime/planner/resolve.py +120 -0
- flashruntime/planner/selector.py +169 -0
- flashruntime/planner/timecost.py +81 -0
- flashruntime/profiling/__init__.py +113 -0
- flashruntime/protocol/__init__.py +18 -0
- flashruntime/protocol/plan_v1alpha1.py +320 -0
- flashruntime/protocol/v1alpha1.py +465 -0
- flashruntime/providers/__init__.py +138 -0
- flashruntime/py.typed +0 -0
- flashruntime/recipes/__init__.py +135 -0
- flashruntime/recipes/command.py +166 -0
- flashruntime/recovery/__init__.py +21 -0
- flashruntime/recovery/policy.py +170 -0
- flashruntime/recovery/signals.py +135 -0
- flashruntime/recovery/taxonomy.py +91 -0
- flashruntime/scheduler/__init__.py +170 -0
- flashruntime/sdk.py +402 -0
- flashruntime/service/__init__.py +3 -0
- flashruntime/service/app.py +391 -0
- flashruntime/service/auth.py +180 -0
- flashruntime/service/checkpoints.py +90 -0
- flashruntime/service/cli.py +167 -0
- flashruntime/service/dashboard.py +193 -0
- flashruntime/service/ledger.py +101 -0
- flashruntime/service/modea.py +821 -0
- flashruntime/strategies/__init__.py +156 -0
- flashruntime/strategies/command.py +56 -0
- flashruntime/torch/__init__.py +274 -0
- flashruntime/viewer/__init__.py +20 -0
- flashruntime/viewer/_docs/benchmarks.html +771 -0
- flashruntime/viewer/_docs/concepts/architecture.html +302 -0
- flashruntime/viewer/_docs/get-started.html +263 -0
- flashruntime/viewer/_docs/guides/federated-averaging.html +363 -0
- flashruntime/viewer/_docs/guides/huggingface.html +223 -0
- flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +271 -0
- flashruntime/viewer/_docs/guides/pytorch.html +313 -0
- flashruntime/viewer/_docs/guides/sklearn.html +232 -0
- flashruntime/viewer/_docs/index.html +251 -0
- flashruntime/viewer/_docs/reference/cli.html +254 -0
- flashruntime/viewer/_docs/reference/integrations.html +240 -0
- flashruntime/viewer/_docs/reference/sdk.html +341 -0
- flashruntime/viewer/_docs/reference/torch-helper.html +244 -0
- flashruntime/viewer/_docs/search-index.json +1 -0
- flashruntime/viewer/_docs/tutorials/convnet.html +571 -0
- flashruntime/viewer/_docs/tutorials/fault-tolerance.html +375 -0
- flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +278 -0
- flashruntime/viewer/flowmap.py +307 -0
- flashruntime/viewer/page.py +594 -0
- flashruntime/viewer/server.py +134 -0
- flashruntime/viewer/state.py +250 -0
- flashruntime/workloads/__init__.py +6 -0
- flashruntime/workloads/command.py +127 -0
- flashruntime-0.3.0.dist-info/METADATA +365 -0
- flashruntime-0.3.0.dist-info/RECORD +95 -0
- flashruntime-0.3.0.dist-info/WHEEL +5 -0
- flashruntime-0.3.0.dist-info/entry_points.txt +2 -0
- flashruntime-0.3.0.dist-info/licenses/LICENSE +202 -0
- 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
|
+
)
|