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