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
hypershell/server.py
ADDED
|
@@ -0,0 +1,1207 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025 Geoffrey Lentner
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
Schedule and update bundles of tasks from the database.
|
|
6
|
+
|
|
7
|
+
The server can submit tasks for you so no need to directly submit before
|
|
8
|
+
invoking the server (see `hypershell.submit`).
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
>>> from hypershell.server import serve_from
|
|
12
|
+
>>> serve_from(['echo AA', 'echo BB', 'echo CC'])
|
|
13
|
+
|
|
14
|
+
To run a server process indefinitely (maybe as a service), invoke `serve_forever()`.
|
|
15
|
+
Other programs can submit tasks at a later point.
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
>>> from hypershell.server import serve_forever
|
|
19
|
+
>>> serve_forever(bundlesize=10, max_retries=1)
|
|
20
|
+
|
|
21
|
+
Embed a `ServerThread` in your application directly. Call `stop()` to stop early.
|
|
22
|
+
Clients cannot connect from a remote machine unless you set the `bind` address
|
|
23
|
+
to 0.0.0.0 (as opposed to localhost which is the default).
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
>>> import sys
|
|
27
|
+
>>> from hypershell.server import ServerThread
|
|
28
|
+
>>> server_thread = ServerThread.new(source=sys.stdin, bind=('0.0.0.0', 8080))
|
|
29
|
+
|
|
30
|
+
Note:
|
|
31
|
+
In order for the `ServerThread` to actively monitor the state set by `stop` and
|
|
32
|
+
halt execution (a requirement because of how CPython does threading), the implementation
|
|
33
|
+
uses a finite state machine. *You should not instantiate this machine directly*.
|
|
34
|
+
|
|
35
|
+
Warning:
|
|
36
|
+
Because the `ServerThread` checks state actively to decide whether to halt, if your
|
|
37
|
+
`source` is blocking (e.g., `sys.stdin`) it will not be able to halt immediately. If
|
|
38
|
+
your main program exits however, the thread will be stopped regardless because it
|
|
39
|
+
runs as a `daemon`.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# type annotations
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
from typing import List, Dict, Tuple, Iterable, IO, Optional, Callable, Type, Final
|
|
46
|
+
from types import TracebackType
|
|
47
|
+
|
|
48
|
+
# standard libs
|
|
49
|
+
import sys
|
|
50
|
+
import time
|
|
51
|
+
from enum import Enum
|
|
52
|
+
from datetime import datetime, timedelta
|
|
53
|
+
from functools import cached_property
|
|
54
|
+
from itertools import islice
|
|
55
|
+
from queue import Empty as QueueEmpty, Full as QueueFull
|
|
56
|
+
|
|
57
|
+
# external libs
|
|
58
|
+
from cmdkit.app import Application
|
|
59
|
+
from cmdkit.cli import Interface, ArgumentError
|
|
60
|
+
|
|
61
|
+
# internal libs
|
|
62
|
+
from hypershell.core.exceptions import get_shared_exception_mapping
|
|
63
|
+
from hypershell.core.config import config, default, find_available_ports
|
|
64
|
+
from hypershell.core.logging import Logger
|
|
65
|
+
from hypershell.core.fsm import State, StateMachine
|
|
66
|
+
from hypershell.core.thread import Thread
|
|
67
|
+
from hypershell.core.queue import QueueServer, QueueConfig
|
|
68
|
+
from hypershell.core.signal import check_signal, SIGNAL_MAP, SIGUSR1, SIGUSR2
|
|
69
|
+
from hypershell.core.heartbeat import Heartbeat, ClientState
|
|
70
|
+
from hypershell.data.model import Task, Client
|
|
71
|
+
from hypershell.data import ensuredb, DATABASE_ENABLED
|
|
72
|
+
from hypershell.submit import SubmitThread, LiveSubmitThread, DEFAULT_BUNDLEWAIT
|
|
73
|
+
from hypershell.client import ClientInfo
|
|
74
|
+
|
|
75
|
+
# public interface
|
|
76
|
+
__all__ = ['serve_from', 'serve_file', 'serve_forever', 'ServerThread', 'ServerApp',
|
|
77
|
+
'DEFAULT_BUNDLESIZE', 'DEFAULT_BUNDLEWAIT', 'DEFAULT_ATTEMPTS',
|
|
78
|
+
'DEFAULT_EVICT', 'DEFAULT_EAGER_MODE', 'DEFAULT_QUERY_PAUSE',
|
|
79
|
+
'DEFAULT_BIND', 'DEFAULT_PORT', 'DEFAULT_AUTH']
|
|
80
|
+
|
|
81
|
+
# initialize logger
|
|
82
|
+
log = Logger.with_name(__name__)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SchedulerState(State, Enum):
|
|
86
|
+
"""Finite states of the scheduler."""
|
|
87
|
+
START = 0
|
|
88
|
+
LOAD = 1
|
|
89
|
+
PACK = 2
|
|
90
|
+
POST = 3
|
|
91
|
+
FINAL = 4
|
|
92
|
+
HALT = 5
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
DEFAULT_BUNDLESIZE: Final[int] = default.server.bundlesize
|
|
96
|
+
"""Default size for task bundles."""
|
|
97
|
+
|
|
98
|
+
DEFAULT_ATTEMPTS: Final[int] = default.server.attempts
|
|
99
|
+
"""Default number of attempts for task retries."""
|
|
100
|
+
|
|
101
|
+
DEFAULT_EAGER_MODE: Final[bool] = default.server.eager
|
|
102
|
+
"""When enabled tasks are retried immediately ahead scheduling new tasks."""
|
|
103
|
+
|
|
104
|
+
DEFAULT_QUERY_PAUSE: Final[int] = default.server.wait
|
|
105
|
+
"""Default polling interval between database queries if no tasks are available."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Scheduler(StateMachine):
|
|
109
|
+
"""Enqueue tasks from database."""
|
|
110
|
+
|
|
111
|
+
tasks: List[Task]
|
|
112
|
+
queue: QueueServer
|
|
113
|
+
bundle: List[bytes]
|
|
114
|
+
|
|
115
|
+
bundlesize: int
|
|
116
|
+
attempts: int
|
|
117
|
+
eager: bool
|
|
118
|
+
forever_mode: bool
|
|
119
|
+
restart_mode: bool
|
|
120
|
+
|
|
121
|
+
state = SchedulerState.START
|
|
122
|
+
states = SchedulerState
|
|
123
|
+
|
|
124
|
+
startup_phase: bool = True
|
|
125
|
+
|
|
126
|
+
def __init__(self: Scheduler, queue: QueueServer, bundlesize: int = DEFAULT_BUNDLESIZE,
|
|
127
|
+
attempts: int = DEFAULT_ATTEMPTS, eager: bool = DEFAULT_EAGER_MODE,
|
|
128
|
+
forever_mode: bool = False, restart_mode: bool = False) -> None:
|
|
129
|
+
"""Initialize queue and parameters."""
|
|
130
|
+
self.queue = queue
|
|
131
|
+
self.bundle = []
|
|
132
|
+
self.bundlesize = bundlesize
|
|
133
|
+
self.attempts = attempts
|
|
134
|
+
self.eager = eager
|
|
135
|
+
self.forever_mode = forever_mode
|
|
136
|
+
self.restart_mode = restart_mode
|
|
137
|
+
if self.restart_mode:
|
|
138
|
+
# NOTE: Halt if everything in the database is already finished
|
|
139
|
+
self.startup_phase = False
|
|
140
|
+
|
|
141
|
+
@cached_property
|
|
142
|
+
def actions(self: Scheduler) -> Dict[SchedulerState, Callable[[], SchedulerState]]:
|
|
143
|
+
return {
|
|
144
|
+
SchedulerState.START: self.start,
|
|
145
|
+
SchedulerState.LOAD: self.load_bundle,
|
|
146
|
+
SchedulerState.PACK: self.pack_bundle,
|
|
147
|
+
SchedulerState.POST: self.post_bundle,
|
|
148
|
+
SchedulerState.FINAL: self.finalize,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
def start(self: Scheduler) -> SchedulerState:
|
|
152
|
+
"""Initial setup then jump to LOAD state."""
|
|
153
|
+
log.debug('Started (scheduler)')
|
|
154
|
+
if self.forever_mode:
|
|
155
|
+
log.info('Scheduler will run forever')
|
|
156
|
+
task_count = Task.count()
|
|
157
|
+
tasks_remaining = Task.count_remaining()
|
|
158
|
+
if task_count > 0:
|
|
159
|
+
log.warning(f'Database exists ({task_count} previous tasks)')
|
|
160
|
+
if tasks_remaining == 0:
|
|
161
|
+
log.warning(f'All tasks completed - did you mean to use the same database?')
|
|
162
|
+
else:
|
|
163
|
+
tasks_interrupted = Task.count_interrupted()
|
|
164
|
+
log.info(f'Found {tasks_remaining} unfinished task(s)')
|
|
165
|
+
Task.revert_interrupted()
|
|
166
|
+
log.info(f'Reverted {tasks_interrupted} previously interrupted task(s)')
|
|
167
|
+
return SchedulerState.LOAD
|
|
168
|
+
|
|
169
|
+
def load_bundle(self: Scheduler) -> SchedulerState:
|
|
170
|
+
"""Load the next task bundle from the database."""
|
|
171
|
+
if check_signal() in (SIGUSR1, SIGUSR2):
|
|
172
|
+
log.warning(f'Signal interrupt ({SIGNAL_MAP[check_signal()]})')
|
|
173
|
+
return SchedulerState.FINAL
|
|
174
|
+
self.tasks = Task.next(limit=self.bundlesize, attempts=self.attempts, eager=self.eager)
|
|
175
|
+
if self.tasks:
|
|
176
|
+
self.startup_phase = False
|
|
177
|
+
return SchedulerState.PACK
|
|
178
|
+
# An empty database must wait for at least one task
|
|
179
|
+
elif not self.forever_mode and Task.count() > 0 and Task.count_remaining() == 0 and not self.startup_phase:
|
|
180
|
+
return SchedulerState.FINAL
|
|
181
|
+
else:
|
|
182
|
+
time.sleep(DEFAULT_QUERY_PAUSE)
|
|
183
|
+
return SchedulerState.LOAD
|
|
184
|
+
|
|
185
|
+
def pack_bundle(self: Scheduler) -> SchedulerState:
|
|
186
|
+
"""Pack tasks into bundle (list)."""
|
|
187
|
+
self.bundle = [task.pack() for task in self.tasks]
|
|
188
|
+
return SchedulerState.POST
|
|
189
|
+
|
|
190
|
+
def post_bundle(self: Scheduler) -> SchedulerState:
|
|
191
|
+
"""Put bundle on outbound queue."""
|
|
192
|
+
try:
|
|
193
|
+
self.queue.scheduled.put(self.bundle, timeout=2)
|
|
194
|
+
log.debug(f'Scheduled {len(self.tasks)} tasks')
|
|
195
|
+
for task in self.tasks:
|
|
196
|
+
log.debug(f'Scheduled task ({task.id})')
|
|
197
|
+
return SchedulerState.LOAD
|
|
198
|
+
except QueueFull:
|
|
199
|
+
return SchedulerState.POST
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def finalize() -> SchedulerState:
|
|
203
|
+
"""Stop scheduler."""
|
|
204
|
+
log.debug('Done (scheduler)')
|
|
205
|
+
return SchedulerState.HALT
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class SchedulerThread(Thread):
|
|
209
|
+
"""Run scheduler within dedicated thread."""
|
|
210
|
+
|
|
211
|
+
def __init__(self: SchedulerThread, queue: QueueServer, bundlesize: int = DEFAULT_BUNDLESIZE,
|
|
212
|
+
attempts: int = DEFAULT_ATTEMPTS, eager: bool = DEFAULT_EAGER_MODE,
|
|
213
|
+
forever_mode: bool = False, restart_mode: bool = False) -> None:
|
|
214
|
+
"""Initialize machine."""
|
|
215
|
+
super().__init__(name='hypershell-scheduler')
|
|
216
|
+
self.machine = Scheduler(queue=queue, bundlesize=bundlesize, attempts=attempts, eager=eager,
|
|
217
|
+
forever_mode=forever_mode, restart_mode=restart_mode)
|
|
218
|
+
|
|
219
|
+
def run_with_exceptions(self: SchedulerThread) -> None:
|
|
220
|
+
"""Run machine."""
|
|
221
|
+
self.machine.run()
|
|
222
|
+
|
|
223
|
+
def stop(self: SchedulerThread, wait: bool = False, timeout: int = None) -> None:
|
|
224
|
+
"""Stop machine."""
|
|
225
|
+
log.warning('Stopping (scheduler)')
|
|
226
|
+
self.machine.halt()
|
|
227
|
+
super().stop(wait=wait, timeout=timeout)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class ConfirmState(State, Enum):
|
|
231
|
+
"""Finite states for task confirmation machine."""
|
|
232
|
+
START = 0
|
|
233
|
+
UNLOAD = 1
|
|
234
|
+
UNPACK = 2
|
|
235
|
+
UPDATE = 3
|
|
236
|
+
FINAL = 4
|
|
237
|
+
HALT = 5
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class Confirm(StateMachine):
|
|
241
|
+
"""Collect task bundle confirmations from clients and update database."""
|
|
242
|
+
|
|
243
|
+
in_memory: bool
|
|
244
|
+
queue: QueueServer
|
|
245
|
+
client_data: Optional[bytes]
|
|
246
|
+
client_info: Optional[ClientInfo]
|
|
247
|
+
|
|
248
|
+
state = ConfirmState.START
|
|
249
|
+
states = ConfirmState
|
|
250
|
+
|
|
251
|
+
def __init__(self: Confirm, queue: QueueServer, in_memory: bool = False) -> None:
|
|
252
|
+
"""Initialize machine."""
|
|
253
|
+
self.in_memory = in_memory
|
|
254
|
+
self.queue = queue
|
|
255
|
+
self.client_data = None
|
|
256
|
+
self.client_info = None
|
|
257
|
+
|
|
258
|
+
@cached_property
|
|
259
|
+
def actions(self: Confirm) -> Dict[ConfirmState, Callable[[], ConfirmState]]:
|
|
260
|
+
return {
|
|
261
|
+
ConfirmState.START: self.start,
|
|
262
|
+
ConfirmState.UNLOAD: self.unload_info,
|
|
263
|
+
ConfirmState.UNPACK: self.unpack_info,
|
|
264
|
+
ConfirmState.UPDATE: self.update_info,
|
|
265
|
+
ConfirmState.FINAL: self.finalize,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def start() -> ConfirmState:
|
|
270
|
+
"""Jump to UNLOAD state."""
|
|
271
|
+
log.debug('Started (confirm)')
|
|
272
|
+
return ConfirmState.UNLOAD
|
|
273
|
+
|
|
274
|
+
def unload_info(self: Confirm) -> ConfirmState:
|
|
275
|
+
"""Get the next task bundle confirmation from shared queue."""
|
|
276
|
+
try:
|
|
277
|
+
self.client_data = self.queue.confirmed.get(timeout=2)
|
|
278
|
+
self.queue.confirmed.task_done()
|
|
279
|
+
return ConfirmState.UNPACK if self.client_data else ConfirmState.FINAL
|
|
280
|
+
except QueueEmpty:
|
|
281
|
+
return ConfirmState.UNLOAD
|
|
282
|
+
|
|
283
|
+
def unpack_info(self: Confirm) -> ConfirmState:
|
|
284
|
+
"""Unpack received client info."""
|
|
285
|
+
self.client_info = ClientInfo.unpack(self.client_data)
|
|
286
|
+
log.debug(f'Confirmed {len(self.client_info.task_ids)} tasks '
|
|
287
|
+
f'({self.client_info.client_host}: {self.client_info.client_id})')
|
|
288
|
+
return ConfirmState.UPDATE
|
|
289
|
+
|
|
290
|
+
def update_info(self: Confirm) -> ConfirmState:
|
|
291
|
+
"""Update client info in database for confirmed task bundle."""
|
|
292
|
+
if not self.in_memory:
|
|
293
|
+
Task.update_all(self.client_info.transpose())
|
|
294
|
+
return ConfirmState.UNLOAD
|
|
295
|
+
|
|
296
|
+
@staticmethod
|
|
297
|
+
def finalize() -> ConfirmState:
|
|
298
|
+
"""Return HALT."""
|
|
299
|
+
log.debug('Done (confirm)')
|
|
300
|
+
return ConfirmState.HALT
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class ConfirmThread(Thread):
|
|
304
|
+
"""Run Confirm machine within dedicated thread."""
|
|
305
|
+
|
|
306
|
+
def __init__(self: ConfirmThread, queue: QueueServer, in_memory: bool = False) -> None:
|
|
307
|
+
"""Initialize machine."""
|
|
308
|
+
super().__init__(name='hypershell-confirm')
|
|
309
|
+
self.machine = Confirm(queue=queue, in_memory=in_memory)
|
|
310
|
+
|
|
311
|
+
def run_with_exceptions(self: ConfirmThread) -> None:
|
|
312
|
+
"""Run machine."""
|
|
313
|
+
self.machine.run()
|
|
314
|
+
|
|
315
|
+
def stop(self: ConfirmThread, wait: bool = False, timeout: int = None) -> None:
|
|
316
|
+
"""Stop machine."""
|
|
317
|
+
log.warning('Stopping (confirm)')
|
|
318
|
+
self.machine.halt()
|
|
319
|
+
super().stop(wait=wait, timeout=timeout)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class ReceiverState(State, Enum):
|
|
323
|
+
"""Finite states for receiver."""
|
|
324
|
+
START = 0
|
|
325
|
+
UNLOAD = 1
|
|
326
|
+
UNPACK = 2
|
|
327
|
+
UPDATE = 3
|
|
328
|
+
FINAL = 4
|
|
329
|
+
HALT = 5
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class Receiver(StateMachine):
|
|
333
|
+
"""Collect incoming finished task bundles and update database."""
|
|
334
|
+
|
|
335
|
+
tasks: List[Task]
|
|
336
|
+
queue: QueueServer
|
|
337
|
+
bundle: List[bytes]
|
|
338
|
+
|
|
339
|
+
in_memory: bool
|
|
340
|
+
redirect_failures: IO
|
|
341
|
+
|
|
342
|
+
state = ReceiverState.START
|
|
343
|
+
states = ReceiverState
|
|
344
|
+
|
|
345
|
+
def __init__(self: Receiver, queue: QueueServer, in_memory: bool = False, redirect_failures: IO = None) -> None:
|
|
346
|
+
"""Initialize receiver."""
|
|
347
|
+
self.queue = queue
|
|
348
|
+
self.bundle = []
|
|
349
|
+
self.in_memory = in_memory
|
|
350
|
+
self.redirect_failures = redirect_failures
|
|
351
|
+
|
|
352
|
+
@cached_property
|
|
353
|
+
def actions(self: Receiver) -> Dict[ReceiverState, Callable[[], ReceiverState]]:
|
|
354
|
+
return {
|
|
355
|
+
ReceiverState.START: self.start,
|
|
356
|
+
ReceiverState.UNLOAD: self.unload_bundle,
|
|
357
|
+
ReceiverState.UNPACK: self.unpack_bundle,
|
|
358
|
+
ReceiverState.UPDATE: self.update_tasks,
|
|
359
|
+
ReceiverState.FINAL: self.finalize,
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
@staticmethod
|
|
363
|
+
def start() -> ReceiverState:
|
|
364
|
+
"""Jump to UNLOAD state."""
|
|
365
|
+
log.debug('Started (receiver)')
|
|
366
|
+
return ReceiverState.UNLOAD
|
|
367
|
+
|
|
368
|
+
def unload_bundle(self: Receiver) -> ReceiverState:
|
|
369
|
+
"""Get the next bundle from the completed task queue."""
|
|
370
|
+
try:
|
|
371
|
+
self.bundle = self.queue.completed.get(timeout=2)
|
|
372
|
+
self.queue.completed.task_done()
|
|
373
|
+
return ReceiverState.UNPACK if self.bundle else ReceiverState.FINAL
|
|
374
|
+
except QueueEmpty:
|
|
375
|
+
log.trace('No completed tasks returned - waiting')
|
|
376
|
+
return ReceiverState.UNLOAD
|
|
377
|
+
|
|
378
|
+
def unpack_bundle(self: Receiver) -> ReceiverState:
|
|
379
|
+
"""Unpack previous bundle into list of tasks."""
|
|
380
|
+
self.tasks = [Task.unpack(data) for data in self.bundle]
|
|
381
|
+
return ReceiverState.UPDATE
|
|
382
|
+
|
|
383
|
+
def update_tasks(self: Receiver) -> ReceiverState:
|
|
384
|
+
"""Update tasks in database with run details."""
|
|
385
|
+
if not self.in_memory:
|
|
386
|
+
Task.update_all([task.to_dict() for task in self.tasks])
|
|
387
|
+
for task in self.tasks:
|
|
388
|
+
log.debug(f'Completed task ({task.id})')
|
|
389
|
+
if task.exit_status != 0:
|
|
390
|
+
log.warning(f'Non-zero exit status ({task.exit_status}) for task ({task.id})')
|
|
391
|
+
if self.redirect_failures:
|
|
392
|
+
print(task.args, file=self.redirect_failures)
|
|
393
|
+
return ReceiverState.UNLOAD
|
|
394
|
+
|
|
395
|
+
@staticmethod
|
|
396
|
+
def finalize() -> ReceiverState:
|
|
397
|
+
"""Return HALT."""
|
|
398
|
+
log.debug('Done (receiver)')
|
|
399
|
+
return ReceiverState.HALT
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class ReceiverThread(Thread):
|
|
403
|
+
"""Run receiver within dedicated thread."""
|
|
404
|
+
|
|
405
|
+
def __init__(self: ReceiverThread,
|
|
406
|
+
queue: QueueServer,
|
|
407
|
+
in_memory: bool = False,
|
|
408
|
+
redirect_failures: IO = None) -> None:
|
|
409
|
+
"""Initialize machine."""
|
|
410
|
+
super().__init__(name='hypershell-receiver')
|
|
411
|
+
self.machine = Receiver(queue=queue, in_memory=in_memory, redirect_failures=redirect_failures)
|
|
412
|
+
|
|
413
|
+
def run_with_exceptions(self: ReceiverThread) -> None:
|
|
414
|
+
"""Run machine."""
|
|
415
|
+
self.machine.run()
|
|
416
|
+
|
|
417
|
+
def stop(self: ReceiverThread, wait: bool = False, timeout: int = None) -> None:
|
|
418
|
+
"""Stop machine."""
|
|
419
|
+
log.warning('Stopping (receiver)')
|
|
420
|
+
self.machine.halt()
|
|
421
|
+
super().stop(wait=wait, timeout=timeout)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class HeartbeatState(State, Enum):
|
|
425
|
+
"""Finite states of the heartbeat machine."""
|
|
426
|
+
START = 0
|
|
427
|
+
NEXT = 1
|
|
428
|
+
UPDATE = 2
|
|
429
|
+
SWITCH = 3
|
|
430
|
+
CHECK = 4
|
|
431
|
+
SIGNAL = 5
|
|
432
|
+
FINAL = 6
|
|
433
|
+
HALT = 7
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
DEFAULT_EVICT: Final[int] = default.server.evict
|
|
437
|
+
"""Default client eviction period in seconds."""
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class HeartMonitor(StateMachine):
|
|
441
|
+
"""Collect heartbeat messages from connected clients."""
|
|
442
|
+
|
|
443
|
+
in_memory: bool
|
|
444
|
+
no_confirm: bool
|
|
445
|
+
queue: QueueServer
|
|
446
|
+
beats: Dict[str, Heartbeat]
|
|
447
|
+
last_check: datetime
|
|
448
|
+
wait_check: timedelta
|
|
449
|
+
evict_after: timedelta
|
|
450
|
+
|
|
451
|
+
startup_phase: bool = True # should not halt until at least one client
|
|
452
|
+
scheduler_done: bool = False # set by parent thread when scheduling is over
|
|
453
|
+
should_signal: bool = False # set by parent thread to signal clients
|
|
454
|
+
latest_heartbeat: Heartbeat = None
|
|
455
|
+
|
|
456
|
+
state = HeartbeatState.START
|
|
457
|
+
states = HeartbeatState
|
|
458
|
+
|
|
459
|
+
def __init__(self: HeartMonitor, queue: QueueServer, evict_after: int = DEFAULT_EVICT,
|
|
460
|
+
in_memory: bool = False, no_confirm: bool = False) -> None:
|
|
461
|
+
"""Initialize with queue server."""
|
|
462
|
+
self.queue = queue
|
|
463
|
+
self.last_check = datetime.now().astimezone()
|
|
464
|
+
self.beats = {}
|
|
465
|
+
self.in_memory = in_memory
|
|
466
|
+
self.no_confirm = no_confirm
|
|
467
|
+
if evict_after >= 10:
|
|
468
|
+
self.wait_check = timedelta(seconds=int(evict_after / 10))
|
|
469
|
+
self.evict_after = timedelta(seconds=evict_after)
|
|
470
|
+
else:
|
|
471
|
+
raise RuntimeError(f'Evict period must be greater than 10 seconds: given {evict_after}')
|
|
472
|
+
|
|
473
|
+
@cached_property
|
|
474
|
+
def actions(self: HeartMonitor) -> Dict[HeartbeatState, Callable[[], HeartbeatState]]:
|
|
475
|
+
return {
|
|
476
|
+
HeartbeatState.START: self.start,
|
|
477
|
+
HeartbeatState.NEXT: self.get_next,
|
|
478
|
+
HeartbeatState.UPDATE: self.update_client,
|
|
479
|
+
HeartbeatState.SWITCH: self.switch_mode,
|
|
480
|
+
HeartbeatState.CHECK: self.check_clients,
|
|
481
|
+
HeartbeatState.SIGNAL: self.signal_clients,
|
|
482
|
+
HeartbeatState.FINAL: self.finalize,
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
@staticmethod
|
|
486
|
+
def start() -> HeartbeatState:
|
|
487
|
+
"""Jump to NEXT state."""
|
|
488
|
+
log.debug('Started (heartbeat)')
|
|
489
|
+
return HeartbeatState.NEXT
|
|
490
|
+
|
|
491
|
+
def get_next(self: HeartMonitor) -> HeartbeatState:
|
|
492
|
+
"""Get and stash heartbeat from clients."""
|
|
493
|
+
try:
|
|
494
|
+
hb_data = self.queue.heartbeat.get(timeout=2)
|
|
495
|
+
self.queue.heartbeat.task_done()
|
|
496
|
+
self.startup_phase = False
|
|
497
|
+
if not hb_data:
|
|
498
|
+
return HeartbeatState.FINAL
|
|
499
|
+
else:
|
|
500
|
+
self.latest_heartbeat = Heartbeat.unpack(hb_data)
|
|
501
|
+
return HeartbeatState.UPDATE
|
|
502
|
+
except QueueEmpty:
|
|
503
|
+
return HeartbeatState.SWITCH
|
|
504
|
+
|
|
505
|
+
def update_client(self: HeartMonitor) -> HeartbeatState:
|
|
506
|
+
"""Update client with heartbeat or disconnect."""
|
|
507
|
+
hb = self.latest_heartbeat
|
|
508
|
+
if hb.state is not ClientState.FINISHED:
|
|
509
|
+
if hb.uuid in self.beats:
|
|
510
|
+
log.trace(f'Heartbeat - running ({hb.host}: {hb.uuid})')
|
|
511
|
+
else:
|
|
512
|
+
log.debug(f'Registered client ({hb.host}: {hb.uuid})')
|
|
513
|
+
if not self.in_memory:
|
|
514
|
+
new_client = Client.from_heartbeat(hb)
|
|
515
|
+
try:
|
|
516
|
+
# Check to see if we are re-registering a falsely-evicted client (Issue #29)
|
|
517
|
+
old_client = Client.from_id(new_client.id)
|
|
518
|
+
log.warning(f'Existing client re-registered ({old_client.host}: {old_client.id})')
|
|
519
|
+
Client.update(old_client.id, disconnected_at=None, evicted=False)
|
|
520
|
+
except Client.NotFound:
|
|
521
|
+
Client.add(new_client)
|
|
522
|
+
self.beats[hb.uuid] = hb
|
|
523
|
+
return HeartbeatState.SWITCH
|
|
524
|
+
else:
|
|
525
|
+
log.trace(f'Client disconnected ({hb.host}: {hb.uuid})')
|
|
526
|
+
if hb.uuid in self.beats:
|
|
527
|
+
self.beats.pop(hb.uuid)
|
|
528
|
+
if not self.in_memory:
|
|
529
|
+
Client.update(hb.uuid, disconnected_at=datetime.now().astimezone())
|
|
530
|
+
return HeartbeatState.SWITCH
|
|
531
|
+
|
|
532
|
+
def switch_mode(self: HeartMonitor) -> HeartbeatState:
|
|
533
|
+
"""Decide to bail, signal, check, or get another heartbeat."""
|
|
534
|
+
if self.startup_phase:
|
|
535
|
+
return HeartbeatState.NEXT
|
|
536
|
+
if self.should_signal:
|
|
537
|
+
return HeartbeatState.SIGNAL
|
|
538
|
+
if not self.beats and self.scheduler_done:
|
|
539
|
+
return HeartbeatState.FINAL
|
|
540
|
+
now = datetime.now().astimezone()
|
|
541
|
+
if (now - self.last_check) > self.wait_check:
|
|
542
|
+
self.last_check = now
|
|
543
|
+
return HeartbeatState.CHECK
|
|
544
|
+
else:
|
|
545
|
+
return HeartbeatState.NEXT
|
|
546
|
+
|
|
547
|
+
def check_clients(self: HeartMonitor) -> HeartbeatState:
|
|
548
|
+
"""Check last heartbeat on all clients and evict if necessary."""
|
|
549
|
+
log.debug(f'Checking clients ({len(self.beats)} connected)')
|
|
550
|
+
for uuid in list(self.beats):
|
|
551
|
+
hb = self.beats.get(uuid)
|
|
552
|
+
age = self.last_check - hb.time
|
|
553
|
+
if age > self.evict_after:
|
|
554
|
+
log.warning(f'Evicting client ({hb.host}: {uuid})')
|
|
555
|
+
self.beats.pop(uuid)
|
|
556
|
+
if not self.in_memory:
|
|
557
|
+
Client.update(hb.uuid, disconnected_at=datetime.now().astimezone(), evicted=True)
|
|
558
|
+
if not self.in_memory and not self.no_confirm:
|
|
559
|
+
log.warning(f'Reverting orphaned tasks ({hb.host}: {uuid})')
|
|
560
|
+
Task.revert_orphaned(uuid)
|
|
561
|
+
return HeartbeatState.SWITCH
|
|
562
|
+
|
|
563
|
+
def signal_clients(self: HeartMonitor) -> HeartbeatState:
|
|
564
|
+
"""Send shutdown signal to all connected clients."""
|
|
565
|
+
log.debug(f'Signaling clients ({len(self.beats)} connected)')
|
|
566
|
+
for hb in self.beats.values():
|
|
567
|
+
self.queue.scheduled.put(None)
|
|
568
|
+
log.debug(f'Disconnect requested ({hb.host}: {hb.uuid})')
|
|
569
|
+
self.should_signal = False
|
|
570
|
+
return HeartbeatState.SWITCH
|
|
571
|
+
|
|
572
|
+
@staticmethod
|
|
573
|
+
def finalize() -> HeartbeatState:
|
|
574
|
+
"""Stop heart monitor."""
|
|
575
|
+
log.debug('Done (heartbeat)')
|
|
576
|
+
return HeartbeatState.HALT
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
class HeartMonitorThread(Thread):
|
|
580
|
+
"""Run heart monitor within dedicated thread."""
|
|
581
|
+
|
|
582
|
+
def __init__(self: HeartMonitorThread, queue: QueueServer, evict_after: int = DEFAULT_EVICT,
|
|
583
|
+
in_memory: bool = False, no_confirm: bool = False) -> None:
|
|
584
|
+
"""Initialize machine."""
|
|
585
|
+
super().__init__(name='hypershell-heartmonitor')
|
|
586
|
+
self.machine = HeartMonitor(queue=queue, evict_after=evict_after,
|
|
587
|
+
in_memory=in_memory, no_confirm=no_confirm)
|
|
588
|
+
|
|
589
|
+
def run_with_exceptions(self: HeartMonitorThread) -> None:
|
|
590
|
+
"""Run machine."""
|
|
591
|
+
self.machine.run()
|
|
592
|
+
|
|
593
|
+
def signal_clients(self: HeartMonitorThread) -> None:
|
|
594
|
+
"""Set signal flag to post sentinel for each connected clients."""
|
|
595
|
+
self.machine.should_signal = True
|
|
596
|
+
|
|
597
|
+
def signal_scheduler_done(self: HeartMonitorThread) -> None:
|
|
598
|
+
"""Set flag to tell heart monitor that scheduler is done."""
|
|
599
|
+
self.machine.scheduler_done = True
|
|
600
|
+
|
|
601
|
+
def stop(self: HeartMonitorThread, wait: bool = False, timeout: int = None) -> None:
|
|
602
|
+
"""Stop machine."""
|
|
603
|
+
log.warning('Stopping (heartbeat)')
|
|
604
|
+
self.machine.halt()
|
|
605
|
+
super().stop(wait=wait, timeout=timeout)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
DEFAULT_BIND: Final[str] = QueueConfig.host
|
|
609
|
+
"""Default bind address for server."""
|
|
610
|
+
|
|
611
|
+
DEFAULT_PORT: Final[int] = QueueConfig.port
|
|
612
|
+
"""Default port number for server."""
|
|
613
|
+
|
|
614
|
+
DEFAULT_AUTH: Final[str] = QueueConfig.auth
|
|
615
|
+
"""Default authentication key for server (**DO NOT USE THIS**)."""
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
class ServerThread(Thread):
|
|
619
|
+
"""
|
|
620
|
+
Run server within dedicated thread.
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
source (Iterable[str], optional):
|
|
624
|
+
Any iterable of command-line tasks.
|
|
625
|
+
A new `source` results in a :class:`~hypershell.submit.SubmitThread` populating
|
|
626
|
+
either the database or the queue directly depending on `in_memory`.
|
|
627
|
+
|
|
628
|
+
restart_mode (bool, optional):
|
|
629
|
+
If `source` is empty, this option allows for the server to continue
|
|
630
|
+
with scheduling from the database until complete.
|
|
631
|
+
Conflicts with `in_memory`. Default is `False`.
|
|
632
|
+
|
|
633
|
+
forever_mode (bool, optional):
|
|
634
|
+
Regardless of `source`, if enabled schedule forever.
|
|
635
|
+
Conflicts with `restart_mode` and `in_memory`. Default is `False`.
|
|
636
|
+
|
|
637
|
+
bundlesize (int, optional):
|
|
638
|
+
Size of task bundles. See :const:`DEFAULT_BUNDLESIZE`.
|
|
639
|
+
|
|
640
|
+
bundlewait (int, optional):
|
|
641
|
+
Waiting period before forcing task bundle push.
|
|
642
|
+
See :const:`~hypershell.submit.DEFAULT_BUNDLEWAIT`.
|
|
643
|
+
|
|
644
|
+
in_memory (bool, optional):
|
|
645
|
+
If True, revert to basic in-memory queue.
|
|
646
|
+
|
|
647
|
+
no_confirm (bool, optional):
|
|
648
|
+
If True, do not check for client confirmation messages.
|
|
649
|
+
|
|
650
|
+
address (tuple, optional):
|
|
651
|
+
Bind address for server with port number.
|
|
652
|
+
See :const:`DEFAULT_BIND` and :const:`DEFAULT_PORT`.
|
|
653
|
+
|
|
654
|
+
auth (str, optional):
|
|
655
|
+
Server authentication key. See :const:`DEFAULT_AUTH`.
|
|
656
|
+
|
|
657
|
+
max_retries (int, optional):
|
|
658
|
+
Number of allowed task retries. See :const:`DEFAULT_ATTEMPTS`.
|
|
659
|
+
|
|
660
|
+
eager (bool, optional):
|
|
661
|
+
When enabled tasks are retried immediately ahead scheduling new tasks.
|
|
662
|
+
See :const:`DEFAULT_EAGER_MODE`.
|
|
663
|
+
|
|
664
|
+
redirect_failures (IO, optional):
|
|
665
|
+
Open file descriptor to write failed tasks.
|
|
666
|
+
|
|
667
|
+
evict_after (int, optional):
|
|
668
|
+
Period in seconds to wait before evicting clients that miss heartbeats.
|
|
669
|
+
See :const:`DEFAULT_EVICT`.
|
|
670
|
+
|
|
671
|
+
Example:
|
|
672
|
+
>>> from hypershell.server import ServerThread
|
|
673
|
+
>>> server = ServerThread.new(restart_mode=True, auth='my-secret-key')
|
|
674
|
+
>>> server.join()
|
|
675
|
+
|
|
676
|
+
See Also:
|
|
677
|
+
- :meth:`serve_from`
|
|
678
|
+
- :meth:`serve_file`
|
|
679
|
+
- :meth:`serve_forever`
|
|
680
|
+
"""
|
|
681
|
+
|
|
682
|
+
queue: QueueServer
|
|
683
|
+
submitter: Optional[SubmitThread]
|
|
684
|
+
scheduler: Optional[SchedulerThread]
|
|
685
|
+
confirm: Optional[ConfirmThread]
|
|
686
|
+
receiver: ReceiverThread
|
|
687
|
+
heartmonitor: HeartMonitorThread
|
|
688
|
+
in_memory: bool
|
|
689
|
+
no_confirm: bool
|
|
690
|
+
|
|
691
|
+
def __init__(self: ServerThread,
|
|
692
|
+
source: Iterable[str] = None,
|
|
693
|
+
bundlesize: int = DEFAULT_BUNDLESIZE,
|
|
694
|
+
bundlewait: int = DEFAULT_BUNDLEWAIT,
|
|
695
|
+
in_memory: bool = False,
|
|
696
|
+
no_confirm: bool = False,
|
|
697
|
+
forever_mode: bool = False,
|
|
698
|
+
restart_mode: bool = False,
|
|
699
|
+
address: Tuple[str, int] = (DEFAULT_BIND, DEFAULT_PORT),
|
|
700
|
+
auth: str = DEFAULT_AUTH,
|
|
701
|
+
max_retries: int = DEFAULT_ATTEMPTS - 1,
|
|
702
|
+
eager: bool = False,
|
|
703
|
+
redirect_failures: IO = None,
|
|
704
|
+
evict_after: int = DEFAULT_EVICT) -> None:
|
|
705
|
+
"""Initialize queue manager and child threads."""
|
|
706
|
+
self.in_memory = in_memory
|
|
707
|
+
self.no_confirm = no_confirm
|
|
708
|
+
if not self.in_memory and not DATABASE_ENABLED:
|
|
709
|
+
log.warning('No database configured - automatically disabled')
|
|
710
|
+
self.in_memory = True
|
|
711
|
+
if self.in_memory and max_retries > 0:
|
|
712
|
+
log.warning('Retries disabled when database disabled')
|
|
713
|
+
queue_config = QueueConfig(host=address[0], port=address[1], auth=auth, size=config.server.queuesize)
|
|
714
|
+
self.queue = QueueServer(config=queue_config)
|
|
715
|
+
if self.in_memory:
|
|
716
|
+
self.scheduler = None
|
|
717
|
+
self.submitter = None if not source else LiveSubmitThread(
|
|
718
|
+
source, queue_config=queue_config, bundlesize=bundlesize, bundlewait=bundlewait)
|
|
719
|
+
else:
|
|
720
|
+
self.submitter = None if not source else SubmitThread(source, bundlesize=bundlesize, bundlewait=bundlewait)
|
|
721
|
+
self.scheduler = SchedulerThread(queue=self.queue, bundlesize=bundlesize, attempts=max_retries + 1,
|
|
722
|
+
eager=eager, forever_mode=forever_mode, restart_mode=restart_mode)
|
|
723
|
+
if self.no_confirm:
|
|
724
|
+
self.confirm = None
|
|
725
|
+
else:
|
|
726
|
+
self.confirm = ConfirmThread(queue=self.queue, in_memory=self.in_memory)
|
|
727
|
+
self.receiver = ReceiverThread(queue=self.queue, in_memory=self.in_memory, redirect_failures=redirect_failures)
|
|
728
|
+
self.heartmonitor = HeartMonitorThread(queue=self.queue, evict_after=evict_after,
|
|
729
|
+
in_memory=in_memory, no_confirm=no_confirm)
|
|
730
|
+
super().__init__(name='hypershell-server')
|
|
731
|
+
|
|
732
|
+
def run_with_exceptions(self: ServerThread) -> None:
|
|
733
|
+
"""Start child threads, wait."""
|
|
734
|
+
log.debug('Started')
|
|
735
|
+
with self.queue:
|
|
736
|
+
self.start_threads()
|
|
737
|
+
self.wait_submitter()
|
|
738
|
+
self.wait_scheduler()
|
|
739
|
+
self.wait_heartbeat()
|
|
740
|
+
self.wait_receiver()
|
|
741
|
+
self.wait_confirm()
|
|
742
|
+
log.debug('Done')
|
|
743
|
+
|
|
744
|
+
def start_threads(self: ServerThread) -> None:
|
|
745
|
+
"""Start child threads."""
|
|
746
|
+
if self.submitter is not None:
|
|
747
|
+
self.submitter.start()
|
|
748
|
+
if self.scheduler is not None:
|
|
749
|
+
self.scheduler.start()
|
|
750
|
+
if not self.no_confirm:
|
|
751
|
+
self.confirm.start()
|
|
752
|
+
self.heartmonitor.start()
|
|
753
|
+
self.receiver.start()
|
|
754
|
+
|
|
755
|
+
def wait_submitter(self: ServerThread) -> None:
|
|
756
|
+
"""Wait on task submission to complete."""
|
|
757
|
+
if self.submitter is not None:
|
|
758
|
+
log.trace('Waiting (submitter)')
|
|
759
|
+
self.submitter.join()
|
|
760
|
+
|
|
761
|
+
def wait_scheduler(self: ServerThread) -> None:
|
|
762
|
+
"""Wait scheduling until complete."""
|
|
763
|
+
if self.scheduler is not None:
|
|
764
|
+
log.trace('Waiting (scheduler)')
|
|
765
|
+
self.scheduler.join()
|
|
766
|
+
|
|
767
|
+
def wait_heartbeat(self: ServerThread) -> None:
|
|
768
|
+
"""Wait for heartmonitor to stop."""
|
|
769
|
+
log.trace('Waiting (heartbeat)')
|
|
770
|
+
self.heartmonitor.signal_scheduler_done()
|
|
771
|
+
self.heartmonitor.signal_clients()
|
|
772
|
+
self.heartmonitor.join()
|
|
773
|
+
|
|
774
|
+
def wait_receiver(self: ServerThread) -> None:
|
|
775
|
+
"""Wait for receiver to stop."""
|
|
776
|
+
log.trace('Waiting (receiver)')
|
|
777
|
+
self.queue.completed.put(None)
|
|
778
|
+
self.receiver.join()
|
|
779
|
+
|
|
780
|
+
def wait_confirm(self: ServerThread) -> None:
|
|
781
|
+
"""Wait for confirm thread to stop."""
|
|
782
|
+
if not self.no_confirm:
|
|
783
|
+
log.trace('Waiting (confirm)')
|
|
784
|
+
self.queue.confirmed.put(None)
|
|
785
|
+
self.confirm.join()
|
|
786
|
+
|
|
787
|
+
def stop(self: ServerThread, wait: bool = False, timeout: int = None) -> None:
|
|
788
|
+
"""Stop child threads before main thread."""
|
|
789
|
+
log.warning('Stopping')
|
|
790
|
+
if self.submitter is not None:
|
|
791
|
+
self.submitter.stop(wait=wait, timeout=timeout)
|
|
792
|
+
if self.scheduler is not None:
|
|
793
|
+
self.scheduler.stop(wait=wait, timeout=timeout)
|
|
794
|
+
self.heartmonitor.stop(wait=wait, timeout=timeout)
|
|
795
|
+
self.queue.completed.put(None)
|
|
796
|
+
self.receiver.stop(wait=wait, timeout=timeout)
|
|
797
|
+
if not self.no_confirm:
|
|
798
|
+
self.queue.confirmed.put(None)
|
|
799
|
+
self.confirm.stop(wait=wait, timeout=timeout)
|
|
800
|
+
super().stop(wait=wait, timeout=timeout)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def serve_from(source: Iterable[str],
|
|
804
|
+
restart_mode: bool = False,
|
|
805
|
+
bundlesize: int = DEFAULT_BUNDLESIZE,
|
|
806
|
+
bundlewait: int = DEFAULT_BUNDLEWAIT,
|
|
807
|
+
in_memory: bool = False,
|
|
808
|
+
no_confirm: bool = False,
|
|
809
|
+
address: Tuple[str, int] = (DEFAULT_BIND, DEFAULT_PORT),
|
|
810
|
+
auth: str = DEFAULT_AUTH,
|
|
811
|
+
max_retries: int = DEFAULT_ATTEMPTS - 1,
|
|
812
|
+
eager: bool = DEFAULT_EAGER_MODE,
|
|
813
|
+
redirect_failures: IO = None,
|
|
814
|
+
evict_after: int = DEFAULT_EVICT) -> None:
|
|
815
|
+
"""
|
|
816
|
+
Start server with given task `source`, run until complete.
|
|
817
|
+
|
|
818
|
+
Args:
|
|
819
|
+
source (Iterable[str]):
|
|
820
|
+
Any iterable of command-line tasks.
|
|
821
|
+
|
|
822
|
+
restart_mode (bool, optional):
|
|
823
|
+
If `source` is empty, this option allows for the server to continue
|
|
824
|
+
with scheduling from the database until complete.
|
|
825
|
+
Conflicts with `in_memory`. Default is `False`.
|
|
826
|
+
|
|
827
|
+
bundlesize (int, optional):
|
|
828
|
+
Size of task bundles. See :const:`DEFAULT_BUNDLESIZE`.
|
|
829
|
+
|
|
830
|
+
bundlewait (int, optional):
|
|
831
|
+
Waiting period before forcing task bundle push.
|
|
832
|
+
See :const:`~hypershell.submit.DEFAULT_BUNDLEWAIT`.
|
|
833
|
+
|
|
834
|
+
in_memory (bool, optional):
|
|
835
|
+
If True, revert to basic in-memory queue.
|
|
836
|
+
|
|
837
|
+
no_confirm (bool, optional):
|
|
838
|
+
If True, do not check for client confirmation messages.
|
|
839
|
+
|
|
840
|
+
address (tuple, optional):
|
|
841
|
+
Bind address for server with port number.
|
|
842
|
+
See :const:`DEFAULT_BIND` and :const:`DEFAULT_PORT`.
|
|
843
|
+
|
|
844
|
+
auth (str, optional):
|
|
845
|
+
Server authentication key. See :const:`DEFAULT_AUTH`.
|
|
846
|
+
|
|
847
|
+
max_retries (int, optional):
|
|
848
|
+
Number of allowed task retries. See :const:`DEFAULT_ATTEMPTS`.
|
|
849
|
+
|
|
850
|
+
eager (bool, optional):
|
|
851
|
+
When enabled tasks are retried immediately ahead scheduling new tasks.
|
|
852
|
+
See :const:`DEFAULT_EAGER_MODE`.
|
|
853
|
+
|
|
854
|
+
redirect_failures (bool, optional):
|
|
855
|
+
Open file descriptor to write failed tasks.
|
|
856
|
+
|
|
857
|
+
evict_after (int, optional):
|
|
858
|
+
Period in seconds to wait before evicting clients that miss heartbeats.
|
|
859
|
+
See :const:`DEFAULT_EVICT`.
|
|
860
|
+
|
|
861
|
+
Example:
|
|
862
|
+
>>> from hypershell.server import serve_from
|
|
863
|
+
>>> serve_from(['echo AA', 'echo BB', 'echo CC'], auth='my-secret-key')
|
|
864
|
+
|
|
865
|
+
See Also:
|
|
866
|
+
- :meth:`serve_file`
|
|
867
|
+
- :meth:`serve_forever`
|
|
868
|
+
"""
|
|
869
|
+
thread = ServerThread.new(source=source,
|
|
870
|
+
restart_mode=restart_mode,
|
|
871
|
+
bundlesize=bundlesize,
|
|
872
|
+
bundlewait=bundlewait,
|
|
873
|
+
in_memory=in_memory,
|
|
874
|
+
no_confirm=no_confirm,
|
|
875
|
+
address=address, auth=auth,
|
|
876
|
+
max_retries=max_retries,
|
|
877
|
+
eager=eager,
|
|
878
|
+
redirect_failures=redirect_failures,
|
|
879
|
+
evict_after=evict_after)
|
|
880
|
+
try:
|
|
881
|
+
thread.join()
|
|
882
|
+
except Exception:
|
|
883
|
+
thread.stop()
|
|
884
|
+
raise
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def serve_file(path: str,
|
|
888
|
+
bundlesize: int = DEFAULT_BUNDLESIZE,
|
|
889
|
+
bundlewait: int = DEFAULT_BUNDLEWAIT,
|
|
890
|
+
in_memory: bool = False,
|
|
891
|
+
no_confirm: bool = False,
|
|
892
|
+
address: Tuple[str, int] = (DEFAULT_BIND, DEFAULT_PORT),
|
|
893
|
+
auth: str = DEFAULT_AUTH,
|
|
894
|
+
max_retries: int = DEFAULT_ATTEMPTS - 1,
|
|
895
|
+
eager: bool = DEFAULT_EAGER_MODE,
|
|
896
|
+
redirect_failures: IO = None,
|
|
897
|
+
evict_after: int = DEFAULT_EVICT,
|
|
898
|
+
**file_options) -> None:
|
|
899
|
+
"""
|
|
900
|
+
Run server with tasks from a local file `path`, run until complete.
|
|
901
|
+
|
|
902
|
+
Args:
|
|
903
|
+
path (str):
|
|
904
|
+
Path to file containing command-line tasks.
|
|
905
|
+
|
|
906
|
+
bundlesize (int, optional):
|
|
907
|
+
Size of task bundles. See :const:`DEFAULT_BUNDLESIZE`.
|
|
908
|
+
|
|
909
|
+
bundlewait (int, optional):
|
|
910
|
+
Waiting period before forcing task bundle push.
|
|
911
|
+
See :const:`~hypershell.submit.DEFAULT_BUNDLEWAIT`.
|
|
912
|
+
|
|
913
|
+
in_memory (bool, optional):
|
|
914
|
+
If True, revert to basic in-memory queue.
|
|
915
|
+
|
|
916
|
+
no_confirm (bool, optional):
|
|
917
|
+
If True, do not check for client confirmation messages.
|
|
918
|
+
|
|
919
|
+
address (tuple, optional):
|
|
920
|
+
Bind address for server with port number.
|
|
921
|
+
See :const:`DEFAULT_BIND` and :const:`DEFAULT_PORT`.
|
|
922
|
+
|
|
923
|
+
auth (str, optional):
|
|
924
|
+
Server authentication key. See :const:`DEFAULT_AUTH`.
|
|
925
|
+
|
|
926
|
+
max_retries (int, optional):
|
|
927
|
+
Number of allowed task retries. See :const:`DEFAULT_ATTEMPTS`.
|
|
928
|
+
|
|
929
|
+
eager (bool, optional):
|
|
930
|
+
When enabled tasks are retried immediately ahead scheduling new tasks.
|
|
931
|
+
See :const:`DEFAULT_EAGER_MODE`.
|
|
932
|
+
|
|
933
|
+
redirect_failures (bool, optional):
|
|
934
|
+
Open file descriptor to write failed tasks.
|
|
935
|
+
|
|
936
|
+
evict_after (int, optional):
|
|
937
|
+
Period in seconds to wait before evicting clients that miss heartbeats.
|
|
938
|
+
See :const:`DEFAULT_EVICT`.
|
|
939
|
+
|
|
940
|
+
Keyword Args:
|
|
941
|
+
file_options (Any, optional):
|
|
942
|
+
Forwarded to :meth:`open` function.
|
|
943
|
+
|
|
944
|
+
Example:
|
|
945
|
+
>>> from hypershell.server import serve_file
|
|
946
|
+
>>> serve_from('/tmp/tasks.in', auth='my-secret-key', bundlesize=10)
|
|
947
|
+
|
|
948
|
+
See Also:
|
|
949
|
+
- :meth:`serve_from`
|
|
950
|
+
- :meth:`serve_forever`
|
|
951
|
+
"""
|
|
952
|
+
with open(path, mode='r', **file_options) as stream:
|
|
953
|
+
serve_from(stream,
|
|
954
|
+
bundlesize=bundlesize,
|
|
955
|
+
bundlewait=bundlewait,
|
|
956
|
+
in_memory=in_memory,
|
|
957
|
+
no_confirm=no_confirm,
|
|
958
|
+
address=address,
|
|
959
|
+
auth=auth,
|
|
960
|
+
max_retries=max_retries,
|
|
961
|
+
eager=eager,
|
|
962
|
+
redirect_failures=redirect_failures,
|
|
963
|
+
evict_after=evict_after)
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def serve_forever(bundlesize: int = DEFAULT_BUNDLESIZE,
|
|
967
|
+
in_memory: bool = False,
|
|
968
|
+
no_confirm: bool = False,
|
|
969
|
+
address: Tuple[str, int] = (DEFAULT_BIND, DEFAULT_PORT),
|
|
970
|
+
auth: str = DEFAULT_AUTH,
|
|
971
|
+
max_retries: int = DEFAULT_ATTEMPTS - 1,
|
|
972
|
+
eager: bool = DEFAULT_EAGER_MODE,
|
|
973
|
+
redirect_failures: IO = None,
|
|
974
|
+
evict_after: int = DEFAULT_EVICT) -> None:
|
|
975
|
+
"""
|
|
976
|
+
Run server forever.
|
|
977
|
+
|
|
978
|
+
Args:
|
|
979
|
+
bundlesize (int, optional):
|
|
980
|
+
Size of task bundles. See :const:`DEFAULT_BUNDLESIZE`.
|
|
981
|
+
|
|
982
|
+
in_memory (bool, optional):
|
|
983
|
+
If True, revert to basic in-memory queue.
|
|
984
|
+
|
|
985
|
+
no_confirm (bool, optional):
|
|
986
|
+
If True, do not check for client confirmation messages.
|
|
987
|
+
|
|
988
|
+
address (tuple, optional):
|
|
989
|
+
Bind address for server with port number.
|
|
990
|
+
See :const:`DEFAULT_BIND` and :const:`DEFAULT_PORT`.
|
|
991
|
+
|
|
992
|
+
auth (str, optional):
|
|
993
|
+
Server authentication key. See :const:`DEFAULT_AUTH`.
|
|
994
|
+
|
|
995
|
+
max_retries (int, optional):
|
|
996
|
+
Number of allowed task retries. See :const:`DEFAULT_ATTEMPTS`.
|
|
997
|
+
|
|
998
|
+
eager (bool, optional):
|
|
999
|
+
When enabled tasks are retried immediately ahead scheduling new tasks.
|
|
1000
|
+
See :const:`DEFAULT_EAGER_MODE`.
|
|
1001
|
+
|
|
1002
|
+
redirect_failures (bool, optional):
|
|
1003
|
+
Open file descriptor to write failed tasks.
|
|
1004
|
+
|
|
1005
|
+
evict_after (int, optional):
|
|
1006
|
+
Period in seconds to wait before evicting clients that miss heartbeats.
|
|
1007
|
+
See :const:`DEFAULT_EVICT`.
|
|
1008
|
+
|
|
1009
|
+
Example:
|
|
1010
|
+
>>> from hypershell.server import serve_forever
|
|
1011
|
+
>>> serve_forever(address=('0.0.0.0', 54321), auth='my-secret-key',
|
|
1012
|
+
... max_retries=2, eager=True, evict_after=600)
|
|
1013
|
+
|
|
1014
|
+
See Also:
|
|
1015
|
+
- :meth:`serve_from`
|
|
1016
|
+
- :meth:`serve_file`
|
|
1017
|
+
"""
|
|
1018
|
+
thread = ServerThread.new(source=None,
|
|
1019
|
+
bundlesize=bundlesize,
|
|
1020
|
+
in_memory=in_memory,
|
|
1021
|
+
no_confirm=no_confirm,
|
|
1022
|
+
address=address,
|
|
1023
|
+
auth=auth,
|
|
1024
|
+
redirect_failures=redirect_failures,
|
|
1025
|
+
forever_mode=True,
|
|
1026
|
+
max_retries=max_retries,
|
|
1027
|
+
eager=eager,
|
|
1028
|
+
evict_after=evict_after)
|
|
1029
|
+
try:
|
|
1030
|
+
thread.join()
|
|
1031
|
+
except Exception:
|
|
1032
|
+
thread.stop()
|
|
1033
|
+
raise
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
APP_NAME = 'hs server'
|
|
1037
|
+
APP_USAGE = f"""\
|
|
1038
|
+
Usage:
|
|
1039
|
+
hs server [-h] [FILE | --forever | --restart] [-b NUM] [-w SEC] [-r NUM [--eager]]
|
|
1040
|
+
[-H ADDR] [-p PORT] [-k KEY] [--no-db | --initdb] [--print | -f PATH] [--no-confirm]
|
|
1041
|
+
|
|
1042
|
+
Launch server, schedule directly or asynchronously from database.\
|
|
1043
|
+
"""
|
|
1044
|
+
|
|
1045
|
+
APP_HELP = f"""\
|
|
1046
|
+
{APP_USAGE}
|
|
1047
|
+
|
|
1048
|
+
The server includes a scheduler component that pulls tasks from the database and offers
|
|
1049
|
+
them up on a distributed queue to clients. It also has a receiver that collects the results
|
|
1050
|
+
of finished tasks. Optionally, the server can submit tasks (FILE). When submitting tasks,
|
|
1051
|
+
the -w/--bundlewait and -b/--bundlesize options are the same as for 'hs submit'.
|
|
1052
|
+
|
|
1053
|
+
With --max-retries greater than zero and with the database configured, the scheduler will
|
|
1054
|
+
check for a non-zero exit status for tasks and re-submit them if their previous number of
|
|
1055
|
+
attempts is less.
|
|
1056
|
+
|
|
1057
|
+
Tasks are bundled and clients pull them in these bundles. However, by default the bundle
|
|
1058
|
+
size is one, meaning that at small scales there is greater responsiveness.
|
|
1059
|
+
|
|
1060
|
+
Arguments:
|
|
1061
|
+
FILE Path to input task file (default: <stdin>).
|
|
1062
|
+
|
|
1063
|
+
Options:
|
|
1064
|
+
-H, --bind ADDR Bind address (default: {DEFAULT_BIND}).
|
|
1065
|
+
-p, --port NUM Port number (default: {DEFAULT_PORT}).
|
|
1066
|
+
-k, --auth KEY Cryptographic key to secure server.
|
|
1067
|
+
--forever Schedule forever.
|
|
1068
|
+
--restart Start scheduling from last completed task.
|
|
1069
|
+
-b, --bundlesize NUM Size of task bundle (default: {DEFAULT_BUNDLESIZE}).
|
|
1070
|
+
-w, --bundlewait SEC Seconds to wait before flushing tasks (default: {DEFAULT_BUNDLEWAIT}).
|
|
1071
|
+
-r, --max-retries NUM Auto-retry failed tasks (default: {DEFAULT_ATTEMPTS - 1}).
|
|
1072
|
+
--eager Schedule failed tasks before new tasks.
|
|
1073
|
+
--no-db Disable database (submit directly to clients).
|
|
1074
|
+
--initdb Auto-initialize database.
|
|
1075
|
+
--no-confirm Disable client confirmation of task bundle received.
|
|
1076
|
+
--print Print failed task args to <stdout>.
|
|
1077
|
+
-f, --failures PATH File path to redirect failed task args.
|
|
1078
|
+
-h, --help Show this message and exit.\
|
|
1079
|
+
"""
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
class ServerApp(Application):
|
|
1083
|
+
"""Run server in stand-alone mode."""
|
|
1084
|
+
|
|
1085
|
+
name = APP_NAME
|
|
1086
|
+
interface = Interface(APP_NAME, APP_USAGE, APP_HELP)
|
|
1087
|
+
|
|
1088
|
+
filepath: str
|
|
1089
|
+
interface.add_argument('filepath', nargs='?', default=None)
|
|
1090
|
+
|
|
1091
|
+
bundlesize: int = config.server.bundlesize
|
|
1092
|
+
interface.add_argument('-b', '--bundlesize', type=int, default=bundlesize)
|
|
1093
|
+
|
|
1094
|
+
bundlewait: int = config.submit.bundlewait
|
|
1095
|
+
interface.add_argument('-w', '--bundlewait', type=int, default=bundlewait)
|
|
1096
|
+
|
|
1097
|
+
eager_mode: bool = config.server.eager
|
|
1098
|
+
max_retries: int = config.server.attempts - 1
|
|
1099
|
+
interface.add_argument('-r', '--max-retries', type=int, default=max_retries)
|
|
1100
|
+
interface.add_argument('--eager', action='store_true')
|
|
1101
|
+
|
|
1102
|
+
forever_mode: bool = False
|
|
1103
|
+
interface.add_argument('--forever', action='store_true', dest='forever_mode')
|
|
1104
|
+
|
|
1105
|
+
restart_mode: bool = False
|
|
1106
|
+
interface.add_argument('--restart', action='store_true', dest='restart_mode')
|
|
1107
|
+
|
|
1108
|
+
host: str = config.server.bind
|
|
1109
|
+
interface.add_argument('-H', '--bind', default=host, dest='host')
|
|
1110
|
+
|
|
1111
|
+
port: int = config.server.port
|
|
1112
|
+
interface.add_argument('-p', '--port', type=int, default=port)
|
|
1113
|
+
|
|
1114
|
+
auth: str = config.server.auth
|
|
1115
|
+
interface.add_argument('-k', '--auth', default=auth)
|
|
1116
|
+
|
|
1117
|
+
in_memory: bool = False
|
|
1118
|
+
auto_initdb: bool = False
|
|
1119
|
+
db_interface = interface.add_mutually_exclusive_group()
|
|
1120
|
+
db_interface.add_argument('--no-db', action='store_true', dest='in_memory')
|
|
1121
|
+
db_interface.add_argument('--initdb', action='store_true', dest='auto_initdb')
|
|
1122
|
+
|
|
1123
|
+
no_confirm: bool = False
|
|
1124
|
+
interface.add_argument('--no-confirm', action='store_true')
|
|
1125
|
+
|
|
1126
|
+
print_mode: bool = False
|
|
1127
|
+
failure_path: str = None
|
|
1128
|
+
output_interface = interface.add_mutually_exclusive_group()
|
|
1129
|
+
output_interface.add_argument('--print', action='store_true', dest='print_mode')
|
|
1130
|
+
output_interface.add_argument('-f', '--failures', default=None, dest='failure_path')
|
|
1131
|
+
|
|
1132
|
+
# Hidden options used as helpers for shell completion
|
|
1133
|
+
interface.add_argument('--available-ports', action='version',
|
|
1134
|
+
version='\n'.join(map(str, islice(find_available_ports(), 10))))
|
|
1135
|
+
|
|
1136
|
+
exceptions = {
|
|
1137
|
+
**get_shared_exception_mapping(__name__)
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
def run(self: ServerApp) -> None:
|
|
1141
|
+
"""Run server."""
|
|
1142
|
+
if self.forever_mode:
|
|
1143
|
+
serve_forever(bundlesize=self.bundlesize,
|
|
1144
|
+
address=(self.host, self.port),
|
|
1145
|
+
auth=self.auth,
|
|
1146
|
+
in_memory=self.in_memory,
|
|
1147
|
+
no_confirm=self.no_confirm,
|
|
1148
|
+
max_retries=self.max_retries,
|
|
1149
|
+
eager=self.eager_mode,
|
|
1150
|
+
redirect_failures=self.failure_stream,
|
|
1151
|
+
evict_after=config.server.evict)
|
|
1152
|
+
else:
|
|
1153
|
+
serve_from(source=self.input_stream,
|
|
1154
|
+
bundlesize=self.bundlesize,
|
|
1155
|
+
bundlewait=self.bundlewait,
|
|
1156
|
+
address=(self.host, self.port),
|
|
1157
|
+
auth=self.auth,
|
|
1158
|
+
in_memory=self.in_memory,
|
|
1159
|
+
no_confirm=self.no_confirm,
|
|
1160
|
+
restart_mode=self.restart_mode,
|
|
1161
|
+
max_retries=self.max_retries,
|
|
1162
|
+
eager=self.eager_mode,
|
|
1163
|
+
redirect_failures=self.failure_stream,
|
|
1164
|
+
evict_after=config.server.evict)
|
|
1165
|
+
|
|
1166
|
+
def check_args(self: ServerApp):
|
|
1167
|
+
"""Fail particular argument combinations."""
|
|
1168
|
+
if self.filepath and self.forever_mode:
|
|
1169
|
+
raise ArgumentError('Cannot specify both FILE and --forever')
|
|
1170
|
+
if self.filepath is None and not self.forever_mode:
|
|
1171
|
+
self.filepath = '-' # NOTE: assume STDIN
|
|
1172
|
+
if self.restart_mode and self.forever_mode:
|
|
1173
|
+
raise ArgumentError('Using --forever with --restart is invalid')
|
|
1174
|
+
|
|
1175
|
+
@cached_property
|
|
1176
|
+
def input_stream(self: ServerApp) -> Optional[IO]:
|
|
1177
|
+
"""Input IO stream for task args."""
|
|
1178
|
+
if self.forever_mode or self.restart_mode:
|
|
1179
|
+
return None
|
|
1180
|
+
else:
|
|
1181
|
+
return sys.stdin if self.filepath == '-' else open(self.filepath, mode='r')
|
|
1182
|
+
|
|
1183
|
+
@cached_property
|
|
1184
|
+
def failure_stream(self: ServerApp) -> Optional[IO]:
|
|
1185
|
+
"""IO stream for failed task args."""
|
|
1186
|
+
if self.print_mode:
|
|
1187
|
+
return sys.stdout
|
|
1188
|
+
elif self.failure_path:
|
|
1189
|
+
return sys.stdout if self.failure_path == '-' else open(self.failure_path, mode='w')
|
|
1190
|
+
else:
|
|
1191
|
+
return None
|
|
1192
|
+
|
|
1193
|
+
def __enter__(self: ServerApp) -> ServerApp:
|
|
1194
|
+
"""Ensure context and database ready."""
|
|
1195
|
+
self.check_args()
|
|
1196
|
+
ensuredb()
|
|
1197
|
+
return self
|
|
1198
|
+
|
|
1199
|
+
def __exit__(self: ServerApp,
|
|
1200
|
+
exc_type: Optional[Type[Exception]],
|
|
1201
|
+
exc_val: Optional[Exception],
|
|
1202
|
+
exc_tb: Optional[TracebackType]) -> None:
|
|
1203
|
+
"""Clean up IO if necessary."""
|
|
1204
|
+
if self.input_stream is not None and self.input_stream is not sys.stdin:
|
|
1205
|
+
self.input_stream.close()
|
|
1206
|
+
if self.failure_stream is not None and self.failure_path is not sys.stdout:
|
|
1207
|
+
self.failure_stream.close()
|