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/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]