hfdask 0.1.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.
- hfdask/__init__.py +30 -0
- hfdask/bootstrap.py +86 -0
- hfdask/cli.py +348 -0
- hfdask/client.py +135 -0
- hfdask/cluster.py +466 -0
- hfdask/config.py +433 -0
- hfdask/hardware.py +195 -0
- hfdask/jobs.py +207 -0
- hfdask/network.py +359 -0
- hfdask/py.typed +0 -0
- hfdask/routing.py +89 -0
- hfdask/runner.py +315 -0
- hfdask-0.1.0.dist-info/METADATA +278 -0
- hfdask-0.1.0.dist-info/RECORD +17 -0
- hfdask-0.1.0.dist-info/WHEEL +4 -0
- hfdask-0.1.0.dist-info/entry_points.txt +3 -0
- hfdask-0.1.0.dist-info/licenses/LICENSE +203 -0
hfdask/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Run Dask workloads on Hugging Face Jobs.
|
|
2
|
+
|
|
3
|
+
`JobSpec` describes a workload and `submit` launches it on one paid HF Job.
|
|
4
|
+
Multi-machine and persistent clusters use `hfdask.cluster`; the CLI ships a
|
|
5
|
+
locked Git project and runs an ordinary Python script from a YAML definition.
|
|
6
|
+
|
|
7
|
+
## Module reference
|
|
8
|
+
|
|
9
|
+
- [Jobs](hfdask/jobs.html) — workload specifications and single-Job lifecycle
|
|
10
|
+
- [Clusters](hfdask/cluster.html) — worker groups, identities, launch, and recovery
|
|
11
|
+
- [Clients](hfdask/client.html) — persistent Dask connections over the encrypted mesh
|
|
12
|
+
- [Routing](hfdask/routing.html) — categorical worker affinity and resource reservations
|
|
13
|
+
- [Configuration](hfdask/config.html) — strict input schemas and validation contracts
|
|
14
|
+
- [CLI](hfdask/cli.html) — source packaging, staging, and script submission
|
|
15
|
+
- [Runner](hfdask/runner.html) — in-Job workload execution
|
|
16
|
+
- [Hardware](hfdask/hardware.html) — detected worker profiles and resources
|
|
17
|
+
- [Network](hfdask/network.html) — authenticated transport and loopback proxies
|
|
18
|
+
|
|
19
|
+
## Lifecycle and safety
|
|
20
|
+
|
|
21
|
+
Submission reserves paid capacity. Keep recovery manifests until termination
|
|
22
|
+
is verified; a local timeout or client disconnect does not release remote Jobs.
|
|
23
|
+
HF credentials stay with the submitter. Only run trusted workloads and grant
|
|
24
|
+
mounts the minimum access they need.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from .cluster import WorkerGroup
|
|
28
|
+
from .jobs import Job, JobFailed, JobSpec, submit
|
|
29
|
+
|
|
30
|
+
__all__ = ["Job", "JobFailed", "JobSpec", "WorkerGroup", "submit"]
|
hfdask/bootstrap.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Stage and launch a locked project using only the image's standard library."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tarfile
|
|
11
|
+
from pathlib import Path, PurePosixPath
|
|
12
|
+
|
|
13
|
+
SOURCE = Path("/tmp/hfdask-source/project.tar.gz")
|
|
14
|
+
ROOT = Path("/tmp/hfdask-project")
|
|
15
|
+
MAX_ARCHIVE_BYTES = 8 * 1024 * 1024
|
|
16
|
+
MAX_SOURCE_BYTES = 8 * 1024 * 1024
|
|
17
|
+
MAX_FILES = 2000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def require_verified_payload(payload: bytes, checksum: str) -> None:
|
|
21
|
+
if len(payload) > MAX_ARCHIVE_BYTES:
|
|
22
|
+
raise SystemExit("hfdask compressed source exceeds 8 MiB")
|
|
23
|
+
if hashlib.sha256(payload).hexdigest() != checksum:
|
|
24
|
+
raise SystemExit("hfdask source SHA256 checksum mismatch")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def require_extraction_budget(file_count: int, total: int) -> None:
|
|
28
|
+
if file_count > MAX_FILES or total > MAX_SOURCE_BYTES:
|
|
29
|
+
raise SystemExit("hfdask source archive exceeds extraction limits")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def require_safe_archive_member(
|
|
33
|
+
member: tarfile.TarInfo, names: set[PurePosixPath]
|
|
34
|
+
) -> PurePosixPath:
|
|
35
|
+
path = PurePosixPath(member.name)
|
|
36
|
+
if (
|
|
37
|
+
not member.isfile()
|
|
38
|
+
or path.is_absolute()
|
|
39
|
+
or ".." in path.parts
|
|
40
|
+
or not path.parts
|
|
41
|
+
or member.size < 0
|
|
42
|
+
or path in names
|
|
43
|
+
):
|
|
44
|
+
raise SystemExit("Unsafe hfdask source archive")
|
|
45
|
+
return path
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main() -> None:
|
|
49
|
+
groups = json.loads(sys.argv[1])
|
|
50
|
+
with SOURCE.open("rb") as source:
|
|
51
|
+
payload = source.read(MAX_ARCHIVE_BYTES + 1)
|
|
52
|
+
require_verified_payload(payload, sys.argv[2])
|
|
53
|
+
print("hfdask: staging project source", flush=True)
|
|
54
|
+
ROOT.mkdir(mode=0o700, parents=True, exist_ok=False)
|
|
55
|
+
with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive:
|
|
56
|
+
members: list[tarfile.TarInfo] = []
|
|
57
|
+
total = 0
|
|
58
|
+
names: set[PurePosixPath] = set()
|
|
59
|
+
for member in archive:
|
|
60
|
+
total += member.size
|
|
61
|
+
require_extraction_budget(len(members) + 1, total)
|
|
62
|
+
path = require_safe_archive_member(member, names)
|
|
63
|
+
names.add(path)
|
|
64
|
+
members.append(member)
|
|
65
|
+
for member in members:
|
|
66
|
+
target = ROOT.joinpath(*PurePosixPath(member.name).parts)
|
|
67
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
extracted = archive.extractfile(member)
|
|
69
|
+
if extracted is None:
|
|
70
|
+
raise SystemExit("Unsafe hfdask source archive")
|
|
71
|
+
with extracted, target.open("xb") as output:
|
|
72
|
+
shutil.copyfileobj(extracted, output)
|
|
73
|
+
target.chmod(member.mode & 0o777)
|
|
74
|
+
os.chdir(ROOT)
|
|
75
|
+
uv = shutil.which("uv")
|
|
76
|
+
if uv is None:
|
|
77
|
+
raise SystemExit("environment.image must contain uv and Python")
|
|
78
|
+
print("hfdask: syncing locked environment", flush=True)
|
|
79
|
+
group_args = [arg for group in groups for arg in ("--group", group)]
|
|
80
|
+
subprocess.run([uv, "sync", "--locked", "--no-dev", *group_args], check=True)
|
|
81
|
+
print("hfdask: starting runner", flush=True)
|
|
82
|
+
os.execv(uv, [uv, "run", "--no-sync", *sys.argv[3:]])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
main()
|
hfdask/cli.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""YAML-driven batch submission of a locked, Git-selected source project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import secrets
|
|
11
|
+
import stat
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tarfile
|
|
15
|
+
import tempfile
|
|
16
|
+
import tomllib
|
|
17
|
+
from dataclasses import replace
|
|
18
|
+
from functools import partial
|
|
19
|
+
from importlib import import_module
|
|
20
|
+
from importlib.resources import files
|
|
21
|
+
from pathlib import Path, PurePosixPath
|
|
22
|
+
from typing import Any
|
|
23
|
+
from uuid import uuid4
|
|
24
|
+
|
|
25
|
+
from huggingface_hub import HfApi, Volume
|
|
26
|
+
|
|
27
|
+
from .cluster import Cluster, Identity, LaunchError, submit_cluster
|
|
28
|
+
from .config import MOUNT_SOURCE, ClusterConfig, PackageConfig, PackagePlan, ProjectConfig
|
|
29
|
+
from .jobs import JobSpec
|
|
30
|
+
|
|
31
|
+
# Bound both upload size and extracted working-tree size.
|
|
32
|
+
MAX_ARCHIVE_BYTES = 8 * 1024 * 1024
|
|
33
|
+
MAX_SOURCE_BYTES = 8 * 1024 * 1024
|
|
34
|
+
MAX_FILES = 2000
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _secret_path(path: PurePosixPath) -> bool:
|
|
38
|
+
return any(
|
|
39
|
+
part
|
|
40
|
+
in {
|
|
41
|
+
".git",
|
|
42
|
+
".venv",
|
|
43
|
+
".ssh",
|
|
44
|
+
".aws",
|
|
45
|
+
".gnupg",
|
|
46
|
+
".hfdask",
|
|
47
|
+
"mise.local.toml",
|
|
48
|
+
".mise.local.toml",
|
|
49
|
+
".netrc",
|
|
50
|
+
".pypirc",
|
|
51
|
+
"credentials",
|
|
52
|
+
"credentials.json",
|
|
53
|
+
"id_rsa",
|
|
54
|
+
"id_ed25519",
|
|
55
|
+
"secrets",
|
|
56
|
+
".secrets",
|
|
57
|
+
}
|
|
58
|
+
or part.startswith((".env", "secrets."))
|
|
59
|
+
or part.endswith((".env", ".pem", ".key", ".p12", ".pfx"))
|
|
60
|
+
for part in path.parts
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def require_safe_project_file(root: Path, name: str) -> Path | None:
|
|
65
|
+
relative = PurePosixPath(name)
|
|
66
|
+
if relative.is_absolute() or ".." in relative.parts:
|
|
67
|
+
raise ValueError(f"Unsafe project path: {name}")
|
|
68
|
+
if _secret_path(relative):
|
|
69
|
+
return None
|
|
70
|
+
candidate = root
|
|
71
|
+
for part in relative.parts:
|
|
72
|
+
candidate = candidate / part
|
|
73
|
+
if candidate.is_symlink():
|
|
74
|
+
raise ValueError(f"Project symlinks are not supported: {name}; exclude with .gitignore")
|
|
75
|
+
if not candidate.exists(): # Tracked files may be deleted in the working tree.
|
|
76
|
+
return None
|
|
77
|
+
if not stat.S_ISREG(candidate.stat().st_mode):
|
|
78
|
+
raise ValueError(f"Project entry is not a regular file: {name}")
|
|
79
|
+
return candidate
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def require_source_budget(total: int, file_count: int) -> None:
|
|
83
|
+
if total > MAX_SOURCE_BYTES or file_count > MAX_FILES:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
"Project source exceeds 8 MiB/2000 files; move data to bucket mounts "
|
|
86
|
+
"and exclude generated files with .gitignore"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def require_project_inputs(files: dict[str, bytes], script: str) -> None:
|
|
91
|
+
for required in ("pyproject.toml", "uv.lock", script):
|
|
92
|
+
if required not in files:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"Required project file {required} is missing or excluded; "
|
|
95
|
+
"run uv lock and check .gitignore"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def require_upload_budget(payload: bytes) -> bytes:
|
|
100
|
+
if len(payload) > MAX_ARCHIVE_BYTES:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
"Compressed source exceeds 8 MiB upload limit; move data to bucket "
|
|
103
|
+
"mounts and exclude generated files with .gitignore"
|
|
104
|
+
)
|
|
105
|
+
return payload
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def package_project(root: Path, script: str, groups: list[str]) -> bytes:
|
|
109
|
+
"""Snapshot working-tree files, not HEAD; never follow project symlinks."""
|
|
110
|
+
inputs = PackageConfig(script=script, groups=groups)
|
|
111
|
+
script = inputs.script
|
|
112
|
+
try:
|
|
113
|
+
selected = subprocess.run(
|
|
114
|
+
["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "."],
|
|
115
|
+
cwd=root,
|
|
116
|
+
check=True,
|
|
117
|
+
capture_output=True,
|
|
118
|
+
).stdout.split(b"\0")
|
|
119
|
+
# --exclude-standard only filters untracked files; also exclude ignored tracked files.
|
|
120
|
+
ignored = set(
|
|
121
|
+
subprocess.run(
|
|
122
|
+
["git", "ls-files", "--cached", "--ignored", "--exclude-standard", "-z", "--", "."],
|
|
123
|
+
cwd=root,
|
|
124
|
+
check=True,
|
|
125
|
+
capture_output=True,
|
|
126
|
+
).stdout.split(b"\0")
|
|
127
|
+
)
|
|
128
|
+
except (OSError, subprocess.CalledProcessError) as error:
|
|
129
|
+
raise ValueError("Run from a Git project containing pyproject.toml and uv.lock") from error
|
|
130
|
+
files: dict[str, bytes] = {}
|
|
131
|
+
modes: dict[str, int] = {}
|
|
132
|
+
total = 0
|
|
133
|
+
for raw in sorted(set(selected) - ignored - {b""}):
|
|
134
|
+
name = os.fsdecode(raw)
|
|
135
|
+
candidate = require_safe_project_file(root, name)
|
|
136
|
+
if candidate is None:
|
|
137
|
+
continue
|
|
138
|
+
metadata = candidate.stat()
|
|
139
|
+
total += metadata.st_size
|
|
140
|
+
require_source_budget(total, len(files) + 1)
|
|
141
|
+
files[name] = candidate.read_bytes()
|
|
142
|
+
modes[name] = 0o755 if metadata.st_mode & 0o111 else 0o644
|
|
143
|
+
require_project_inputs(files, script)
|
|
144
|
+
metadata = tomllib.loads(files["pyproject.toml"].decode())
|
|
145
|
+
PackagePlan.model_validate(
|
|
146
|
+
{
|
|
147
|
+
"script": script,
|
|
148
|
+
"groups": inputs.groups,
|
|
149
|
+
"project": ProjectConfig.model_validate(metadata.get("project", {})),
|
|
150
|
+
"dependency-groups": metadata.get("dependency-groups", {}),
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
buffer = io.BytesIO()
|
|
154
|
+
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
|
|
155
|
+
for name, data in files.items():
|
|
156
|
+
member = tarfile.TarInfo(name)
|
|
157
|
+
member.size = len(data)
|
|
158
|
+
member.mode = modes[name]
|
|
159
|
+
archive.addfile(member, io.BytesIO(data))
|
|
160
|
+
return require_upload_budget(buffer.getvalue())
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def require_private_source_bucket(api: HfApi, bucket_id: str) -> None:
|
|
164
|
+
if api.bucket_info(bucket_id).private is not True:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
f"Source bucket {bucket_id} must be verified private; "
|
|
167
|
+
"refusing upload without changing visibility"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def prepare_spec(spec: JobSpec, root: Path, script: str, groups: list[str], api: HfApi) -> JobSpec:
|
|
172
|
+
"""Validate and stage source once; retained artifacts incur storage until deleted."""
|
|
173
|
+
payload = package_project(root, script, groups)
|
|
174
|
+
bucket_id = f"{spec.namespace}/jobs-artifacts"
|
|
175
|
+
folder = f"hfdask-source/{uuid4()}"
|
|
176
|
+
remote_path = f"{folder}/project.tar.gz"
|
|
177
|
+
api.create_bucket(bucket_id, private=True, exist_ok=True)
|
|
178
|
+
require_private_source_bucket(api, bucket_id)
|
|
179
|
+
bootstrap = files("hfdask").joinpath("bootstrap.py").read_bytes()
|
|
180
|
+
api.batch_bucket_files(
|
|
181
|
+
bucket_id,
|
|
182
|
+
add=[
|
|
183
|
+
(payload, remote_path),
|
|
184
|
+
(bootstrap, f"{folder}/bootstrap.py"),
|
|
185
|
+
],
|
|
186
|
+
)
|
|
187
|
+
print(
|
|
188
|
+
f"Source artifact: hf://buckets/{bucket_id}/{remote_path} "
|
|
189
|
+
"(retained after run; storage charges may apply until manually deleted)",
|
|
190
|
+
file=sys.stderr,
|
|
191
|
+
)
|
|
192
|
+
return replace(
|
|
193
|
+
spec,
|
|
194
|
+
bootstrap=(
|
|
195
|
+
"python3",
|
|
196
|
+
"/tmp/hfdask-source/bootstrap.py",
|
|
197
|
+
json.dumps(groups),
|
|
198
|
+
hashlib.sha256(payload).hexdigest(),
|
|
199
|
+
),
|
|
200
|
+
volumes=[
|
|
201
|
+
*spec.volumes,
|
|
202
|
+
Volume(
|
|
203
|
+
type="bucket",
|
|
204
|
+
source=bucket_id,
|
|
205
|
+
path=folder,
|
|
206
|
+
mount_path="/tmp/hfdask-source",
|
|
207
|
+
read_only=True,
|
|
208
|
+
),
|
|
209
|
+
],
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def load_cluster(
|
|
214
|
+
path: Path,
|
|
215
|
+
root: Path,
|
|
216
|
+
script: str,
|
|
217
|
+
) -> tuple[JobSpec, dict[str, Any], float, list[str]]:
|
|
218
|
+
# Load lazily so non-CLI library users do not need PyYAML at import time.
|
|
219
|
+
yaml = import_module("yaml")
|
|
220
|
+
|
|
221
|
+
config = ClusterConfig.model_validate(yaml.safe_load(path.read_text()))
|
|
222
|
+
scheduler_worker = config.coordinator.worker
|
|
223
|
+
volumes: list[Volume] = []
|
|
224
|
+
for mount in config.mounts:
|
|
225
|
+
location = MOUNT_SOURCE.fullmatch(mount.source)
|
|
226
|
+
assert location is not None # Validated by MountConfig; conversion only below.
|
|
227
|
+
kind, namespace, name, subfolder = location.groups()
|
|
228
|
+
volumes.append(
|
|
229
|
+
Volume(
|
|
230
|
+
type=kind[:-1],
|
|
231
|
+
source=f"{namespace}/{name}",
|
|
232
|
+
revision=mount.revision,
|
|
233
|
+
path=subfolder or "",
|
|
234
|
+
mount_path=mount.target,
|
|
235
|
+
read_only=mount.read_only,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
spec = JobSpec(
|
|
239
|
+
namespace=config.namespace,
|
|
240
|
+
image=config.environment.image,
|
|
241
|
+
entrypoint="hfdask.runner:run_script",
|
|
242
|
+
kwargs={"script": script},
|
|
243
|
+
# YAML counts remote workers; JobSpec includes the colocated worker.
|
|
244
|
+
workers=config.workers.count + int(scheduler_worker),
|
|
245
|
+
flavor=config.workers.flavor,
|
|
246
|
+
timeout=config.timeout,
|
|
247
|
+
volumes=volumes,
|
|
248
|
+
env={
|
|
249
|
+
"PYTHONPATH": "/tmp/hfdask-project:"
|
|
250
|
+
+ str(PurePosixPath("/tmp/hfdask-project") / PurePosixPath(script).parent),
|
|
251
|
+
"DASK_DISTRIBUTED__WORKER__DAEMON": "False",
|
|
252
|
+
"DASK_DISTRIBUTED__WORKER__MULTIPROCESSING_METHOD": "spawn",
|
|
253
|
+
"VLLM_WORKER_MULTIPROC_METHOD": "spawn",
|
|
254
|
+
},
|
|
255
|
+
)
|
|
256
|
+
options: dict[str, Any] = {
|
|
257
|
+
"public_relays": True,
|
|
258
|
+
"scheduler_worker": scheduler_worker,
|
|
259
|
+
"scheduler_flavor": config.coordinator.flavor,
|
|
260
|
+
}
|
|
261
|
+
return spec, options, config.timeout_seconds, config.environment.groups
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def save_manifest(path: Path, cluster: Cluster) -> None:
|
|
265
|
+
"""Atomic replacement; manifests contain only public recovery handles."""
|
|
266
|
+
with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as output:
|
|
267
|
+
temporary = Path(output.name)
|
|
268
|
+
try:
|
|
269
|
+
json.dump(cluster.manifest(), output, indent=2)
|
|
270
|
+
output.write("\n")
|
|
271
|
+
output.flush()
|
|
272
|
+
os.fsync(output.fileno())
|
|
273
|
+
except BaseException:
|
|
274
|
+
temporary.unlink(missing_ok=True)
|
|
275
|
+
raise
|
|
276
|
+
try:
|
|
277
|
+
temporary.replace(path)
|
|
278
|
+
finally:
|
|
279
|
+
temporary.unlink(missing_ok=True)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def main(argv: list[str] | None = None) -> int:
|
|
283
|
+
parser = argparse.ArgumentParser(prog="hfdask")
|
|
284
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
285
|
+
run = commands.add_parser("run", help="Run a project-relative Dask script on a YAML cluster")
|
|
286
|
+
run.add_argument("--cluster", type=Path, required=True)
|
|
287
|
+
run.add_argument(
|
|
288
|
+
"--manifest", type=Path, help="Public recovery manifest (default: .hfdask/run-*.json)"
|
|
289
|
+
)
|
|
290
|
+
run.add_argument("script")
|
|
291
|
+
args = parser.parse_args(argv)
|
|
292
|
+
cluster: Cluster | None = None
|
|
293
|
+
manifest: Path = (
|
|
294
|
+
args.manifest
|
|
295
|
+
if args.manifest is not None
|
|
296
|
+
else Path(".hfdask") / f"run-{secrets.token_hex(8)}.json"
|
|
297
|
+
)
|
|
298
|
+
try:
|
|
299
|
+
spec, options, timeout, groups = load_cluster(args.cluster, Path.cwd(), args.script)
|
|
300
|
+
nodes = spec.workers + 1 - int(options["scheduler_worker"])
|
|
301
|
+
identities = [Identity(secrets.token_bytes(32)) for _ in range(nodes)]
|
|
302
|
+
# Validate transport identities before reserving the manifest or submitting.
|
|
303
|
+
for identity in identities:
|
|
304
|
+
identity.public_id()
|
|
305
|
+
|
|
306
|
+
manifest.parent.mkdir(parents=True, exist_ok=True)
|
|
307
|
+
with manifest.open("x") as output:
|
|
308
|
+
output.write("{}\n")
|
|
309
|
+
print(f"Recovery manifest: {manifest}", file=sys.stderr)
|
|
310
|
+
api = HfApi()
|
|
311
|
+
spec = prepare_spec(spec, Path.cwd(), args.script, groups, api)
|
|
312
|
+
try:
|
|
313
|
+
cluster = submit_cluster(
|
|
314
|
+
spec, identities, api=api, **options, on_submitted=partial(save_manifest, manifest)
|
|
315
|
+
)
|
|
316
|
+
except LaunchError as error:
|
|
317
|
+
cluster = error.cluster
|
|
318
|
+
raise
|
|
319
|
+
cluster.wait(timeout=timeout)
|
|
320
|
+
return 0
|
|
321
|
+
# CLI boundary: report failures and release known jobs before returning an exit code.
|
|
322
|
+
except (Exception, KeyboardInterrupt) as error: # noqa: BLE001
|
|
323
|
+
print(f"hfdask: {error or 'interrupted'}", file=sys.stderr)
|
|
324
|
+
if isinstance(error, LaunchError) and error.__cause__ is not None:
|
|
325
|
+
print(f"Submission cause: {error.__cause__}", file=sys.stderr)
|
|
326
|
+
if cluster is not None:
|
|
327
|
+
try:
|
|
328
|
+
save_manifest(manifest, cluster)
|
|
329
|
+
# A manifest failure must not prevent cleanup; print recovery handles instead.
|
|
330
|
+
except Exception as save_error: # noqa: BLE001
|
|
331
|
+
print(
|
|
332
|
+
f"Manifest write failed: {save_error}; known jobs: "
|
|
333
|
+
f"{json.dumps(cluster.manifest())}",
|
|
334
|
+
file=sys.stderr,
|
|
335
|
+
)
|
|
336
|
+
try:
|
|
337
|
+
cluster.close()
|
|
338
|
+
# Report any cleanup failure without claiming that capacity was released.
|
|
339
|
+
except Exception as close_error: # noqa: BLE001
|
|
340
|
+
print(f"Cleanup unverified: {close_error}; retain {manifest}", file=sys.stderr)
|
|
341
|
+
interrupted = isinstance(error, KeyboardInterrupt) or (
|
|
342
|
+
isinstance(error, LaunchError) and isinstance(error.__cause__, KeyboardInterrupt)
|
|
343
|
+
)
|
|
344
|
+
return 130 if interrupted else 1
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
if __name__ == "__main__":
|
|
348
|
+
raise SystemExit(main())
|
hfdask/client.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""External synchronous Dask clients over the cluster's encrypted mesh."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import concurrent.futures
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .cluster import Identity
|
|
13
|
+
from .config import ConnectConfig, ConnectionConfig
|
|
14
|
+
|
|
15
|
+
_connection_lock = threading.Lock()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def _serve(
|
|
19
|
+
config: ConnectionConfig,
|
|
20
|
+
identity: Identity,
|
|
21
|
+
ready: concurrent.futures.Future[tuple[int, ...]],
|
|
22
|
+
stop: threading.Event,
|
|
23
|
+
timeout: float,
|
|
24
|
+
) -> None:
|
|
25
|
+
import iroh
|
|
26
|
+
|
|
27
|
+
from .hardware import service_owners
|
|
28
|
+
from .network import ALPN, Mesh, fetch_worker_counts
|
|
29
|
+
|
|
30
|
+
# FFI annotates BaseEventLoop but uses the standard AbstractEventLoop interface.
|
|
31
|
+
iroh.iroh_ffi.uniffi_set_event_loop(asyncio.get_running_loop()) # ty: ignore[invalid-argument-type]
|
|
32
|
+
mode = (
|
|
33
|
+
iroh.RelayMode.custom_from_urls(config.relays)
|
|
34
|
+
if config.relays
|
|
35
|
+
else iroh.RelayMode.default_mode()
|
|
36
|
+
)
|
|
37
|
+
endpoint = await iroh.Endpoint.bind(
|
|
38
|
+
iroh.EndpointOptions(
|
|
39
|
+
preset=iroh.preset_n0(), secret_key=identity.secret, alpns=[ALPN], relay_mode=mode
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
try:
|
|
43
|
+
await asyncio.wait_for(endpoint.online(), timeout=timeout)
|
|
44
|
+
peers = [
|
|
45
|
+
iroh.EndpointAddr(iroh.EndpointId.from_bytes(bytes.fromhex(peer)), None, [])
|
|
46
|
+
for peer in config.peers
|
|
47
|
+
]
|
|
48
|
+
nodes = config.job_nodes
|
|
49
|
+
workers_per_node = await fetch_worker_counts(endpoint, peers[0], timeout)
|
|
50
|
+
if len(workers_per_node) != nodes:
|
|
51
|
+
raise ConnectionError("Live worker topology does not match the manifest")
|
|
52
|
+
async with Mesh(
|
|
53
|
+
endpoint,
|
|
54
|
+
peers,
|
|
55
|
+
nodes,
|
|
56
|
+
services=service_owners(workers_per_node),
|
|
57
|
+
workers_per_node=workers_per_node,
|
|
58
|
+
max_connections=max(256, 4 * sum(workers_per_node)),
|
|
59
|
+
):
|
|
60
|
+
ready.set_result(workers_per_node)
|
|
61
|
+
while not stop.is_set():
|
|
62
|
+
await asyncio.sleep(0.1)
|
|
63
|
+
finally:
|
|
64
|
+
await endpoint.close()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@contextmanager
|
|
68
|
+
def connect(
|
|
69
|
+
manifest: dict[str, Any], identity: Identity, *, timeout: float = 1200
|
|
70
|
+
) -> Iterator[Any]:
|
|
71
|
+
"""Connect to a persistent cluster without taking ownership of its HF Jobs.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
manifest: Public `hfdask.cluster.Cluster.manifest` from `boot_cluster`.
|
|
75
|
+
identity: Private client identity supplied at boot, not a Job node identity.
|
|
76
|
+
timeout: Positive timeout in seconds used for mesh startup, Dask connection,
|
|
77
|
+
and worker readiness. These phases do not share one total deadline.
|
|
78
|
+
|
|
79
|
+
Yields:
|
|
80
|
+
A synchronous Dask client after the expected worker topology is ready.
|
|
81
|
+
It is not installed as Dask's default client.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
ValueError: If the manifest, identity, or timeout fails validation.
|
|
85
|
+
TimeoutError: If mesh startup or worker readiness exceeds its deadline.
|
|
86
|
+
RuntimeError: If this process already has a mesh client or cleanup cannot finish.
|
|
87
|
+
|
|
88
|
+
One mesh connection per host: fixed loopback ports start at 21000. Keep the
|
|
89
|
+
context open while using futures. Exiting closes the client and local mesh,
|
|
90
|
+
never the HF Jobs; explicitly call `hfdask.cluster.Cluster.close` to release them.
|
|
91
|
+
"""
|
|
92
|
+
from distributed import Client
|
|
93
|
+
|
|
94
|
+
from .runner import wait_topology
|
|
95
|
+
|
|
96
|
+
connection = ConnectionConfig.model_validate(manifest.get("connection"))
|
|
97
|
+
ConnectConfig(timeout=timeout, connection=connection, public_id=identity.public_id())
|
|
98
|
+
|
|
99
|
+
if not _connection_lock.acquire(blocking=False):
|
|
100
|
+
raise RuntimeError("A mesh client is already connected in this process")
|
|
101
|
+
ready: concurrent.futures.Future[tuple[int, ...]] = concurrent.futures.Future()
|
|
102
|
+
stopped = threading.Event()
|
|
103
|
+
loop = asyncio.new_event_loop()
|
|
104
|
+
task: asyncio.Task[None] | None = None
|
|
105
|
+
|
|
106
|
+
def serve() -> None:
|
|
107
|
+
nonlocal task
|
|
108
|
+
asyncio.set_event_loop(loop)
|
|
109
|
+
task = loop.create_task(_serve(connection, identity, ready, stopped, timeout))
|
|
110
|
+
try:
|
|
111
|
+
loop.run_until_complete(task)
|
|
112
|
+
except BaseException as error: # noqa: BLE001 - propagate startup failures across threads.
|
|
113
|
+
if not ready.done():
|
|
114
|
+
ready.set_exception(error)
|
|
115
|
+
finally:
|
|
116
|
+
loop.run_until_complete(loop.shutdown_asyncgens())
|
|
117
|
+
loop.close()
|
|
118
|
+
|
|
119
|
+
thread = threading.Thread(target=serve, name="hfdask-client-mesh", daemon=True)
|
|
120
|
+
try:
|
|
121
|
+
thread.start()
|
|
122
|
+
workers_per_node = ready.result(timeout=timeout + 5)
|
|
123
|
+
with Client("tcp://127.0.0.1:21000", timeout=timeout, set_as_default=False) as client:
|
|
124
|
+
wait_topology(client, workers_per_node, timeout)
|
|
125
|
+
yield client
|
|
126
|
+
finally:
|
|
127
|
+
stopped.set()
|
|
128
|
+
if thread.ident is not None:
|
|
129
|
+
thread.join(timeout=5)
|
|
130
|
+
if thread.is_alive() and not loop.is_closed() and task is not None:
|
|
131
|
+
loop.call_soon_threadsafe(task.cancel)
|
|
132
|
+
thread.join(timeout=5)
|
|
133
|
+
if thread.is_alive():
|
|
134
|
+
raise RuntimeError("Mesh cleanup did not finish; restart this client process")
|
|
135
|
+
_connection_lock.release()
|