hypershell 2.6.4__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.
- hypershell/__init__.py +115 -0
- hypershell/client.py +1255 -0
- hypershell/cluster/__init__.py +378 -0
- hypershell/cluster/local.py +210 -0
- hypershell/cluster/remote.py +721 -0
- hypershell/cluster/ssh.py +352 -0
- hypershell/config.py +440 -0
- hypershell/core/__init__.py +4 -0
- hypershell/core/config.py +359 -0
- hypershell/core/exceptions.py +120 -0
- hypershell/core/fsm.py +82 -0
- hypershell/core/heartbeat.py +70 -0
- hypershell/core/logging.py +201 -0
- hypershell/core/platform.py +121 -0
- hypershell/core/queue.py +155 -0
- hypershell/core/remote.py +192 -0
- hypershell/core/signal.py +79 -0
- hypershell/core/sys.py +39 -0
- hypershell/core/tag.py +47 -0
- hypershell/core/template.py +201 -0
- hypershell/core/thread.py +56 -0
- hypershell/core/types.py +32 -0
- hypershell/data/__init__.py +137 -0
- hypershell/data/core.py +232 -0
- hypershell/data/model.py +674 -0
- hypershell/server.py +1207 -0
- hypershell/submit.py +815 -0
- hypershell/task.py +1141 -0
- hypershell-2.6.4.dist-info/LICENSE +201 -0
- hypershell-2.6.4.dist-info/METADATA +117 -0
- hypershell-2.6.4.dist-info/RECORD +33 -0
- hypershell-2.6.4.dist-info/WHEEL +4 -0
- hypershell-2.6.4.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025 Geoffrey Lentner
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Remote cluster implementation."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# type annotations
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from typing import Tuple, List, Dict, IO, Iterable, Callable, Type, Final
|
|
10
|
+
|
|
11
|
+
# standard libs
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
import shlex
|
|
16
|
+
import secrets
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from datetime import datetime, timedelta
|
|
19
|
+
from functools import cached_property
|
|
20
|
+
from subprocess import Popen
|
|
21
|
+
|
|
22
|
+
# internal libs
|
|
23
|
+
from hypershell.core.fsm import State, StateMachine
|
|
24
|
+
from hypershell.core.config import default, load_task_env
|
|
25
|
+
from hypershell.core.thread import Thread
|
|
26
|
+
from hypershell.core.logging import Logger, HOSTNAME
|
|
27
|
+
from hypershell.core.template import DEFAULT_TEMPLATE
|
|
28
|
+
from hypershell.data.model import Task, Client
|
|
29
|
+
from hypershell.client import DEFAULT_DELAY, DEFAULT_SIGNALWAIT
|
|
30
|
+
from hypershell.submit import DEFAULT_BUNDLEWAIT
|
|
31
|
+
from hypershell.server import ServerThread, DEFAULT_PORT, DEFAULT_BUNDLESIZE, DEFAULT_ATTEMPTS
|
|
32
|
+
|
|
33
|
+
# public interface
|
|
34
|
+
__all__ = ['run_cluster', 'RemoteCluster', 'AutoScalingCluster',
|
|
35
|
+
'DEFAULT_REMOTE_EXE', 'DEFAULT_LAUNCHER',
|
|
36
|
+
'DEFAULT_AUTOSCALE_POLICY', 'DEFAULT_AUTOSCALE_PERIOD', 'DEFAULT_AUTOSCALE_FACTOR',
|
|
37
|
+
'DEFAULT_AUTOSCALE_INIT_SIZE', 'DEFAULT_AUTOSCALE_MIN_SIZE', 'DEFAULT_AUTOSCALE_MAX_SIZE',
|
|
38
|
+
'DEFAULT_AUTOSCALE_LAUNCHER', ]
|
|
39
|
+
|
|
40
|
+
# initialize logger
|
|
41
|
+
log = Logger.with_name('hypershell.cluster')
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# NOTE: retain old name for remote executable (for now)
|
|
45
|
+
DEFAULT_REMOTE_EXE: Final[str] = 'hyper-shell'
|
|
46
|
+
"""Default remote executable name."""
|
|
47
|
+
|
|
48
|
+
DEFAULT_LAUNCHER: Final[str] = 'mpirun'
|
|
49
|
+
"""Default launcher program."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_cluster(autoscaling: bool = False, **options) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Run cluster with remote clients until completion.
|
|
55
|
+
|
|
56
|
+
All function arguments are forwarded directly into either the
|
|
57
|
+
:class:`~hypershell.cluster.remote.RemoteCluster` or
|
|
58
|
+
:class:`~hypershell.cluster.remote.AutoScalingCluster` thread.
|
|
59
|
+
|
|
60
|
+
If `autoscaling` is enabled then we use the
|
|
61
|
+
:class:`~hypershell.cluster.remote.AutoScalingCluster` instead
|
|
62
|
+
of the :class:`~hypershell.cluster.remote.RemoteCluster`.
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
>>> from hypershell.cluster import run_cluster
|
|
66
|
+
>>> run_cluster(
|
|
67
|
+
... restart_mode=True, launcher='srun', max_retries=2, eager=True,
|
|
68
|
+
... client_timeout=600, task_timeout=300, capture=True
|
|
69
|
+
... )
|
|
70
|
+
|
|
71
|
+
See Also:
|
|
72
|
+
- :class:`~hypershell.cluster.remote.RemoteCluster`
|
|
73
|
+
- :class:`~hypershell.cluster.remote.AutoScalingCluster`
|
|
74
|
+
"""
|
|
75
|
+
if autoscaling:
|
|
76
|
+
thread = AutoScalingCluster.new(**options)
|
|
77
|
+
else:
|
|
78
|
+
thread = RemoteCluster.new(**options)
|
|
79
|
+
try:
|
|
80
|
+
thread.join()
|
|
81
|
+
except Exception:
|
|
82
|
+
thread.stop()
|
|
83
|
+
raise
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class RemoteCluster(Thread):
|
|
87
|
+
"""
|
|
88
|
+
Run server with remote clients via external launcher (e.g., `mpirun`).
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
source (Iterable[str], optional):
|
|
92
|
+
Any iterable of command-line tasks.
|
|
93
|
+
A new `source` results in a :class:`~hypershell.submit.SubmitThread` populating
|
|
94
|
+
either the database or the queue directly depending on `in_memory`.
|
|
95
|
+
|
|
96
|
+
num_tasks (int, optional):
|
|
97
|
+
Number of parallel task executor threads.
|
|
98
|
+
See :const:`~hypershell.client.DEFAULT_NUM_TASKS`.
|
|
99
|
+
|
|
100
|
+
template (str, optional):
|
|
101
|
+
Template command pattern.
|
|
102
|
+
See :const:`~hypershell.client.DEFAULT_TEMPLATE`.
|
|
103
|
+
|
|
104
|
+
bundlesize (int optional):
|
|
105
|
+
Size of task bundles returned to server.
|
|
106
|
+
See :const:`~hypershell.server.DEFAULT_BUNDLESIZE`.
|
|
107
|
+
|
|
108
|
+
bundlewait (int optional):
|
|
109
|
+
Waiting period in seconds before forcing return of task bundle to server.
|
|
110
|
+
See :const:`~hypershell.submit.DEFAULT_BUNDLEWAIT`.
|
|
111
|
+
|
|
112
|
+
bind (tuple, optional):
|
|
113
|
+
Bind address for server with port number (default: 0.0.0.0).
|
|
114
|
+
See :const:`~hypershell.server.DEFAULT_PORT`.
|
|
115
|
+
|
|
116
|
+
launcher (str, optional):
|
|
117
|
+
Launcher program used to bring up clients on other hosts.
|
|
118
|
+
See :const:`~hypershell.cluster.remote.DEFAULT_LAUNCHER`.
|
|
119
|
+
|
|
120
|
+
launcher_args (List[str], optional):
|
|
121
|
+
Additional command-line arguments for launcher program.
|
|
122
|
+
|
|
123
|
+
remote_exe (str, optional):
|
|
124
|
+
Program name or path for remote executable.
|
|
125
|
+
See :const:`~hypershell.cluster.remote.DEFAULT_REMOTE_EXE`.
|
|
126
|
+
|
|
127
|
+
in_memory (bool, optional):
|
|
128
|
+
If True, revert to basic in-memory queue.
|
|
129
|
+
|
|
130
|
+
no_confirm (bool, optional):
|
|
131
|
+
Disable client confirmation of tasks received.
|
|
132
|
+
|
|
133
|
+
forever_mode (bool, optional):
|
|
134
|
+
Regardless of `source`, if enabled schedule forever.
|
|
135
|
+
Conflicts with `restart_mode` and `in_memory`. Default is `False`.
|
|
136
|
+
|
|
137
|
+
restart_mode (bool, optional):
|
|
138
|
+
If `source` is empty, this option allows for the server to continue
|
|
139
|
+
with scheduling from the database until complete.
|
|
140
|
+
Conflicts with `in_memory`. Default is `False`.
|
|
141
|
+
|
|
142
|
+
max_retries (int, optional):
|
|
143
|
+
Number of allowed task retries.
|
|
144
|
+
See :const:`~hypershell.server.DEFAULT_ATTEMPTS`.
|
|
145
|
+
|
|
146
|
+
eager (bool, optional):
|
|
147
|
+
When enabled tasks are retried immediately ahead scheduling new tasks.
|
|
148
|
+
See :const:`~hypershell.server.DEFAULT_EAGER_MODE`.
|
|
149
|
+
|
|
150
|
+
redirect_failures (IO, optional):
|
|
151
|
+
Open file-like object to write failed tasks.
|
|
152
|
+
|
|
153
|
+
delay_start (float, optional):
|
|
154
|
+
Delay in seconds before connecting to server.
|
|
155
|
+
See :const:`~hypershell.client.DEFAULT_DELAY`.
|
|
156
|
+
|
|
157
|
+
capture (bool, optional):
|
|
158
|
+
Isolate task <stdout> and <stderr> in discrete files.
|
|
159
|
+
Defaults to `False`.
|
|
160
|
+
|
|
161
|
+
client_timeout (int, optional):
|
|
162
|
+
Timeout in seconds before disconnecting from server.
|
|
163
|
+
By default, the client waits for server tor request disconnect.
|
|
164
|
+
|
|
165
|
+
task_timeout (int, optional):
|
|
166
|
+
Task-level walltime limit in seconds.
|
|
167
|
+
By default, the client waits indefinitely on tasks.
|
|
168
|
+
|
|
169
|
+
task_signalwait (int, optional):
|
|
170
|
+
Signal escalation waiting period in seconds on task timeout.
|
|
171
|
+
See :const:`~hypershell.client.DEFAULT_SIGNALWAIT`.
|
|
172
|
+
|
|
173
|
+
Example:
|
|
174
|
+
>>> from hypershell.cluster import RemoteCluster
|
|
175
|
+
>>> cluster = RemoteCluster.new(
|
|
176
|
+
... restart_mode=True, launcher='srun', max_retries=2, eager=True,
|
|
177
|
+
... client_timeout=600, task_timeout=300, capture=True
|
|
178
|
+
... )
|
|
179
|
+
>>> cluster.join()
|
|
180
|
+
|
|
181
|
+
See Also:
|
|
182
|
+
- :class:`~hypershell.server.ServerThread`
|
|
183
|
+
- :meth:`~hypershell.cluster.remote.run_cluster`
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
server: ServerThread
|
|
187
|
+
clients: Popen
|
|
188
|
+
client_argv: List[str]
|
|
189
|
+
|
|
190
|
+
def __init__(self: RemoteCluster,
|
|
191
|
+
source: Iterable[str] = None,
|
|
192
|
+
num_tasks: int = 1,
|
|
193
|
+
template: str = DEFAULT_TEMPLATE,
|
|
194
|
+
bundlesize: int = DEFAULT_BUNDLESIZE,
|
|
195
|
+
bundlewait: int = DEFAULT_BUNDLEWAIT,
|
|
196
|
+
bind: Tuple[str, int] = ('0.0.0.0', DEFAULT_PORT),
|
|
197
|
+
launcher: str = DEFAULT_LAUNCHER,
|
|
198
|
+
launcher_args: List[str] = None,
|
|
199
|
+
remote_exe: str = DEFAULT_REMOTE_EXE,
|
|
200
|
+
in_memory: bool = False,
|
|
201
|
+
no_confirm: bool = False,
|
|
202
|
+
forever_mode: bool = False,
|
|
203
|
+
restart_mode: bool = False,
|
|
204
|
+
max_retries: int = DEFAULT_ATTEMPTS,
|
|
205
|
+
eager: bool = False,
|
|
206
|
+
redirect_failures: IO = None,
|
|
207
|
+
delay_start: float = DEFAULT_DELAY,
|
|
208
|
+
capture: bool = False,
|
|
209
|
+
client_timeout: int = None,
|
|
210
|
+
task_timeout: int = None,
|
|
211
|
+
task_signalwait: int = DEFAULT_SIGNALWAIT) -> None:
|
|
212
|
+
"""Initialize server and client threads with external launcher."""
|
|
213
|
+
auth = secrets.token_hex(64)
|
|
214
|
+
self.server = ServerThread(source=source,
|
|
215
|
+
bundlesize=bundlesize,
|
|
216
|
+
bundlewait=bundlewait,
|
|
217
|
+
in_memory=in_memory,
|
|
218
|
+
no_confirm=no_confirm,
|
|
219
|
+
address=bind,
|
|
220
|
+
auth=auth,
|
|
221
|
+
max_retries=max_retries,
|
|
222
|
+
eager=eager,
|
|
223
|
+
forever_mode=forever_mode,
|
|
224
|
+
restart_mode=restart_mode,
|
|
225
|
+
redirect_failures=redirect_failures)
|
|
226
|
+
launcher = shlex.split(launcher)
|
|
227
|
+
if launcher_args is None:
|
|
228
|
+
launcher_args = []
|
|
229
|
+
else:
|
|
230
|
+
launcher_args = [arg for arg_group in launcher_args for arg in shlex.split(arg_group)]
|
|
231
|
+
client_args = []
|
|
232
|
+
if capture is True:
|
|
233
|
+
client_args.append('--capture')
|
|
234
|
+
if no_confirm is True:
|
|
235
|
+
client_args.append('--no-confirm')
|
|
236
|
+
if client_timeout is not None:
|
|
237
|
+
client_args.extend(['-T', str(client_timeout)])
|
|
238
|
+
if task_timeout is not None:
|
|
239
|
+
client_args.extend(['-W', str(task_timeout)])
|
|
240
|
+
self.client_argv = [
|
|
241
|
+
*launcher, *launcher_args, remote_exe, 'client',
|
|
242
|
+
'-H', HOSTNAME, '-p', str(bind[1]), '-N', str(num_tasks), '-b', str(bundlesize), '-w', str(bundlewait),
|
|
243
|
+
'-t', template, '-k', auth, '-d', str(delay_start), '-S', str(task_signalwait), *client_args
|
|
244
|
+
]
|
|
245
|
+
super().__init__(name='hypershell-cluster')
|
|
246
|
+
|
|
247
|
+
def run_with_exceptions(self: RemoteCluster) -> None:
|
|
248
|
+
"""Start child threads, wait."""
|
|
249
|
+
self.server.start()
|
|
250
|
+
while not self.server.queue.ready:
|
|
251
|
+
time.sleep(0.1)
|
|
252
|
+
log.debug(f'Launching clients: {self.client_argv}')
|
|
253
|
+
self.clients = Popen(self.client_argv,
|
|
254
|
+
stdout=sys.stdout, stderr=sys.stderr,
|
|
255
|
+
env={**os.environ, **load_task_env()})
|
|
256
|
+
self.clients.wait()
|
|
257
|
+
self.server.join()
|
|
258
|
+
|
|
259
|
+
def stop(self: RemoteCluster, wait: bool = False, timeout: int = None) -> None:
|
|
260
|
+
"""Stop child threads before main thread."""
|
|
261
|
+
self.server.stop(wait=wait, timeout=timeout)
|
|
262
|
+
self.clients.terminate()
|
|
263
|
+
super().stop(wait=wait, timeout=timeout)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
DEFAULT_AUTOSCALE_POLICY: Final[str] = default.autoscale.policy
|
|
267
|
+
"""Default autoscaling policy."""
|
|
268
|
+
|
|
269
|
+
DEFAULT_AUTOSCALE_FACTOR: Final[float] = default.autoscale.factor
|
|
270
|
+
"""Default scaling factor."""
|
|
271
|
+
|
|
272
|
+
DEFAULT_AUTOSCALE_PERIOD: Final[int] = default.autoscale.period
|
|
273
|
+
"""Default period in seconds between autoscaling events."""
|
|
274
|
+
|
|
275
|
+
DEFAULT_AUTOSCALE_INIT_SIZE: Final[int] = default.autoscale.size.init
|
|
276
|
+
"""Default initial size of cluster (number of clients)."""
|
|
277
|
+
|
|
278
|
+
DEFAULT_AUTOSCALE_MIN_SIZE: Final[int] = default.autoscale.size.min
|
|
279
|
+
"""Default minimum size of cluster (number of clients)."""
|
|
280
|
+
|
|
281
|
+
DEFAULT_AUTOSCALE_MAX_SIZE: Final[int] = default.autoscale.size.max
|
|
282
|
+
"""Default maximum size of cluster (number of clients)."""
|
|
283
|
+
|
|
284
|
+
DEFAULT_AUTOSCALE_LAUNCHER: Final[str] = default.autoscale.launcher
|
|
285
|
+
"""Default launcher program for scaling clients."""
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class AutoScalerState(State, Enum):
|
|
289
|
+
"""Finite states for AutoScaler."""
|
|
290
|
+
START = 0
|
|
291
|
+
INIT = 1
|
|
292
|
+
WAIT = 2
|
|
293
|
+
CHECK = 3
|
|
294
|
+
CHECK_FIXED = 4
|
|
295
|
+
CHECK_DYNAMIC = 5
|
|
296
|
+
SCALE = 6
|
|
297
|
+
CLEAN = 7
|
|
298
|
+
FINAL = 8
|
|
299
|
+
HALT = 9
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class AutoScalerPolicy(Enum):
|
|
303
|
+
"""Allowed scaling policies."""
|
|
304
|
+
FIXED = 1
|
|
305
|
+
DYNAMIC = 2
|
|
306
|
+
|
|
307
|
+
@classmethod
|
|
308
|
+
def from_name(cls: Type[AutoScalerPolicy], name: str) -> AutoScalerPolicy:
|
|
309
|
+
"""Return decided enum type from name."""
|
|
310
|
+
try:
|
|
311
|
+
return cls[name.upper()]
|
|
312
|
+
except KeyError:
|
|
313
|
+
raise RuntimeError(f'Unknown {cls.__name__} \'{name}\'')
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class AutoScalerPhase(Enum):
|
|
317
|
+
"""Launch phase."""
|
|
318
|
+
INIT = 1
|
|
319
|
+
STEADY = 2
|
|
320
|
+
STOP = 3
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class AutoScaler(StateMachine):
|
|
324
|
+
"""Monitor task pressure and scale accordingly."""
|
|
325
|
+
|
|
326
|
+
policy: AutoScalerPolicy
|
|
327
|
+
factor: float
|
|
328
|
+
period: int
|
|
329
|
+
init_size: int
|
|
330
|
+
min_size: int
|
|
331
|
+
max_size: int
|
|
332
|
+
launcher: List[str]
|
|
333
|
+
|
|
334
|
+
clients: List[Popen]
|
|
335
|
+
last_check: datetime
|
|
336
|
+
wait_check: timedelta
|
|
337
|
+
|
|
338
|
+
phase: AutoScalerPhase = AutoScalerPhase.INIT
|
|
339
|
+
state: AutoScalerState = AutoScalerState.START
|
|
340
|
+
states: Type[State] = AutoScalerState
|
|
341
|
+
|
|
342
|
+
def __init__(self: AutoScaler,
|
|
343
|
+
launcher: List[str],
|
|
344
|
+
policy: str = DEFAULT_AUTOSCALE_POLICY,
|
|
345
|
+
factor: float = DEFAULT_AUTOSCALE_FACTOR,
|
|
346
|
+
period: int = DEFAULT_AUTOSCALE_PERIOD,
|
|
347
|
+
init_size: int = DEFAULT_AUTOSCALE_INIT_SIZE,
|
|
348
|
+
min_size: int = DEFAULT_AUTOSCALE_MIN_SIZE,
|
|
349
|
+
max_size: int = DEFAULT_AUTOSCALE_MAX_SIZE,
|
|
350
|
+
) -> None:
|
|
351
|
+
"""Initialize with scaling parameters."""
|
|
352
|
+
self.policy = AutoScalerPolicy.from_name(policy)
|
|
353
|
+
self.factor = factor
|
|
354
|
+
self.period = period
|
|
355
|
+
self.init_size = init_size
|
|
356
|
+
self.min_size = min_size
|
|
357
|
+
self.max_size = max_size
|
|
358
|
+
self.launcher = launcher
|
|
359
|
+
self.last_check = datetime.now()
|
|
360
|
+
self.wait_check = timedelta(seconds=period)
|
|
361
|
+
self.clients = []
|
|
362
|
+
|
|
363
|
+
@cached_property
|
|
364
|
+
def actions(self: AutoScaler) -> Dict[AutoScalerState, Callable[[], AutoScalerState]]:
|
|
365
|
+
return {
|
|
366
|
+
AutoScalerState.START: self.start,
|
|
367
|
+
AutoScalerState.INIT: self.init,
|
|
368
|
+
AutoScalerState.WAIT: self.wait,
|
|
369
|
+
AutoScalerState.CHECK: self.check,
|
|
370
|
+
AutoScalerState.CHECK_FIXED: self.check_fixed,
|
|
371
|
+
AutoScalerState.CHECK_DYNAMIC: self.check_dynamic,
|
|
372
|
+
AutoScalerState.SCALE: self.scale,
|
|
373
|
+
AutoScalerState.CLEAN: self.clean,
|
|
374
|
+
AutoScalerState.FINAL: self.finalize,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
def start(self: AutoScaler) -> AutoScalerState:
|
|
378
|
+
"""Jump to INIT state."""
|
|
379
|
+
log.info(f'Autoscale start (policy: {self.policy.name.lower()}, init-size: {self.init_size})')
|
|
380
|
+
log.debug(f'Autoscale launcher: {self.launcher}')
|
|
381
|
+
return AutoScalerState.INIT
|
|
382
|
+
|
|
383
|
+
def init(self: AutoScaler) -> AutoScalerState:
|
|
384
|
+
"""Launch initial clients."""
|
|
385
|
+
if len(self.clients) < self.init_size:
|
|
386
|
+
return AutoScalerState.SCALE
|
|
387
|
+
else:
|
|
388
|
+
self.phase = AutoScalerPhase.STEADY
|
|
389
|
+
return AutoScalerState.WAIT
|
|
390
|
+
|
|
391
|
+
def wait(self: AutoScaler) -> AutoScalerState:
|
|
392
|
+
"""Wait for specified period of time."""
|
|
393
|
+
if self.phase is AutoScalerPhase.STEADY:
|
|
394
|
+
waited = datetime.now() - self.last_check
|
|
395
|
+
if waited > self.wait_check:
|
|
396
|
+
return AutoScalerState.CHECK
|
|
397
|
+
else:
|
|
398
|
+
log.trace(f'Autoscale wait ({timedelta(seconds=round(waited.total_seconds()))})')
|
|
399
|
+
time.sleep(1)
|
|
400
|
+
return AutoScalerState.WAIT
|
|
401
|
+
else:
|
|
402
|
+
return AutoScalerState.FINAL
|
|
403
|
+
|
|
404
|
+
def check(self: AutoScaler) -> AutoScalerState:
|
|
405
|
+
"""Check if we need to scale up."""
|
|
406
|
+
self.clean()
|
|
407
|
+
self.last_check = datetime.now()
|
|
408
|
+
if self.policy is AutoScalerPolicy.FIXED:
|
|
409
|
+
return AutoScalerState.CHECK_FIXED
|
|
410
|
+
else:
|
|
411
|
+
return AutoScalerState.CHECK_DYNAMIC
|
|
412
|
+
|
|
413
|
+
def check_fixed(self: AutoScaler) -> AutoScalerState:
|
|
414
|
+
"""Scaling procedure for a fixed policy cluster."""
|
|
415
|
+
launched_size = len(self.clients)
|
|
416
|
+
registered_size = Client.count_connected()
|
|
417
|
+
task_count = Task.count_remaining()
|
|
418
|
+
log.debug(f'Autoscale check (clients: {registered_size}/{launched_size}, tasks: {task_count})')
|
|
419
|
+
if launched_size < self.min_size:
|
|
420
|
+
log.debug(f'Autoscale min-size reached ({launched_size} < {self.min_size})')
|
|
421
|
+
return AutoScalerState.SCALE
|
|
422
|
+
if launched_size == 0 and task_count == 0:
|
|
423
|
+
return AutoScalerState.WAIT
|
|
424
|
+
if launched_size == 0 and task_count > 0:
|
|
425
|
+
log.debug(f'Autoscale adding client ({task_count} tasks remaining)')
|
|
426
|
+
return AutoScalerState.SCALE
|
|
427
|
+
else:
|
|
428
|
+
return AutoScalerState.WAIT
|
|
429
|
+
|
|
430
|
+
def check_dynamic(self: AutoScaler) -> AutoScalerState:
|
|
431
|
+
"""Scaling procedure for a dynamic policy cluster."""
|
|
432
|
+
launched_size = len(self.clients)
|
|
433
|
+
registered_size = Client.count_connected()
|
|
434
|
+
task_count = Task.count_remaining()
|
|
435
|
+
pressure = Task.task_pressure(self.factor)
|
|
436
|
+
pressure_val = 'unknown' if pressure is None else f'{pressure:.2f}'
|
|
437
|
+
log.debug(f'Autoscale check (pressure: {pressure_val}, '
|
|
438
|
+
f'clients: {registered_size}/{launched_size}, tasks: {task_count})')
|
|
439
|
+
if launched_size < self.min_size:
|
|
440
|
+
log.debug(f'Autoscale min-size reached ({launched_size} < {self.min_size})')
|
|
441
|
+
return AutoScalerState.SCALE
|
|
442
|
+
if pressure is not None:
|
|
443
|
+
if pressure > 1:
|
|
444
|
+
log.debug(f'Autoscale pressure high ({pressure:.2f})')
|
|
445
|
+
if launched_size >= self.max_size:
|
|
446
|
+
log.debug(f'Autoscale max-size reached ({launched_size} >= {self.max_size})')
|
|
447
|
+
return AutoScalerState.WAIT
|
|
448
|
+
else:
|
|
449
|
+
return AutoScalerState.SCALE
|
|
450
|
+
else:
|
|
451
|
+
log.debug(f'Autoscale pressure low ({pressure:.2f})')
|
|
452
|
+
return AutoScalerState.WAIT
|
|
453
|
+
else:
|
|
454
|
+
if launched_size == 0 and task_count == 0:
|
|
455
|
+
log.debug(f'Autoscale pause (no clients and no tasks)')
|
|
456
|
+
return AutoScalerState.WAIT
|
|
457
|
+
if launched_size == 0 and task_count > 0:
|
|
458
|
+
log.debug(f'Autoscale adding client ({task_count} tasks remaining)')
|
|
459
|
+
return AutoScalerState.SCALE
|
|
460
|
+
else:
|
|
461
|
+
log.debug('Autoscale pause (waiting on clients to complete initial tasks)')
|
|
462
|
+
return AutoScalerState.WAIT
|
|
463
|
+
|
|
464
|
+
def scale(self: AutoScaler) -> AutoScalerState:
|
|
465
|
+
"""Launch new client."""
|
|
466
|
+
proc = Popen(self.launcher, stdout=sys.stdout, stderr=sys.stderr,
|
|
467
|
+
bufsize=0, universal_newlines=True, env={**os.environ, **load_task_env()})
|
|
468
|
+
log.trace(f'Autoscale adding client ({proc.pid})')
|
|
469
|
+
self.clients.append(proc)
|
|
470
|
+
if self.phase is AutoScalerPhase.INIT:
|
|
471
|
+
return AutoScalerState.INIT
|
|
472
|
+
else:
|
|
473
|
+
return AutoScalerState.WAIT
|
|
474
|
+
|
|
475
|
+
def clean(self: AutoScaler) -> None:
|
|
476
|
+
"""Remove clients that have exited."""
|
|
477
|
+
marked = []
|
|
478
|
+
for i, client in enumerate(self.clients):
|
|
479
|
+
log.trace(f'Autoscale clean ({i+1}: {client.pid})')
|
|
480
|
+
status = client.poll()
|
|
481
|
+
if status is not None:
|
|
482
|
+
marked.append(i)
|
|
483
|
+
if status != 0:
|
|
484
|
+
log.warning(f'Autoscale client ({i+1}) exited with status {status}')
|
|
485
|
+
else:
|
|
486
|
+
log.debug(f'Autoscale client disconnected ({client.pid})')
|
|
487
|
+
self.clients = [client for i, client in enumerate(self.clients) if i not in marked]
|
|
488
|
+
|
|
489
|
+
@staticmethod
|
|
490
|
+
def finalize() -> AutoScalerState:
|
|
491
|
+
"""Finalize."""
|
|
492
|
+
log.debug(f'Done (autoscaler)')
|
|
493
|
+
return AutoScalerState.HALT
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
class AutoScalerThread(Thread):
|
|
497
|
+
"""Run AutoScaler within dedicated thread."""
|
|
498
|
+
|
|
499
|
+
def __init__(self: AutoScalerThread,
|
|
500
|
+
launcher: List[str],
|
|
501
|
+
policy: str = DEFAULT_AUTOSCALE_POLICY,
|
|
502
|
+
factor: float = DEFAULT_AUTOSCALE_FACTOR,
|
|
503
|
+
period: int = DEFAULT_AUTOSCALE_PERIOD,
|
|
504
|
+
init_size: int = DEFAULT_AUTOSCALE_INIT_SIZE,
|
|
505
|
+
min_size: int = DEFAULT_AUTOSCALE_MIN_SIZE,
|
|
506
|
+
max_size: int = DEFAULT_AUTOSCALE_MAX_SIZE,
|
|
507
|
+
) -> None:
|
|
508
|
+
"""Initialize task executor."""
|
|
509
|
+
super().__init__(name=f'hypershell-autoscaler')
|
|
510
|
+
self.machine = AutoScaler(launcher, policy=policy, factor=factor, period=period,
|
|
511
|
+
init_size=init_size, min_size=min_size, max_size=max_size)
|
|
512
|
+
|
|
513
|
+
def run_with_exceptions(self: AutoScalerThread) -> None:
|
|
514
|
+
"""Run machine."""
|
|
515
|
+
self.machine.run()
|
|
516
|
+
|
|
517
|
+
def stop(self: AutoScalerThread, wait: bool = False, timeout: int = None) -> None:
|
|
518
|
+
"""Stop machine."""
|
|
519
|
+
log.warning(f'Stopping (autoscaler)')
|
|
520
|
+
self.machine.halt()
|
|
521
|
+
super().stop(wait=wait, timeout=timeout)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
class AutoScalingCluster(Thread):
|
|
525
|
+
"""
|
|
526
|
+
Run cluster with autoscaling remote clients via external launcher.
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
source (Iterable[str], optional):
|
|
530
|
+
Any iterable of command-line tasks.
|
|
531
|
+
A new `source` results in a :class:`~hypershell.submit.SubmitThread` populating
|
|
532
|
+
either the database or the queue directly depending on `in_memory`.
|
|
533
|
+
|
|
534
|
+
num_tasks (int, optional):
|
|
535
|
+
Number of parallel task executor threads.
|
|
536
|
+
See :const:`~hypershell.client.DEFAULT_NUM_TASKS`.
|
|
537
|
+
|
|
538
|
+
template (str, optional):
|
|
539
|
+
Template command pattern.
|
|
540
|
+
See :const:`~hypershell.client.DEFAULT_TEMPLATE`.
|
|
541
|
+
|
|
542
|
+
bundlesize (int optional):
|
|
543
|
+
Size of task bundles returned to server.
|
|
544
|
+
See :const:`~hypershell.server.DEFAULT_BUNDLESIZE`.
|
|
545
|
+
|
|
546
|
+
bundlewait (int optional):
|
|
547
|
+
Waiting period in seconds before forcing return of task bundle to server.
|
|
548
|
+
See :const:`~hypershell.submit.DEFAULT_BUNDLEWAIT`.
|
|
549
|
+
|
|
550
|
+
bind (tuple, optional):
|
|
551
|
+
Bind address for server with port number (default: 0.0.0.0).
|
|
552
|
+
See :const:`~hypershell.server.DEFAULT_PORT`.
|
|
553
|
+
|
|
554
|
+
launcher (str, optional):
|
|
555
|
+
Launcher program used to bring up clients on other hosts.
|
|
556
|
+
See :const:`~hypershell.cluster.remote.DEFAULT_AUTOSCALE_LAUNCHER`.
|
|
557
|
+
|
|
558
|
+
launcher_args (List[str], optional):
|
|
559
|
+
Additional command-line arguments for launcher program.
|
|
560
|
+
|
|
561
|
+
remote_exe (str, optional):
|
|
562
|
+
Program name or path for remote executable.
|
|
563
|
+
See :const:`~hypershell.cluster.remote.DEFAULT_REMOTE_EXE`.
|
|
564
|
+
|
|
565
|
+
in_memory (bool, optional):
|
|
566
|
+
If True, revert to basic in-memory queue.
|
|
567
|
+
|
|
568
|
+
no_confirm (bool, optional):
|
|
569
|
+
Disable client confirmation of tasks received.
|
|
570
|
+
|
|
571
|
+
forever_mode (bool, optional):
|
|
572
|
+
Regardless of `source`, if enabled schedule forever.
|
|
573
|
+
Conflicts with `restart_mode` and `in_memory`. Default is `False`.
|
|
574
|
+
|
|
575
|
+
restart_mode (bool, optional):
|
|
576
|
+
If `source` is empty, this option allows for the server to continue
|
|
577
|
+
with scheduling from the database until complete.
|
|
578
|
+
Conflicts with `in_memory`. Default is `False`.
|
|
579
|
+
|
|
580
|
+
max_retries (int, optional):
|
|
581
|
+
Number of allowed task retries.
|
|
582
|
+
See :const:`~hypershell.server.DEFAULT_ATTEMPTS`.
|
|
583
|
+
|
|
584
|
+
eager (bool, optional):
|
|
585
|
+
When enabled tasks are retried immediately ahead scheduling new tasks.
|
|
586
|
+
See :const:`~hypershell.server.DEFAULT_EAGER_MODE`.
|
|
587
|
+
|
|
588
|
+
redirect_failures (IO, optional):
|
|
589
|
+
Open file-like object to write failed tasks.
|
|
590
|
+
|
|
591
|
+
delay_start (float, optional):
|
|
592
|
+
Delay in seconds before connecting to server.
|
|
593
|
+
See :const:`~hypershell.client.DEFAULT_DELAY`.
|
|
594
|
+
|
|
595
|
+
capture (bool, optional):
|
|
596
|
+
Isolate task <stdout> and <stderr> in discrete files.
|
|
597
|
+
Defaults to `False`.
|
|
598
|
+
|
|
599
|
+
client_timeout (int, optional):
|
|
600
|
+
Timeout in seconds before disconnecting from server.
|
|
601
|
+
By default, the client waits for server tor request disconnect.
|
|
602
|
+
|
|
603
|
+
task_timeout (int, optional):
|
|
604
|
+
Task-level walltime limit in seconds.
|
|
605
|
+
By default, the client waits indefinitely on tasks.
|
|
606
|
+
|
|
607
|
+
task_signalwait (int, optional):
|
|
608
|
+
Signal escalation waiting period in seconds on task timeout.
|
|
609
|
+
See :const:`~hypershell.client.DEFAULT_SIGNALWAIT`.
|
|
610
|
+
|
|
611
|
+
policy (str, optional):
|
|
612
|
+
Autoscaling policy (either 'fixed' or 'dynamic').
|
|
613
|
+
See :const:`~hypershell.cluster.remote.DEFAULT_AUTOSCALE_POLICY`
|
|
614
|
+
|
|
615
|
+
period (int, optional):
|
|
616
|
+
Period in seconds between autoscaling events.
|
|
617
|
+
See :const:`~hypershell.cluster.remote.DEFAULT_AUTOSCALE_PERIOD`
|
|
618
|
+
|
|
619
|
+
factor (float, optional):
|
|
620
|
+
Autoscaling factor.
|
|
621
|
+
See :const:`~hypershell.cluster.remote.DEFAULT_AUTOSCALE_FACTOR`
|
|
622
|
+
|
|
623
|
+
init_size (int, optional):
|
|
624
|
+
Initial size of cluster (number of clients).
|
|
625
|
+
See :const:`~hypershell.cluster.remote.DEFAULT_AUTOSCALE_INIT_SIZE`
|
|
626
|
+
|
|
627
|
+
min_size (int, optional):
|
|
628
|
+
Minimum size of cluster (number of clients).
|
|
629
|
+
See :const:`~hypershell.cluster.remote.DEFAULT_AUTOSCALE_MIN_SIZE`
|
|
630
|
+
|
|
631
|
+
max_size (int, optional):
|
|
632
|
+
Maximum size of cluster (number of clients).
|
|
633
|
+
See :const:`~hypershell.cluster.remote.DEFAULT_AUTOSCALE_MAX_SIZE`
|
|
634
|
+
|
|
635
|
+
Example:
|
|
636
|
+
>>> from hypershell.cluster import AutoScalingCluster
|
|
637
|
+
>>> cluster = AutoScalingCluster.new(
|
|
638
|
+
... restart_mode=True, max_retries=2, eager=True,
|
|
639
|
+
... client_timeout=600, task_timeout=300, capture=True,
|
|
640
|
+
... launcher='srun -Q -A standby -t 01:00:00 --exclusive --signal=USR1@600'
|
|
641
|
+
... )
|
|
642
|
+
>>> cluster.join()
|
|
643
|
+
|
|
644
|
+
See Also:
|
|
645
|
+
- :class:`~hypershell.server.ServerThread`
|
|
646
|
+
- :meth:`~hypershell.cluster.remote.run_cluster`
|
|
647
|
+
"""
|
|
648
|
+
|
|
649
|
+
server: ServerThread
|
|
650
|
+
clients: Dict[str, Popen]
|
|
651
|
+
launch_argv: str
|
|
652
|
+
|
|
653
|
+
def __init__(self: AutoScalingCluster,
|
|
654
|
+
source: Iterable[str] = None,
|
|
655
|
+
num_tasks: int = 1,
|
|
656
|
+
template: str = DEFAULT_TEMPLATE,
|
|
657
|
+
bundlesize: int = DEFAULT_BUNDLESIZE,
|
|
658
|
+
bundlewait: int = DEFAULT_BUNDLEWAIT,
|
|
659
|
+
bind: Tuple[str, int] = ('0.0.0.0', DEFAULT_PORT),
|
|
660
|
+
launcher: str = DEFAULT_AUTOSCALE_LAUNCHER,
|
|
661
|
+
launcher_args: List[str] = None,
|
|
662
|
+
remote_exe: str = DEFAULT_REMOTE_EXE,
|
|
663
|
+
in_memory: bool = False, # noqa: ignored (passed by ClusterApp)
|
|
664
|
+
no_confirm: bool = False, # noqa: ignored (passed by ClusterApp)
|
|
665
|
+
forever_mode: bool = False, # noqa: ignored (passed by ClusterApp)
|
|
666
|
+
restart_mode: bool = False, # noqa: ignored (passed by ClusterApp)
|
|
667
|
+
max_retries: int = DEFAULT_ATTEMPTS,
|
|
668
|
+
eager: bool = False,
|
|
669
|
+
redirect_failures: IO = None,
|
|
670
|
+
delay_start: float = DEFAULT_DELAY,
|
|
671
|
+
capture: bool = False,
|
|
672
|
+
client_timeout: int = None,
|
|
673
|
+
task_timeout: int = None,
|
|
674
|
+
task_signalwait: int = DEFAULT_SIGNALWAIT,
|
|
675
|
+
policy: str = DEFAULT_AUTOSCALE_POLICY,
|
|
676
|
+
period: int = DEFAULT_AUTOSCALE_PERIOD,
|
|
677
|
+
factor: float = DEFAULT_AUTOSCALE_FACTOR,
|
|
678
|
+
init_size: int = DEFAULT_AUTOSCALE_INIT_SIZE,
|
|
679
|
+
min_size: int = DEFAULT_AUTOSCALE_MIN_SIZE,
|
|
680
|
+
max_size: int = DEFAULT_AUTOSCALE_MAX_SIZE,
|
|
681
|
+
) -> None:
|
|
682
|
+
"""Initialize server and autoscaler."""
|
|
683
|
+
auth = secrets.token_hex(64)
|
|
684
|
+
self.server = ServerThread(source=source, auth=auth, bundlesize=bundlesize, bundlewait=bundlewait,
|
|
685
|
+
max_retries=max_retries, eager=eager, address=bind, forever_mode=True,
|
|
686
|
+
redirect_failures=redirect_failures)
|
|
687
|
+
launcher = shlex.split(launcher)
|
|
688
|
+
if launcher_args is None:
|
|
689
|
+
launcher_args = []
|
|
690
|
+
else:
|
|
691
|
+
launcher_args = [arg for arg_group in launcher_args for arg in shlex.split(arg_group)]
|
|
692
|
+
client_args = []
|
|
693
|
+
if capture:
|
|
694
|
+
client_args.append('--capture')
|
|
695
|
+
if client_timeout is not None:
|
|
696
|
+
client_args.extend(['-T', str(client_timeout)])
|
|
697
|
+
if task_timeout is not None:
|
|
698
|
+
client_args.extend(['-W', str(task_timeout)])
|
|
699
|
+
launcher.extend([
|
|
700
|
+
*launcher_args, remote_exe, 'client',
|
|
701
|
+
'-H', HOSTNAME, '-p', str(bind[1]), '-N', str(num_tasks), '-b', str(bundlesize), '-w', str(bundlewait),
|
|
702
|
+
'-t', template, '-k', auth, '-d', str(delay_start), '-S', str(task_signalwait), *client_args
|
|
703
|
+
])
|
|
704
|
+
self.autoscaler = AutoScalerThread(launcher, policy=policy, factor=factor, period=period,
|
|
705
|
+
init_size=init_size, min_size=min_size, max_size=max_size)
|
|
706
|
+
super().__init__(name='hypershell-cluster')
|
|
707
|
+
|
|
708
|
+
def run_with_exceptions(self: AutoScalingCluster) -> None:
|
|
709
|
+
"""Start child threads, wait."""
|
|
710
|
+
self.server.start()
|
|
711
|
+
while not self.server.queue.ready:
|
|
712
|
+
time.sleep(0.1)
|
|
713
|
+
self.autoscaler.start()
|
|
714
|
+
self.autoscaler.join()
|
|
715
|
+
self.server.join()
|
|
716
|
+
|
|
717
|
+
def stop(self: AutoScalingCluster, wait: bool = False, timeout: int = None) -> None:
|
|
718
|
+
"""Stop child threads before main thread."""
|
|
719
|
+
self.server.stop(wait=wait, timeout=timeout)
|
|
720
|
+
self.autoscaler.stop(wait=wait, timeout=timeout)
|
|
721
|
+
super().stop(wait=wait, timeout=timeout)
|