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
|
+
"""FlashML public protocol, version v1alpha1.
|
|
2
|
+
|
|
3
|
+
Every wire-visible model in the FlashML system lives here: the JobSpec users
|
|
4
|
+
submit, the job/event/artifact records FlashML Cloud stores and displays, and
|
|
5
|
+
the node registration/heartbeat messages FlashNode sends. flashnode and
|
|
6
|
+
flashml-cloud import these — they never define their own copies.
|
|
7
|
+
|
|
8
|
+
Versioning: `apiVersion` on the JobSpec and `schema_version` on wire messages
|
|
9
|
+
identify this revision. Additive changes are allowed within v1alpha1;
|
|
10
|
+
breaking changes require a new module (v1alpha2, ...).
|
|
11
|
+
|
|
12
|
+
Deployment-private details (ACK cluster IDs, ACR credentials, OSS keys, SLS
|
|
13
|
+
projects, Kubernetes namespaces, RuntimeClass names) are deliberately absent:
|
|
14
|
+
they belong to backend/deployment configuration, never the public spec.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from typing import Any, Literal
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, Field, field_validator
|
|
25
|
+
|
|
26
|
+
API_VERSION = "flashml.dev/v1alpha1"
|
|
27
|
+
SCHEMA_VERSION = "v1alpha1"
|
|
28
|
+
|
|
29
|
+
# Label keys stamped on every backend resource created for a job.
|
|
30
|
+
LABEL_JOB_ID = "flashml.dev/job-id"
|
|
31
|
+
LABEL_RUNTIME_ID = "flashml.dev/runtime-id"
|
|
32
|
+
LABEL_BACKEND = "flashml.dev/backend"
|
|
33
|
+
LABEL_PROFILE = "flashml.dev/deployment-profile"
|
|
34
|
+
|
|
35
|
+
_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def utcnow() -> datetime:
|
|
39
|
+
return datetime.now(timezone.utc)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# JobSpec
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ExecutionSpec(BaseModel):
|
|
48
|
+
# "ray": coordinated execution on a managed pool (KubeRay backend).
|
|
49
|
+
# "leases": Mode A — the job expands into independent tasks that
|
|
50
|
+
# FlashNode workers pull, heartbeat, and commit (additive, July 2026).
|
|
51
|
+
backend: Literal["ray", "leases"] = "ray"
|
|
52
|
+
environment: Literal["auto", "local", "alibaba-ack"] = "auto"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ImageSpec(BaseModel):
|
|
56
|
+
repository: str
|
|
57
|
+
tag: str
|
|
58
|
+
|
|
59
|
+
@field_validator("tag")
|
|
60
|
+
@classmethod
|
|
61
|
+
def _no_latest(cls, v: str) -> str:
|
|
62
|
+
if v == "latest" or not v:
|
|
63
|
+
raise ValueError("image tag must be pinned; 'latest' is not allowed")
|
|
64
|
+
return v
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def reference(self) -> str:
|
|
68
|
+
return f"{self.repository}:{self.tag}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class WorkloadSpec(BaseModel):
|
|
72
|
+
type: str
|
|
73
|
+
parameters: dict[str, Any] = Field(default_factory=dict)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ResourcesSpec(BaseModel):
|
|
77
|
+
minimumWorkers: int = Field(ge=1, default=2)
|
|
78
|
+
maximumWorkers: int = Field(ge=1, default=3)
|
|
79
|
+
cpuPerTask: float = Field(gt=0, default=1)
|
|
80
|
+
memoryPerTask: str = "512Mi"
|
|
81
|
+
|
|
82
|
+
@field_validator("maximumWorkers")
|
|
83
|
+
@classmethod
|
|
84
|
+
def _max_ge_min(cls, v: int, info: Any) -> int:
|
|
85
|
+
minimum = info.data.get("minimumWorkers")
|
|
86
|
+
if minimum is not None and v < minimum:
|
|
87
|
+
raise ValueError("maximumWorkers must be >= minimumWorkers")
|
|
88
|
+
return v
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class PlacementSpec(BaseModel):
|
|
92
|
+
pool: Literal["any", "local", "edge", "standard-cloud", "secure-cloud"] = "any"
|
|
93
|
+
architectures: list[Literal["amd64", "arm64"]] = Field(default_factory=lambda: ["amd64"])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class IsolationSpec(BaseModel):
|
|
97
|
+
tier: Literal["standard", "sandboxed"] = "standard"
|
|
98
|
+
allowFallback: bool = False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class RetryPolicySpec(BaseModel):
|
|
102
|
+
maxTaskAttempts: int = Field(ge=1, default=4)
|
|
103
|
+
retryWorkerLoss: bool = True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ArtifactsSpec(BaseModel):
|
|
107
|
+
# Backend-neutral prefix; "{job_id}" is substituted at submission time.
|
|
108
|
+
outputPrefix: str = "artifact://jobs/{job_id}/"
|
|
109
|
+
|
|
110
|
+
@field_validator("outputPrefix")
|
|
111
|
+
@classmethod
|
|
112
|
+
def _scheme(cls, v: str) -> str:
|
|
113
|
+
if not v.startswith("artifact://"):
|
|
114
|
+
raise ValueError("outputPrefix must use the artifact:// scheme")
|
|
115
|
+
return v
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class JobMetadata(BaseModel):
|
|
119
|
+
name: str
|
|
120
|
+
labels: dict[str, str] = Field(default_factory=dict)
|
|
121
|
+
|
|
122
|
+
@field_validator("name")
|
|
123
|
+
@classmethod
|
|
124
|
+
def _dns_name(cls, v: str) -> str:
|
|
125
|
+
if not _NAME_RE.match(v):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
"job name must be a lowercase DNS-1123 label (a-z, 0-9, '-', max 63 chars)"
|
|
128
|
+
)
|
|
129
|
+
return v
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class JobSpecInner(BaseModel):
|
|
133
|
+
execution: ExecutionSpec = Field(default_factory=ExecutionSpec)
|
|
134
|
+
image: ImageSpec
|
|
135
|
+
workload: WorkloadSpec
|
|
136
|
+
resources: ResourcesSpec = Field(default_factory=ResourcesSpec)
|
|
137
|
+
placement: PlacementSpec = Field(default_factory=PlacementSpec)
|
|
138
|
+
isolation: IsolationSpec = Field(default_factory=IsolationSpec)
|
|
139
|
+
retryPolicy: RetryPolicySpec = Field(default_factory=RetryPolicySpec)
|
|
140
|
+
artifacts: ArtifactsSpec = Field(default_factory=ArtifactsSpec)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class JobSpec(BaseModel):
|
|
144
|
+
apiVersion: Literal["flashml.dev/v1alpha1"] = API_VERSION
|
|
145
|
+
kind: Literal["Job"] = "Job"
|
|
146
|
+
metadata: JobMetadata
|
|
147
|
+
spec: JobSpecInner
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Job state and events
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class JobState(str, Enum):
|
|
156
|
+
PENDING = "PENDING"
|
|
157
|
+
SUBMITTED = "SUBMITTED"
|
|
158
|
+
RUNNING = "RUNNING"
|
|
159
|
+
RECOVERING = "RECOVERING"
|
|
160
|
+
SUCCEEDED = "SUCCEEDED"
|
|
161
|
+
FAILED = "FAILED"
|
|
162
|
+
CANCELLED = "CANCELLED"
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def terminal(self) -> bool:
|
|
166
|
+
return self in (JobState.SUCCEEDED, JobState.FAILED, JobState.CANCELLED)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class EventType(str, Enum):
|
|
170
|
+
JOB_ACCEPTED = "JOB_ACCEPTED"
|
|
171
|
+
BACKEND_SELECTED = "BACKEND_SELECTED"
|
|
172
|
+
RAYJOB_CREATED = "RAYJOB_CREATED"
|
|
173
|
+
RAY_CLUSTER_STARTING = "RAY_CLUSTER_STARTING"
|
|
174
|
+
NODE_REGISTERED = "NODE_REGISTERED"
|
|
175
|
+
NODE_READY = "NODE_READY"
|
|
176
|
+
TASK_ATTEMPT_STARTED = "TASK_ATTEMPT_STARTED"
|
|
177
|
+
TASK_ATTEMPT_COMPLETED = "TASK_ATTEMPT_COMPLETED"
|
|
178
|
+
NODE_HEARTBEAT_LOST = "NODE_HEARTBEAT_LOST"
|
|
179
|
+
RAY_WORKER_LOST = "RAY_WORKER_LOST"
|
|
180
|
+
TASK_ATTEMPT_RETRIED = "TASK_ATTEMPT_RETRIED"
|
|
181
|
+
RAY_WORKER_REPLACED = "RAY_WORKER_REPLACED"
|
|
182
|
+
ITERATION_COMPLETED = "ITERATION_COMPLETED"
|
|
183
|
+
ARTIFACT_UPLOAD_STARTED = "ARTIFACT_UPLOAD_STARTED"
|
|
184
|
+
ARTIFACT_COMMITTED = "ARTIFACT_COMMITTED"
|
|
185
|
+
JOB_SUCCEEDED = "JOB_SUCCEEDED"
|
|
186
|
+
JOB_FAILED = "JOB_FAILED"
|
|
187
|
+
# Mode A lease lifecycle (additive, July 2026)
|
|
188
|
+
TASK_CREATED = "TASK_CREATED"
|
|
189
|
+
LEASE_CLAIMED = "LEASE_CLAIMED"
|
|
190
|
+
LEASE_RENEWED = "LEASE_RENEWED"
|
|
191
|
+
LEASE_EXPIRED = "LEASE_EXPIRED"
|
|
192
|
+
TASK_REQUEUED = "TASK_REQUEUED"
|
|
193
|
+
TASK_COMMIT_ACCEPTED = "TASK_COMMIT_ACCEPTED"
|
|
194
|
+
TASK_COMMIT_REJECTED = "TASK_COMMIT_REJECTED"
|
|
195
|
+
TASK_ATTEMPT_FAILED = "TASK_ATTEMPT_FAILED"
|
|
196
|
+
TASK_EXHAUSTED = "TASK_EXHAUSTED"
|
|
197
|
+
# Checkpoint catalog lifecycle (additive, July 2026)
|
|
198
|
+
CHECKPOINT_PARTS_REGISTERED = "CHECKPOINT_PARTS_REGISTERED"
|
|
199
|
+
CHECKPOINT_MANIFEST_COMMITTED = "CHECKPOINT_MANIFEST_COMMITTED"
|
|
200
|
+
CHECKPOINT_RESTORE_VERIFIED = "CHECKPOINT_RESTORE_VERIFIED"
|
|
201
|
+
CHECKPOINT_REJECTED = "CHECKPOINT_REJECTED"
|
|
202
|
+
# Recovery policy (additive, July 2026)
|
|
203
|
+
FAILURE_CLASSIFIED = "FAILURE_CLASSIFIED"
|
|
204
|
+
RECOVERY_ACTION_SELECTED = "RECOVERY_ACTION_SELECTED"
|
|
205
|
+
RECOVERY_FROZEN = "RECOVERY_FROZEN"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class Event(BaseModel):
|
|
209
|
+
"""One entry in the FlashRuntime event ledger.
|
|
210
|
+
|
|
211
|
+
`source` names the raw signal the event was derived from (e.g.
|
|
212
|
+
"kubernetes.pod", "ray.job-log", "workload.summary", "flashnode") so
|
|
213
|
+
recovery evidence is traceable, never fabricated.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
|
|
217
|
+
job_id: str
|
|
218
|
+
type: EventType
|
|
219
|
+
timestamp: datetime = Field(default_factory=utcnow)
|
|
220
|
+
source: str
|
|
221
|
+
message: str = ""
|
|
222
|
+
data: dict[str, Any] = Field(default_factory=dict)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
# Artifacts
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class ArtifactRecord(BaseModel):
|
|
231
|
+
schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
|
|
232
|
+
uri: str # backend-neutral, e.g. artifact://jobs/<job-id>/centroids.json
|
|
233
|
+
backend: Literal["minio", "oss", "local"]
|
|
234
|
+
bucket: str
|
|
235
|
+
object_key: str
|
|
236
|
+
etag: str | None = None
|
|
237
|
+
sha256: str | None = None
|
|
238
|
+
size_bytes: int | None = None
|
|
239
|
+
created_at: datetime = Field(default_factory=utcnow)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# Job record (runtime-owned view served to FlashML Cloud)
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class JobRecord(BaseModel):
|
|
248
|
+
schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
|
|
249
|
+
job_id: str
|
|
250
|
+
spec: JobSpec
|
|
251
|
+
state: JobState = JobState.PENDING
|
|
252
|
+
backend: str = "kuberay"
|
|
253
|
+
deployment_profile: str = "local"
|
|
254
|
+
runtime_execution_id: str | None = None # e.g. the RayJob name
|
|
255
|
+
created_at: datetime = Field(default_factory=utcnow)
|
|
256
|
+
finished_at: datetime | None = None
|
|
257
|
+
error: str | None = None
|
|
258
|
+
artifacts: list[ArtifactRecord] = Field(default_factory=list)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
# FlashNode <-> FlashML Cloud
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class NodeEnvironment(str, Enum):
|
|
267
|
+
LOCAL = "local"
|
|
268
|
+
CLOUD = "cloud"
|
|
269
|
+
EDGE = "edge"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class NodeCapabilities(BaseModel):
|
|
273
|
+
cpu_cores: float | None = None
|
|
274
|
+
memory_bytes: int | None = None
|
|
275
|
+
gpus: list[dict[str, Any]] = Field(default_factory=list)
|
|
276
|
+
os: str = ""
|
|
277
|
+
architecture: str = ""
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class NodeRegistration(BaseModel):
|
|
281
|
+
schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
|
|
282
|
+
node_id: str
|
|
283
|
+
kubernetes_node: str
|
|
284
|
+
hostname: str
|
|
285
|
+
capabilities: NodeCapabilities
|
|
286
|
+
environment: NodeEnvironment = NodeEnvironment.LOCAL
|
|
287
|
+
sandbox_capable: bool = False
|
|
288
|
+
#: This node runs an argv-capable sandboxed runner. Defaults False so
|
|
289
|
+
#: every already-deployed agent is excluded from argv work until it is
|
|
290
|
+
#: upgraded and explicitly opted in (security fields fail closed).
|
|
291
|
+
argv_capable: bool = False
|
|
292
|
+
#: This node can run "module" (python -m <allowlisted module>) tasks.
|
|
293
|
+
#: Defaults True — unlike argv_capable this is an AVAILABILITY gate, not
|
|
294
|
+
#: a safety one: a module task placed on an incapable node just wastes
|
|
295
|
+
#: attempts, it never escapes a sandbox. Defaulting True means every
|
|
296
|
+
#: already-deployed agent (whose registration predates this field)
|
|
297
|
+
#: keeps receiving module work; only a node that explicitly opts into
|
|
298
|
+
#: an argv-only runner sets this False (see scheduler.IsolationAwarePlacement).
|
|
299
|
+
module_capable: bool = True
|
|
300
|
+
pool: str = "local"
|
|
301
|
+
runtime_profile: str = "kubernetes"
|
|
302
|
+
labels: dict[str, str] = Field(default_factory=dict)
|
|
303
|
+
agent_version: str = ""
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class NodeHeartbeat(BaseModel):
|
|
307
|
+
schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
|
|
308
|
+
node_id: str
|
|
309
|
+
timestamp: datetime = Field(default_factory=utcnow)
|
|
310
|
+
status: Literal["online", "draining", "terminating"] = "online"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class NodeStatusView(BaseModel):
|
|
314
|
+
"""Cloud-side view of a node, derived from registration + heartbeats."""
|
|
315
|
+
|
|
316
|
+
schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
|
|
317
|
+
registration: NodeRegistration
|
|
318
|
+
online: bool
|
|
319
|
+
last_heartbeat: datetime | None = None
|
|
320
|
+
accepted_task_count: int = 0
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# ---------------------------------------------------------------------------
|
|
324
|
+
# Mode A: tasks, leases, attempts (additive, July 2026)
|
|
325
|
+
#
|
|
326
|
+
# The lease pattern: work is never pushed. A worker *claims* a time-bounded
|
|
327
|
+
# lease on a task, renews it with heartbeats, and only the first attempt to
|
|
328
|
+
# commit a valid result wins — late duplicates are rejected. A dead worker
|
|
329
|
+
# needs no handling: its lease expires and the task requeues.
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class TaskState(str, Enum):
|
|
334
|
+
PENDING = "PENDING" # claimable
|
|
335
|
+
LEASED = "LEASED" # one live lease holds it
|
|
336
|
+
COMPLETED = "COMPLETED" # a commit was accepted (terminal)
|
|
337
|
+
FAILED = "FAILED" # attempts exhausted (terminal)
|
|
338
|
+
CANCELLED = "CANCELLED" # withdrawn by the job (terminal)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class TaskSpec(BaseModel):
|
|
342
|
+
"""One unit of independent work. `payload` is workload-defined (e.g. a
|
|
343
|
+
hyperparameter trial config); `commit_key` is the idempotency anchor —
|
|
344
|
+
exactly one accepted output may ever exist under it."""
|
|
345
|
+
|
|
346
|
+
schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
|
|
347
|
+
task_id: str
|
|
348
|
+
job_id: str
|
|
349
|
+
payload: dict[str, Any] = Field(default_factory=dict)
|
|
350
|
+
commit_key: str
|
|
351
|
+
max_attempts: int = Field(default=3, ge=1)
|
|
352
|
+
lease_seconds: float = Field(default=60.0, gt=0)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class Lease(BaseModel):
|
|
356
|
+
"""A time-bounded right to execute one attempt of one task."""
|
|
357
|
+
|
|
358
|
+
schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
|
|
359
|
+
lease_id: str
|
|
360
|
+
task_id: str
|
|
361
|
+
job_id: str
|
|
362
|
+
node_id: str
|
|
363
|
+
attempt_number: int
|
|
364
|
+
deadline: datetime
|
|
365
|
+
payload: dict[str, Any] = Field(default_factory=dict)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class TaskAttempt(BaseModel):
|
|
369
|
+
"""One execution try. `accepted` flips true only on the winning commit."""
|
|
370
|
+
|
|
371
|
+
schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
|
|
372
|
+
attempt_id: str
|
|
373
|
+
task_id: str
|
|
374
|
+
job_id: str
|
|
375
|
+
node_id: str
|
|
376
|
+
attempt_number: int
|
|
377
|
+
started: datetime
|
|
378
|
+
finished: datetime | None = None
|
|
379
|
+
outcome: Literal["running", "committed", "rejected", "failed", "expired"] = "running"
|
|
380
|
+
output_sha256: str | None = None
|
|
381
|
+
accepted: bool = False
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ---------------------------------------------------------------------------
|
|
385
|
+
# Checkpoint manifests (additive, July 2026)
|
|
386
|
+
#
|
|
387
|
+
# A checkpoint is not a path — it is a *manifest* proving completeness and
|
|
388
|
+
# compatibility. Parts upload first; the manifest is written last, only
|
|
389
|
+
# after every expected part's hash verifies. No manifest ⇒ no checkpoint.
|
|
390
|
+
# ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class CheckpointPart(BaseModel):
|
|
394
|
+
key: str # storage key relative to the manifest's prefix
|
|
395
|
+
sha256: str
|
|
396
|
+
size_bytes: int = Field(ge=0)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class CheckpointValidation(str, Enum):
|
|
400
|
+
HASH_VERIFIED = "hash_verified" # all parts present, hashes match
|
|
401
|
+
RESTORE_VERIFIED = "restore_verified" # a real load succeeded from this manifest
|
|
402
|
+
INVALID = "invalid" # quarantined; recovery must never select it
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class CheckpointManifest(BaseModel):
|
|
406
|
+
schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
|
|
407
|
+
manifest_id: str
|
|
408
|
+
job_id: str
|
|
409
|
+
attempt_id: str
|
|
410
|
+
step: int = Field(ge=0)
|
|
411
|
+
framework: str = "" # e.g. "pytorch-2.9"
|
|
412
|
+
strategy_family: str = "" # e.g. "ddp", "fsdp2"
|
|
413
|
+
world_size: int = Field(default=1, ge=1)
|
|
414
|
+
compatible_world_sizes: list[int] = Field(default_factory=list) # via resharding
|
|
415
|
+
storage_prefix: str # e.g. "artifact://checkpoints/<job>/<attempt>/<step>/"
|
|
416
|
+
parts: list[CheckpointPart]
|
|
417
|
+
validation: CheckpointValidation
|
|
418
|
+
created: datetime = Field(default_factory=utcnow)
|
|
419
|
+
checkpoint_duration_s: float | None = None
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# ---------------------------------------------------------------------------
|
|
423
|
+
# Failure taxonomy and recovery decisions (additive, July 2026)
|
|
424
|
+
# ---------------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class FailureClass(str, Enum):
|
|
428
|
+
APPLICATION_ERROR = "application_error"
|
|
429
|
+
DATA_ERROR = "data_error"
|
|
430
|
+
WORKER_CRASH = "worker_crash"
|
|
431
|
+
NODE_LOSS = "node_loss"
|
|
432
|
+
ACCELERATOR_FAILURE = "accelerator_failure"
|
|
433
|
+
COMMUNICATION_ERROR = "communication_error" # NCCL/RCCL/rendezvous
|
|
434
|
+
NETWORK_DEGRADATION = "network_degradation"
|
|
435
|
+
STORAGE_TIMEOUT = "storage_timeout"
|
|
436
|
+
ARTIFACT_CORRUPTION = "artifact_corruption"
|
|
437
|
+
PREEMPTION = "preemption"
|
|
438
|
+
CORRELATED_INCIDENT = "correlated_incident"
|
|
439
|
+
CONTROL_PLANE_FAILURE = "control_plane_failure"
|
|
440
|
+
UNKNOWN = "unknown"
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
class RecoveryActionType(str, Enum):
|
|
444
|
+
FAIL_JOB = "fail_job" # deterministic app error: fail fast, tell the user
|
|
445
|
+
RETRY_TASK = "retry_task" # Mode A: requeue the attempt elsewhere
|
|
446
|
+
RESTART_GROUP = "restart_group" # Mode B: whole-group restart from checkpoint
|
|
447
|
+
REPLACE_NODE = "replace_node" # cordon + acquire replacement, then restart/requeue
|
|
448
|
+
PAUSE_JOB = "pause_job" # e.g. storage outage: protect state, stop burning compute
|
|
449
|
+
FREEZE_AUTOMATION = "freeze_automation" # correlated incident: no retry storms; escalate
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
class RecoveryDecision(BaseModel):
|
|
453
|
+
"""One typed, logged recovery decision. `policy_version` + inputs make it
|
|
454
|
+
reproducible; there is no discretionary path."""
|
|
455
|
+
|
|
456
|
+
schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
|
|
457
|
+
policy_version: str
|
|
458
|
+
failure_class: FailureClass
|
|
459
|
+
action: RecoveryActionType
|
|
460
|
+
scope: Literal["task", "node", "group", "job", "pool"]
|
|
461
|
+
cordon_node: bool = False
|
|
462
|
+
needs_checkpoint: bool = False
|
|
463
|
+
reason: str
|
|
464
|
+
evidence: dict[str, Any] = Field(default_factory=dict)
|
|
465
|
+
decided_at: datetime = Field(default_factory=utcnow)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Resource providers: where machines come from (the fourth axis).
|
|
2
|
+
|
|
3
|
+
A provider turns money into capacity: list what's available (`offers`),
|
|
4
|
+
acquire some (`acquire`), release it (`release`). It NEVER runs workloads
|
|
5
|
+
— acquired machines join the system the same way a laptop does (a
|
|
6
|
+
FlashNode agent registers and pulls leases) or as a Mode B pool (KubeRay
|
|
7
|
+
on the acquired cluster). Keeping acquisition separate from execution is
|
|
8
|
+
what lets one job span a rented pool and a volunteer laptop without either
|
|
9
|
+
knowing about the other.
|
|
10
|
+
|
|
11
|
+
Rules distilled from the evaluation (§J) and the master report:
|
|
12
|
+
- One adapter first, strict conformance, before any second provider —
|
|
13
|
+
every adapter is a permanent support cost.
|
|
14
|
+
- Providers are *quoted*, not trusted: an Offer's price/specs are claims;
|
|
15
|
+
admission benchmarks (flashnode `benchmark/`) verify capability after
|
|
16
|
+
the machine joins, exactly as for volunteered hardware.
|
|
17
|
+
- The planner consumes `hourly_usd` and capability fields to price plans;
|
|
18
|
+
the scheduler never talks to providers — acquisition is a deliberate,
|
|
19
|
+
budgeted act by the coordinator/cloud, not a side effect of placement.
|
|
20
|
+
- Idempotency: `release` on an unknown/already-released id is a no-op —
|
|
21
|
+
reconciliation loops must be safe to re-run.
|
|
22
|
+
|
|
23
|
+
Status: interface complete (final surface); first concrete adapter per
|
|
24
|
+
demand (SkyPilot as provisioner, or Alibaba ECS for Stage-5 automation —
|
|
25
|
+
see HANDBOOK §4).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from abc import ABC, abstractmethod
|
|
31
|
+
from datetime import datetime
|
|
32
|
+
from typing import Any, ClassVar
|
|
33
|
+
|
|
34
|
+
from pydantic import BaseModel, Field
|
|
35
|
+
|
|
36
|
+
__all__ = ["Requirements", "Offer", "AcquiredCapacity", "ResourceProvider", "ProviderError"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ProviderError(Exception):
|
|
40
|
+
"""Provider API failure (auth, quota, capacity gone). Carries enough
|
|
41
|
+
context to be a ledger event — provider errors are recovery evidence
|
|
42
|
+
(FailureClass.PREEMPTION / capacity loss), never silent retries."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Requirements(BaseModel):
|
|
46
|
+
"""What the caller needs — the provider filters its inventory by this.
|
|
47
|
+
All fields optional: an empty Requirements means 'show me everything'."""
|
|
48
|
+
|
|
49
|
+
gpus: int | None = Field(default=None, ge=0)
|
|
50
|
+
gpu_type: str | None = None
|
|
51
|
+
vram_gb: float | None = Field(default=None, gt=0)
|
|
52
|
+
cpus: int | None = Field(default=None, ge=1)
|
|
53
|
+
memory_gb: float | None = Field(default=None, gt=0)
|
|
54
|
+
region: str | None = None
|
|
55
|
+
max_hourly_usd: float | None = Field(default=None, gt=0)
|
|
56
|
+
spot_ok: bool = Field(
|
|
57
|
+
default=True,
|
|
58
|
+
description="Whether preemptible capacity is acceptable — spot "
|
|
59
|
+
"failure rates feed the planner's expected-failure-cost math",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Offer(BaseModel):
|
|
64
|
+
"""One acquirable configuration, as quoted by the provider now.
|
|
65
|
+
|
|
66
|
+
Offers are snapshots: they can expire or be taken. `acquire` must
|
|
67
|
+
re-validate; a stale offer raises ProviderError, never silently
|
|
68
|
+
substitutes different hardware."""
|
|
69
|
+
|
|
70
|
+
provider: str
|
|
71
|
+
gpu_type: str | None = None
|
|
72
|
+
gpus: int = Field(default=0, ge=0)
|
|
73
|
+
cpus: int | None = None
|
|
74
|
+
memory_gb: float | None = None
|
|
75
|
+
vram_gb: float | None = None
|
|
76
|
+
hourly_usd: float = Field(gt=0)
|
|
77
|
+
region: str | None = None
|
|
78
|
+
spot: bool = False
|
|
79
|
+
interconnect: str | None = Field(
|
|
80
|
+
default=None, description="Planner link-class name when known (e.g. 'same_host_nvlink')"
|
|
81
|
+
)
|
|
82
|
+
offer_id: str = Field(default="", description="Provider-native id for acquire()")
|
|
83
|
+
expires_at: datetime | None = None
|
|
84
|
+
raw: dict[str, Any] = Field(
|
|
85
|
+
default_factory=dict, description="Provider response verbatim, for audit"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class AcquiredCapacity(BaseModel):
|
|
90
|
+
"""A machine (or pool) we are now paying for.
|
|
91
|
+
|
|
92
|
+
Contains what the *coordinator* needs to onboard it — never workload
|
|
93
|
+
credentials. `join_hint` describes how it enters the system: a cloud-
|
|
94
|
+
init/user-data script that runs `flashnode work --coordinator ...`
|
|
95
|
+
(Mode A) or a kubeconfig reference for a Mode B pool."""
|
|
96
|
+
|
|
97
|
+
capacity_id: str
|
|
98
|
+
provider: str
|
|
99
|
+
offer: Offer
|
|
100
|
+
acquired_at: datetime
|
|
101
|
+
join_hint: dict[str, Any] = Field(default_factory=dict)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ResourceProvider(ABC):
|
|
105
|
+
"""One source of rentable capacity.
|
|
106
|
+
|
|
107
|
+
Lifecycle: `healthy()` preflight → `offers()` → `acquire()` →
|
|
108
|
+
(capacity works for a while) → `release()`. Implementations must be
|
|
109
|
+
safe to call from reconciliation loops: every method idempotent or
|
|
110
|
+
read-only, every failure a typed ProviderError.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
#: Registry/ledger name: "alibaba-ecs", "skypilot", "runpod", ...
|
|
114
|
+
name: ClassVar[str]
|
|
115
|
+
|
|
116
|
+
def healthy(self) -> tuple[bool, str]:
|
|
117
|
+
"""Credentials valid, API reachable? (ok, reason). Called before
|
|
118
|
+
showing this provider's offers to anything that spends money."""
|
|
119
|
+
return True, "no preflight implemented"
|
|
120
|
+
|
|
121
|
+
@abstractmethod
|
|
122
|
+
def offers(self, requirements: Requirements) -> list[Offer]:
|
|
123
|
+
"""Quote current availability matching `requirements`, cheapest
|
|
124
|
+
first. Read-only; may be cached briefly by the caller. An empty
|
|
125
|
+
list is an answer, not an error."""
|
|
126
|
+
|
|
127
|
+
@abstractmethod
|
|
128
|
+
def acquire(self, offer: Offer) -> AcquiredCapacity:
|
|
129
|
+
"""Buy it. Either returns capacity we are provably paying for, or
|
|
130
|
+
raises ProviderError with nothing leaked — a half-acquired machine
|
|
131
|
+
the caller doesn't know about is a money leak; implementations
|
|
132
|
+
must clean up their own partial failures."""
|
|
133
|
+
|
|
134
|
+
@abstractmethod
|
|
135
|
+
def release(self, capacity_id: str) -> None:
|
|
136
|
+
"""Stop paying. Idempotent: unknown/already-released ids are
|
|
137
|
+
no-ops. This is the method cost-safety leans on — it must be the
|
|
138
|
+
most reliable code in the adapter."""
|
flashruntime/py.typed
ADDED
|
File without changes
|