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.
@@ -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()