kkrunner 0.1.0b1__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.
- kkrunner/__init__.py +16 -0
- kkrunner/__main__.py +3 -0
- kkrunner/_version.py +5 -0
- kkrunner/backends/__init__.py +35 -0
- kkrunner/backends/endpoint.py +179 -0
- kkrunner/backends/local.py +328 -0
- kkrunner/backends/local_managed.py +363 -0
- kkrunner/backends/local_process.py +144 -0
- kkrunner/backends/mpi.py +63 -0
- kkrunner/backends/pool.py +68 -0
- kkrunner/backends/psij.py +930 -0
- kkrunner/cli.py +180 -0
- kkrunner/files/__init__.py +27 -0
- kkrunner/files/availability.py +92 -0
- kkrunner/files/fingerprints.py +35 -0
- kkrunner/files/manifest.py +376 -0
- kkrunner/files/paths.py +66 -0
- kkrunner/files/queries.py +110 -0
- kkrunner/files/task_file.py +67 -0
- kkrunner/files/versions.py +187 -0
- kkrunner/projects/__init__.py +21 -0
- kkrunner/projects/manager.py +89 -0
- kkrunner/projects/repository.py +436 -0
- kkrunner/projects/resolution.py +19 -0
- kkrunner/remote/__init__.py +6 -0
- kkrunner/remote/executor.py +715 -0
- kkrunner/remote/mailbox.py +85 -0
- kkrunner/remote/protocol.py +27 -0
- kkrunner/remote/runner.py +771 -0
- kkrunner/remote/service.py +600 -0
- kkrunner/remote/stream.py +154 -0
- kkrunner/remote/supervisor.py +318 -0
- kkrunner/remote/transport.py +429 -0
- kkrunner/remote/worker.py +199 -0
- kkrunner/runs/__init__.py +45 -0
- kkrunner/runs/base.py +700 -0
- kkrunner/runs/conflicts.py +92 -0
- kkrunner/runs/manager.py +1132 -0
- kkrunner/runs/metadata.py +40 -0
- kkrunner/runs/model.py +58 -0
- kkrunner/runs/processing.py +214 -0
- kkrunner/runs/records.py +252 -0
- kkrunner/support/__init__.py +1 -0
- kkrunner/support/async_utils.py +30 -0
- kkrunner/support/decorators.py +24 -0
- kkrunner/support/durability.py +137 -0
- kkrunner/support/file_locks.py +91 -0
- kkrunner/support/growing_file_tail.py +260 -0
- kkrunner/support/process_identity.py +85 -0
- kkrunner-0.1.0b1.dist-info/METADATA +162 -0
- kkrunner-0.1.0b1.dist-info/RECORD +55 -0
- kkrunner-0.1.0b1.dist-info/WHEEL +5 -0
- kkrunner-0.1.0b1.dist-info/entry_points.txt +2 -0
- kkrunner-0.1.0b1.dist-info/licenses/LICENCE +19 -0
- kkrunner-0.1.0b1.dist-info/top_level.txt +1 -0
kkrunner/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Backend-independent entry points for execution and durable run management."""
|
|
2
|
+
|
|
3
|
+
from kkrunner._version import __version__
|
|
4
|
+
from kkrunner.files import TaskFile
|
|
5
|
+
from kkrunner.runs import ExecutionOutcome, ExecutionSpec, ProcessingPending, RunManager, RunState, RunStore
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ExecutionOutcome",
|
|
9
|
+
"ExecutionSpec",
|
|
10
|
+
"ProcessingPending",
|
|
11
|
+
"RunManager",
|
|
12
|
+
"RunState",
|
|
13
|
+
"RunStore",
|
|
14
|
+
"TaskFile",
|
|
15
|
+
"__version__",
|
|
16
|
+
]
|
kkrunner/__main__.py
ADDED
kkrunner/_version.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Execution backends and their reusable endpoint identities."""
|
|
2
|
+
|
|
3
|
+
from importlib import import_module
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from kkrunner.backends.endpoint import ExecutorEndpoint
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from kkrunner.backends.local import LocalExecutor
|
|
10
|
+
from kkrunner.backends.pool import ExecutorPool, default_executor_pool
|
|
11
|
+
from kkrunner.backends.psij import PsijExecutor
|
|
12
|
+
|
|
13
|
+
# Run contracts use endpoint metadata. Load implementations only on request so
|
|
14
|
+
# importing those contracts does not load backends or create an import cycle.
|
|
15
|
+
_EXPORTS = {
|
|
16
|
+
"LocalExecutor": "kkrunner.backends.local",
|
|
17
|
+
"PsijExecutor": "kkrunner.backends.psij",
|
|
18
|
+
"ExecutorPool": "kkrunner.backends.pool",
|
|
19
|
+
"default_executor_pool": "kkrunner.backends.pool",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
__all__ = ["ExecutorEndpoint", "ExecutorPool", "LocalExecutor", "PsijExecutor", "default_executor_pool"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def __getattr__(name):
|
|
26
|
+
module = _EXPORTS.get(name)
|
|
27
|
+
if module is None:
|
|
28
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
29
|
+
value = getattr(import_module(module), name)
|
|
30
|
+
globals()[name] = value
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def __dir__():
|
|
35
|
+
return sorted(set(globals()) | set(__all__))
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Serializable execution endpoint identity and reconstruction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ExecutorEndpoint:
|
|
11
|
+
"""Identity of a reusable execution backend.
|
|
12
|
+
|
|
13
|
+
The endpoint contains only connection/backend properties. Launch and
|
|
14
|
+
staging policy deliberately live elsewhere so one executor can be shared
|
|
15
|
+
by profiles that differ only in how individual calculations are launched.
|
|
16
|
+
The value is serializable and hashable, so it can also be persisted with a
|
|
17
|
+
run and used as an :class:`ExecutorPool` key after process restart.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
executor: str = "local"
|
|
21
|
+
host: Any = None
|
|
22
|
+
ssh_options: Any = ()
|
|
23
|
+
remote_command: Any = ()
|
|
24
|
+
psij_backend: Any = None
|
|
25
|
+
psij_url: Any = None
|
|
26
|
+
ssh_zstd: bool = False
|
|
27
|
+
filesystem_id: str | None = None
|
|
28
|
+
|
|
29
|
+
def __post_init__(self):
|
|
30
|
+
object.__setattr__(self, "executor", str(self.executor or "local"))
|
|
31
|
+
if self.filesystem_id is not None and (not isinstance(self.filesystem_id, str) or not self.filesystem_id):
|
|
32
|
+
raise TypeError("filesystem_id must be a nonempty string or None")
|
|
33
|
+
for name in ("ssh_options", "remote_command"):
|
|
34
|
+
value = getattr(self, name)
|
|
35
|
+
if value is None:
|
|
36
|
+
value = ()
|
|
37
|
+
elif isinstance(value, str):
|
|
38
|
+
value = (value,)
|
|
39
|
+
else:
|
|
40
|
+
value = tuple(value)
|
|
41
|
+
object.__setattr__(self, name, value)
|
|
42
|
+
|
|
43
|
+
for name in ("host", "psij_backend", "psij_url"):
|
|
44
|
+
value = getattr(self, name)
|
|
45
|
+
if value is not None and not isinstance(value, str):
|
|
46
|
+
raise TypeError(f"ExecutorEndpoint.{name} must be a string or None")
|
|
47
|
+
for name in ("ssh_options", "remote_command"):
|
|
48
|
+
if any(not isinstance(item, str) for item in getattr(self, name)):
|
|
49
|
+
raise TypeError(f"ExecutorEndpoint.{name} must contain strings")
|
|
50
|
+
if type(self.ssh_zstd) is not bool:
|
|
51
|
+
raise TypeError("ExecutorEndpoint.ssh_zstd must be a bool")
|
|
52
|
+
|
|
53
|
+
# Identity describes the backend actually constructed below. Irrelevant
|
|
54
|
+
# profile fields must not split its pool key or collision namespace.
|
|
55
|
+
if self.executor in ("local", "psij"):
|
|
56
|
+
for name, value in (
|
|
57
|
+
("host", None),
|
|
58
|
+
("ssh_options", ()),
|
|
59
|
+
("remote_command", ()),
|
|
60
|
+
("ssh_zstd", False),
|
|
61
|
+
):
|
|
62
|
+
object.__setattr__(self, name, value)
|
|
63
|
+
if self.executor == "local":
|
|
64
|
+
object.__setattr__(self, "psij_backend", None)
|
|
65
|
+
object.__setattr__(self, "psij_url", None)
|
|
66
|
+
elif self.executor == "psij":
|
|
67
|
+
object.__setattr__(self, "psij_backend", self.psij_backend or "slurm")
|
|
68
|
+
elif self.executor == "ssh":
|
|
69
|
+
if not self.remote_command:
|
|
70
|
+
object.__setattr__(self, "remote_command", ("kkrunner", "remote"))
|
|
71
|
+
if self.psij_backend is not None or self.psij_url is not None:
|
|
72
|
+
object.__setattr__(self, "psij_backend", self.psij_backend or "slurm")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def endpoint_for_executor(executor) -> ExecutorEndpoint:
|
|
76
|
+
"""Describe a live executor without persisting launch/staging policy."""
|
|
77
|
+
from kkrunner.backends.local import LocalExecutor
|
|
78
|
+
from kkrunner.backends.psij import PsijExecutor
|
|
79
|
+
from kkrunner.remote.executor import RemoteEndpointExecutor
|
|
80
|
+
from kkrunner.remote.transport import SshTransport
|
|
81
|
+
|
|
82
|
+
if isinstance(executor, LocalExecutor):
|
|
83
|
+
return ExecutorEndpoint()
|
|
84
|
+
if isinstance(executor, PsijExecutor):
|
|
85
|
+
return ExecutorEndpoint(
|
|
86
|
+
executor="psij",
|
|
87
|
+
psij_backend=executor.backend,
|
|
88
|
+
psij_url=executor.url,
|
|
89
|
+
)
|
|
90
|
+
if isinstance(executor, RemoteEndpointExecutor):
|
|
91
|
+
transport = executor.transport
|
|
92
|
+
psij_backend = None
|
|
93
|
+
psij_url = None
|
|
94
|
+
if executor.backend == "psij":
|
|
95
|
+
options = executor.backend_options or {}
|
|
96
|
+
# ``RemoteEndpointExecutor(..., backend="psij")`` is a complete
|
|
97
|
+
# public configuration even when no options are supplied: the
|
|
98
|
+
# target-side PSI/J backend then defaults to Slurm. Persist that
|
|
99
|
+
# default explicitly so restart reconstruction cannot silently
|
|
100
|
+
# turn the endpoint into the ordinary remote-local backend.
|
|
101
|
+
psij_backend = options.get("backend") or "slurm"
|
|
102
|
+
psij_url = options.get("url")
|
|
103
|
+
|
|
104
|
+
if not isinstance(transport, SshTransport):
|
|
105
|
+
return ExecutorEndpoint(
|
|
106
|
+
executor=(
|
|
107
|
+
"custom:{}.{}:{}".format(
|
|
108
|
+
type(executor).__module__,
|
|
109
|
+
type(executor).__qualname__,
|
|
110
|
+
executor.name or "remote",
|
|
111
|
+
)
|
|
112
|
+
),
|
|
113
|
+
host=getattr(transport, "host", None),
|
|
114
|
+
remote_command=executor.command,
|
|
115
|
+
filesystem_id=executor.filesystem_id,
|
|
116
|
+
psij_backend=psij_backend,
|
|
117
|
+
psij_url=psij_url,
|
|
118
|
+
)
|
|
119
|
+
return ExecutorEndpoint(
|
|
120
|
+
executor="ssh",
|
|
121
|
+
host=transport.host,
|
|
122
|
+
ssh_options=transport.options,
|
|
123
|
+
ssh_zstd=transport.zstd,
|
|
124
|
+
remote_command=executor.command,
|
|
125
|
+
filesystem_id=executor.filesystem_id,
|
|
126
|
+
psij_backend=psij_backend,
|
|
127
|
+
psij_url=psij_url,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Custom executors can participate in duplicate-submit protection in this
|
|
131
|
+
# process. Reconstruction is intentionally limited to known endpoint types.
|
|
132
|
+
return ExecutorEndpoint(
|
|
133
|
+
executor="custom:{}.{}:{}".format(
|
|
134
|
+
type(executor).__module__,
|
|
135
|
+
type(executor).__qualname__,
|
|
136
|
+
executor.name or type(executor).__qualname__,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def create_executor_from_endpoint(endpoint: ExecutorEndpoint):
|
|
142
|
+
"""Reconstruct a standard executor from persisted endpoint identity."""
|
|
143
|
+
from kkrunner.backends.local import LocalExecutor
|
|
144
|
+
from kkrunner.backends.psij import PsijExecutor
|
|
145
|
+
from kkrunner.remote.executor import RemoteEndpointExecutor
|
|
146
|
+
from kkrunner.remote.transport import SshTransport
|
|
147
|
+
|
|
148
|
+
if isinstance(endpoint, dict):
|
|
149
|
+
endpoint = ExecutorEndpoint(**endpoint)
|
|
150
|
+
|
|
151
|
+
if endpoint.executor == "local":
|
|
152
|
+
return LocalExecutor()
|
|
153
|
+
if endpoint.executor == "psij":
|
|
154
|
+
return PsijExecutor(
|
|
155
|
+
endpoint.psij_backend or "slurm",
|
|
156
|
+
url=endpoint.psij_url,
|
|
157
|
+
)
|
|
158
|
+
if endpoint.executor == "ssh":
|
|
159
|
+
if not endpoint.host:
|
|
160
|
+
raise ValueError("SSH execution endpoint requires 'host'")
|
|
161
|
+
kwargs = {}
|
|
162
|
+
if endpoint.remote_command:
|
|
163
|
+
kwargs["command"] = endpoint.remote_command
|
|
164
|
+
if endpoint.psij_backend is not None or endpoint.psij_url is not None:
|
|
165
|
+
kwargs["backend"] = "psij"
|
|
166
|
+
kwargs["backend_options"] = {
|
|
167
|
+
"backend": endpoint.psij_backend or "slurm",
|
|
168
|
+
"url": endpoint.psij_url,
|
|
169
|
+
}
|
|
170
|
+
return RemoteEndpointExecutor(
|
|
171
|
+
SshTransport(
|
|
172
|
+
endpoint.host,
|
|
173
|
+
options=endpoint.ssh_options,
|
|
174
|
+
zstd=endpoint.ssh_zstd,
|
|
175
|
+
),
|
|
176
|
+
filesystem_id=endpoint.filesystem_id,
|
|
177
|
+
**kwargs,
|
|
178
|
+
)
|
|
179
|
+
raise ValueError(f"Cannot reconstruct unsupported execution endpoint {endpoint.executor!r}")
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Local subprocess execution backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from threading import Event, Lock, Thread
|
|
7
|
+
|
|
8
|
+
from kkrunner.backends.local_process import materialize_local_spec, pump_stream, start_local_process, stop_local_process
|
|
9
|
+
from kkrunner.files.availability import clear_declared_outputs, discover_local_files, validate_output_collisions
|
|
10
|
+
from kkrunner.files.manifest import FileManifest
|
|
11
|
+
from kkrunner.files.paths import file_path
|
|
12
|
+
from kkrunner.runs.base import (
|
|
13
|
+
ExecutionOutcome,
|
|
14
|
+
ExecutionSpec,
|
|
15
|
+
Executor,
|
|
16
|
+
FailurePhase,
|
|
17
|
+
OutputObservers,
|
|
18
|
+
RunState,
|
|
19
|
+
SubmissionNotStarted,
|
|
20
|
+
close_output_files,
|
|
21
|
+
finish_monitor,
|
|
22
|
+
materialize_output_streams,
|
|
23
|
+
wait_for_monitor,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LocalRunner:
|
|
28
|
+
"""One local subprocess execution.
|
|
29
|
+
|
|
30
|
+
The runner owns only process lifecycle, output-file writing and raw output
|
|
31
|
+
observation. It deliberately performs no task-specific parsing.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, spec: ExecutionSpec, callback=None):
|
|
35
|
+
self.spec = spec
|
|
36
|
+
self.callback = callback
|
|
37
|
+
self.result = ExecutionOutcome(
|
|
38
|
+
output_file=file_path(spec.output_file, spec.directory),
|
|
39
|
+
input_file=file_path(spec.input_file, spec.directory),
|
|
40
|
+
error_file=file_path(spec.error_file, spec.directory),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
self._state = RunState.NEW
|
|
44
|
+
self._output_observers = OutputObservers()
|
|
45
|
+
self._lock = Lock()
|
|
46
|
+
self._cancel_requested = Event()
|
|
47
|
+
self._started = Event()
|
|
48
|
+
self._done = Event()
|
|
49
|
+
|
|
50
|
+
self._thread = None
|
|
51
|
+
self._loop = None
|
|
52
|
+
self._process = None
|
|
53
|
+
self._pid = None
|
|
54
|
+
self._stop_task = None
|
|
55
|
+
self._exception = None
|
|
56
|
+
self._callback_exception = None
|
|
57
|
+
self._failure_phase = None
|
|
58
|
+
self._failure_message = None
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def state(self):
|
|
62
|
+
return self._state
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def exception(self):
|
|
66
|
+
return self._exception
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def backend_id(self):
|
|
70
|
+
return None if self._pid is None else str(self._pid)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def failure_phase(self):
|
|
74
|
+
return self._failure_phase
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def failure_message(self):
|
|
78
|
+
return self._failure_message
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def return_code(self):
|
|
82
|
+
return self.result.return_code
|
|
83
|
+
|
|
84
|
+
def add_output_listener(self, callback):
|
|
85
|
+
self._output_observers.add(callback)
|
|
86
|
+
|
|
87
|
+
def add_output_error_listener(self, callback):
|
|
88
|
+
self._output_observers.add_error_listener(callback)
|
|
89
|
+
|
|
90
|
+
def _output_target(self, kind):
|
|
91
|
+
# A missing stderr target means live observation only, not an
|
|
92
|
+
# implicit merge into the saved stdout artifact.
|
|
93
|
+
return self.spec.error_file if kind == "err" else self.spec.output_file
|
|
94
|
+
|
|
95
|
+
def _forward_output(self, data, kind):
|
|
96
|
+
target = self._output_target(kind)
|
|
97
|
+
if target is not None:
|
|
98
|
+
target.write(data)
|
|
99
|
+
target.flush()
|
|
100
|
+
self._output_observers.emit_bytes(data, kind)
|
|
101
|
+
|
|
102
|
+
def _ensure_stop_task(self):
|
|
103
|
+
if self._process is None or self._process.returncode is not None:
|
|
104
|
+
return None
|
|
105
|
+
if self._stop_task is None or self._stop_task.done():
|
|
106
|
+
self._stop_task = asyncio.create_task(stop_local_process(self._process))
|
|
107
|
+
return self._stop_task
|
|
108
|
+
|
|
109
|
+
def cancel(self):
|
|
110
|
+
self._cancel_requested.set()
|
|
111
|
+
loop = self._loop
|
|
112
|
+
if loop is None:
|
|
113
|
+
if self._state == RunState.NEW:
|
|
114
|
+
self._state = RunState.CANCELLED
|
|
115
|
+
return
|
|
116
|
+
try:
|
|
117
|
+
loop.call_soon_threadsafe(self._ensure_stop_task)
|
|
118
|
+
except RuntimeError:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
async def _execute(self):
|
|
122
|
+
self._loop = asyncio.get_running_loop()
|
|
123
|
+
if self._cancel_requested.is_set():
|
|
124
|
+
self._state = RunState.CANCELLED
|
|
125
|
+
self._started.set()
|
|
126
|
+
return self.result
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
protected = [
|
|
130
|
+
file_path(value, self.spec.directory)
|
|
131
|
+
for value in (self.spec.input_file, self.spec.stdin, *self.spec.input_files, *self.spec.cache_files)
|
|
132
|
+
]
|
|
133
|
+
# Preserve already-open stdout/stderr handles: an unlink would
|
|
134
|
+
# disconnect their inode from the named destination.
|
|
135
|
+
outputs = [file_path(value, self.spec.directory) for value in self.spec.output_files]
|
|
136
|
+
validate_output_collisions(
|
|
137
|
+
(*outputs, self.result.output_file, self.result.error_file),
|
|
138
|
+
protected=protected,
|
|
139
|
+
)
|
|
140
|
+
self.spec = materialize_output_streams(self.spec)
|
|
141
|
+
clear_declared_outputs(
|
|
142
|
+
outputs,
|
|
143
|
+
protected=(*protected, self.result.output_file, self.result.error_file),
|
|
144
|
+
)
|
|
145
|
+
try:
|
|
146
|
+
self._process = await start_local_process(self.spec)
|
|
147
|
+
except BaseException as exc:
|
|
148
|
+
self._failure_phase = FailurePhase.LAUNCH
|
|
149
|
+
self._failure_message = str(exc)
|
|
150
|
+
self._state = RunState.FAILED
|
|
151
|
+
raise
|
|
152
|
+
|
|
153
|
+
self._pid = self._process.pid
|
|
154
|
+
self._state = RunState.RUNNING
|
|
155
|
+
self._started.set()
|
|
156
|
+
|
|
157
|
+
if self._cancel_requested.is_set():
|
|
158
|
+
stop_task = self._ensure_stop_task()
|
|
159
|
+
if stop_task is not None:
|
|
160
|
+
await stop_task
|
|
161
|
+
|
|
162
|
+
wait_task = asyncio.create_task(self._process.wait())
|
|
163
|
+
stdout_task = asyncio.create_task(
|
|
164
|
+
pump_stream(
|
|
165
|
+
self._process.stdout,
|
|
166
|
+
lambda data: self._forward_output(data, "out"),
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
stderr_task = asyncio.create_task(
|
|
170
|
+
pump_stream(
|
|
171
|
+
self._process.stderr,
|
|
172
|
+
lambda data: self._forward_output(data, "err"),
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
tasks = (wait_task, stdout_task, stderr_task)
|
|
176
|
+
try:
|
|
177
|
+
return_code, _, _ = await asyncio.gather(*tasks)
|
|
178
|
+
finally:
|
|
179
|
+
for task in tasks:
|
|
180
|
+
if not task.done():
|
|
181
|
+
task.cancel()
|
|
182
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
183
|
+
|
|
184
|
+
self.result.return_code = return_code
|
|
185
|
+
if self._cancel_requested.is_set():
|
|
186
|
+
self._state = RunState.CANCELLED
|
|
187
|
+
elif return_code == 0:
|
|
188
|
+
self._state = RunState.FINISHED
|
|
189
|
+
else:
|
|
190
|
+
self._state = RunState.FAILED
|
|
191
|
+
self._failure_phase = FailurePhase.EXECUTION
|
|
192
|
+
self._failure_message = f"Process exited with status {return_code}"
|
|
193
|
+
if self._state == RunState.FINISHED:
|
|
194
|
+
files = discover_local_files(FileManifest.from_spec(self.spec))
|
|
195
|
+
if files.failed_artifacts:
|
|
196
|
+
self._state = RunState.FAILED
|
|
197
|
+
self._failure_phase = FailurePhase.ARTIFACTS
|
|
198
|
+
self._failure_message = files.availability_message
|
|
199
|
+
return self.result
|
|
200
|
+
except BaseException:
|
|
201
|
+
self._started.set()
|
|
202
|
+
if self._state not in (RunState.CANCELLED, RunState.FAILED):
|
|
203
|
+
self._state = RunState.FAILED
|
|
204
|
+
self._failure_phase = FailurePhase.EXECUTION
|
|
205
|
+
if self._process is not None and self._process.returncode is None:
|
|
206
|
+
await stop_local_process(self._process)
|
|
207
|
+
raise
|
|
208
|
+
finally:
|
|
209
|
+
if self._stop_task is not None:
|
|
210
|
+
await asyncio.gather(self._stop_task, return_exceptions=True)
|
|
211
|
+
self._output_observers.finish_bytes()
|
|
212
|
+
close_output_files(self.spec.output_file, self.spec.error_file)
|
|
213
|
+
self._process = None
|
|
214
|
+
self._loop = None
|
|
215
|
+
|
|
216
|
+
def _thread_main(self):
|
|
217
|
+
try:
|
|
218
|
+
asyncio.run(self._execute())
|
|
219
|
+
except BaseException as exc:
|
|
220
|
+
self._exception = exc
|
|
221
|
+
if self._failure_message is None:
|
|
222
|
+
self._failure_message = str(exc)
|
|
223
|
+
finally:
|
|
224
|
+
finish_monitor(self)
|
|
225
|
+
|
|
226
|
+
def submit(self):
|
|
227
|
+
with self._lock:
|
|
228
|
+
if self._state == RunState.CANCELLED and self._thread is None:
|
|
229
|
+
raise RuntimeError("Run was cancelled before submission")
|
|
230
|
+
if self._state != RunState.NEW:
|
|
231
|
+
raise RuntimeError(f"Run is already {self._state.value}")
|
|
232
|
+
self._state = RunState.SUBMITTING
|
|
233
|
+
self._thread = Thread(target=self._thread_main, daemon=True)
|
|
234
|
+
self._thread.start()
|
|
235
|
+
|
|
236
|
+
self._started.wait()
|
|
237
|
+
if self._pid is None and self._state == RunState.FAILED:
|
|
238
|
+
self._done.wait()
|
|
239
|
+
error = self._exception or RuntimeError(self._failure_message or "Launch failed")
|
|
240
|
+
raise SubmissionNotStarted(str(error)) from self._exception
|
|
241
|
+
|
|
242
|
+
def wait(self):
|
|
243
|
+
if self._state == RunState.NEW:
|
|
244
|
+
raise RuntimeError("Run has not been submitted")
|
|
245
|
+
if self._state == RunState.CANCELLED and self._thread is None:
|
|
246
|
+
return self.result
|
|
247
|
+
return wait_for_monitor(self)
|
|
248
|
+
|
|
249
|
+
def run(self):
|
|
250
|
+
if self._state == RunState.NEW:
|
|
251
|
+
self.submit()
|
|
252
|
+
return self.wait()
|
|
253
|
+
|
|
254
|
+
async def run_async(self):
|
|
255
|
+
if self._state == RunState.NEW:
|
|
256
|
+
self.submit()
|
|
257
|
+
return await asyncio.to_thread(self.wait)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class LocalExecutor(Executor):
|
|
261
|
+
"""Create raw runners executed as local subprocesses."""
|
|
262
|
+
|
|
263
|
+
name = "local"
|
|
264
|
+
|
|
265
|
+
def create_run(self, spec: ExecutionSpec, callback=None):
|
|
266
|
+
return LocalRunner(materialize_local_spec(spec), callback=callback)
|
|
267
|
+
|
|
268
|
+
def create_managed_run(self, spec: ExecutionSpec, *, run_id: str, callback=None):
|
|
269
|
+
from kkrunner.backends.local_managed import ManagedLocalRunner
|
|
270
|
+
|
|
271
|
+
return ManagedLocalRunner(
|
|
272
|
+
self,
|
|
273
|
+
spec,
|
|
274
|
+
run_id=run_id,
|
|
275
|
+
callback=callback,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def recover_submission(self, run_id, submission, *, submitted_at=None):
|
|
279
|
+
from kkrunner.backends.local_managed import _local_status_from_facts
|
|
280
|
+
from kkrunner.remote.service import recover_remote
|
|
281
|
+
|
|
282
|
+
reply = recover_remote(
|
|
283
|
+
run_id,
|
|
284
|
+
{
|
|
285
|
+
"executor": "local",
|
|
286
|
+
"directory": submission.execution.directory.local,
|
|
287
|
+
},
|
|
288
|
+
)
|
|
289
|
+
if reply is None:
|
|
290
|
+
# The absence of a ticket is not proof that submission never
|
|
291
|
+
# happened. Never authorize an automatic duplicate launch.
|
|
292
|
+
raise NotImplementedError("Submission identity is not recoverable")
|
|
293
|
+
return _local_status_from_facts(
|
|
294
|
+
reply["remote_id"],
|
|
295
|
+
reply,
|
|
296
|
+
submission.files,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def attach(self, run, callback=None, output_listener=None, start=True):
|
|
300
|
+
from kkrunner.backends.local_managed import attach_local
|
|
301
|
+
|
|
302
|
+
return attach_local(
|
|
303
|
+
self,
|
|
304
|
+
run,
|
|
305
|
+
callback=callback,
|
|
306
|
+
output_listener=output_listener,
|
|
307
|
+
start=start,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def acknowledge_submission(self, run):
|
|
311
|
+
from kkrunner.remote.service import acknowledge_submission
|
|
312
|
+
|
|
313
|
+
acknowledge_submission(run.id)
|
|
314
|
+
|
|
315
|
+
def acknowledge(self, run):
|
|
316
|
+
from kkrunner.remote.service import acknowledge_remote
|
|
317
|
+
|
|
318
|
+
acknowledge_remote(run.execution.backend_id)
|
|
319
|
+
|
|
320
|
+
def status(self, *runs):
|
|
321
|
+
from kkrunner.backends.local_managed import local_status
|
|
322
|
+
|
|
323
|
+
statuses = []
|
|
324
|
+
for run in runs:
|
|
325
|
+
if run.execution.endpoint.executor != "local":
|
|
326
|
+
raise ValueError("Run does not belong to the local backend")
|
|
327
|
+
statuses.append(local_status(run.execution.backend_id, run.files))
|
|
328
|
+
return tuple(statuses)
|