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
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
"""The controller: follows the workflows of the runs and records their end.
|
|
2
|
+
|
|
3
|
+
The state of a run is the service's, not the one of Argo (Ruling 17b): a split task is
|
|
4
|
+
done when its `<task>-merge` node succeeded and failed when its `<task>-w0` or its
|
|
5
|
+
merge failed (after the retries of Argo), an extra worker never fails a task; an
|
|
6
|
+
unsplit task is its node `<task>`. When every task is done the run succeeded, when a
|
|
7
|
+
task failed the run failed; in both cases the controller stops the workflow, so extra
|
|
8
|
+
workers still waiting for admission go away, and releases the reserved node.
|
|
9
|
+
|
|
10
|
+
A pod is retried once with the retry memory after a kill for memory: a worker, a
|
|
11
|
+
merge or an unsplit task killed for memory again with the retry memory fails the run
|
|
12
|
+
(Ruling 35), whose message names the retry memory. A kill for memory needs the
|
|
13
|
+
evidence of the out of memory killer (Ruling 39): the reason `OOMKilled` of the
|
|
14
|
+
backend, or the report of an attempt which exited 137 counting kills of the out of
|
|
15
|
+
memory killer in its cgroup; a pod killed after its grace period was evicted.
|
|
16
|
+
|
|
17
|
+
The states of the tasks and the out of memory kills are stored in the record at every
|
|
18
|
+
poll. When the workflow is gone (deleted by its time to live before the controller saw
|
|
19
|
+
its end) the tasks are resolved from these states and from the store: a split task
|
|
20
|
+
whose merge wrote `out/.simrun-merged` is done (Ruling 23); only the tasks still
|
|
21
|
+
undetermined fail.
|
|
22
|
+
|
|
23
|
+
The reports of the workers of a task (of `simrun-exec` for an unsplit plugin task,
|
|
24
|
+
by the task id) are recorded as resource observations once the state of the task is
|
|
25
|
+
final, each marked in `tasks/<task>/recorded/` right after its
|
|
26
|
+
observation; a kill for memory of an attempt without a report is an observation of
|
|
27
|
+
the pod shape at its limit (Ruling 24).
|
|
28
|
+
|
|
29
|
+
The logs of the pods of a task are kept in the run store once the task is final and
|
|
30
|
+
its pods terminated, the ones which ended before the workflow is stopped or
|
|
31
|
+
cancelled (`simrun.service.logs`); an ended run stays followed (`pending_logs`) until
|
|
32
|
+
its logs are kept or its workflow is gone.
|
|
33
|
+
|
|
34
|
+
A terminal record is written first; a release of the node or a cancellation of the
|
|
35
|
+
workflow which fails stays in the record (`pending_release`, `pending_cancel`) and is
|
|
36
|
+
retried at the next polls, which do nothing else for a terminal run (Ruling 25). No
|
|
37
|
+
call of the backend is made while the record lock is held.
|
|
38
|
+
|
|
39
|
+
The controller keeps no state in memory: every `reconcile` reads the records of the
|
|
40
|
+
live runs of the index of the store (`simrun.service.index`), which the service
|
|
41
|
+
rebuilds from the records at its start, so a restarted service continues where the
|
|
42
|
+
one before stopped; a record which cannot be read is logged and skipped.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
import json
|
|
46
|
+
import logging
|
|
47
|
+
import re
|
|
48
|
+
import threading
|
|
49
|
+
from collections.abc import Callable
|
|
50
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
51
|
+
from datetime import UTC, datetime
|
|
52
|
+
|
|
53
|
+
from simrun import podspec
|
|
54
|
+
from simrun.planner import PodShape
|
|
55
|
+
from simrun.plugins import TaskType
|
|
56
|
+
from simrun.plugins_builtin import COMMAND
|
|
57
|
+
from simrun.resources import GIB, ResourceStore, resource_key
|
|
58
|
+
from simrun.service import logs
|
|
59
|
+
from simrun.service.backend import (
|
|
60
|
+
FINAL_PHASES,
|
|
61
|
+
PREEMPTED_PHASE,
|
|
62
|
+
RETRYING_PHASE,
|
|
63
|
+
Backend,
|
|
64
|
+
BackendStatus,
|
|
65
|
+
FailedAttempt,
|
|
66
|
+
)
|
|
67
|
+
from simrun.service.index import IndexedRunStore
|
|
68
|
+
from simrun.store import RunRecord, RunState, TaskState
|
|
69
|
+
from simrun.worker import EXIT_POOL_BROKEN, MERGED_MARKER, WORKERS_DIR, WorkerReport
|
|
70
|
+
from simrun.workflow import TaskSpec, Workflow
|
|
71
|
+
|
|
72
|
+
logger = logging.getLogger(__name__)
|
|
73
|
+
|
|
74
|
+
TERMINAL_RUN_STATES = frozenset(
|
|
75
|
+
{RunState.SUCCEEDED, RunState.FAILED, RunState.CANCELLED}
|
|
76
|
+
)
|
|
77
|
+
FINAL_TASK_STATES = frozenset({TaskState.DONE, TaskState.FAILED, TaskState.SKIPPED})
|
|
78
|
+
FAILED_PHASES = frozenset({"Failed", "Error"})
|
|
79
|
+
SKIPPED_PHASES = frozenset({"Skipped", "Omitted"})
|
|
80
|
+
# seconds a run may stay submitting after its image was ready (or after its creation
|
|
81
|
+
# when it has none yet) and while no submission of it runs in this process, before
|
|
82
|
+
# the controller takes the submission as lost
|
|
83
|
+
SUBMIT_GRACE_S = 120.0
|
|
84
|
+
# submissions which resolve, build and submit at the same time
|
|
85
|
+
SUBMIT_WORKERS = 8
|
|
86
|
+
# a marker per report of a task recorded as an observation
|
|
87
|
+
RECORDED_DIR = "recorded"
|
|
88
|
+
# seconds after the end of a run after which the logs of its pods are kept even
|
|
89
|
+
# when a pod has not terminated
|
|
90
|
+
LOGS_WAIT_S = 600.0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _split_state(task_id: str, nodes: dict[str, str]) -> TaskState:
|
|
94
|
+
w0 = nodes.get(f"{task_id}-w0")
|
|
95
|
+
merge = nodes.get(f"{task_id}-merge")
|
|
96
|
+
extra = re.compile(rf"{re.escape(task_id)}-extra(\(\d+:\d+\))?")
|
|
97
|
+
extras = [phase for node, phase in nodes.items() if extra.fullmatch(node)]
|
|
98
|
+
if merge == "Succeeded":
|
|
99
|
+
return TaskState.DONE
|
|
100
|
+
if w0 in FAILED_PHASES or merge in FAILED_PHASES:
|
|
101
|
+
return TaskState.FAILED
|
|
102
|
+
if w0 in SKIPPED_PHASES:
|
|
103
|
+
return TaskState.SKIPPED
|
|
104
|
+
if "Running" in (w0, merge, *extras) or w0 == "Succeeded":
|
|
105
|
+
return TaskState.RUNNING
|
|
106
|
+
if PREEMPTED_PHASE in (w0, merge, *extras):
|
|
107
|
+
return TaskState.PREEMPTED
|
|
108
|
+
if RETRYING_PHASE in (w0, merge, *extras):
|
|
109
|
+
return TaskState.RETRYING
|
|
110
|
+
if w0 is not None or merge is not None or extras:
|
|
111
|
+
return TaskState.QUEUED
|
|
112
|
+
return TaskState.WAITING
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _unsplit_state(phase: str | None) -> TaskState:
|
|
116
|
+
if phase is None:
|
|
117
|
+
return TaskState.WAITING
|
|
118
|
+
if phase == "Succeeded":
|
|
119
|
+
return TaskState.DONE
|
|
120
|
+
if phase in FAILED_PHASES:
|
|
121
|
+
return TaskState.FAILED
|
|
122
|
+
if phase in SKIPPED_PHASES:
|
|
123
|
+
return TaskState.SKIPPED
|
|
124
|
+
if phase == "Running":
|
|
125
|
+
return TaskState.RUNNING
|
|
126
|
+
if phase == PREEMPTED_PHASE:
|
|
127
|
+
return TaskState.PREEMPTED
|
|
128
|
+
if phase == RETRYING_PHASE:
|
|
129
|
+
return TaskState.RETRYING
|
|
130
|
+
return TaskState.QUEUED
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def task_states(
|
|
134
|
+
workflow: Workflow, types: dict[str, TaskType], status: BackendStatus
|
|
135
|
+
) -> dict[str, TaskState]:
|
|
136
|
+
"""The state of every task of a workflow from the phases of its DAG nodes.
|
|
137
|
+
|
|
138
|
+
A task is running when one of its pods runs (or its coordinator is done and the
|
|
139
|
+
merge waits), preempted when none runs and a pod evicted waits for its retry,
|
|
140
|
+
retrying when none runs and a pod which failed otherwise waits for its retry,
|
|
141
|
+
queued when it has pods of which none runs, waiting without pods.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
workflow: The workflow of the run.
|
|
145
|
+
types: The task types by name.
|
|
146
|
+
status: The status of the workflow in the backend.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
The state of every task by id.
|
|
150
|
+
"""
|
|
151
|
+
states: dict[str, TaskState] = {}
|
|
152
|
+
for task in workflow.tasks:
|
|
153
|
+
if types[task.type].splittable:
|
|
154
|
+
states[task.id] = _split_state(task.id, status.tasks)
|
|
155
|
+
else:
|
|
156
|
+
states[task.id] = _unsplit_state(status.tasks.get(task.id))
|
|
157
|
+
return states
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def attempt_of(name: str) -> int:
|
|
161
|
+
"""The attempt of a report file `<worker>-<attempt>.json`, 0 without one."""
|
|
162
|
+
try:
|
|
163
|
+
return int(name.removesuffix(".json").rsplit("-", 1)[1])
|
|
164
|
+
except IndexError, ValueError:
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def is_oom(report: WorkerReport, attempt: int, kills: dict[str, list[int]]) -> bool:
|
|
169
|
+
"""Whether a report is of a worker which was killed for memory.
|
|
170
|
+
|
|
171
|
+
Only the evidence of the out of memory killer counts (Rulings 34, 39): a peak
|
|
172
|
+
near the limit is no kill, the kernel reclaims the page cache there, and an
|
|
173
|
+
exit 137 may be a kill after the grace period.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
report: The report.
|
|
177
|
+
attempt: Its attempt.
|
|
178
|
+
kills: The attempts killed for memory per worker (`RunRecord.oom_kills`).
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
True when the attempt is one killed for memory, or the report counts kills
|
|
182
|
+
of the out of memory killer in its cgroup.
|
|
183
|
+
"""
|
|
184
|
+
return report.oom_kills > 0 or attempt in kills.get(report.worker, ())
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def closed(
|
|
188
|
+
record: RunRecord,
|
|
189
|
+
state: RunState,
|
|
190
|
+
tasks: dict[str, TaskState],
|
|
191
|
+
message: str | None,
|
|
192
|
+
**update: object,
|
|
193
|
+
) -> RunRecord:
|
|
194
|
+
"""The terminal record: tasks not final are skipped, the node is to release.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
record: The record.
|
|
198
|
+
state: The terminal state.
|
|
199
|
+
tasks: The states of the tasks.
|
|
200
|
+
message: The message, e.g. why the run failed.
|
|
201
|
+
**update: Further fields of the record.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
The record, ended now.
|
|
205
|
+
"""
|
|
206
|
+
final = {
|
|
207
|
+
task: s if s in FINAL_TASK_STATES else TaskState.SKIPPED
|
|
208
|
+
for task, s in tasks.items()
|
|
209
|
+
}
|
|
210
|
+
return record.model_copy(
|
|
211
|
+
update={
|
|
212
|
+
"state": state,
|
|
213
|
+
"tasks": final,
|
|
214
|
+
"message": message,
|
|
215
|
+
"finished": datetime.now(UTC),
|
|
216
|
+
"pending_release": record.reserve,
|
|
217
|
+
# the logs of its pods, kept once they ended (or the workflow is gone)
|
|
218
|
+
"pending_logs": record.backend_name is not None,
|
|
219
|
+
**update,
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class Controller:
|
|
225
|
+
"""Maps the status of the backend onto the records of the runs."""
|
|
226
|
+
|
|
227
|
+
def __init__(
|
|
228
|
+
self,
|
|
229
|
+
store: IndexedRunStore,
|
|
230
|
+
resources: ResourceStore,
|
|
231
|
+
backend: Backend,
|
|
232
|
+
types: dict[str, TaskType],
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Create the controller.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
store: The run store with its index.
|
|
238
|
+
resources: The store of the resource observations.
|
|
239
|
+
backend: The backend the workflows run on.
|
|
240
|
+
types: The task types by name.
|
|
241
|
+
"""
|
|
242
|
+
self.store = store
|
|
243
|
+
self.resources = resources
|
|
244
|
+
self.backend = backend
|
|
245
|
+
self.types = types
|
|
246
|
+
# held by every read-modify-write of a record, by the controller and the API;
|
|
247
|
+
# never held during a call of the backend or a walk of the store
|
|
248
|
+
self.lock = threading.RLock()
|
|
249
|
+
self._reports_lock = threading.Lock()
|
|
250
|
+
# a submission created before the controller started is not in flight
|
|
251
|
+
self.started = datetime.now(UTC)
|
|
252
|
+
self._submissions = ThreadPoolExecutor(
|
|
253
|
+
max_workers=SUBMIT_WORKERS, thread_name_prefix="simrun-submit"
|
|
254
|
+
)
|
|
255
|
+
self._in_flight: set[str] = set()
|
|
256
|
+
# resolves, builds and submits a run from its record (`Submitter.run`); a
|
|
257
|
+
# submission a stopped service left is resumed with it
|
|
258
|
+
self.resume: Callable[[str], None] | None = None
|
|
259
|
+
|
|
260
|
+
def start_submission(self, run_id: str, job: Callable[[str], None]) -> None:
|
|
261
|
+
"""Run the submission of a run in the background, see `in_flight`.
|
|
262
|
+
|
|
263
|
+
A job which raises fails a run which is still submitting; its workflow may
|
|
264
|
+
exist, so it is cancelled.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
run_id: The run, written `submitting`.
|
|
268
|
+
job: Resolves, builds and submits the run; called with its id.
|
|
269
|
+
"""
|
|
270
|
+
with self.lock:
|
|
271
|
+
self._in_flight.add(run_id)
|
|
272
|
+
|
|
273
|
+
def run() -> None:
|
|
274
|
+
try:
|
|
275
|
+
job(run_id)
|
|
276
|
+
except Exception as error:
|
|
277
|
+
logger.exception("the submission of run %s failed", run_id)
|
|
278
|
+
self._submission_failed(run_id, f"the submission failed: {error}")
|
|
279
|
+
finally:
|
|
280
|
+
with self.lock:
|
|
281
|
+
self._in_flight.discard(run_id)
|
|
282
|
+
|
|
283
|
+
self._submissions.submit(run)
|
|
284
|
+
|
|
285
|
+
def in_flight(self, run_id: str) -> bool:
|
|
286
|
+
"""Whether the submission of a run runs in this process."""
|
|
287
|
+
with self.lock:
|
|
288
|
+
return run_id in self._in_flight
|
|
289
|
+
|
|
290
|
+
def close(self) -> None:
|
|
291
|
+
"""Stop taking submissions, without waiting for the running ones.
|
|
292
|
+
|
|
293
|
+
A build may take an hour; the pod of the service is killed long before.
|
|
294
|
+
A run left submitting is resumed at the next start of the service
|
|
295
|
+
(`recover`), from its record.
|
|
296
|
+
"""
|
|
297
|
+
self._submissions.shutdown(wait=False, cancel_futures=True)
|
|
298
|
+
|
|
299
|
+
def _submission_failed(self, run_id: str, message: str) -> None:
|
|
300
|
+
"""Fail a run which is still submitting."""
|
|
301
|
+
try:
|
|
302
|
+
record = self.store.read(run_id)
|
|
303
|
+
except KeyError:
|
|
304
|
+
return
|
|
305
|
+
if record.state == RunState.SUBMITTING:
|
|
306
|
+
self._fail_submission(record, message)
|
|
307
|
+
|
|
308
|
+
def _fail_submission(self, record: RunRecord, message: str) -> None:
|
|
309
|
+
"""Fail a submitting run unless it changed, cancel its workflow, settle it."""
|
|
310
|
+
failed = closed(
|
|
311
|
+
record,
|
|
312
|
+
RunState.FAILED,
|
|
313
|
+
record.tasks,
|
|
314
|
+
message,
|
|
315
|
+
pending_cancel=record.backend_name is not None,
|
|
316
|
+
)
|
|
317
|
+
if self.write_if_unchanged(record, failed):
|
|
318
|
+
self.settle(record.id)
|
|
319
|
+
|
|
320
|
+
def recover(self) -> None:
|
|
321
|
+
"""Settle what a stopped service left: called at the start of the service.
|
|
322
|
+
|
|
323
|
+
The service is one process, so a run submitting since before the controller
|
|
324
|
+
started has lost its submission: it is resumed from its record
|
|
325
|
+
(`resume`, idempotent: an image which was built is found, a node reserved
|
|
326
|
+
for the run is kept, a workflow which was created is taken as submitted).
|
|
327
|
+
A submission created later may be in flight (the API accepts runs while this
|
|
328
|
+
runs) and is left to `reconcile`, which never fails a submission running in
|
|
329
|
+
this process. A record which is not terminal and has no workflow name was
|
|
330
|
+
written before names were stored, and fails. The pending logs, releases and
|
|
331
|
+
cancellations of terminal runs are retried.
|
|
332
|
+
"""
|
|
333
|
+
for run_id in self.store.index.live_ids():
|
|
334
|
+
try:
|
|
335
|
+
self._recover(run_id)
|
|
336
|
+
except Exception:
|
|
337
|
+
logger.exception("recovering run %s failed", run_id)
|
|
338
|
+
|
|
339
|
+
def _recover(self, run_id: str) -> None:
|
|
340
|
+
record = self.store.read(run_id)
|
|
341
|
+
if record.state not in TERMINAL_RUN_STATES and record.backend_name is None:
|
|
342
|
+
self._submission_lost(record)
|
|
343
|
+
elif (
|
|
344
|
+
record.state == RunState.SUBMITTING
|
|
345
|
+
and record.created < self.started
|
|
346
|
+
and not self.in_flight(run_id)
|
|
347
|
+
):
|
|
348
|
+
if self.resume is None:
|
|
349
|
+
self._submission_lost(record)
|
|
350
|
+
else:
|
|
351
|
+
logger.info("resuming the submission of run %s", run_id)
|
|
352
|
+
self.start_submission(run_id, self.resume)
|
|
353
|
+
return
|
|
354
|
+
self.settle(run_id)
|
|
355
|
+
|
|
356
|
+
def _submission_lost(self, record: RunRecord) -> None:
|
|
357
|
+
"""Fail a run whose submission did not complete, and settle it."""
|
|
358
|
+
logger.warning("the submission of run %s did not complete, it fails", record.id)
|
|
359
|
+
self._fail_submission(record, "the submission of the run did not complete")
|
|
360
|
+
|
|
361
|
+
def reconcile(self) -> None:
|
|
362
|
+
"""Bring the records of the live runs up to date with the backend.
|
|
363
|
+
|
|
364
|
+
The live runs are the ones of the index which have not ended or have a
|
|
365
|
+
release or a cancellation pending; an ended run is not read again. Runs
|
|
366
|
+
missing from the index are indexed first (`IndexedRunStore.reindex`) and
|
|
367
|
+
followed also while their index rows cannot be written.
|
|
368
|
+
"""
|
|
369
|
+
unindexed: list[str] = []
|
|
370
|
+
try:
|
|
371
|
+
with self.lock:
|
|
372
|
+
unindexed = self.store.reindex()
|
|
373
|
+
except Exception:
|
|
374
|
+
logger.exception("indexing the runs missing from the index failed")
|
|
375
|
+
for run_id in sorted({*self.store.index.live_ids(), *unindexed}):
|
|
376
|
+
try:
|
|
377
|
+
self._reconcile(run_id)
|
|
378
|
+
except Exception:
|
|
379
|
+
logger.exception("reconciling run %s failed", run_id)
|
|
380
|
+
|
|
381
|
+
def _reconcile(self, run_id: str) -> None:
|
|
382
|
+
record = self.store.read(run_id)
|
|
383
|
+
if record.state in TERMINAL_RUN_STATES:
|
|
384
|
+
self.settle(run_id)
|
|
385
|
+
return
|
|
386
|
+
if record.state == RunState.SUBMITTING:
|
|
387
|
+
if self.in_flight(run_id):
|
|
388
|
+
return # resolved, built or submitted: its workflow may not exist yet
|
|
389
|
+
since = record.built or record.created
|
|
390
|
+
if (datetime.now(UTC) - since).total_seconds() >= SUBMIT_GRACE_S:
|
|
391
|
+
self._submission_lost(record)
|
|
392
|
+
return
|
|
393
|
+
name = record.backend_name
|
|
394
|
+
if name is None:
|
|
395
|
+
return
|
|
396
|
+
status: BackendStatus | None
|
|
397
|
+
try:
|
|
398
|
+
status = self.backend.status(name)
|
|
399
|
+
except KeyError:
|
|
400
|
+
status = None
|
|
401
|
+
if status is None:
|
|
402
|
+
new = self._gone(record, name)
|
|
403
|
+
stop = False
|
|
404
|
+
else:
|
|
405
|
+
new = self._next(record, status)
|
|
406
|
+
stop = status.phase not in FINAL_PHASES
|
|
407
|
+
# recorded before the record is written: a failure is retried at the next poll
|
|
408
|
+
self._record_reports(new)
|
|
409
|
+
if status is not None:
|
|
410
|
+
kept = self._keep_logs(new)
|
|
411
|
+
if new.state in TERMINAL_RUN_STATES and kept:
|
|
412
|
+
new = new.model_copy(update={"pending_logs": False})
|
|
413
|
+
if new == record or not self.write_if_unchanged(record, new):
|
|
414
|
+
return
|
|
415
|
+
if new.state in TERMINAL_RUN_STATES:
|
|
416
|
+
logger.info("run %s %s", run_id, new.state.value)
|
|
417
|
+
if stop:
|
|
418
|
+
try:
|
|
419
|
+
self.backend.stop(name)
|
|
420
|
+
except Exception:
|
|
421
|
+
logger.exception("stopping the workflow %s failed", name)
|
|
422
|
+
self.settle(run_id)
|
|
423
|
+
|
|
424
|
+
def write_if_unchanged(self, before: RunRecord, after: RunRecord) -> bool:
|
|
425
|
+
"""Write `after` when the record on disk is still `before`.
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
Whether `after` was written; not when the run changed or is gone.
|
|
429
|
+
"""
|
|
430
|
+
with self.lock:
|
|
431
|
+
try:
|
|
432
|
+
current = self.store.read(before.id)
|
|
433
|
+
except KeyError:
|
|
434
|
+
return False
|
|
435
|
+
if current != before:
|
|
436
|
+
return False
|
|
437
|
+
self.store.write(after)
|
|
438
|
+
return True
|
|
439
|
+
|
|
440
|
+
def _pods(
|
|
441
|
+
self, task: TaskSpec, shape: PodShape | None
|
|
442
|
+
) -> list[tuple[str, PodShape | None]]:
|
|
443
|
+
"""The patterns of the pod keys of a task with the shapes of their pods."""
|
|
444
|
+
task_type = self.types[task.type]
|
|
445
|
+
if not task_type.splittable:
|
|
446
|
+
return [(re.escape(task.id), shape)]
|
|
447
|
+
merge = (
|
|
448
|
+
None
|
|
449
|
+
if shape is None
|
|
450
|
+
else podspec.merge_shape(shape, task_type.hint.memory_base_gb)
|
|
451
|
+
)
|
|
452
|
+
return [
|
|
453
|
+
(rf"{re.escape(task.id)}-w\d+", shape),
|
|
454
|
+
(rf"{re.escape(task.id)}-merge", merge),
|
|
455
|
+
]
|
|
456
|
+
|
|
457
|
+
def _task_of(self, record: RunRecord, key: str) -> TaskSpec | None:
|
|
458
|
+
"""The task of a pod key: a worker, a merge or an unsplit task."""
|
|
459
|
+
for task in record.workflow.tasks:
|
|
460
|
+
if any(re.fullmatch(p, key) for p, _ in self._pods(task, None)):
|
|
461
|
+
return task
|
|
462
|
+
return None
|
|
463
|
+
|
|
464
|
+
def _reported_kill(self, record: RunRecord, key: str, attempt: int) -> bool:
|
|
465
|
+
"""Whether the report of an attempt counts kills of the out of memory killer.
|
|
466
|
+
|
|
467
|
+
A worker (or `simrun-exec`) which exits 137 itself because a process of its
|
|
468
|
+
pool (its command) died writes its report first.
|
|
469
|
+
"""
|
|
470
|
+
task = self._task_of(record, key)
|
|
471
|
+
if task is None:
|
|
472
|
+
return False
|
|
473
|
+
path = (
|
|
474
|
+
self.store.run_dir(record.id)
|
|
475
|
+
/ "tasks"
|
|
476
|
+
/ task.id
|
|
477
|
+
/ WORKERS_DIR
|
|
478
|
+
/ f"{key}-{attempt}.json"
|
|
479
|
+
)
|
|
480
|
+
try:
|
|
481
|
+
report = WorkerReport(**json.loads(path.read_text()))
|
|
482
|
+
except FileNotFoundError:
|
|
483
|
+
return False
|
|
484
|
+
except (OSError, ValueError, TypeError) as error:
|
|
485
|
+
logger.warning("report %s of run %s: %s", path.name, record.id, error)
|
|
486
|
+
return False
|
|
487
|
+
return report.oom_kills > 0
|
|
488
|
+
|
|
489
|
+
def _memory_kills(
|
|
490
|
+
self, record: RunRecord, failed: dict[str, list[FailedAttempt]]
|
|
491
|
+
) -> dict[str, list[int]]:
|
|
492
|
+
"""The attempts killed for memory per pod key, with the ones known before.
|
|
493
|
+
|
|
494
|
+
An attempt was killed for memory with the evidence of the out of memory
|
|
495
|
+
killer (Ruling 39): its container `OOMKilled`, or an exit 137 whose report
|
|
496
|
+
counts kills of the out of memory killer. A pod killed after its grace
|
|
497
|
+
period (exit 137 without either) was evicted.
|
|
498
|
+
"""
|
|
499
|
+
kills = {key: set(attempts) for key, attempts in record.oom_kills.items()}
|
|
500
|
+
for key, attempts in failed.items():
|
|
501
|
+
for a in attempts:
|
|
502
|
+
if a.attempt in kills.get(key, ()):
|
|
503
|
+
continue
|
|
504
|
+
if a.oom_killed or (
|
|
505
|
+
a.exit_code == EXIT_POOL_BROKEN
|
|
506
|
+
and self._reported_kill(record, key, a.attempt)
|
|
507
|
+
):
|
|
508
|
+
kills.setdefault(key, set()).add(a.attempt)
|
|
509
|
+
return {key: sorted(attempts) for key, attempts in sorted(kills.items())}
|
|
510
|
+
|
|
511
|
+
def _killed_twice(
|
|
512
|
+
self,
|
|
513
|
+
record: RunRecord,
|
|
514
|
+
failed: dict[str, list[FailedAttempt]],
|
|
515
|
+
kills: dict[str, list[int]],
|
|
516
|
+
) -> tuple[str, str] | None:
|
|
517
|
+
"""The task and the message of a pod killed for memory with the retry memory.
|
|
518
|
+
|
|
519
|
+
The attempt after an exit 137 has the retry memory (`RETRY_FACTOR` per
|
|
520
|
+
process, the `podSpecPatch` of `simrun.argo`), so a kill for memory of
|
|
521
|
+
such an attempt after a first kill fails the run instead of retrying again
|
|
522
|
+
(Ruling 35). Argo knows only the last attempt: an attempt after one which
|
|
523
|
+
was preempted has the first memory again, and its kill does not count
|
|
524
|
+
(Ruling 39), the attempt after it has the retry memory again.
|
|
525
|
+
"""
|
|
526
|
+
for task in record.workflow.tasks:
|
|
527
|
+
shape_values = record.plans.get(task.id, {}).get("shape")
|
|
528
|
+
if shape_values is None:
|
|
529
|
+
continue
|
|
530
|
+
pods = self._pods(task, PodShape(**shape_values))
|
|
531
|
+
for key, killed in kills.items():
|
|
532
|
+
exits = {a.attempt: a.exit_code for a in failed.get(key, [])}
|
|
533
|
+
counted = sum(
|
|
534
|
+
1
|
|
535
|
+
for i, attempt in enumerate(killed)
|
|
536
|
+
if i == 0 or exits.get(attempt - 1) == EXIT_POOL_BROKEN
|
|
537
|
+
)
|
|
538
|
+
if counted < 2:
|
|
539
|
+
continue
|
|
540
|
+
for pattern, pod in pods:
|
|
541
|
+
if pod is not None and re.fullmatch(pattern, key):
|
|
542
|
+
return task.id, (
|
|
543
|
+
f"task {task.id}: {key} was killed for memory "
|
|
544
|
+
f"{len(killed)} times, also with the retry memory of "
|
|
545
|
+
f"{pod.retry_memory_gb} GiB ({pod.retry_per_worker_gb} "
|
|
546
|
+
"GiB per process); give the task more memory"
|
|
547
|
+
)
|
|
548
|
+
return None
|
|
549
|
+
|
|
550
|
+
def _next(self, record: RunRecord, status: BackendStatus) -> RunRecord:
|
|
551
|
+
"""The record after a status of its workflow."""
|
|
552
|
+
tasks = task_states(record.workflow, self.types, status)
|
|
553
|
+
kills = self._memory_kills(record, status.failed)
|
|
554
|
+
record = record.model_copy(update={"oom_kills": kills})
|
|
555
|
+
killed = self._killed_twice(record, status.failed, kills)
|
|
556
|
+
if killed is not None:
|
|
557
|
+
task_id, message = killed
|
|
558
|
+
return closed(
|
|
559
|
+
record, RunState.FAILED, {**tasks, task_id: TaskState.FAILED}, message
|
|
560
|
+
)
|
|
561
|
+
failed = [t for t, s in tasks.items() if s == TaskState.FAILED]
|
|
562
|
+
if failed:
|
|
563
|
+
message = f"task {', '.join(failed)} failed"
|
|
564
|
+
return closed(record, RunState.FAILED, tasks, message)
|
|
565
|
+
if all(s == TaskState.DONE for s in tasks.values()):
|
|
566
|
+
return closed(record, RunState.SUCCEEDED, tasks, None)
|
|
567
|
+
if status.phase in FINAL_PHASES:
|
|
568
|
+
message = f"the workflow ended with phase {status.phase}"
|
|
569
|
+
return closed(record, RunState.FAILED, tasks, message)
|
|
570
|
+
started = {
|
|
571
|
+
TaskState.RUNNING,
|
|
572
|
+
TaskState.PREEMPTED,
|
|
573
|
+
TaskState.RETRYING,
|
|
574
|
+
TaskState.DONE,
|
|
575
|
+
}
|
|
576
|
+
state = (
|
|
577
|
+
RunState.RUNNING
|
|
578
|
+
if any(s in started for s in tasks.values())
|
|
579
|
+
else RunState.QUEUED
|
|
580
|
+
)
|
|
581
|
+
return record.model_copy(update={"state": state, "tasks": tasks})
|
|
582
|
+
|
|
583
|
+
def _gone(self, record: RunRecord, name: str) -> RunRecord:
|
|
584
|
+
"""The record of a run whose workflow is gone, resolved from the store."""
|
|
585
|
+
tasks: dict[str, TaskState] = {}
|
|
586
|
+
undetermined: list[str] = []
|
|
587
|
+
for task in record.workflow.tasks:
|
|
588
|
+
last = record.tasks.get(task.id, TaskState.WAITING)
|
|
589
|
+
out = self.store.run_dir(record.id) / "tasks" / task.id / "out"
|
|
590
|
+
if last in FINAL_TASK_STATES:
|
|
591
|
+
tasks[task.id] = last
|
|
592
|
+
elif self.types[task.type].splittable and (out / MERGED_MARKER).is_file():
|
|
593
|
+
tasks[task.id] = TaskState.DONE
|
|
594
|
+
else:
|
|
595
|
+
tasks[task.id] = TaskState.FAILED
|
|
596
|
+
undetermined.append(task.id)
|
|
597
|
+
failed = [t for t, s in tasks.items() if s == TaskState.FAILED]
|
|
598
|
+
if undetermined:
|
|
599
|
+
message = f"the workflow {name} is gone: {', '.join(undetermined)} unknown"
|
|
600
|
+
elif failed:
|
|
601
|
+
message = f"task {', '.join(failed)} failed"
|
|
602
|
+
elif all(s == TaskState.DONE for s in tasks.values()):
|
|
603
|
+
return closed(record, RunState.SUCCEEDED, tasks, None)
|
|
604
|
+
else:
|
|
605
|
+
message = f"the workflow {name} is gone"
|
|
606
|
+
return closed(record, RunState.FAILED, tasks, message)
|
|
607
|
+
|
|
608
|
+
def cancel(self, run_id: str) -> RunRecord:
|
|
609
|
+
"""Cancel a run: write it cancelled, then cancel its workflow, release its node.
|
|
610
|
+
|
|
611
|
+
Raises:
|
|
612
|
+
KeyError: The run does not exist.
|
|
613
|
+
ValueError: The run has ended.
|
|
614
|
+
"""
|
|
615
|
+
with self.lock:
|
|
616
|
+
record = self.store.read(run_id)
|
|
617
|
+
if record.state in TERMINAL_RUN_STATES:
|
|
618
|
+
raise ValueError(f"run {run_id} has ended: {record.state}")
|
|
619
|
+
cancelled = closed(
|
|
620
|
+
record,
|
|
621
|
+
RunState.CANCELLED,
|
|
622
|
+
record.tasks,
|
|
623
|
+
"cancelled",
|
|
624
|
+
pending_cancel=record.backend_name is not None,
|
|
625
|
+
)
|
|
626
|
+
self.store.write(cancelled)
|
|
627
|
+
self.settle(run_id)
|
|
628
|
+
return self.store.read(run_id)
|
|
629
|
+
|
|
630
|
+
def settle(self, run_id: str) -> None:
|
|
631
|
+
"""Retry the pending logs, release and cancellation of a terminal run.
|
|
632
|
+
|
|
633
|
+
The logs are kept before the workflow is cancelled; a workflow which does
|
|
634
|
+
not exist (any more) has no logs to keep. An unknown node counts as released
|
|
635
|
+
and an unknown workflow as cancelled; a node is released only from the
|
|
636
|
+
reservation of this run. A run which was deleted meanwhile is ignored.
|
|
637
|
+
"""
|
|
638
|
+
try:
|
|
639
|
+
record = self.store.read(run_id)
|
|
640
|
+
except KeyError:
|
|
641
|
+
return
|
|
642
|
+
if record.state not in TERMINAL_RUN_STATES:
|
|
643
|
+
return
|
|
644
|
+
logs_kept = not record.pending_logs or record.backend_name is None
|
|
645
|
+
if not logs_kept and record.backend_name is not None:
|
|
646
|
+
try:
|
|
647
|
+
self.backend.status(record.backend_name)
|
|
648
|
+
logs_kept = self._keep_logs(record)
|
|
649
|
+
except KeyError:
|
|
650
|
+
logs_kept = True # no workflow: no pods, no logs
|
|
651
|
+
except Exception:
|
|
652
|
+
logger.exception(
|
|
653
|
+
"the status of the workflow %s cannot be read", record.backend_name
|
|
654
|
+
)
|
|
655
|
+
cancelled = not record.pending_cancel or record.backend_name is None
|
|
656
|
+
if not cancelled and record.backend_name is not None:
|
|
657
|
+
try:
|
|
658
|
+
self.backend.cancel(record.backend_name)
|
|
659
|
+
cancelled = True
|
|
660
|
+
except KeyError:
|
|
661
|
+
cancelled = True
|
|
662
|
+
except Exception:
|
|
663
|
+
logger.exception(
|
|
664
|
+
"cancelling the workflow %s failed", record.backend_name
|
|
665
|
+
)
|
|
666
|
+
released = record.pending_release is None
|
|
667
|
+
if record.pending_release is not None:
|
|
668
|
+
try:
|
|
669
|
+
self.backend.unreserve(record.pending_release, record.id)
|
|
670
|
+
released = True
|
|
671
|
+
except KeyError:
|
|
672
|
+
logger.info(
|
|
673
|
+
"node %s is unknown, taken as released", record.pending_release
|
|
674
|
+
)
|
|
675
|
+
released = True
|
|
676
|
+
except Exception:
|
|
677
|
+
logger.exception("releasing node %s failed", record.pending_release)
|
|
678
|
+
update: dict[str, object] = {}
|
|
679
|
+
if logs_kept and record.pending_logs:
|
|
680
|
+
update["pending_logs"] = False
|
|
681
|
+
if cancelled and record.pending_cancel:
|
|
682
|
+
update["pending_cancel"] = False
|
|
683
|
+
if released and record.pending_release is not None:
|
|
684
|
+
update["pending_release"] = None
|
|
685
|
+
if update:
|
|
686
|
+
self.write_if_unchanged(record, record.model_copy(update=update))
|
|
687
|
+
|
|
688
|
+
def _keep_logs(self, record: RunRecord) -> bool:
|
|
689
|
+
"""Keep the logs of the tasks in a final state whose logs are not kept yet.
|
|
690
|
+
|
|
691
|
+
A task's logs are kept once all its pods terminated (a skipped task's pods
|
|
692
|
+
stop after the workflow was stopped), or `LOGS_WAIT_S` after the end of the
|
|
693
|
+
run whatever they are. Called before the record is written, so the logs of
|
|
694
|
+
the pods which ended are read before the workflow is stopped or cancelled; a
|
|
695
|
+
failure is logged and retried at the next poll (`pending_logs`).
|
|
696
|
+
|
|
697
|
+
Returns:
|
|
698
|
+
Whether the logs of every final task are kept.
|
|
699
|
+
"""
|
|
700
|
+
name = record.backend_name
|
|
701
|
+
if name is None:
|
|
702
|
+
return True
|
|
703
|
+
waited = record.finished is not None and (
|
|
704
|
+
(datetime.now(UTC) - record.finished).total_seconds() >= LOGS_WAIT_S
|
|
705
|
+
)
|
|
706
|
+
complete = True
|
|
707
|
+
for task_id, state in record.tasks.items():
|
|
708
|
+
task_dir = self.store.run_dir(record.id) / "tasks" / task_id
|
|
709
|
+
if state not in FINAL_TASK_STATES or logs.kept(task_dir):
|
|
710
|
+
continue
|
|
711
|
+
try:
|
|
712
|
+
pods = self.backend.logs(name, task_id, logs.KEPT_TAIL_LINES)
|
|
713
|
+
if not waited and not all(p.done for p in pods):
|
|
714
|
+
complete = False
|
|
715
|
+
continue
|
|
716
|
+
logs.keep(task_dir, pods)
|
|
717
|
+
except Exception:
|
|
718
|
+
complete = False
|
|
719
|
+
logger.exception(
|
|
720
|
+
"keeping the logs of task %s of run %s failed", task_id, record.id
|
|
721
|
+
)
|
|
722
|
+
return complete
|
|
723
|
+
|
|
724
|
+
def _record_reports(self, record: RunRecord) -> None:
|
|
725
|
+
"""Record the reports of the tasks in a final state not recorded before."""
|
|
726
|
+
with self._reports_lock:
|
|
727
|
+
for task in record.workflow.tasks:
|
|
728
|
+
# a command task has the memory of its settings, nothing to learn
|
|
729
|
+
if (
|
|
730
|
+
record.tasks.get(task.id) in FINAL_TASK_STATES
|
|
731
|
+
and task.type != COMMAND.name
|
|
732
|
+
):
|
|
733
|
+
self._record_task(record, task)
|
|
734
|
+
|
|
735
|
+
def _record_task(self, record: RunRecord, task: TaskSpec) -> None:
|
|
736
|
+
task_id = task.id
|
|
737
|
+
task_dir = self.store.run_dir(record.id) / "tasks" / task_id
|
|
738
|
+
reports_dir = task_dir / WORKERS_DIR
|
|
739
|
+
marks = task_dir / RECORDED_DIR
|
|
740
|
+
reports = (
|
|
741
|
+
sorted(
|
|
742
|
+
p.name
|
|
743
|
+
for p in reports_dir.iterdir()
|
|
744
|
+
if p.suffix == ".json" and not p.name.startswith(".")
|
|
745
|
+
)
|
|
746
|
+
if reports_dir.is_dir()
|
|
747
|
+
else []
|
|
748
|
+
)
|
|
749
|
+
# the workers of a split task, an unsplit task by its id (`simrun-exec`)
|
|
750
|
+
worker = re.compile(rf"{re.escape(task_id)}(-w\d+)?")
|
|
751
|
+
kills = {
|
|
752
|
+
(w, attempt)
|
|
753
|
+
for w, attempts in record.oom_kills.items()
|
|
754
|
+
if worker.fullmatch(w)
|
|
755
|
+
for attempt in attempts
|
|
756
|
+
}
|
|
757
|
+
if not reports and not kills:
|
|
758
|
+
return
|
|
759
|
+
task_type = self.types[task.type]
|
|
760
|
+
model = record.sources[0].name if record.sources else ""
|
|
761
|
+
key = resource_key(task_type, task.settings, model)
|
|
762
|
+
base = task_type.hint.memory_base_gb
|
|
763
|
+
marks.mkdir(exist_ok=True)
|
|
764
|
+
for name in reports:
|
|
765
|
+
if (marks / name).exists():
|
|
766
|
+
continue
|
|
767
|
+
try:
|
|
768
|
+
report = WorkerReport(**json.loads((reports_dir / name).read_text()))
|
|
769
|
+
except (ValueError, TypeError) as error:
|
|
770
|
+
logger.warning("report %s of run %s: %s", name, record.id, error)
|
|
771
|
+
(marks / name).touch()
|
|
772
|
+
continue
|
|
773
|
+
oom = is_oom(report, attempt_of(name), record.oom_kills)
|
|
774
|
+
self.resources.record(key, report, base, oom=oom, run=record.id)
|
|
775
|
+
(marks / name).touch()
|
|
776
|
+
shape = record.plans.get(task_id, {}).get("shape")
|
|
777
|
+
for w, attempt in sorted(kills):
|
|
778
|
+
mark = marks / f"{w}-{attempt}.oom"
|
|
779
|
+
if f"{w}-{attempt}.json" in reports or mark.exists() or shape is None:
|
|
780
|
+
continue
|
|
781
|
+
report = WorkerReport(
|
|
782
|
+
worker=w,
|
|
783
|
+
node="",
|
|
784
|
+
processes=shape["processes"],
|
|
785
|
+
memory_limit_bytes=int(shape["memory_gb"] * GIB),
|
|
786
|
+
)
|
|
787
|
+
self.resources.record(key, report, base, oom=True, run=record.id)
|
|
788
|
+
mark.touch()
|