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,109 @@
1
+ """Local checkpoint manifests: parts-first / manifest-last for processes
2
+ that have only a filesystem (no coordinator).
3
+
4
+ `write_manifest` hashes every part file already on disk and writes
5
+ manifest.json LAST — a crash mid-checkpoint leaves part files but no
6
+ manifest, so the checkpoint does not exist. `latest_valid_manifest`
7
+ re-verifies every part hash on read: a corrupted or truncated part
8
+ disqualifies its manifest, so recovery can never restore from it.
9
+
10
+ Consumers: `flashruntime.torch.checkpoint()` and the Hugging Face Trainer
11
+ callback. Pure stdlib + pydantic (protocol models) — safe in the core.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import uuid
18
+ from pathlib import Path
19
+
20
+ from flashruntime.protocol.v1alpha1 import (
21
+ CheckpointManifest,
22
+ CheckpointPart,
23
+ CheckpointValidation,
24
+ )
25
+
26
+ MANIFEST_NAME = "manifest.json"
27
+
28
+
29
+ def _sha256(path: Path) -> str:
30
+ digest = hashlib.sha256()
31
+ with open(path, "rb") as f:
32
+ for chunk in iter(lambda: f.read(1 << 20), b""):
33
+ digest.update(chunk)
34
+ return digest.hexdigest()
35
+
36
+
37
+ def write_manifest(
38
+ step_dir: Path,
39
+ *,
40
+ job_id: str,
41
+ attempt_id: str,
42
+ step: int,
43
+ world_size: int = 1,
44
+ framework: str = "",
45
+ ) -> CheckpointManifest:
46
+ """Hash every part file in `step_dir`, then write the manifest LAST."""
47
+ step_dir = Path(step_dir)
48
+ parts = [
49
+ CheckpointPart(key=p.name, sha256=_sha256(p), size_bytes=p.stat().st_size)
50
+ for p in sorted(step_dir.iterdir())
51
+ if p.is_file() and p.name != MANIFEST_NAME
52
+ ]
53
+ if not parts:
54
+ raise ValueError(f"no part files in {step_dir}")
55
+ manifest = CheckpointManifest(
56
+ manifest_id=f"ck-{uuid.uuid4().hex[:12]}",
57
+ job_id=job_id,
58
+ attempt_id=attempt_id,
59
+ step=step,
60
+ framework=framework,
61
+ world_size=world_size,
62
+ compatible_world_sizes=[world_size],
63
+ storage_prefix=str(step_dir),
64
+ parts=parts,
65
+ validation=CheckpointValidation.HASH_VERIFIED,
66
+ )
67
+ (step_dir / MANIFEST_NAME).write_text(manifest.model_dump_json(indent=2)) # LAST
68
+ return manifest
69
+
70
+
71
+ def verify_manifest(manifest: CheckpointManifest, step_dir: Path) -> bool:
72
+ """True iff every part named in `manifest` is present in `step_dir` and
73
+ re-hashes to its recorded sha256 — i.e. this checkpoint is safe to
74
+ restore. The single source of truth for that question: both
75
+ `latest_valid_manifest` (recovery's picker) and the viewer's per-step
76
+ listing call it, so the part-hashing lives in exactly one place and can
77
+ never drift between the two. Never raises: a part we cannot re-hash
78
+ (directory, unreadable, gone) means the manifest cannot be verified, so
79
+ it is simply invalid."""
80
+ step_dir = Path(step_dir)
81
+ try:
82
+ return all(
83
+ (step_dir / part.key).is_file() and _sha256(step_dir / part.key) == part.sha256
84
+ for part in manifest.parts
85
+ )
86
+ except (OSError, ValueError):
87
+ return False
88
+
89
+
90
+ def latest_valid_manifest(ckpt_root: Path, pattern: str = "step-*") -> CheckpointManifest | None:
91
+ """Newest manifest whose parts all re-verify on disk, or None.
92
+
93
+ `pattern` matches the per-step directory names ("step-*" for
94
+ flashruntime.torch, "checkpoint-*" for Hugging Face Trainer output).
95
+ """
96
+ ckpt_root = Path(ckpt_root)
97
+ if not ckpt_root.is_dir():
98
+ return None
99
+ best: CheckpointManifest | None = None
100
+ for mf_path in ckpt_root.glob(f"{pattern}/{MANIFEST_NAME}"):
101
+ try:
102
+ manifest = CheckpointManifest.model_validate_json(mf_path.read_text())
103
+ except (OSError, ValueError):
104
+ # unreadable/invalid manifest (bad JSON, or manifest.json is a
105
+ # directory / unreadable file → OSError): treat as nonexistent
106
+ continue
107
+ if verify_manifest(manifest, mf_path.parent) and (best is None or manifest.step > best.step):
108
+ best = manifest
109
+ return best
@@ -0,0 +1,86 @@
1
+ """Manifest persistence seam (research item R1).
2
+
3
+ Today `CheckpointCatalog` keeps manifests in a plain dict — a coordinator
4
+ restart orphans perfectly good checkpoint *files* because the metadata
5
+ proving their validity dies with the process. This module defines the
6
+ persistence interface that closes that gap, mirroring the lease pattern
7
+ that already works (`leases/store.py` + `leases/sqlite_store.py`):
8
+
9
+ the catalog mutates live objects in an insertion-ordered cache and
10
+ calls `save(manifest)` after every transition; a durable store
11
+ persists there and rehydrates the cache at construction.
12
+
13
+ Migration path (half a day, SPRINT_PLAN Day 3):
14
+ 1. `CheckpointCatalog.__init__` grows `store: ManifestStore | None = None`
15
+ (default `InMemoryManifestStore()` — zero behavior change).
16
+ 2. The catalog's `_manifests` dict is replaced by the store's cache, and
17
+ every mutation (`commit`, `mark_restore_verified`, `quarantine`) ends
18
+ with `store.save(manifest)`.
19
+ 3. `SqliteManifestStore` lands beside `leases.db` (one table: manifest_id
20
+ PK, scope job_id, manifest JSON, seq for order) with the same
21
+ COALESCE-seq upsert as `SqliteLeaseStore._persist` — copy that code,
22
+ it is tested and correct.
23
+ 4. The conformance test is `tests/test_checkpoint.py` re-run against the
24
+ new store, plus a restart-survival test shaped exactly like
25
+ `tests/test_leases_sqlite.py::test_inflight_work_survives_coordinator_restart`.
26
+
27
+ Note the pending-parts registry (`CheckpointCatalog._registered`) is
28
+ deliberately NOT persisted: parts without a committed manifest are, by the
29
+ manifest-last rule, not checkpoints — losing them on restart just means
30
+ the in-flight checkpoint re-uploads, which the relay already handles.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from typing import Protocol
36
+
37
+ from flashruntime.protocol.v1alpha1 import CheckpointManifest
38
+
39
+ __all__ = ["ManifestStore", "InMemoryManifestStore"]
40
+
41
+
42
+ class ManifestStore(Protocol):
43
+ """Minimal persistence contract for checkpoint manifests.
44
+
45
+ Semantics (identical in spirit to `LeaseStore`):
46
+ - `add` registers a NEW manifest (duplicate manifest_id → ValueError);
47
+ - `save` persists the current state of an already-added manifest
48
+ (validation upgrades/quarantine); no-op for in-memory stores whose
49
+ cache holds live references;
50
+ - `get`/`all` read from the cache — `all(scope)` filters by the
51
+ catalog's scope key (the service uses `f"{job_id}::{task_id}"`) and
52
+ MUST preserve insertion order (selection tie-breaks depend on it).
53
+ """
54
+
55
+ def add(self, manifest: CheckpointManifest) -> None: ...
56
+
57
+ def save(self, manifest: CheckpointManifest) -> None: ...
58
+
59
+ def get(self, manifest_id: str) -> CheckpointManifest | None: ...
60
+
61
+ def all(self, scope: str | None = None) -> list[CheckpointManifest]: ...
62
+
63
+
64
+ class InMemoryManifestStore:
65
+ """Reference implementation: live references, insertion-ordered,
66
+ volatile. This is functionally what the catalog does today — extracted
67
+ behind the seam so the SQLite store is a drop-in."""
68
+
69
+ def __init__(self) -> None:
70
+ self._manifests: dict[str, CheckpointManifest] = {}
71
+
72
+ def add(self, manifest: CheckpointManifest) -> None:
73
+ if manifest.manifest_id in self._manifests:
74
+ raise ValueError(f"manifest {manifest.manifest_id} already exists")
75
+ self._manifests[manifest.manifest_id] = manifest
76
+
77
+ def save(self, manifest: CheckpointManifest) -> None:
78
+ pass # live references — mutations already visible
79
+
80
+ def get(self, manifest_id: str) -> CheckpointManifest | None:
81
+ return self._manifests.get(manifest_id)
82
+
83
+ def all(self, scope: str | None = None) -> list[CheckpointManifest]:
84
+ return [
85
+ m for m in self._manifests.values() if scope is None or m.job_id == scope
86
+ ]
@@ -0,0 +1,5 @@
1
+ """Framework adapters: each builds CommandWorkloads from one framework's
2
+ LAUNCH AND CHECKPOINT CONVENTIONS — never its model code, never a
3
+ module-level framework import (four-axes rule). Import the submodule you
4
+ need: `from flashruntime.integrations import sklearn, pytorch, huggingface`.
5
+ """
@@ -0,0 +1,59 @@
1
+ """Hugging Face adapter: HF Trainer already wraps DDP/FSDP internally when
2
+ launched by torchrun, so launching is the pytorch adapter's job. What HF
3
+ adds is its callback seam — `flashruntime_callback()` commits Trainer
4
+ checkpoints as verified manifests. transformers is imported only inside
5
+ that factory, in the user's training process.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ from flashruntime.checkpoint.local import latest_valid_manifest
14
+ from flashruntime.integrations.pytorch import ddp
15
+ from flashruntime.workloads.command import CommandWorkload
16
+
17
+
18
+ def trainer(
19
+ script: str, *, source: str = ".", nproc_per_node: int = 1, script_args: str = ""
20
+ ) -> CommandWorkload:
21
+ return ddp(script, source=source, nproc_per_node=nproc_per_node, script_args=script_args)
22
+
23
+
24
+ def latest_checkpoint(output_dir: str | Path) -> str | None:
25
+ """Newest Trainer checkpoint dir with a VALID manifest — pass as
26
+ `trainer.train(resume_from_checkpoint=...)`. None means fresh start."""
27
+ manifest = latest_valid_manifest(Path(output_dir), pattern="checkpoint-*")
28
+ return None if manifest is None else manifest.storage_prefix
29
+
30
+
31
+ def flashruntime_callback():
32
+ """Build the TrainerCallback (transformers import paid here, in the
33
+ user's process only): on_save commits a manifest, on_log relays metrics."""
34
+ from transformers import TrainerCallback # noqa: PLC0415 — user process only
35
+
36
+ from flashruntime.checkpoint.local import write_manifest
37
+
38
+ class FlashRuntimeCallback(TrainerCallback):
39
+ def on_save(self, args, state, control, **kwargs):
40
+ if state.is_world_process_zero:
41
+ step_dir = Path(args.output_dir) / f"checkpoint-{state.global_step}"
42
+ if step_dir.is_dir():
43
+ write_manifest(
44
+ step_dir,
45
+ job_id=os.environ.get("FLASHML_JOB_ID", "local"),
46
+ attempt_id=os.environ.get("FLASHML_ATTEMPT_ID", "local"),
47
+ step=state.global_step,
48
+ framework="transformers",
49
+ )
50
+ return control
51
+
52
+ def on_log(self, args, state, control, logs=None, **kwargs):
53
+ if logs and state.is_world_process_zero:
54
+ from flashruntime.torch import log_metrics
55
+
56
+ log_metrics({**logs, "step": state.global_step})
57
+ return control
58
+
59
+ return FlashRuntimeCallback()
@@ -0,0 +1,52 @@
1
+ """PyTorch adapter: launch conventions only. torchrun starts N processes
2
+ and hands each RANK/WORLD_SIZE — the user's code (or
3
+ flashruntime.torch.prepare) wires DDP from there. No torch import here.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import shlex
9
+
10
+ from flashruntime.protocol.plan_v1alpha1 import CheckpointPolicy
11
+ from flashruntime.workloads.command import CommandWorkload, OutputSpec, Source
12
+
13
+
14
+ def ddp(
15
+ script: str,
16
+ *,
17
+ source: str = ".",
18
+ nproc_per_node: int = 2,
19
+ nnodes: int = 1,
20
+ script_args: str = "",
21
+ env: dict[str, str] | None = None,
22
+ ) -> CommandWorkload:
23
+ if nnodes > 1:
24
+ raise NotImplementedError(
25
+ "multi-node rendezvous is a launcher concern — later slice (spec §10); "
26
+ "--standalone below is single-node by definition"
27
+ )
28
+ command = [
29
+ "torchrun",
30
+ f"--nproc-per-node={nproc_per_node}",
31
+ f"--nnodes={nnodes}",
32
+ "--standalone",
33
+ # Single-node by definition (nnodes > 1 raised above), so pin the
34
+ # advertised rendezvous address. Without this torchrun advertises
35
+ # socket.getfqdn(), which on some macOS DNS setups returns an
36
+ # unresolvable ip6.arpa name — workers then retry DNS forever and
37
+ # the run hangs before spawning a single process.
38
+ "--local-addr=127.0.0.1",
39
+ script,
40
+ *shlex.split(script_args),
41
+ ]
42
+ return CommandWorkload(
43
+ command=command,
44
+ source=Source(path=source),
45
+ env=env or {},
46
+ mode="coordinated",
47
+ checkpoint=CheckpointPolicy(
48
+ backend="local_manifest",
49
+ note="flashruntime.torch.checkpoint: parts-first/manifest-last under FLASHML_CKPT_DIR",
50
+ ),
51
+ outputs=OutputSpec(collect=["metrics.json"]),
52
+ )
@@ -0,0 +1,42 @@
1
+ """sklearn adapter: distribute across runs, never inside .fit().
2
+
3
+ The contract with the user's script is pure convention: CLI flags in,
4
+ metrics.json out. No sklearn import here — the script owns the estimator.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import itertools
10
+
11
+ from flashruntime.workloads.command import CommandWorkload, OutputSpec, Source
12
+
13
+
14
+ def sweep(
15
+ script: str,
16
+ task_params: list[dict],
17
+ *,
18
+ source: str = ".",
19
+ metric: str = "accuracy_mean",
20
+ maximize: bool = True,
21
+ python: str = "python",
22
+ ) -> CommandWorkload:
23
+ """One independent task per params dict. Every dict must carry every
24
+ key (the CLI flags are built from the union)."""
25
+ keys = sorted({k for p in task_params for k in p})
26
+ command = [python, script]
27
+ for key in keys:
28
+ command += [f"--{key}", "{" + key + "}"]
29
+ return CommandWorkload(
30
+ command=command,
31
+ source=Source(path=source),
32
+ task_params=task_params,
33
+ mode="independent_tasks",
34
+ outputs=OutputSpec(collect=["metrics.json"], primary_metric=metric, maximize=maximize),
35
+ )
36
+
37
+
38
+ def hpo(script: str, grid: dict[str, list], **kwargs) -> CommandWorkload:
39
+ """Cartesian grid search: {"model": ["logreg","rf"], "C": [0.1, 1]} → 4 trials."""
40
+ keys = sorted(grid)
41
+ trials = [dict(zip(keys, combo)) for combo in itertools.product(*(grid[k] for k in keys))]
42
+ return sweep(script, trials, **kwargs)
@@ -0,0 +1,130 @@
1
+ """Launchers: start a compiled `LaunchSpec` and watch it run.
2
+
3
+ The launcher owns everything environment-shaped that compilation must not
4
+ touch: resolving rendezvous addresses/ports, materializing `spec.files`
5
+ into a workdir, injecting credentials, spawning the processes (locally,
6
+ via torchrun, on Kubernetes, on Slurm), and reporting their fate.
7
+
8
+ Division of labor (the four-axes rule, HANDBOOK §2.1):
9
+ compiler = *what* to run · launcher = *how processes start* ·
10
+ provider = *where machines come from* · recipe = *what the user's code is*.
11
+
12
+ Failure semantics: a launcher NEVER retries or recovers — it reports.
13
+ Recovery is the coordinator's job (`recovery.decide()` chooses
14
+ RESTART_GROUP etc.); a launcher that silently relaunched would corrupt the
15
+ ledger's story and double-spend the user's budget.
16
+
17
+ Status: interface complete (final surface); `TorchrunLauncher` is the
18
+ first concrete (SPRINT_PLAN Week 2), then kubernetes/slurm/skypilot per
19
+ demand — see FLASHRUNTIME_EVALUATION §J for each substrate's role.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import enum
25
+ from abc import ABC, abstractmethod
26
+ from typing import ClassVar
27
+
28
+ from flashruntime.strategies import LaunchSpec
29
+
30
+ __all__ = ["LaunchState", "LaunchHandle", "Launcher", "LaunchError"]
31
+
32
+
33
+ class LaunchError(Exception):
34
+ """Launch could not start (preflight or spawn failure). Once processes
35
+ are running, failures are reported through `LaunchHandle.poll`, never
36
+ raised — the coordinator must always get a story, not a stack trace."""
37
+
38
+
39
+ class LaunchState(str, enum.Enum):
40
+ """Coarse lifecycle every substrate can map onto (K8s pod phases,
41
+ process exit codes, Slurm job states all reduce to these five)."""
42
+
43
+ PENDING = "PENDING" # accepted, processes not yet running
44
+ RUNNING = "RUNNING" # at least one worker alive, none failed
45
+ SUCCEEDED = "SUCCEEDED" # all workers exited 0
46
+ FAILED = "FAILED" # any worker exited non-zero / infra killed it
47
+ CANCELLED = "CANCELLED" # cancel() honored
48
+
49
+ @property
50
+ def terminal(self) -> bool:
51
+ return self in (LaunchState.SUCCEEDED, LaunchState.FAILED, LaunchState.CANCELLED)
52
+
53
+
54
+ class LaunchHandle(ABC):
55
+ """A live (or finished) launch. Handles are cheap views over substrate
56
+ state — polling must be side-effect free and safe to call forever."""
57
+
58
+ @abstractmethod
59
+ def poll(self) -> LaunchState:
60
+ """Current state. Must be non-blocking and idempotent. After a
61
+ terminal state is returned once, every later call returns the same
62
+ terminal state (substrates that garbage-collect finished jobs must
63
+ cache the outcome)."""
64
+
65
+ @abstractmethod
66
+ def cancel(self) -> None:
67
+ """Best-effort stop: idempotent, non-blocking, never raises on an
68
+ already-terminal launch. The next `poll()` after a successful
69
+ cancel eventually reports CANCELLED (grace periods allowed)."""
70
+
71
+ def wait(self, timeout_seconds: float | None = None) -> LaunchState:
72
+ """Convenience: poll until terminal or timeout; returns the last
73
+ observed state (callers check `.terminal`). Default implementation
74
+ polls at 1 s; substrates with native wait primitives override."""
75
+ import time
76
+
77
+ deadline = None if timeout_seconds is None else time.monotonic() + timeout_seconds
78
+ while True:
79
+ state = self.poll()
80
+ if state.terminal or (deadline and time.monotonic() >= deadline):
81
+ return state
82
+ time.sleep(1.0)
83
+
84
+ def logs(self, tail_lines: int = 200) -> str:
85
+ """Recent combined output for the ledger/dashboard. Optional —
86
+ default is an honest empty string, never fabricated content."""
87
+ return ""
88
+
89
+ @property
90
+ def execution_id(self) -> str:
91
+ """Substrate-native identifier (pid list, RayJob name, Slurm job
92
+ id) for the ledger's `runtime_execution_id`. Default: unnamed."""
93
+ return ""
94
+
95
+
96
+ class Launcher(ABC):
97
+ """One way of starting process groups.
98
+
99
+ Contract:
100
+ - `healthy()` is the ONLY place environment preflight lives (binary on
101
+ PATH, cluster reachable, quota available). Compilers stay pure.
102
+ - `launch()` must either raise `LaunchError` before anything started,
103
+ or return a handle — never leave orphaned half-started state without
104
+ a handle that can cancel it.
105
+ - Secrets/credentials are injected here (from the launcher's own
106
+ config), merged UNDER `spec.env` so a plan can never override
107
+ security-relevant variables.
108
+ """
109
+
110
+ #: Name matching `StrategyPlan.launcher` values
111
+ #: ("local", "torchrun", "kubernetes", "slurm", "skypilot",
112
+ #: "flashruntime-leases").
113
+ name: ClassVar[str]
114
+
115
+ def healthy(self) -> tuple[bool, str]:
116
+ """Preflight: can this launcher launch right now? Returns
117
+ (ok, human-readable reason). Called before spending money; a False
118
+ here routes the coordinator to PAUSE, not to a doomed launch."""
119
+ return True, "no preflight implemented"
120
+
121
+ @abstractmethod
122
+ def launch(self, spec: LaunchSpec, job_id: str, attempt_id: str) -> LaunchHandle:
123
+ """Start the group described by `spec`.
124
+
125
+ Inputs: the compiled spec; job/attempt ids for labeling substrate
126
+ resources (the flashml.dev/* label convention) so operators can
127
+ trace any pod/process back to its ledger entry.
128
+ Output: a `LaunchHandle` (see its contract).
129
+ Raises: `LaunchError` only for failures *before* anything runs.
130
+ """
@@ -0,0 +1,126 @@
1
+ """LocalProcessLauncher — the first concrete Launcher.
2
+
3
+ Runs a LaunchSpec as one OS process on this machine: cwd from
4
+ `workdir_hint`, the caller's environment merged UNDER `spec.env` and the
5
+ FlashRuntime contract variables, stdout+stderr captured to a log file in
6
+ the attempt's output directory. This is Mode 0 execution and the substrate
7
+ under `flash.submit(...)`'s local path.
8
+
9
+ Contract variables exported to the child (opt-in for user code):
10
+ FLASHML_OUTPUT_DIR — per-attempt scratch/output directory
11
+ FLASHML_CKPT_DIR — per-JOB checkpoint tree (attempts share it, so a
12
+ restarted attempt can restore its predecessor's
13
+ manifests — the resume path depends on this)
14
+ FLASHML_JOB_ID / FLASHML_ATTEMPT_ID
15
+
16
+ Honors the Launcher contract: LaunchError only before a process exists;
17
+ after that, every failure is reported through poll(), never raised — and
18
+ this launcher never retries (recovery belongs to the coordinator).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import subprocess
25
+ from pathlib import Path
26
+
27
+ from flashruntime.launchers import Launcher, LaunchError, LaunchHandle, LaunchState
28
+ from flashruntime.strategies import LaunchSpec
29
+
30
+
31
+ class LocalLaunchHandle(LaunchHandle):
32
+ def __init__(self, proc: subprocess.Popen, log_path: Path, output_dir: Path):
33
+ self._proc = proc
34
+ self._log_path = log_path
35
+ self.output_dir = output_dir
36
+ self._final: LaunchState | None = None
37
+ self._cancelled = False
38
+
39
+ def poll(self) -> LaunchState:
40
+ if self._final is not None:
41
+ return self._final
42
+ code = self._proc.poll()
43
+ if code is None:
44
+ return LaunchState.RUNNING
45
+ if self._cancelled:
46
+ self._final = LaunchState.CANCELLED
47
+ else:
48
+ self._final = LaunchState.SUCCEEDED if code == 0 else LaunchState.FAILED
49
+ return self._final
50
+
51
+ def cancel(self) -> None:
52
+ if self.poll().terminal:
53
+ return
54
+ self._cancelled = True
55
+ self._proc.terminate()
56
+
57
+ def wait(self, timeout_seconds: float | None = None) -> LaunchState:
58
+ # native wait beats the ABC's 1 s polling loop
59
+ try:
60
+ self._proc.wait(timeout=timeout_seconds)
61
+ except subprocess.TimeoutExpired:
62
+ pass
63
+ return self.poll()
64
+
65
+ def logs(self, tail_lines: int = 200) -> str:
66
+ if not self._log_path.is_file():
67
+ return ""
68
+ lines = self._log_path.read_text(errors="replace").splitlines()
69
+ return "\n".join(lines[-tail_lines:])
70
+
71
+ @property
72
+ def execution_id(self) -> str:
73
+ return str(self._proc.pid)
74
+
75
+ @property
76
+ def exit_code(self) -> int | None:
77
+ """The child's raw OS return code once terminal (None while running).
78
+ The coarse LaunchState collapses every nonzero exit to FAILED, but
79
+ recovery needs the number: 137/-9 (signal death) and a bare SystemExit
80
+ classify differently from an ImportError traceback. Populated by the
81
+ preceding poll()/wait(); a negative value is a POSIX signal number."""
82
+ return self._proc.returncode
83
+
84
+
85
+ class LocalProcessLauncher(Launcher):
86
+ name = "local"
87
+
88
+ def __init__(self, output_root: str | Path):
89
+ self._output_root = Path(output_root)
90
+
91
+ def launch(self, spec: LaunchSpec, job_id: str, attempt_id: str) -> LocalLaunchHandle:
92
+ workdir = Path(spec.workdir_hint or ".").expanduser()
93
+ if not spec.argv:
94
+ raise LaunchError("empty argv")
95
+ if not workdir.is_dir():
96
+ raise LaunchError(f"workdir does not exist: {workdir}")
97
+ outdir = self._output_root / job_id / attempt_id
98
+ outdir.mkdir(parents=True, exist_ok=True)
99
+ for name, content in spec.files.items():
100
+ (outdir / name).write_text(content)
101
+ env = {
102
+ **os.environ,
103
+ **spec.env,
104
+ "FLASHML_OUTPUT_DIR": str(outdir),
105
+ "FLASHML_CKPT_DIR": str(self._output_root / job_id / "ckpt"),
106
+ "FLASHML_JOB_ID": job_id,
107
+ "FLASHML_ATTEMPT_ID": attempt_id,
108
+ }
109
+ log_path = outdir / "launcher.log"
110
+ # Popen dups the fd into the child, so the parent's handle can (and
111
+ # must) be closed immediately — leaving it open leaks a descriptor
112
+ # and trips ResourceWarning; logs() re-reads the file from disk.
113
+ log_file = open(log_path, "w")
114
+ try:
115
+ proc = subprocess.Popen(
116
+ spec.argv,
117
+ cwd=str(workdir),
118
+ env=env,
119
+ stdout=log_file,
120
+ stderr=subprocess.STDOUT,
121
+ )
122
+ except OSError as exc:
123
+ raise LaunchError(f"failed to start {spec.argv[0]!r}: {exc}") from exc
124
+ finally:
125
+ log_file.close()
126
+ return LocalLaunchHandle(proc, log_path, outdir)
@@ -0,0 +1,27 @@
1
+ """Task lease semantics: assignment, renewal via heartbeat, expiration.
2
+
3
+ A lease grants one node the right to execute a task attempt for a bounded
4
+ period. Missed heartbeats past the deadline expire the lease and the task
5
+ is reassigned. Only one attempt may ever commit — late duplicates are
6
+ rejected (and recorded).
7
+
8
+ Embeddable, pure-Python, no I/O:
9
+
10
+ from flashruntime.leases import LeaseManager
11
+ from flashruntime.protocol.v1alpha1 import TaskSpec
12
+
13
+ mgr = LeaseManager(on_event=print)
14
+ mgr.add_task(TaskSpec(task_id="t1", job_id="j1", commit_key="j1/t1"))
15
+ lease = mgr.claim(node_id="laptop-1")
16
+ ... work ...
17
+ mgr.heartbeat(lease.lease_id)
18
+ mgr.complete(lease.lease_id, output_sha256="...")
19
+
20
+ The FlashRuntime service exposes this same manager over HTTP; flashnode's
21
+ device executor is its remote client.
22
+ """
23
+
24
+ from flashruntime.leases.manager import LeaseError, LeaseManager
25
+ from flashruntime.leases.store import InMemoryLeaseStore, LeaseStore, TaskRecord
26
+
27
+ __all__ = ["LeaseManager", "LeaseError", "LeaseStore", "InMemoryLeaseStore", "TaskRecord"]