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/cwl.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""CWL v1.2 workflows from runs, executed with cwltool (or Toil on Slurm).
|
|
2
|
+
|
|
3
|
+
The same run as `simrun.argo`, with the same commands and environment, as one CWL
|
|
4
|
+
`Workflow`. The steps work on the run store by absolute path, the workflow input
|
|
5
|
+
`store` (a directory every step sees: a local directory, or the NFS mount on Slurm),
|
|
6
|
+
and communicate through it: the ledger and the outputs live in the store. The only
|
|
7
|
+
CWL output of a step is its standard output, which exists to order the steps.
|
|
8
|
+
|
|
9
|
+
A split task is three steps of command line tools: `<task>-w0` (worker
|
|
10
|
+
`<task>-w0`, `SIMRUN_ROLE=base`), the coordinator, which returns once the ledger is
|
|
11
|
+
complete; `<task>-extra`, scattering over the worker indices `1..pods - 1` (workers
|
|
12
|
+
`<task>-w<index>`, `SIMRUN_ROLE=extra`; left out for one pod), which exit when nothing
|
|
13
|
+
is pending; and `<task>-merge` after `<task>-w0` only. With `--parallel` the
|
|
14
|
+
coordinator and the extra workers run side by side; without it the steps run one after
|
|
15
|
+
the other and the run still ends, since either one worker computes everything or the
|
|
16
|
+
next finds nothing pending. A task waits for the merge of the tasks it reads from. A
|
|
17
|
+
failed extra worker does not fail the workflow (`successCodes` of the step), as
|
|
18
|
+
`continueOn` in Argo; the coordinator releases its claims and computes them, and a
|
|
19
|
+
deterministic unit failure fails the workflow through `<task>-w0`.
|
|
20
|
+
|
|
21
|
+
The export targets a shared file system without containers (`cwltool --no-container`,
|
|
22
|
+
Toil on Slurm with the store on NFS): `store` is a path, not a CWL `Directory`, and
|
|
23
|
+
the image is only a `DockerRequirement` hint. Running the steps in containers is
|
|
24
|
+
deferred to the Slurm plan.
|
|
25
|
+
|
|
26
|
+
The scratch directory of a worker is `<scratch>/<run>/<task>/<worker>` below the
|
|
27
|
+
workflow input `scratch` (`<run>/<task>/merge` for a merge, `<run>/<task>` for an
|
|
28
|
+
unsplit task). The image is a `DockerRequirement` hint, so `--no-container` runs the
|
|
29
|
+
commands of the environment.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import math
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
import yaml
|
|
37
|
+
|
|
38
|
+
from simrun import podspec
|
|
39
|
+
from simrun.config import ClusterConfig
|
|
40
|
+
from simrun.planner import PodShape, TaskPlan
|
|
41
|
+
from simrun.plugins import TaskType
|
|
42
|
+
from simrun.store import RunRecord
|
|
43
|
+
from simrun.workflow import TaskSpec
|
|
44
|
+
|
|
45
|
+
CWL_VERSION = "v1.2"
|
|
46
|
+
DEFAULT_SCRATCH = "/tmp/simrun-scratch"
|
|
47
|
+
# an extra worker is tolerated as `continueOn` tolerates it in Argo (Ruling 17b):
|
|
48
|
+
# done, analysis error, configuration error, out of memory, stopped; the coordinator
|
|
49
|
+
# recomputes what it released and fails itself on a deterministic error
|
|
50
|
+
EXTRA_SUCCESS_CODES = [0, 3, 4, 137, 143]
|
|
51
|
+
STORE = "$(inputs.store)"
|
|
52
|
+
MIB = 1024
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _env(
|
|
56
|
+
record: RunRecord,
|
|
57
|
+
task: TaskSpec,
|
|
58
|
+
task_type: TaskType,
|
|
59
|
+
shape: PodShape,
|
|
60
|
+
scratch: str,
|
|
61
|
+
worker: dict[str, str] | None = None,
|
|
62
|
+
) -> list[dict[str, str]]:
|
|
63
|
+
"""The environment of the worker harness, see `simrun.podspec`."""
|
|
64
|
+
values = {
|
|
65
|
+
**podspec.env_values(record, task, task_type, shape, STORE, scratch),
|
|
66
|
+
**(worker or {}),
|
|
67
|
+
}
|
|
68
|
+
return [{"envName": k, "envValue": v} for k, v in values.items()]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _requirements(env: list[dict[str, str]], shape: PodShape) -> list[dict[str, Any]]:
|
|
72
|
+
requirements: list[dict[str, Any]] = [
|
|
73
|
+
{"class": "EnvVarRequirement", "envDef": env},
|
|
74
|
+
{
|
|
75
|
+
"class": "ResourceRequirement",
|
|
76
|
+
"coresMin": shape.cores,
|
|
77
|
+
"ramMin": math.ceil(shape.memory_gb * MIB),
|
|
78
|
+
},
|
|
79
|
+
]
|
|
80
|
+
return requirements
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _tool(
|
|
84
|
+
name: str,
|
|
85
|
+
env: list[dict[str, str]],
|
|
86
|
+
shape: PodShape,
|
|
87
|
+
image: str | None,
|
|
88
|
+
command: list[str],
|
|
89
|
+
after: bool,
|
|
90
|
+
index: bool = False,
|
|
91
|
+
success_codes: list[int] | None = None,
|
|
92
|
+
) -> dict[str, Any]:
|
|
93
|
+
"""A command line tool: `command`, with the store and scratch as inputs."""
|
|
94
|
+
inputs: dict[str, Any] = {"store": "string", "scratch": "string"}
|
|
95
|
+
if after:
|
|
96
|
+
inputs["after"] = "File[]"
|
|
97
|
+
if index:
|
|
98
|
+
inputs["index"] = "int"
|
|
99
|
+
tool: dict[str, Any] = {
|
|
100
|
+
"class": "CommandLineTool",
|
|
101
|
+
"requirements": _requirements(env, shape),
|
|
102
|
+
"inputs": inputs,
|
|
103
|
+
"arguments": command,
|
|
104
|
+
"stdout": f"{name}.log",
|
|
105
|
+
"outputs": {"log": "stdout"},
|
|
106
|
+
}
|
|
107
|
+
if image is not None:
|
|
108
|
+
# a hint, so that `--no-container` runs the commands of the environment
|
|
109
|
+
tool["hints"] = [{"class": "DockerRequirement", "dockerPull": image}]
|
|
110
|
+
if success_codes is not None:
|
|
111
|
+
tool["successCodes"] = success_codes
|
|
112
|
+
return tool
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _step(
|
|
116
|
+
tool: dict[str, Any],
|
|
117
|
+
after: list[str],
|
|
118
|
+
scatter: str | None = None,
|
|
119
|
+
) -> dict[str, Any]:
|
|
120
|
+
inputs: dict[str, Any] = {"store": "store", "scratch": "scratch"}
|
|
121
|
+
if after:
|
|
122
|
+
inputs["after"] = {"source": after, "linkMerge": "merge_nested"}
|
|
123
|
+
step: dict[str, Any] = {"run": tool, "in": inputs, "out": ["log"]}
|
|
124
|
+
if scatter is not None:
|
|
125
|
+
inputs["index"] = scatter
|
|
126
|
+
step["scatter"] = "index"
|
|
127
|
+
return step
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def to_cwl(
|
|
131
|
+
record: RunRecord,
|
|
132
|
+
plans: dict[str, TaskPlan],
|
|
133
|
+
types: dict[str, TaskType],
|
|
134
|
+
*,
|
|
135
|
+
scratch: str = DEFAULT_SCRATCH,
|
|
136
|
+
grace_s: int = ClusterConfig.model_fields["grace_s"].default,
|
|
137
|
+
) -> dict[str, Any]:
|
|
138
|
+
"""The CWL workflow of a run.
|
|
139
|
+
|
|
140
|
+
The settings of every task are written to `<task dir>/settings.json` before the
|
|
141
|
+
workflow runs; the workflow does not carry them. Its inputs are `store` (the
|
|
142
|
+
directory of the run store) and `scratch` (the local scratch, default `scratch`).
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
record: The run.
|
|
146
|
+
plans: The plan of every task, by task id.
|
|
147
|
+
types: The task types, by name.
|
|
148
|
+
scratch: The default of the workflow input `scratch`.
|
|
149
|
+
grace_s: Seconds a worker has to stop after SIGTERM.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
The workflow as a CWL document.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
ValueError: A task has no image or no command, or stages a port which is no
|
|
156
|
+
input.
|
|
157
|
+
"""
|
|
158
|
+
workflow = record.workflow
|
|
159
|
+
split = {t.id: types[t.type].splittable for t in workflow.tasks}
|
|
160
|
+
inputs: dict[str, Any] = {
|
|
161
|
+
"store": "string",
|
|
162
|
+
"scratch": {"type": "string", "default": scratch},
|
|
163
|
+
}
|
|
164
|
+
steps: dict[str, Any] = {}
|
|
165
|
+
outputs: dict[str, Any] = {}
|
|
166
|
+
run_scratch = f"$(inputs.scratch)/{record.id}"
|
|
167
|
+
for task in workflow.order():
|
|
168
|
+
task_type = types[task.type]
|
|
169
|
+
plan = plans[task.id]
|
|
170
|
+
shape = plan.shape
|
|
171
|
+
after = [
|
|
172
|
+
f"{dep}-merge/log" if split[dep] else f"{dep}/log"
|
|
173
|
+
for dep in task.dependencies()
|
|
174
|
+
]
|
|
175
|
+
if not split[task.id]:
|
|
176
|
+
scratch = f"{run_scratch}/{task.id}"
|
|
177
|
+
image, argv = podspec.unsplit_command(
|
|
178
|
+
record, task, task_type, STORE, scratch
|
|
179
|
+
)
|
|
180
|
+
env = _env(record, task, task_type, shape, scratch)
|
|
181
|
+
tool = _tool(task.id, env, shape, image, argv, bool(after))
|
|
182
|
+
steps[task.id] = _step(tool, after)
|
|
183
|
+
outputs[f"{task.id}_log"] = {
|
|
184
|
+
"type": "File",
|
|
185
|
+
"outputSource": f"{task.id}/log",
|
|
186
|
+
}
|
|
187
|
+
continue
|
|
188
|
+
image = podspec.image(record, task, task_type)
|
|
189
|
+
stale = str(grace_s + podspec.STALE_MARGIN_S)
|
|
190
|
+
w0 = f"{task.id}-w0"
|
|
191
|
+
env = _env(
|
|
192
|
+
record,
|
|
193
|
+
task,
|
|
194
|
+
task_type,
|
|
195
|
+
shape,
|
|
196
|
+
f"{run_scratch}/{task.id}/{w0}",
|
|
197
|
+
{"SIMRUN_WORKER": w0, "SIMRUN_ROLE": "base", "SIMRUN_STALE_S": stale},
|
|
198
|
+
)
|
|
199
|
+
steps[w0] = _step(
|
|
200
|
+
_tool(w0, env, shape, image, [podspec.WORKER_COMMAND], bool(after)), after
|
|
201
|
+
)
|
|
202
|
+
if plan.pods > 1:
|
|
203
|
+
extra = f"{task.id}-extra"
|
|
204
|
+
indices = f"{task.id}_extra_indices"
|
|
205
|
+
inputs[indices] = {
|
|
206
|
+
"type": "int[]",
|
|
207
|
+
"default": list(range(1, plan.pods)),
|
|
208
|
+
}
|
|
209
|
+
env = _env(
|
|
210
|
+
record,
|
|
211
|
+
task,
|
|
212
|
+
task_type,
|
|
213
|
+
shape,
|
|
214
|
+
f"{run_scratch}/{task.id}/{task.id}-w$(inputs.index)",
|
|
215
|
+
{
|
|
216
|
+
"SIMRUN_WORKER": f"{task.id}-w$(inputs.index)",
|
|
217
|
+
"SIMRUN_ROLE": "extra",
|
|
218
|
+
"SIMRUN_STALE_S": stale,
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
tool = _tool(
|
|
222
|
+
extra,
|
|
223
|
+
env,
|
|
224
|
+
shape,
|
|
225
|
+
image,
|
|
226
|
+
[podspec.WORKER_COMMAND],
|
|
227
|
+
bool(after),
|
|
228
|
+
index=True,
|
|
229
|
+
success_codes=EXTRA_SUCCESS_CODES,
|
|
230
|
+
)
|
|
231
|
+
steps[extra] = _step(tool, after, scatter=indices)
|
|
232
|
+
merge_shape = podspec.merge_shape(shape, task_type.hint.memory_base_gb)
|
|
233
|
+
merge = f"{task.id}-merge"
|
|
234
|
+
env = _env(
|
|
235
|
+
record, task, task_type, merge_shape, f"{run_scratch}/{task.id}/merge"
|
|
236
|
+
)
|
|
237
|
+
steps[merge] = _step(
|
|
238
|
+
_tool(merge, env, merge_shape, image, [podspec.MERGE_COMMAND], True),
|
|
239
|
+
[f"{w0}/log"],
|
|
240
|
+
)
|
|
241
|
+
outputs[f"{task.id}_log"] = {"type": "File", "outputSource": f"{merge}/log"}
|
|
242
|
+
return {
|
|
243
|
+
"cwlVersion": CWL_VERSION,
|
|
244
|
+
"class": "Workflow",
|
|
245
|
+
"id": f"simrun-{record.id}",
|
|
246
|
+
"requirements": [
|
|
247
|
+
{"class": "ScatterFeatureRequirement"},
|
|
248
|
+
{"class": "MultipleInputFeatureRequirement"},
|
|
249
|
+
],
|
|
250
|
+
"inputs": inputs,
|
|
251
|
+
"outputs": outputs,
|
|
252
|
+
"steps": steps,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def export_cwl(
|
|
257
|
+
record: RunRecord,
|
|
258
|
+
plans: dict[str, TaskPlan],
|
|
259
|
+
types: dict[str, TaskType],
|
|
260
|
+
path: Path,
|
|
261
|
+
**options: Any,
|
|
262
|
+
) -> Path:
|
|
263
|
+
"""Write the CWL workflow of a run as YAML and validate it with cwl-utils.
|
|
264
|
+
|
|
265
|
+
cwl-utils is the optional extra `cwl` (`pip install "simrun[cwl]"`), which also
|
|
266
|
+
installs cwltool, the runner of the workflow.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
record: The run.
|
|
270
|
+
plans: The plan of every task, by task id.
|
|
271
|
+
types: The task types, by name.
|
|
272
|
+
path: The file to write.
|
|
273
|
+
**options: The keyword arguments of `to_cwl`.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
The path.
|
|
277
|
+
"""
|
|
278
|
+
try:
|
|
279
|
+
from cwl_utils.parser import load_document_by_uri
|
|
280
|
+
except ImportError as error:
|
|
281
|
+
raise ImportError(
|
|
282
|
+
'the CWL export needs cwl-utils: pip install "simrun[cwl]"'
|
|
283
|
+
) from error
|
|
284
|
+
document = to_cwl(record, plans, types, **options)
|
|
285
|
+
path.write_text(yaml.safe_dump(document, sort_keys=False))
|
|
286
|
+
load_document_by_uri(path)
|
|
287
|
+
return path
|
simrun/demo.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""The demo plugin: sums of squares, split into one unit per number.
|
|
2
|
+
|
|
3
|
+
A unit writes its square into the output port `squares` (`out/squares/<i>.json`), the
|
|
4
|
+
merge the sum into the summary of the task (`out/summary.json`, which `compare`
|
|
5
|
+
reads; no port).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
from simrun.plugins import ResourceHint, TaskPaths, TaskType
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SquaresSettings(BaseModel):
|
|
19
|
+
"""Settings of the squares demo.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
n: Number of units.
|
|
23
|
+
sleep_s: Seconds each unit sleeps.
|
|
24
|
+
fail_at: Index of a unit which fails.
|
|
25
|
+
memory_mb: Megabytes each unit allocates.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
n: int = Field(title="Units")
|
|
29
|
+
sleep_s: float = Field(0.0, title="Sleep per unit (s)")
|
|
30
|
+
fail_at: int | None = Field(None, title="Fail at unit")
|
|
31
|
+
memory_mb: int = Field(0, title="Memory per unit (MB)")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def count_units(settings: SquaresSettings) -> int:
|
|
35
|
+
"""Count the units of work.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
settings: Settings of the task.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The number of units.
|
|
42
|
+
"""
|
|
43
|
+
return settings.n
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
PORT = "squares"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def setup(settings: SquaresSettings, paths: TaskPaths) -> None:
|
|
50
|
+
"""Create the directory of the output port, once per process.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
settings: Settings of the task.
|
|
54
|
+
paths: Locations of the task.
|
|
55
|
+
"""
|
|
56
|
+
(paths.out / PORT).mkdir(parents=True, exist_ok=True)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def compute_unit(settings: SquaresSettings, index: int, paths: TaskPaths) -> None:
|
|
60
|
+
"""Write the square of the index, atomically (temporary file, rename).
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
settings: Settings of the task.
|
|
64
|
+
index: Index of the unit.
|
|
65
|
+
paths: Locations of the task.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
RuntimeError: The unit is the one set to fail.
|
|
69
|
+
"""
|
|
70
|
+
ballast = bytearray(settings.memory_mb * 2**20)
|
|
71
|
+
ballast[::4096] = b"\x01" * len(ballast[::4096])
|
|
72
|
+
time.sleep(settings.sleep_s)
|
|
73
|
+
if settings.fail_at == index:
|
|
74
|
+
raise RuntimeError(f"unit {index} failed on request")
|
|
75
|
+
units = paths.out / PORT
|
|
76
|
+
units.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
tmp = units / f".{index}.json.{uuid.uuid4().hex}.tmp"
|
|
78
|
+
tmp.write_text(json.dumps({"i": index, "square": index * index}))
|
|
79
|
+
os.replace(tmp, units / f"{index}.json")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def merge(settings: SquaresSettings, paths: TaskPaths) -> None:
|
|
83
|
+
"""Write the sum of the squares.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
settings: Settings of the task.
|
|
87
|
+
paths: Locations of the task.
|
|
88
|
+
"""
|
|
89
|
+
total = sum(
|
|
90
|
+
json.loads((paths.out / PORT / f"{i}.json").read_text())["square"]
|
|
91
|
+
for i in range(settings.n)
|
|
92
|
+
)
|
|
93
|
+
(paths.out / "summary.json").write_text(json.dumps({"sum": total, "n": settings.n}))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
SQUARES = TaskType(
|
|
97
|
+
name="demo.squares",
|
|
98
|
+
settings=SquaresSettings,
|
|
99
|
+
outputs=(PORT,),
|
|
100
|
+
hint=ResourceHint(memory_base_gb=0.5, memory_per_worker_gb=0.5),
|
|
101
|
+
count_units="simrun.demo:count_units",
|
|
102
|
+
setup="simrun.demo:setup",
|
|
103
|
+
compute_unit="simrun.demo:compute_unit",
|
|
104
|
+
merge="simrun.demo:merge",
|
|
105
|
+
description="Squares of numbers, split over servers",
|
|
106
|
+
)
|