simrun 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.
- simrun/__init__.py +3 -0
- simrun/argo.py +449 -0
- simrun/build_constraints.txt +20 -0
- simrun/cgroup.py +77 -0
- simrun/cli.py +350 -0
- simrun/client.py +407 -0
- simrun/config.py +63 -0
- simrun/cwl.py +287 -0
- simrun/demo.py +106 -0
- simrun/edit.py +408 -0
- simrun/environment.py +370 -0
- simrun/ledger.py +286 -0
- simrun/mcp.py +161 -0
- simrun/planner.py +303 -0
- simrun/plugins.py +149 -0
- simrun/plugins_builtin.py +33 -0
- simrun/podspec.py +166 -0
- simrun/resources.py +270 -0
- simrun/service/__init__.py +1 -0
- simrun/service/app.py +1721 -0
- simrun/service/argo_backend.py +842 -0
- simrun/service/auth.py +93 -0
- simrun/service/backend.py +170 -0
- simrun/service/compare.py +77 -0
- simrun/service/controller.py +788 -0
- simrun/service/edits.py +406 -0
- simrun/service/index.py +292 -0
- simrun/service/logs.py +91 -0
- simrun/service/submission.py +349 -0
- simrun/snapshot.py +196 -0
- simrun/store.py +190 -0
- simrun/testing.py +263 -0
- simrun/worker.py +1097 -0
- simrun/workflow.py +188 -0
- simrun-0.1.0.dist-info/METADATA +107 -0
- simrun-0.1.0.dist-info/RECORD +39 -0
- simrun-0.1.0.dist-info/WHEEL +4 -0
- simrun-0.1.0.dist-info/entry_points.txt +10 -0
- simrun-0.1.0.dist-info/licenses/LICENSE +7 -0
simrun/environment.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""The environment of a run: a lock from the model and its snapshotted libraries.
|
|
2
|
+
|
|
3
|
+
The image of a run holds only the third party dependencies. The code of the model
|
|
4
|
+
repository and of the libraries comes from snapshots which the pod mounts read-only
|
|
5
|
+
and installs editable at start (`SIMRUN_EDITABLE`, see `simrun.worker`), so a change
|
|
6
|
+
of the code never rebuilds an image and a change of a dependency does.
|
|
7
|
+
|
|
8
|
+
A pod installs the snapshots without build isolation and without an index, so the
|
|
9
|
+
image holds their build backends: the `[build-system].requires` of the model and of
|
|
10
|
+
every library (the legacy backend of setuptools without a `[build-system]`, PEP 517)
|
|
11
|
+
and what the backend asks for an editable build (`get_requires_for_build_editable`,
|
|
12
|
+
PEP 660, e.g. `editables` of hatchling), which a hook asks at resolve time in an
|
|
13
|
+
environment of `uv run` with the static requirements, in a shadow tree of the
|
|
14
|
+
snapshot. They are dependencies of the temporary project, so the lock pins them with
|
|
15
|
+
the third party dependencies and the image installs them. Their versions are the pins
|
|
16
|
+
of a constraints file of the service (`build_constraints.txt`, `BUILD_CONSTRAINTS`),
|
|
17
|
+
for the packages the `uv.lock` of the model does not have, so a release of a backend
|
|
18
|
+
changes no lock.
|
|
19
|
+
|
|
20
|
+
The lock is resolved in a temporary project, a writable shadow tree of the model
|
|
21
|
+
(`simrun.worker.shadow_tree`: its directories, links to its files) with its
|
|
22
|
+
`pyproject.toml` changed and a copy of its `uv.lock`, and a directory
|
|
23
|
+
`.simrun-store` with a shadow tree of every library at `.simrun-store/<snapshot>`.
|
|
24
|
+
`uv lock` builds the metadata of a source with dynamic metadata, which a backend
|
|
25
|
+
writes next to the sources (e.g. the `*.egg-info` of setuptools): into the shadows,
|
|
26
|
+
never into the snapshots, which are named by the hash of their content. The sources
|
|
27
|
+
of the libraries are the relative paths `.simrun-store/<snapshot>`, so the lock
|
|
28
|
+
carries these paths and not the place the store is mounted at (`/mnt/cluster` on the
|
|
29
|
+
development server, `/store` in the service): the same snapshots give the same lock
|
|
30
|
+
and hash anywhere.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
import logging
|
|
36
|
+
import re
|
|
37
|
+
import subprocess
|
|
38
|
+
import tempfile
|
|
39
|
+
import tomllib
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
import tomlkit
|
|
44
|
+
|
|
45
|
+
from simrun.snapshot import SourceProvenance
|
|
46
|
+
from simrun.worker import shadow_tree
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
# the directory of the shadows of the libraries in the project which is locked, a
|
|
51
|
+
# name which a model repository does not use
|
|
52
|
+
SHADOWS_DIR = ".simrun-store"
|
|
53
|
+
# the pinned versions of the build backends, see the file
|
|
54
|
+
BUILD_CONSTRAINTS = Path(__file__).with_name("build_constraints.txt")
|
|
55
|
+
NAME = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
|
|
56
|
+
PIN = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)\s*==\s*([^\s;#]+)$")
|
|
57
|
+
# what the worker of simrun imports besides the standard library
|
|
58
|
+
WORKER_REQUIREMENTS = ("pydantic>=2.14", "pyyaml>=6.0.3")
|
|
59
|
+
LOCAL_SOURCES = ("directory", "editable", "path", "virtual")
|
|
60
|
+
# the build system of a project without `[build-system]` (PEP 517)
|
|
61
|
+
DEFAULT_REQUIRES = ("setuptools>=40.8.0",)
|
|
62
|
+
DEFAULT_BACKEND = "setuptools.build_meta:__legacy__"
|
|
63
|
+
# asks a build backend in the source tree what it needs for an editable build
|
|
64
|
+
EDITABLE_HOOK = """
|
|
65
|
+
import importlib, json, os, sys
|
|
66
|
+
backend, source, out, *paths = sys.argv[1:]
|
|
67
|
+
os.chdir(source)
|
|
68
|
+
sys.path[:0] = [os.path.join(source, path) for path in paths]
|
|
69
|
+
module, _, attributes = backend.partition(":")
|
|
70
|
+
target = importlib.import_module(module)
|
|
71
|
+
for attribute in filter(None, attributes.split(".")):
|
|
72
|
+
target = getattr(target, attribute)
|
|
73
|
+
hook = getattr(target, "get_requires_for_build_editable", None)
|
|
74
|
+
with open(out, "w") as handle:
|
|
75
|
+
json.dump([] if hook is None else list(hook()), handle)
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def normalise(name: str) -> str:
|
|
80
|
+
"""Normalised name of a package (PEP 503), as `uv.lock` writes it."""
|
|
81
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _reject_host_paths(lock: str, allowed: list[str]) -> None:
|
|
85
|
+
"""Raise when the lock has a local package which is not a snapshotted source."""
|
|
86
|
+
for package in tomllib.loads(lock).get("package", []):
|
|
87
|
+
source = package.get("source", {})
|
|
88
|
+
url = str(source.get("url", ""))
|
|
89
|
+
local = any(key in source for key in LOCAL_SOURCES) or url.startswith("file:")
|
|
90
|
+
if local and normalise(package["name"]) not in {normalise(a) for a in allowed}:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"The model depends on the local package {package['name']!r}, which "
|
|
93
|
+
"is not a snapshotted source: pass its working tree as a source"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_constraints(path: Path) -> dict[str, str]:
|
|
98
|
+
"""The pins of a constraints file by normalised name, e.g. `hatchling==1.33.0`.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
ValueError: A line is no exact pin `<name>==<version>`.
|
|
102
|
+
"""
|
|
103
|
+
pins: dict[str, str] = {}
|
|
104
|
+
for line in path.read_text().splitlines():
|
|
105
|
+
text = line.partition("#")[0].strip()
|
|
106
|
+
if not text:
|
|
107
|
+
continue
|
|
108
|
+
found = PIN.match(text)
|
|
109
|
+
if found is None:
|
|
110
|
+
raise ValueError(f"{path}: {text!r} is no pin <name>==<version>")
|
|
111
|
+
pins[normalise(found[1])] = f"{found[1]}=={found[2]}"
|
|
112
|
+
return pins
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _pinned(requirements: list[str], pins: dict[str, str]) -> list[str]:
|
|
116
|
+
"""The pins of the packages of requirements, for `uv run --with`."""
|
|
117
|
+
names = [NAME.match(r) for r in requirements]
|
|
118
|
+
return [pins[normalise(n[1])] for n in names if n and normalise(n[1]) in pins]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class Environment:
|
|
123
|
+
"""The resolved environment of a run.
|
|
124
|
+
|
|
125
|
+
Attributes:
|
|
126
|
+
lock: Text of the `uv.lock`.
|
|
127
|
+
lock_sha256: Hash of the lock and the python version, the identity of the image.
|
|
128
|
+
python: Python version of the image.
|
|
129
|
+
simrun_version: Version of the simrun wheel the image installs.
|
|
130
|
+
editable: Snapshot directories of the model and the libraries, relative to
|
|
131
|
+
the store in the order of the sources, installed at pod start.
|
|
132
|
+
requirements: The third party dependencies of the lock as pinned requirements,
|
|
133
|
+
without the model and the snapshotted libraries.
|
|
134
|
+
build_requirements: The requirements of the build backends of the sources,
|
|
135
|
+
which the lock pins with the dependencies.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
lock: str
|
|
139
|
+
lock_sha256: str
|
|
140
|
+
python: str
|
|
141
|
+
simrun_version: str
|
|
142
|
+
editable: list[str]
|
|
143
|
+
requirements: str = field(default="", compare=False)
|
|
144
|
+
build_requirements: tuple[str, ...] = field(default=(), compare=False)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _uv(args: list[str], cwd: Path) -> str:
|
|
148
|
+
result = subprocess.run(["uv", *args], cwd=cwd, capture_output=True, text=True)
|
|
149
|
+
if result.returncode != 0:
|
|
150
|
+
raise RuntimeError(
|
|
151
|
+
f"uv {args[0]} failed with exit code {result.returncode}: "
|
|
152
|
+
f"{result.stderr.strip()}"
|
|
153
|
+
)
|
|
154
|
+
return result.stdout
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _pyproject(store: Path, source: SourceProvenance) -> Path:
|
|
158
|
+
path = store / source.snapshot / "pyproject.toml"
|
|
159
|
+
if not path.is_file():
|
|
160
|
+
raise ValueError(f"The source {source.name!r} has no pyproject.toml: {path}")
|
|
161
|
+
return path
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _package_name(path: Path) -> str:
|
|
165
|
+
with path.open("rb") as handle:
|
|
166
|
+
name = tomllib.load(handle).get("project", {}).get("name")
|
|
167
|
+
if not isinstance(name, str):
|
|
168
|
+
raise ValueError(f"{path} has no [project].name")
|
|
169
|
+
return name
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def build_requirements(
|
|
173
|
+
directory: Path, name: str, python: str, pins: dict[str, str] | None = None
|
|
174
|
+
) -> list[str]:
|
|
175
|
+
"""What building a source editable needs: its build system and its backend's hook.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
directory: The snapshot of the source, with its `pyproject.toml`.
|
|
179
|
+
name: The name of the source, for messages.
|
|
180
|
+
python: The python version the hook runs with.
|
|
181
|
+
pins: The pinned versions of build backends (`load_constraints`), applied
|
|
182
|
+
to the environment the hook runs in.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
`[build-system].requires` and the result of the backend's
|
|
186
|
+
`get_requires_for_build_editable`.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
RuntimeError: The backend cannot be loaded or its hook fails.
|
|
190
|
+
"""
|
|
191
|
+
with (directory / "pyproject.toml").open("rb") as handle:
|
|
192
|
+
system = tomllib.load(handle).get("build-system")
|
|
193
|
+
if system is None:
|
|
194
|
+
requires, backend, paths = list(DEFAULT_REQUIRES), DEFAULT_BACKEND, []
|
|
195
|
+
else:
|
|
196
|
+
requires = [str(r) for r in system.get("requires", [])]
|
|
197
|
+
backend = str(system.get("build-backend", DEFAULT_BACKEND))
|
|
198
|
+
paths = [str(p) for p in system.get("backend-path", [])]
|
|
199
|
+
with tempfile.TemporaryDirectory(prefix="simrun-build-") as tmp:
|
|
200
|
+
work = Path(tmp)
|
|
201
|
+
# the hook may write into the tree, never into the snapshot
|
|
202
|
+
shadow = shadow_tree(directory, work / "source")
|
|
203
|
+
out = work / "requires.json"
|
|
204
|
+
pinned = _pinned(requires, pins or {})
|
|
205
|
+
with_args = [arg for r in (*requires, *pinned) for arg in ("--with", r)]
|
|
206
|
+
command = ["run", "--no-project", "--isolated", "--python", python]
|
|
207
|
+
command += [*with_args, "--", "python", "-P", "-c", EDITABLE_HOOK]
|
|
208
|
+
try:
|
|
209
|
+
_uv([*command, backend, str(shadow), str(out), *paths], work)
|
|
210
|
+
dynamic = json.loads(out.read_text())
|
|
211
|
+
except (RuntimeError, OSError, ValueError) as error:
|
|
212
|
+
raise RuntimeError(
|
|
213
|
+
f"the build backend {backend!r} of the source {name!r} cannot tell "
|
|
214
|
+
f"what an editable build needs: {error}"
|
|
215
|
+
) from error
|
|
216
|
+
return [*requires, *(str(r) for r in dynamic)]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def resolve_environment(
|
|
220
|
+
store: Path,
|
|
221
|
+
model: SourceProvenance,
|
|
222
|
+
libraries: list[SourceProvenance],
|
|
223
|
+
python: str = "3.14",
|
|
224
|
+
*,
|
|
225
|
+
simrun_version: str,
|
|
226
|
+
build_constraints: Path = BUILD_CONSTRAINTS,
|
|
227
|
+
) -> Environment:
|
|
228
|
+
"""Lock the dependencies of the model with its libraries as snapshot paths.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
store: Root of the run store.
|
|
232
|
+
model: Snapshot of the model repository, which has the `pyproject.toml`.
|
|
233
|
+
libraries: Snapshots of the libraries; each replaces the entry of
|
|
234
|
+
`[tool.uv.sources]` of its package name, the other entries are kept.
|
|
235
|
+
python: Python version of the image.
|
|
236
|
+
simrun_version: Version of the simrun wheel of the image, part of the hash.
|
|
237
|
+
build_constraints: The pinned versions of the build backends; a pin of a
|
|
238
|
+
package the `uv.lock` of the model does not have constrains the lock.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
The environment.
|
|
242
|
+
|
|
243
|
+
Raises:
|
|
244
|
+
ValueError: A snapshot has no `pyproject.toml`, the model has no `uv.lock`
|
|
245
|
+
(it would resolve against the live index and the hash would drift), or
|
|
246
|
+
the model depends on a local package which is not a snapshotted source.
|
|
247
|
+
RuntimeError: `uv` fails to lock, or a build backend cannot tell what an
|
|
248
|
+
editable build needs.
|
|
249
|
+
"""
|
|
250
|
+
pyproject = _pyproject(store, model)
|
|
251
|
+
names = [_package_name(_pyproject(store, library)) for library in libraries]
|
|
252
|
+
model_lock = store / model.snapshot / "uv.lock"
|
|
253
|
+
if not model_lock.is_file():
|
|
254
|
+
raise ValueError(
|
|
255
|
+
f"The model {model.name!r} has no uv.lock: run `uv lock` and commit it"
|
|
256
|
+
)
|
|
257
|
+
pins = load_constraints(build_constraints)
|
|
258
|
+
locked = {
|
|
259
|
+
normalise(p["name"])
|
|
260
|
+
for p in tomllib.loads(model_lock.read_text()).get("package", [])
|
|
261
|
+
}
|
|
262
|
+
builds: list[str] = []
|
|
263
|
+
for source in (model, *libraries):
|
|
264
|
+
for requirement in build_requirements(
|
|
265
|
+
store / source.snapshot, source.name, python, pins
|
|
266
|
+
):
|
|
267
|
+
if requirement not in builds:
|
|
268
|
+
builds.append(requirement)
|
|
269
|
+
document = tomlkit.parse(pyproject.read_text())
|
|
270
|
+
project = document.setdefault("project", tomlkit.table())
|
|
271
|
+
dependencies = project.setdefault("dependencies", tomlkit.array())
|
|
272
|
+
# uv applies a source only to a dependency of the root, so the libraries are
|
|
273
|
+
# dependencies of the temporary project, which locks their dependencies as well
|
|
274
|
+
# the worker requirements are always added: uv intersects the requirements of a
|
|
275
|
+
# package, so the lock never resolves one below the floor of the worker; the
|
|
276
|
+
# build requirements too, the pods build the sources in the image
|
|
277
|
+
for requirement in (*WORKER_REQUIREMENTS, *builds, *names):
|
|
278
|
+
dependencies.append(requirement)
|
|
279
|
+
tool = document.setdefault("tool", tomlkit.table())
|
|
280
|
+
uv = tool.setdefault("uv", tomlkit.table())
|
|
281
|
+
# the backends at their pins, unless the lock of the model has them already
|
|
282
|
+
constraints = uv.setdefault("constraint-dependencies", tomlkit.array())
|
|
283
|
+
for key, pin in pins.items():
|
|
284
|
+
if key not in locked:
|
|
285
|
+
constraints.append(pin)
|
|
286
|
+
sources = uv.setdefault("sources", tomlkit.table())
|
|
287
|
+
for name, library in zip(names, libraries, strict=True):
|
|
288
|
+
for key in [k for k in sources if normalise(k) == normalise(name)]:
|
|
289
|
+
del sources[key] # the same package spelled otherwise
|
|
290
|
+
entry = tomlkit.inline_table()
|
|
291
|
+
entry["path"] = f"{SHADOWS_DIR}/{library.snapshot}"
|
|
292
|
+
sources[name] = entry
|
|
293
|
+
with tempfile.TemporaryDirectory(prefix="simrun-env-") as tmp:
|
|
294
|
+
# uv writes the metadata it builds into the shadows, never the snapshots
|
|
295
|
+
work = shadow_tree(store / model.snapshot, Path(tmp) / "project")
|
|
296
|
+
for name in ("pyproject.toml", "uv.lock"):
|
|
297
|
+
(work / name).unlink()
|
|
298
|
+
(work / "pyproject.toml").write_text(tomlkit.dumps(document))
|
|
299
|
+
(work / "uv.lock").write_bytes(model_lock.read_bytes())
|
|
300
|
+
for library in libraries:
|
|
301
|
+
shadow_tree(store / library.snapshot, work / SHADOWS_DIR / library.snapshot)
|
|
302
|
+
try:
|
|
303
|
+
_uv(["lock", "--python", python], work)
|
|
304
|
+
except RuntimeError as error:
|
|
305
|
+
raise RuntimeError(
|
|
306
|
+
"The dependencies of the model cannot be locked with the "
|
|
307
|
+
f"requirements of the worker {', '.join(WORKER_REQUIREMENTS)}, the "
|
|
308
|
+
f"build requirements {', '.join(builds)} and the libraries: {error}"
|
|
309
|
+
) from error
|
|
310
|
+
lock = (work / "uv.lock").read_text()
|
|
311
|
+
_reject_host_paths(lock, [_package_name(pyproject), *names])
|
|
312
|
+
skipped = [arg for name in names for arg in ("--no-emit-package", name)]
|
|
313
|
+
requirements = _uv(
|
|
314
|
+
[
|
|
315
|
+
"export",
|
|
316
|
+
"--frozen",
|
|
317
|
+
"--no-emit-project",
|
|
318
|
+
"--no-dev",
|
|
319
|
+
"--no-default-groups",
|
|
320
|
+
"--no-header",
|
|
321
|
+
"--no-annotate",
|
|
322
|
+
"--python",
|
|
323
|
+
python,
|
|
324
|
+
*skipped,
|
|
325
|
+
],
|
|
326
|
+
work,
|
|
327
|
+
)
|
|
328
|
+
digest = hashlib.sha256(f"{python}\n{simrun_version}\n{lock}".encode()).hexdigest()
|
|
329
|
+
return Environment(
|
|
330
|
+
lock=lock,
|
|
331
|
+
lock_sha256=digest,
|
|
332
|
+
python=python,
|
|
333
|
+
simrun_version=simrun_version,
|
|
334
|
+
editable=[model.snapshot, *(library.snapshot for library in libraries)],
|
|
335
|
+
requirements=requirements,
|
|
336
|
+
build_requirements=tuple(builds),
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def image_ref(env: Environment, registry: str) -> str:
|
|
341
|
+
"""Reference of the image of an environment in the registry."""
|
|
342
|
+
return f"{registry}/simrun-env:{env.lock_sha256[:16]}"
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def dockerfile(env: Environment, base_image: str, wheel: str) -> str:
|
|
346
|
+
"""Dockerfile of the image: the dependencies, build backends and `simrun`, no source.
|
|
347
|
+
|
|
348
|
+
The pinned requirements are written with a heredoc, so no source is copied from the
|
|
349
|
+
build context. The model and the libraries are installed at pod start.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
env: The environment.
|
|
353
|
+
base_image: The base image, with uv and the python of the environment, in
|
|
354
|
+
the registry of the cluster (`ClusterConfig.base_image_ref`).
|
|
355
|
+
wheel: File name of the simrun wheel in the build context, the only file
|
|
356
|
+
copied; simrun is unpublished, so it is never installed by name.
|
|
357
|
+
"""
|
|
358
|
+
return (
|
|
359
|
+
f"FROM {base_image}\n"
|
|
360
|
+
"ENV UV_PYTHON_DOWNLOADS=never UV_LINK_MODE=copy VIRTUAL_ENV=/opt/venv "
|
|
361
|
+
'PATH="/opt/venv/bin:$PATH"\n'
|
|
362
|
+
f"RUN uv venv /opt/venv --python {env.python}\n"
|
|
363
|
+
"RUN cat > /tmp/requirements.txt <<'SIMRUN_REQUIREMENTS'\n"
|
|
364
|
+
f"{env.requirements.rstrip()}\n"
|
|
365
|
+
"SIMRUN_REQUIREMENTS\n"
|
|
366
|
+
"RUN uv pip install --no-deps -r /tmp/requirements.txt "
|
|
367
|
+
"&& rm /tmp/requirements.txt\n"
|
|
368
|
+
f"COPY {wheel} /tmp/{wheel}\n"
|
|
369
|
+
f"RUN uv pip install --no-deps /tmp/{wheel} && rm /tmp/{wheel}\n"
|
|
370
|
+
)
|
simrun/ledger.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""The unit ledger: workers of a split task claim units through atomic renames."""
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Self
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
SIZE_FILE = "size"
|
|
16
|
+
HEARTBEATS_DIR = "heartbeats"
|
|
17
|
+
SIZE_RETRIES = 20
|
|
18
|
+
SIZE_RETRY_SECONDS = 0.25
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class LedgerCounts:
|
|
23
|
+
"""The number of units per state."""
|
|
24
|
+
|
|
25
|
+
pending: int
|
|
26
|
+
claimed: int
|
|
27
|
+
done: int
|
|
28
|
+
total: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _check_worker(worker: str) -> None:
|
|
32
|
+
if not worker or "@" in worker or "/" in worker:
|
|
33
|
+
raise ValueError(f"invalid worker id {worker!r}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Ledger:
|
|
37
|
+
"""A directory with `pending/<i>`, `claimed/<i>@<worker>` and `done/<i>`.
|
|
38
|
+
|
|
39
|
+
Workers touch `heartbeats/<worker>`, so that the claims of a worker which died
|
|
40
|
+
without releasing them are found by the age of its heartbeat (`release_stale`).
|
|
41
|
+
|
|
42
|
+
Every transition is one `os.rename`, atomic on a local file system and on NFS v4,
|
|
43
|
+
so a unit is held by at most one worker. On NFS the mount must be NFS >= 4.1
|
|
44
|
+
(sessions give renames exactly-once semantics; `claim` also tolerates a resent
|
|
45
|
+
rename that answers ENOENT although it happened).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, root: Path, n_units: int) -> None:
|
|
49
|
+
"""Open the ledger at `root` of `n_units` units (see `create`)."""
|
|
50
|
+
self.root = root
|
|
51
|
+
self.n_units = n_units
|
|
52
|
+
self._candidates: list[int] | None = None
|
|
53
|
+
self._position = 0
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def create(cls, root: Path, n_units: int) -> Self:
|
|
57
|
+
"""Create the ledger, or open an existing one of the same size.
|
|
58
|
+
|
|
59
|
+
The ledger is built in a temporary directory and renamed, so it appears
|
|
60
|
+
complete or not at all, also when several processes create it at once or one
|
|
61
|
+
of them crashes.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
root: The ledger directory.
|
|
65
|
+
n_units: The number of units, indices `0..n_units-1`.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The ledger.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
ValueError: An existing ledger has another size.
|
|
72
|
+
"""
|
|
73
|
+
if not root.exists():
|
|
74
|
+
root.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
tmp = root.parent / f".{root.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}"
|
|
76
|
+
try:
|
|
77
|
+
(tmp / "pending").mkdir(parents=True)
|
|
78
|
+
(tmp / "claimed").mkdir()
|
|
79
|
+
(tmp / "done").mkdir()
|
|
80
|
+
for i in range(n_units):
|
|
81
|
+
os.close(
|
|
82
|
+
os.open(
|
|
83
|
+
tmp / "pending" / str(i),
|
|
84
|
+
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
|
|
85
|
+
0o644,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
(tmp / SIZE_FILE).write_text(str(n_units))
|
|
89
|
+
try:
|
|
90
|
+
os.rename(tmp, root)
|
|
91
|
+
except OSError as error:
|
|
92
|
+
# Another process published the ledger first. A cached lookup
|
|
93
|
+
# on NFS may still deny that root exists, so decide by errno.
|
|
94
|
+
if error.errno not in (errno.EEXIST, errno.ENOTEMPTY):
|
|
95
|
+
raise
|
|
96
|
+
logger.debug("ledger %s was created by another process", root)
|
|
97
|
+
finally:
|
|
98
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
99
|
+
size = cls._read_size(root)
|
|
100
|
+
if size != n_units:
|
|
101
|
+
raise ValueError(f"ledger {root} has {size} units, not {n_units}")
|
|
102
|
+
return cls(root, size)
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _read_size(root: Path) -> int:
|
|
106
|
+
"""Read the size file, retrying while a cached negative lookup expires."""
|
|
107
|
+
for attempt in range(SIZE_RETRIES):
|
|
108
|
+
try:
|
|
109
|
+
return int((root / SIZE_FILE).read_text())
|
|
110
|
+
except FileNotFoundError:
|
|
111
|
+
if attempt == SIZE_RETRIES - 1:
|
|
112
|
+
raise
|
|
113
|
+
time.sleep(SIZE_RETRY_SECONDS)
|
|
114
|
+
raise AssertionError("unreachable")
|
|
115
|
+
|
|
116
|
+
def _names(self, state: str) -> list[str]:
|
|
117
|
+
return os.listdir(self.root / state)
|
|
118
|
+
|
|
119
|
+
def claim(self, worker: str) -> int | None:
|
|
120
|
+
"""Claim the lowest pending unit, or return `None` when none is pending."""
|
|
121
|
+
_check_worker(worker)
|
|
122
|
+
# A cached listing may have been passed by released units; list afresh once.
|
|
123
|
+
for fresh in (self._candidates is None, True):
|
|
124
|
+
if self._candidates is None:
|
|
125
|
+
self._candidates = sorted(int(n) for n in self._names("pending"))
|
|
126
|
+
self._position = 0
|
|
127
|
+
while self._position < len(self._candidates):
|
|
128
|
+
index = self._candidates[self._position]
|
|
129
|
+
self._position += 1
|
|
130
|
+
try:
|
|
131
|
+
os.rename(
|
|
132
|
+
self.root / "pending" / str(index),
|
|
133
|
+
self.root / "claimed" / f"{index}@{worker}",
|
|
134
|
+
)
|
|
135
|
+
except FileNotFoundError:
|
|
136
|
+
# Another worker won it, or a resent rename of ours (NFS) that
|
|
137
|
+
# had happened answered ENOENT: then the claim is ours.
|
|
138
|
+
if (self.root / "claimed" / f"{index}@{worker}").exists():
|
|
139
|
+
return index
|
|
140
|
+
continue
|
|
141
|
+
return index
|
|
142
|
+
self._candidates = None
|
|
143
|
+
if fresh:
|
|
144
|
+
return None
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
def complete(self, index: int, worker: str) -> None:
|
|
148
|
+
"""Mark a unit claimed by `worker` as done."""
|
|
149
|
+
_check_worker(worker)
|
|
150
|
+
try:
|
|
151
|
+
os.rename(
|
|
152
|
+
self.root / "claimed" / f"{index}@{worker}",
|
|
153
|
+
self.root / "done" / str(index),
|
|
154
|
+
)
|
|
155
|
+
except FileNotFoundError:
|
|
156
|
+
raise ValueError(f"unit {index} is not claimed by {worker}") from None
|
|
157
|
+
|
|
158
|
+
def release(self, index: int, worker: str) -> None:
|
|
159
|
+
"""Return a unit claimed by `worker` to pending."""
|
|
160
|
+
_check_worker(worker)
|
|
161
|
+
try:
|
|
162
|
+
os.rename(
|
|
163
|
+
self.root / "claimed" / f"{index}@{worker}",
|
|
164
|
+
self.root / "pending" / str(index),
|
|
165
|
+
)
|
|
166
|
+
except FileNotFoundError:
|
|
167
|
+
raise ValueError(f"unit {index} is not claimed by {worker}") from None
|
|
168
|
+
self._candidates = None
|
|
169
|
+
|
|
170
|
+
def release_all(self, worker: str) -> list[int]:
|
|
171
|
+
"""Free every unit claimed by `worker`, as a restarted worker does.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The indices released, sorted.
|
|
175
|
+
"""
|
|
176
|
+
_check_worker(worker)
|
|
177
|
+
suffix = f"@{worker}"
|
|
178
|
+
released: list[int] = []
|
|
179
|
+
for name in self._names("claimed"):
|
|
180
|
+
if name.endswith(suffix):
|
|
181
|
+
index = int(name[: -len(suffix)])
|
|
182
|
+
try:
|
|
183
|
+
self.release(index, worker)
|
|
184
|
+
except ValueError:
|
|
185
|
+
continue
|
|
186
|
+
released.append(index)
|
|
187
|
+
return sorted(released)
|
|
188
|
+
|
|
189
|
+
def heartbeat(self, worker: str) -> None:
|
|
190
|
+
"""Touch the heartbeat file of `worker`, created with its directory lazily.
|
|
191
|
+
|
|
192
|
+
Its modification time is set by the file server, so the heartbeats of workers
|
|
193
|
+
on different servers compare without their clocks.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
OSError: The time of the file was not set.
|
|
197
|
+
"""
|
|
198
|
+
_check_worker(worker)
|
|
199
|
+
path = self.root / HEARTBEATS_DIR / worker
|
|
200
|
+
try:
|
|
201
|
+
# not Path.touch, which ignores a failure of utime: a heartbeat which
|
|
202
|
+
# does not advance must raise
|
|
203
|
+
os.utime(path, None)
|
|
204
|
+
except FileNotFoundError:
|
|
205
|
+
path.parent.mkdir(exist_ok=True)
|
|
206
|
+
os.close(os.open(path, os.O_CREAT | os.O_WRONLY, 0o644))
|
|
207
|
+
|
|
208
|
+
def _mtime(self, worker: str) -> float | None:
|
|
209
|
+
"""The time of the last heartbeat of `worker`, `None` without one.
|
|
210
|
+
|
|
211
|
+
The file is opened, which revalidates its attributes on NFS (close-to-open).
|
|
212
|
+
"""
|
|
213
|
+
try:
|
|
214
|
+
fd = os.open(self.root / HEARTBEATS_DIR / worker, os.O_RDONLY)
|
|
215
|
+
except FileNotFoundError:
|
|
216
|
+
return None
|
|
217
|
+
try:
|
|
218
|
+
return os.fstat(fd).st_mtime
|
|
219
|
+
finally:
|
|
220
|
+
os.close(fd)
|
|
221
|
+
|
|
222
|
+
def release_stale(self, stale_s: float, reference: str) -> list[int]:
|
|
223
|
+
"""Return the claims of workers without a recent heartbeat to pending.
|
|
224
|
+
|
|
225
|
+
The heartbeat of `reference` is touched and its time is "now"; a claim of
|
|
226
|
+
another worker which has no heartbeat or one older than `now - stale_s` is
|
|
227
|
+
released. The claims of `reference` are never released.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
stale_s: Seconds after which a heartbeat is stale.
|
|
231
|
+
reference: The worker which releases, e.g. the coordinator.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
The indices released, sorted.
|
|
235
|
+
|
|
236
|
+
Raises:
|
|
237
|
+
OSError: The heartbeat of `reference` was not touched.
|
|
238
|
+
"""
|
|
239
|
+
self.heartbeat(reference)
|
|
240
|
+
now = self._mtime(reference)
|
|
241
|
+
assert now is not None
|
|
242
|
+
beats: dict[str, float | None] = {}
|
|
243
|
+
released: list[int] = []
|
|
244
|
+
for name in self._names("claimed"):
|
|
245
|
+
index, _, worker = name.partition("@")
|
|
246
|
+
if worker == reference:
|
|
247
|
+
continue
|
|
248
|
+
if worker not in beats:
|
|
249
|
+
beats[worker] = self._mtime(worker)
|
|
250
|
+
beat = beats[worker]
|
|
251
|
+
if beat is not None and beat >= now - stale_s:
|
|
252
|
+
continue
|
|
253
|
+
try:
|
|
254
|
+
os.rename(self.root / "claimed" / name, self.root / "pending" / index)
|
|
255
|
+
except FileNotFoundError:
|
|
256
|
+
# completed, released or released by another reference meanwhile
|
|
257
|
+
continue
|
|
258
|
+
logger.info("released unit %s of %s, its heartbeat is stale", index, worker)
|
|
259
|
+
released.append(int(index))
|
|
260
|
+
if released:
|
|
261
|
+
self._candidates = None
|
|
262
|
+
return sorted(released)
|
|
263
|
+
|
|
264
|
+
def counts(self) -> LedgerCounts:
|
|
265
|
+
"""The number of units per state."""
|
|
266
|
+
pending = len(self._names("pending"))
|
|
267
|
+
claimed = len(self._names("claimed"))
|
|
268
|
+
done = len(self._names("done"))
|
|
269
|
+
return LedgerCounts(pending, claimed, done, self.n_units)
|
|
270
|
+
|
|
271
|
+
def claims(self) -> dict[str, list[int]]:
|
|
272
|
+
"""The claimed units by worker id, the indices sorted."""
|
|
273
|
+
claimed: dict[str, list[int]] = {}
|
|
274
|
+
for name in self._names("claimed"):
|
|
275
|
+
index, _, worker = name.partition("@")
|
|
276
|
+
claimed.setdefault(worker, []).append(int(index))
|
|
277
|
+
return {worker: sorted(units) for worker, units in claimed.items()}
|
|
278
|
+
|
|
279
|
+
def complete_ledger(self) -> bool:
|
|
280
|
+
"""Whether every unit is done."""
|
|
281
|
+
return len(self._names("done")) == self.n_units
|
|
282
|
+
|
|
283
|
+
def missing(self) -> list[int]:
|
|
284
|
+
"""The units not done, sorted."""
|
|
285
|
+
done = {int(n) for n in self._names("done")}
|
|
286
|
+
return [i for i in range(self.n_units) if i not in done]
|