libpipe 0.7__tar.gz → 0.8__tar.gz
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.
- libpipe-0.8/PKG-INFO +58 -0
- libpipe-0.8/README.md +30 -0
- {libpipe-0.7 → libpipe-0.8}/libpipe/worker.py +364 -98
- {libpipe-0.7 → libpipe-0.8}/pyproject.toml +1 -1
- libpipe-0.7/PKG-INFO +0 -42
- libpipe-0.7/README.md +0 -13
- {libpipe-0.7 → libpipe-0.8}/LICENSE +0 -0
- {libpipe-0.7 → libpipe-0.8}/libpipe/__init__.py +0 -0
- {libpipe-0.7 → libpipe-0.8}/libpipe/attrmap.py +0 -0
- {libpipe-0.7 → libpipe-0.8}/libpipe/futils.py +0 -0
- {libpipe-0.7 → libpipe-0.8}/libpipe/msutils.py +0 -0
- {libpipe-0.7 → libpipe-0.8}/libpipe/settings.py +0 -0
- {libpipe-0.7 → libpipe-0.8}/libpipe/tools/__init__.py +0 -0
- {libpipe-0.7 → libpipe-0.8}/libpipe/tools/libnodes_bash.sh +0 -0
- {libpipe-0.7 → libpipe-0.8}/libpipe/tools/nodetool.py +0 -0
libpipe-0.8/PKG-INFO
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: libpipe
|
|
3
|
+
Version: 0.8
|
|
4
|
+
Summary: Pipeline orchestration and node management utilities
|
|
5
|
+
License: GPL-3.0-or-later
|
|
6
|
+
Author: Florent Mertens
|
|
7
|
+
Author-email: florent.mertens@gmail.com
|
|
8
|
+
Requires-Python: >=3.10,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: asyncssh (>=2.0)
|
|
17
|
+
Requires-Dist: click (>=8.0)
|
|
18
|
+
Requires-Dist: progressbar2 (>=3.40)
|
|
19
|
+
Requires-Dist: pytest (>=8.0) ; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-cov (>=5.0) ; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff (==0.16.1) ; extra == "dev"
|
|
22
|
+
Requires-Dist: toml (>=0.10)
|
|
23
|
+
Project-URL: Homepage, https://gitlab.com/flomertens/libpipe
|
|
24
|
+
Project-URL: Issues, https://gitlab.com/flomertens/libpipe/-/issues
|
|
25
|
+
Project-URL: Repository, https://gitlab.com/flomertens/libpipe
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
Libpipe
|
|
29
|
+
=======
|
|
30
|
+
|
|
31
|
+
Common pipeline framework and library
|
|
32
|
+
|
|
33
|
+
Installation
|
|
34
|
+
------------
|
|
35
|
+
|
|
36
|
+
libpipe can be installed via pip:
|
|
37
|
+
|
|
38
|
+
$ pip install libpipe
|
|
39
|
+
|
|
40
|
+
and requires Python 3.10 or higher.
|
|
41
|
+
|
|
42
|
+
Worker pools on a Slurm cluster
|
|
43
|
+
-------------------------------
|
|
44
|
+
|
|
45
|
+
A `WorkerPool` runs its tasks locally, on other hosts over SSH, or through Slurm with the
|
|
46
|
+
host `slurm`: each task is then started with `srun`, as a job of its own when the pool runs
|
|
47
|
+
on a login node, as a job step inside an allocation (`salloc`, `sbatch`).
|
|
48
|
+
|
|
49
|
+
pool = worker.WorkerPool(['slurm'], name='Imaging', max_tasks_per_worker=8,
|
|
50
|
+
slurm_options='-p regular --mem=200G --time=24:00:00')
|
|
51
|
+
|
|
52
|
+
`max_tasks_per_worker` is the number of jobs in flight. The srun options (partition,
|
|
53
|
+
account, resources) come from `slurm_options`, or from the `LIBPIPE_SLURM_OPTIONS`
|
|
54
|
+
environment variable; `max_time` becomes the job time limit when the options set none.
|
|
55
|
+
Host constraints (`run_on_host`) do not apply: Slurm places the tasks, so the data must be
|
|
56
|
+
on a file system shared with the compute nodes. Give the memory (`--mem`) in the options:
|
|
57
|
+
inside an allocation, a step without it can claim all the memory of its node.
|
|
58
|
+
|
libpipe-0.8/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Libpipe
|
|
2
|
+
=======
|
|
3
|
+
|
|
4
|
+
Common pipeline framework and library
|
|
5
|
+
|
|
6
|
+
Installation
|
|
7
|
+
------------
|
|
8
|
+
|
|
9
|
+
libpipe can be installed via pip:
|
|
10
|
+
|
|
11
|
+
$ pip install libpipe
|
|
12
|
+
|
|
13
|
+
and requires Python 3.10 or higher.
|
|
14
|
+
|
|
15
|
+
Worker pools on a Slurm cluster
|
|
16
|
+
-------------------------------
|
|
17
|
+
|
|
18
|
+
A `WorkerPool` runs its tasks locally, on other hosts over SSH, or through Slurm with the
|
|
19
|
+
host `slurm`: each task is then started with `srun`, as a job of its own when the pool runs
|
|
20
|
+
on a login node, as a job step inside an allocation (`salloc`, `sbatch`).
|
|
21
|
+
|
|
22
|
+
pool = worker.WorkerPool(['slurm'], name='Imaging', max_tasks_per_worker=8,
|
|
23
|
+
slurm_options='-p regular --mem=200G --time=24:00:00')
|
|
24
|
+
|
|
25
|
+
`max_tasks_per_worker` is the number of jobs in flight. The srun options (partition,
|
|
26
|
+
account, resources) come from `slurm_options`, or from the `LIBPIPE_SLURM_OPTIONS`
|
|
27
|
+
environment variable; `max_time` becomes the job time limit when the options set none.
|
|
28
|
+
Host constraints (`run_on_host`) do not apply: Slurm places the tasks, so the data must be
|
|
29
|
+
on a file system shared with the compute nodes. Give the memory (`--mem`) in the options:
|
|
30
|
+
inside an allocation, a step without it can claim all the memory of its node.
|
|
@@ -15,6 +15,7 @@ import signal
|
|
|
15
15
|
import socket
|
|
16
16
|
import sys
|
|
17
17
|
import time
|
|
18
|
+
import warnings
|
|
18
19
|
from enum import Enum
|
|
19
20
|
|
|
20
21
|
import asyncssh
|
|
@@ -23,6 +24,26 @@ from click import secho, style
|
|
|
23
24
|
|
|
24
25
|
localhost_shortname = socket.gethostname().split('.', 1)[0]
|
|
25
26
|
|
|
27
|
+
# Host name selecting the Slurm client: each task runs through srun
|
|
28
|
+
SLURM_HOST = 'slurm'
|
|
29
|
+
SLURM_OPTIONS_ENV = 'LIBPIPE_SLURM_OPTIONS'
|
|
30
|
+
SLURM_MARKER = '[libpipe] slurm job '
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_event_loop():
|
|
34
|
+
"""The current event loop, or a new one when there is none or it is closed (e.g. after
|
|
35
|
+
an asyncio.run() elsewhere in the process)."""
|
|
36
|
+
try:
|
|
37
|
+
with warnings.catch_warnings():
|
|
38
|
+
warnings.simplefilter('ignore', DeprecationWarning)
|
|
39
|
+
loop = asyncio.get_event_loop()
|
|
40
|
+
except RuntimeError:
|
|
41
|
+
loop = None
|
|
42
|
+
if loop is None or loop.is_closed():
|
|
43
|
+
loop = asyncio.new_event_loop()
|
|
44
|
+
asyncio.set_event_loop(loop)
|
|
45
|
+
return loop
|
|
46
|
+
|
|
26
47
|
|
|
27
48
|
def kill_remote(host, user, pid):
|
|
28
49
|
os.system(f'ssh {user}@{host} "kill -TERM -{pid}"')
|
|
@@ -53,10 +74,11 @@ def get_hosts(host_string):
|
|
|
53
74
|
|
|
54
75
|
|
|
55
76
|
def get_worker_pool(name, nodes='localhost', max_concurrent=4, env_file=None, max_time=None,
|
|
56
|
-
debug=False, dry_run=False):
|
|
77
|
+
debug=False, dry_run=False, slurm_options=None):
|
|
57
78
|
hosts = get_hosts(nodes)
|
|
58
79
|
return WorkerPool(hosts, name=name, max_tasks_per_worker=max_concurrent,
|
|
59
|
-
env_source_file=env_file, max_time=max_time, debug=debug, dry_run=dry_run
|
|
80
|
+
env_source_file=env_file, max_time=max_time, debug=debug, dry_run=dry_run,
|
|
81
|
+
slurm_options=slurm_options)
|
|
60
82
|
|
|
61
83
|
|
|
62
84
|
class TaskStatus(Enum):
|
|
@@ -108,6 +130,10 @@ class Task:
|
|
|
108
130
|
self.timed_out = False
|
|
109
131
|
self.pid_filename = pid_filename
|
|
110
132
|
self.keep_pid_file_on_error = keep_pid_file_on_error
|
|
133
|
+
# Set when the process starts (timeouts count from then), and when it was signalled
|
|
134
|
+
self.start_time = None
|
|
135
|
+
self.sigterm_time = None
|
|
136
|
+
self.slurm_job_id = None
|
|
111
137
|
|
|
112
138
|
def done_callback(self):
|
|
113
139
|
err_msg = ''
|
|
@@ -126,12 +152,14 @@ class Task:
|
|
|
126
152
|
|
|
127
153
|
def init_log(self):
|
|
128
154
|
if self.output_file is not None:
|
|
129
|
-
|
|
155
|
+
# Line-buffered: the log follows the task, and survives a crash of the pool
|
|
156
|
+
self.fd = open(self.output_file, 'w', buffering=1) # noqa: SIM115 - see close_log()
|
|
130
157
|
self.fd.write(f'# Logging starting at {datetime.datetime.now()}\n')
|
|
131
158
|
self.fd.write(f'# Input command: {self.command}\n')
|
|
132
159
|
|
|
133
160
|
def set_process(self, process):
|
|
134
161
|
self.process = process
|
|
162
|
+
self.start_time = time.time()
|
|
135
163
|
if hasattr(self.process.process, 'pid'):
|
|
136
164
|
self.log(f'Starting on host {self.process.client.host} with PID '
|
|
137
165
|
f'{self.process.process.pid}\n\n', self.process.client.host)
|
|
@@ -145,24 +173,14 @@ class Task:
|
|
|
145
173
|
self.log(f'Error creating PID file {self.pid_filename}',
|
|
146
174
|
self.process.client.host, err=True)
|
|
147
175
|
|
|
148
|
-
async def terminate(self):
|
|
149
|
-
if self.process is not None
|
|
150
|
-
|
|
151
|
-
self.log(f'Killing local process PID {self.process.process.pid}\n',
|
|
152
|
-
'local', err=True)
|
|
153
|
-
try:
|
|
154
|
-
os.killpg(os.getpgid(self.process.process.pid), signal.SIGTERM)
|
|
155
|
-
except ProcessLookupError:
|
|
156
|
-
print(f'Process {self.process.process.pid} not found')
|
|
157
|
-
else:
|
|
158
|
-
self.log(f'Killing remote process PID {self.process.process.pid}\n',
|
|
159
|
-
self.process.client.host, err=True)
|
|
160
|
-
kill_remote(self.process.client.host, self.process.client.user,
|
|
161
|
-
self.process.process.pid)
|
|
176
|
+
async def terminate(self, sig=signal.SIGTERM):
|
|
177
|
+
if self.process is not None:
|
|
178
|
+
await self.process.client.terminate(self, sig)
|
|
162
179
|
|
|
163
180
|
def log(self, line, host, err=False):
|
|
164
181
|
if isinstance(line, bytes):
|
|
165
|
-
|
|
182
|
+
# Tools can print any byte: a decoding error must not interrupt the task
|
|
183
|
+
line = line.decode(errors='replace')
|
|
166
184
|
if self.fd is not None:
|
|
167
185
|
self.fd.write(line)
|
|
168
186
|
line = line.strip()
|
|
@@ -194,33 +212,63 @@ class Task:
|
|
|
194
212
|
print(f'Error closing log file for task {self.name}. Ignoring.')
|
|
195
213
|
|
|
196
214
|
async def run(self, client_pool):
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
self.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
ret_status, err_msg = await worker.run(self)
|
|
206
|
-
|
|
207
|
-
if not ret_status:
|
|
208
|
-
# mark host temporarily bad so next retry likely uses a different host
|
|
209
|
-
host = worker.client.host
|
|
210
|
-
client_pool.bad_hosts_until[host] = time.time() + client_pool.host_cooldown_s
|
|
215
|
+
try:
|
|
216
|
+
self.init_log()
|
|
217
|
+
except OSError as exc:
|
|
218
|
+
self.status = TaskStatus.ERROR
|
|
219
|
+
print(style(f'[{self.name}] Error opening log file {self.output_file}: {exc}',
|
|
220
|
+
fg='red'))
|
|
221
|
+
return
|
|
211
222
|
|
|
212
|
-
|
|
213
|
-
|
|
223
|
+
try:
|
|
224
|
+
for i in range(self.n_try):
|
|
225
|
+
self.timed_out = False
|
|
226
|
+
self.process = None
|
|
227
|
+
self.start_time = None
|
|
228
|
+
self.sigterm_time = None
|
|
214
229
|
|
|
215
|
-
|
|
216
|
-
self.status = TaskStatus.
|
|
217
|
-
break
|
|
230
|
+
worker = await client_pool.get_worker(self.run_on_host)
|
|
231
|
+
self.status = TaskStatus.RUNNING
|
|
218
232
|
|
|
233
|
+
try:
|
|
234
|
+
ret_status, err_msg = await worker.run(self)
|
|
235
|
+
except BaseException:
|
|
236
|
+
client_pool.release_worker()
|
|
237
|
+
raise
|
|
238
|
+
|
|
239
|
+
if not ret_status:
|
|
240
|
+
# mark host temporarily bad so next retry likely uses a different host
|
|
241
|
+
host = worker.client.host
|
|
242
|
+
client_pool.bad_hosts_until[host] = time.time() + client_pool.host_cooldown_s
|
|
243
|
+
client_pool.release_worker()
|
|
244
|
+
|
|
245
|
+
if err_msg:
|
|
246
|
+
self.log(err_msg, worker.client.host, err=True)
|
|
247
|
+
|
|
248
|
+
if ret_status and not self.timed_out:
|
|
249
|
+
self.status = TaskStatus.SUCCESS
|
|
250
|
+
break
|
|
251
|
+
|
|
252
|
+
self.status = TaskStatus.ERROR
|
|
253
|
+
if not ret_status and self.process is not None:
|
|
254
|
+
# The process had started and may still be running: running the task
|
|
255
|
+
# again could have two copies write the same outputs
|
|
256
|
+
self.log('Not retrying: the process had started\n', worker.client.host,
|
|
257
|
+
err=True)
|
|
258
|
+
break
|
|
259
|
+
if i + 1 < self.n_try:
|
|
260
|
+
self.log(f'Will retry {i + 1} / {self.n_try} ...\n', worker.client.host,
|
|
261
|
+
err=True)
|
|
262
|
+
except asyncio.CancelledError:
|
|
263
|
+
self.status = TaskStatus.CANCELED
|
|
264
|
+
raise
|
|
265
|
+
except Exception as exc:
|
|
266
|
+
# An error of this task must not bring down the pool and its other tasks
|
|
219
267
|
self.status = TaskStatus.ERROR
|
|
220
|
-
|
|
221
|
-
self.log(f'
|
|
222
|
-
|
|
223
|
-
|
|
268
|
+
with contextlib.suppress(Exception):
|
|
269
|
+
self.log(f'Unexpected error running task: {exc!r}\n', 'pool', err=True)
|
|
270
|
+
finally:
|
|
271
|
+
self.close_log()
|
|
224
272
|
|
|
225
273
|
|
|
226
274
|
class SSHClient(asyncssh.SSHClient):
|
|
@@ -299,7 +347,8 @@ class Client:
|
|
|
299
347
|
raise ConnectionError('Client not connected')
|
|
300
348
|
|
|
301
349
|
async with self.creating_session:
|
|
302
|
-
process = await self.conn.create_process('echo $$;' + task.command
|
|
350
|
+
process = await self.conn.create_process('echo $$;' + task.command,
|
|
351
|
+
encoding='utf-8', errors='replace')
|
|
303
352
|
|
|
304
353
|
try:
|
|
305
354
|
process.pid = int(await process.stdout.readline())
|
|
@@ -320,6 +369,31 @@ class Client:
|
|
|
320
369
|
await self.conn.run('sync')
|
|
321
370
|
await asyncio.sleep(1)
|
|
322
371
|
|
|
372
|
+
async def terminate(self, task, sig=signal.SIGTERM):
|
|
373
|
+
pid = getattr(task.process.process, 'pid', None)
|
|
374
|
+
if pid is None:
|
|
375
|
+
task.log('Cannot kill the remote process: PID unknown\n', self.host, err=True)
|
|
376
|
+
return
|
|
377
|
+
task.log(f'Killing remote process PID {pid} ({sig.name})\n', self.host, err=True)
|
|
378
|
+
cmd = f'kill -{sig.name.removeprefix("SIG")} -{pid}'
|
|
379
|
+
# On the open connection, or on a new one when it is lost: never blocking the pool
|
|
380
|
+
# on a password prompt or an unreachable host
|
|
381
|
+
try:
|
|
382
|
+
if self.conn is not None:
|
|
383
|
+
await asyncio.wait_for(self.conn.run(cmd, check=False), timeout=15)
|
|
384
|
+
return
|
|
385
|
+
except Exception: # fall back to a new connection
|
|
386
|
+
pass
|
|
387
|
+
try:
|
|
388
|
+
process = await asyncio.create_subprocess_exec(
|
|
389
|
+
'ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10',
|
|
390
|
+
f'{self.user}@{self.host}', cmd,
|
|
391
|
+
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL)
|
|
392
|
+
await asyncio.wait_for(process.wait(), timeout=20)
|
|
393
|
+
except Exception as exc:
|
|
394
|
+
task.log(f'Could not kill remote process PID {pid}: {exc!r}\n', self.host,
|
|
395
|
+
err=True)
|
|
396
|
+
|
|
323
397
|
def close(self):
|
|
324
398
|
if self.conn is not None and not self.closing:
|
|
325
399
|
self.closing = True
|
|
@@ -356,6 +430,13 @@ class LocalClient:
|
|
|
356
430
|
os.sync()
|
|
357
431
|
await asyncio.sleep(1)
|
|
358
432
|
|
|
433
|
+
async def terminate(self, task, sig=signal.SIGTERM):
|
|
434
|
+
pid = task.process.process.pid
|
|
435
|
+
task.log(f'Killing local process PID {pid} ({sig.name})\n', self.host, err=True)
|
|
436
|
+
# The process leads its own group (setpgrp): this reaches its children as well
|
|
437
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
438
|
+
os.killpg(pid, sig)
|
|
439
|
+
|
|
359
440
|
def connected(self):
|
|
360
441
|
return self.started.is_set()
|
|
361
442
|
|
|
@@ -364,6 +445,106 @@ class LocalClient:
|
|
|
364
445
|
pass
|
|
365
446
|
|
|
366
447
|
|
|
448
|
+
class SlurmClient:
|
|
449
|
+
"""Run each task with srun: a Slurm job of its own when the pool runs on a login node, a
|
|
450
|
+
job step when it runs inside an allocation (salloc, sbatch).
|
|
451
|
+
|
|
452
|
+
Options (resources, partition, account, ...) come from slurm_options, or from the
|
|
453
|
+
LIBPIPE_SLURM_OPTIONS environment variable. max_time becomes the job time limit.
|
|
454
|
+
"""
|
|
455
|
+
|
|
456
|
+
def __init__(self, options=None, job_name='libpipe', max_time=None):
|
|
457
|
+
self.host = SLURM_HOST
|
|
458
|
+
self.started = asyncio.Event()
|
|
459
|
+
if options is None:
|
|
460
|
+
options = os.environ.get(SLURM_OPTIONS_ENV, '')
|
|
461
|
+
self.options = shlex.split(options) if isinstance(options, str) else list(options)
|
|
462
|
+
self.job_name = re.sub(r'\s+', '_', job_name)
|
|
463
|
+
self.max_time = max_time
|
|
464
|
+
self.exact = False
|
|
465
|
+
|
|
466
|
+
async def start(self):
|
|
467
|
+
if self.started.is_set():
|
|
468
|
+
return
|
|
469
|
+
# Inside an allocation, --exact (Slurm >= 21.08) gives each step only its resources,
|
|
470
|
+
# so that steps run side by side instead of one after the other
|
|
471
|
+
if os.environ.get('SLURM_JOB_ID'):
|
|
472
|
+
self.exact = await self._slurm_version() >= (21, 8)
|
|
473
|
+
self.started.set()
|
|
474
|
+
|
|
475
|
+
@staticmethod
|
|
476
|
+
async def _slurm_version():
|
|
477
|
+
try:
|
|
478
|
+
process = await asyncio.create_subprocess_exec(
|
|
479
|
+
'srun', '--version', stdout=asyncio.subprocess.PIPE,
|
|
480
|
+
stderr=asyncio.subprocess.DEVNULL)
|
|
481
|
+
out, _ = await process.communicate()
|
|
482
|
+
except FileNotFoundError:
|
|
483
|
+
return (0, 0)
|
|
484
|
+
m = re.search(r'(\d+)\.(\d+)', out.decode())
|
|
485
|
+
return (int(m.group(1)), int(m.group(2))) if m else (0, 0)
|
|
486
|
+
|
|
487
|
+
def srun_command(self, task):
|
|
488
|
+
args = ['srun', f'--job-name={self.job_name}:{task.name}', '--ntasks=1', '--nodes=1',
|
|
489
|
+
'--unbuffered']
|
|
490
|
+
if self.exact:
|
|
491
|
+
args.append('--exact')
|
|
492
|
+
has_time = any(o == '-t' or o.startswith(('-t', '--time')) for o in self.options)
|
|
493
|
+
if self.max_time and not has_time:
|
|
494
|
+
args.append(f'--time={int(-(-self.max_time // 60))}')
|
|
495
|
+
args += self.options
|
|
496
|
+
marker = f'echo "{SLURM_MARKER}${{SLURM_JOB_ID}} on $(hostname -s)"'
|
|
497
|
+
return f'{shlex.join(args)} bash -c {shlex.quote(f"{marker}; {task.command}")}'
|
|
498
|
+
|
|
499
|
+
async def _log_stdout(self, task, stream):
|
|
500
|
+
"""Log the task output, recording the job id from the marker line."""
|
|
501
|
+
while True:
|
|
502
|
+
try:
|
|
503
|
+
line = await stream.readline()
|
|
504
|
+
except ValueError:
|
|
505
|
+
task.log('(line too long)', self.host, err=True)
|
|
506
|
+
continue
|
|
507
|
+
if not line:
|
|
508
|
+
break
|
|
509
|
+
text = line.decode(errors='replace')
|
|
510
|
+
if task.slurm_job_id is None and text.startswith(SLURM_MARKER):
|
|
511
|
+
task.slurm_job_id = text[len(SLURM_MARKER):].split()[0]
|
|
512
|
+
task.log(line, self.host)
|
|
513
|
+
|
|
514
|
+
async def execute(self, task):
|
|
515
|
+
task.slurm_job_id = None
|
|
516
|
+
process = await asyncio.create_subprocess_shell(
|
|
517
|
+
self.srun_command(task), stdout=asyncio.subprocess.PIPE,
|
|
518
|
+
stderr=asyncio.subprocess.PIPE, preexec_fn=os.setpgrp)
|
|
519
|
+
task.set_process(Process(self, process))
|
|
520
|
+
|
|
521
|
+
await asyncio.gather(
|
|
522
|
+
self._log_stdout(task, process.stdout),
|
|
523
|
+
task.log_stream(process.stderr, self.host, err=True),
|
|
524
|
+
)
|
|
525
|
+
task.returncode = await process.wait()
|
|
526
|
+
|
|
527
|
+
async def terminate(self, task, sig=signal.SIGTERM):
|
|
528
|
+
job_id = getattr(task, 'slurm_job_id', None)
|
|
529
|
+
if job_id:
|
|
530
|
+
task.log(f'Cancelling Slurm job {job_id} ({sig.name})\n', self.host, err=True)
|
|
531
|
+
args = ['scancel'] + (['-s', 'KILL'] if sig == signal.SIGKILL else []) + [job_id]
|
|
532
|
+
with contextlib.suppress(Exception):
|
|
533
|
+
process = await asyncio.create_subprocess_exec(*args)
|
|
534
|
+
await asyncio.wait_for(process.wait(), timeout=30)
|
|
535
|
+
else:
|
|
536
|
+
# Still pending: stopping srun withdraws the job request
|
|
537
|
+
task.log(f'Stopping srun PID {task.process.process.pid}\n', self.host, err=True)
|
|
538
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
539
|
+
os.killpg(task.process.process.pid, sig)
|
|
540
|
+
|
|
541
|
+
def connected(self):
|
|
542
|
+
return self.started.is_set()
|
|
543
|
+
|
|
544
|
+
def close(self):
|
|
545
|
+
pass
|
|
546
|
+
|
|
547
|
+
|
|
367
548
|
class Worker:
|
|
368
549
|
|
|
369
550
|
def __init__(self, client, name):
|
|
@@ -381,6 +562,10 @@ class Worker:
|
|
|
381
562
|
|
|
382
563
|
try:
|
|
383
564
|
await self.client.start()
|
|
565
|
+
await self.client.started.wait()
|
|
566
|
+
except asyncio.CancelledError:
|
|
567
|
+
self.status = WorkerStatus.IDLE
|
|
568
|
+
raise
|
|
384
569
|
except Exception as exc:
|
|
385
570
|
err_msg = f'Error starting client: {exc!s}\n'
|
|
386
571
|
# transient failure: allow task retry to pick a worker again
|
|
@@ -389,32 +574,32 @@ class Worker:
|
|
|
389
574
|
self.client.close() # ensure clean slate
|
|
390
575
|
return False, err_msg
|
|
391
576
|
|
|
392
|
-
await self.client.started.wait()
|
|
393
|
-
|
|
394
577
|
try:
|
|
395
578
|
await self.client.execute(task)
|
|
396
|
-
except ConnectionError as exc:
|
|
397
|
-
err_msg = f'Error connecting client: {exc!s}\n'
|
|
398
|
-
self.status = WorkerStatus.IDLE
|
|
399
|
-
with contextlib.suppress(Exception):
|
|
400
|
-
self.client.close()
|
|
401
|
-
return False, err_msg
|
|
402
579
|
except asyncio.CancelledError:
|
|
403
|
-
|
|
404
|
-
|
|
580
|
+
self.status = WorkerStatus.IDLE
|
|
581
|
+
raise
|
|
405
582
|
except Exception as exc:
|
|
406
|
-
|
|
583
|
+
if isinstance(exc, ConnectionError):
|
|
584
|
+
err_msg = f'Error connecting client: {exc!s}\n'
|
|
585
|
+
else:
|
|
586
|
+
err_msg = f'Error executing task {self.name}: {exc!r}\n'
|
|
587
|
+
if task.process is not None:
|
|
588
|
+
# The process may still run: stop it, the task will not be run again
|
|
589
|
+
with contextlib.suppress(Exception):
|
|
590
|
+
await task.terminate()
|
|
407
591
|
self.status = WorkerStatus.IDLE
|
|
592
|
+
if isinstance(exc, ConnectionError):
|
|
593
|
+
with contextlib.suppress(Exception):
|
|
594
|
+
self.client.close()
|
|
408
595
|
return False, err_msg
|
|
409
596
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
# err_msg = await loop.run_in_executor(None, task.done_callback)
|
|
415
|
-
# except Exception as exc:
|
|
416
|
-
# err_msg = f'Error executing callback of task {self.name}: {exc}\n'
|
|
597
|
+
if task.returncode is None:
|
|
598
|
+
# e.g. the SSH connection dropped before the exit status arrived
|
|
599
|
+
self.status = WorkerStatus.IDLE
|
|
600
|
+
return False, 'No exit status received: the outcome of the task is unknown\n'
|
|
417
601
|
|
|
602
|
+
err_msg = task.done_callback()
|
|
418
603
|
self.status = WorkerStatus.IDLE
|
|
419
604
|
|
|
420
605
|
return True, err_msg
|
|
@@ -446,7 +631,9 @@ class WorkerPoolReport:
|
|
|
446
631
|
self.n_success_nonzero = len(self.success_nonzero)
|
|
447
632
|
self.n_success_unknown_rc = len(self.success_unknown_rc)
|
|
448
633
|
|
|
449
|
-
|
|
634
|
+
# A task without exit status did not demonstrably succeed
|
|
635
|
+
self.ok = (self.n_failed == 0 and self.n_success_nonzero == 0
|
|
636
|
+
and self.n_success_unknown_rc == 0)
|
|
450
637
|
|
|
451
638
|
def host_of(self, task):
|
|
452
639
|
if task.process and task.process.client:
|
|
@@ -459,22 +646,26 @@ class WorkerPool:
|
|
|
459
646
|
def __init__(self, hosts, name='Worker', max_tasks_per_worker=4,
|
|
460
647
|
env_source_file=None, user=None, max_time=None, force_sync=False,
|
|
461
648
|
debug=False, dry_run=False, password=None, connect_timeout=15,
|
|
462
|
-
host_cooldown_s=10):
|
|
649
|
+
host_cooldown_s=10, slurm_options=None, kill_grace_s=30):
|
|
463
650
|
"""Initiate a worker pool.
|
|
464
651
|
|
|
465
652
|
Args:
|
|
466
653
|
name (str): Name of the worker pool.
|
|
467
|
-
hosts (list): List of host names, reachable via ssh (if not local host).
|
|
654
|
+
hosts (list): List of host names, reachable via ssh (if not local host). The host
|
|
655
|
+
'slurm' runs the tasks through srun (see SlurmClient).
|
|
468
656
|
max_tasks_per_worker (int, optional): Maximum number of tasks to execute
|
|
469
|
-
concurrently on an host.
|
|
657
|
+
concurrently on an host (for 'slurm', the number of jobs in flight).
|
|
470
658
|
env_source_file (str, optional): Name of a file to source before executing a task.
|
|
471
659
|
user (str, optional): User to connect
|
|
472
660
|
connect_timeout (int, optional): Per-attempt SSH connection timeout, in seconds.
|
|
473
661
|
host_cooldown_s (int, optional): Time a just-failed host is avoided while
|
|
474
662
|
a healthy one exists.
|
|
663
|
+
slurm_options (str, optional): srun options for the 'slurm' host, e.g.
|
|
664
|
+
'-p cpu -c 16 --mem=64G' (default: $LIBPIPE_SLURM_OPTIONS).
|
|
665
|
+
kill_grace_s (int, optional): Time a task past max_time gets to stop after SIGTERM
|
|
666
|
+
before SIGKILL.
|
|
475
667
|
"""
|
|
476
|
-
|
|
477
|
-
asyncio.set_event_loop(asyncio.new_event_loop())
|
|
668
|
+
get_event_loop()
|
|
478
669
|
|
|
479
670
|
if user is None:
|
|
480
671
|
user = getpass.getuser()
|
|
@@ -492,10 +683,16 @@ class WorkerPool:
|
|
|
492
683
|
# host -> epoch time until which a just-failed host is avoided (steering only)
|
|
493
684
|
self.host_cooldown_s = host_cooldown_s
|
|
494
685
|
self.bad_hosts_until = {}
|
|
686
|
+
self.kill_grace_s = kill_grace_s
|
|
687
|
+
# Tasks waiting for a worker, in arrival order: (requested host, future)
|
|
688
|
+
self._waiters = collections.deque()
|
|
689
|
+
self._cooldown_timer = None
|
|
495
690
|
|
|
496
691
|
for host in self.hosts:
|
|
497
692
|
if host == localhost_shortname or host == 'localhost':
|
|
498
693
|
client = LocalClient(localhost_shortname, force_sync=force_sync)
|
|
694
|
+
elif host == SLURM_HOST:
|
|
695
|
+
client = SlurmClient(slurm_options, job_name=name, max_time=max_time)
|
|
499
696
|
else:
|
|
500
697
|
client = Client(host, user, force_sync=force_sync, password=password,
|
|
501
698
|
connect_timeout=connect_timeout)
|
|
@@ -514,29 +711,76 @@ class WorkerPool:
|
|
|
514
711
|
|
|
515
712
|
self.pbar = progressbar.ProgressBar(redirect_stdout=True, widgets=widgets)
|
|
516
713
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
now = time.time()
|
|
714
|
+
def _find_idle_worker(self, host):
|
|
715
|
+
now = time.time()
|
|
520
716
|
|
|
521
|
-
|
|
522
|
-
|
|
717
|
+
# hosts not currently in cooldown
|
|
718
|
+
good_hosts = {h for h in self.hosts if self.bad_hosts_until.get(h, 0) <= now}
|
|
523
719
|
|
|
524
|
-
|
|
525
|
-
|
|
720
|
+
for worker in self.workers:
|
|
721
|
+
w_host = worker.client.host
|
|
526
722
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
723
|
+
# respect run_on_host if requested
|
|
724
|
+
if host is not None and w_host != host:
|
|
725
|
+
continue
|
|
530
726
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
727
|
+
# if we have at least one good host, avoid bad ones
|
|
728
|
+
if host is None and good_hosts and w_host not in good_hosts:
|
|
729
|
+
continue
|
|
534
730
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
731
|
+
if worker.status is WorkerStatus.IDLE:
|
|
732
|
+
return worker
|
|
733
|
+
return None
|
|
538
734
|
|
|
539
|
-
|
|
735
|
+
async def get_worker(self, host=None):
|
|
736
|
+
fut = asyncio.get_running_loop().create_future()
|
|
737
|
+
self._waiters.append((host, fut))
|
|
738
|
+
self._dispatch()
|
|
739
|
+
try:
|
|
740
|
+
return await fut
|
|
741
|
+
except asyncio.CancelledError:
|
|
742
|
+
if fut.done() and not fut.cancelled():
|
|
743
|
+
# A worker was handed over just as the task was cancelled: give it back
|
|
744
|
+
fut.result().status = WorkerStatus.IDLE
|
|
745
|
+
self._dispatch()
|
|
746
|
+
with contextlib.suppress(ValueError):
|
|
747
|
+
self._waiters.remove((host, fut))
|
|
748
|
+
raise
|
|
749
|
+
|
|
750
|
+
def release_worker(self):
|
|
751
|
+
"""Hand the worker just freed to the next task waiting for it."""
|
|
752
|
+
self._dispatch()
|
|
753
|
+
|
|
754
|
+
def _dispatch(self):
|
|
755
|
+
"""Hand the idle workers to the waiting tasks, in arrival order."""
|
|
756
|
+
i = 0
|
|
757
|
+
while i < len(self._waiters) and any(w.status is WorkerStatus.IDLE
|
|
758
|
+
for w in self.workers):
|
|
759
|
+
host, fut = self._waiters[i]
|
|
760
|
+
if fut.done():
|
|
761
|
+
del self._waiters[i]
|
|
762
|
+
continue
|
|
763
|
+
worker = self._find_idle_worker(host)
|
|
764
|
+
if worker is None:
|
|
765
|
+
i += 1
|
|
766
|
+
continue
|
|
767
|
+
worker.status = WorkerStatus.COMMITED
|
|
768
|
+
fut.set_result(worker)
|
|
769
|
+
del self._waiters[i]
|
|
770
|
+
self._schedule_cooldown_dispatch()
|
|
771
|
+
|
|
772
|
+
def _schedule_cooldown_dispatch(self):
|
|
773
|
+
"""The end of a host cooldown can make an idle worker usable: dispatch again then."""
|
|
774
|
+
if self._cooldown_timer is not None:
|
|
775
|
+
self._cooldown_timer.cancel()
|
|
776
|
+
self._cooldown_timer = None
|
|
777
|
+
if not self._waiters:
|
|
778
|
+
return
|
|
779
|
+
now = time.time()
|
|
780
|
+
ends = [t for t in self.bad_hosts_until.values() if t > now]
|
|
781
|
+
if ends:
|
|
782
|
+
self._cooldown_timer = asyncio.get_running_loop().call_later(
|
|
783
|
+
min(ends) - now + 0.01, self._dispatch)
|
|
540
784
|
|
|
541
785
|
def add(self, command, name=None, output_file=None, task_done_callback=None, done_callback=None,
|
|
542
786
|
run_on_host=None, n_try=3, pid_filename=None, keep_pid_file_on_error=False):
|
|
@@ -549,8 +793,10 @@ class WorkerPool:
|
|
|
549
793
|
output_file (str, optional): Optional filename to log output into.
|
|
550
794
|
"""
|
|
551
795
|
if run_on_host is not None and run_on_host not in self.hosts:
|
|
552
|
-
|
|
553
|
-
|
|
796
|
+
# Slurm places the tasks itself: a host constraint is expected not to apply
|
|
797
|
+
if SLURM_HOST not in self.hosts:
|
|
798
|
+
print(f'Requested run_on_host "{run_on_host}" is not in the hosts list. '
|
|
799
|
+
'Releasing constraint ...')
|
|
554
800
|
run_on_host = None
|
|
555
801
|
|
|
556
802
|
script = f". {shlex.quote(self.env_file)}; {command}" if self.env_file else command
|
|
@@ -587,21 +833,37 @@ class WorkerPool:
|
|
|
587
833
|
self.pbar.update(n_done)
|
|
588
834
|
|
|
589
835
|
if self.max_time is not None:
|
|
590
|
-
|
|
591
|
-
if worker.status == WorkerStatus.RUNNING:
|
|
592
|
-
running_time = time.time() - worker.execute_start_time
|
|
593
|
-
if running_time > (self.max_time + update_interval):
|
|
594
|
-
task = worker.running_task
|
|
595
|
-
task.timed_out = True
|
|
596
|
-
task.log(f'Task timeout: waited for {running_time:.2f} s',
|
|
597
|
-
worker.client.host, err=True)
|
|
598
|
-
task.log(f'Command was: {task.command}',
|
|
599
|
-
worker.client.host, err=True)
|
|
600
|
-
await task.terminate()
|
|
836
|
+
await self._enforce_max_time()
|
|
601
837
|
|
|
602
838
|
except asyncio.CancelledError:
|
|
603
839
|
pass
|
|
604
840
|
|
|
841
|
+
async def _enforce_max_time(self):
|
|
842
|
+
now = time.time()
|
|
843
|
+
for worker in self.workers:
|
|
844
|
+
# Slurm enforces max_time as the job time limit, from the job start
|
|
845
|
+
if isinstance(worker.client, SlurmClient) or worker.status != WorkerStatus.RUNNING:
|
|
846
|
+
continue
|
|
847
|
+
task = worker.running_task
|
|
848
|
+
# Count from the process start: connecting to the host is not running the task
|
|
849
|
+
if task is None or task.start_time is None or task.status != TaskStatus.RUNNING:
|
|
850
|
+
continue
|
|
851
|
+
running_time = now - task.start_time
|
|
852
|
+
if running_time <= self.max_time:
|
|
853
|
+
continue
|
|
854
|
+
if task.sigterm_time is None:
|
|
855
|
+
task.timed_out = True
|
|
856
|
+
task.sigterm_time = now
|
|
857
|
+
task.log(f'Task timeout: running for {running_time:.2f} s', worker.client.host,
|
|
858
|
+
err=True)
|
|
859
|
+
task.log(f'Command was: {task.command}', worker.client.host, err=True)
|
|
860
|
+
await task.terminate(signal.SIGTERM)
|
|
861
|
+
elif now - task.sigterm_time > self.kill_grace_s and task.sigterm_time > 0:
|
|
862
|
+
task.log(f'Still running {self.kill_grace_s} s after SIGTERM: sending SIGKILL',
|
|
863
|
+
worker.client.host, err=True)
|
|
864
|
+
task.sigterm_time = -1 # SIGKILL sent, nothing more to do
|
|
865
|
+
await task.terminate(signal.SIGKILL)
|
|
866
|
+
|
|
605
867
|
async def terminate_all(self):
|
|
606
868
|
for worker in self.workers:
|
|
607
869
|
worker.close()
|
|
@@ -615,7 +877,7 @@ class WorkerPool:
|
|
|
615
877
|
async def shutdown(self, exit_code=0):
|
|
616
878
|
print('Shutting down')
|
|
617
879
|
await self.terminate_all()
|
|
618
|
-
asyncio.
|
|
880
|
+
asyncio.get_running_loop().call_soon_threadsafe(sys.exit, exit_code)
|
|
619
881
|
|
|
620
882
|
async def process_tasks(self):
|
|
621
883
|
# Sort tasks so that those with run_on_host are started first
|
|
@@ -636,7 +898,7 @@ class WorkerPool:
|
|
|
636
898
|
self.pbar.finish()
|
|
637
899
|
return [], []
|
|
638
900
|
|
|
639
|
-
loop =
|
|
901
|
+
loop = get_event_loop()
|
|
640
902
|
|
|
641
903
|
loop.add_signal_handler(signal.SIGTERM,
|
|
642
904
|
lambda: asyncio.ensure_future(self.shutdown(exit_code=1)))
|
|
@@ -648,6 +910,10 @@ class WorkerPool:
|
|
|
648
910
|
raise
|
|
649
911
|
except asyncio.CancelledError:
|
|
650
912
|
secho('Error: one (or more) task(s) have been canceled', fg='red')
|
|
913
|
+
except Exception:
|
|
914
|
+
# Do not leave the running tasks behind
|
|
915
|
+
loop.run_until_complete(self.terminate_all())
|
|
916
|
+
raise
|
|
651
917
|
finally:
|
|
652
918
|
for worker in self.workers:
|
|
653
919
|
worker.close()
|
libpipe-0.7/PKG-INFO
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: libpipe
|
|
3
|
-
Version: 0.7
|
|
4
|
-
Summary: Pipeline orchestration and node management utilities
|
|
5
|
-
License-Expression: GPL-3.0-or-later
|
|
6
|
-
License-File: LICENSE
|
|
7
|
-
Author: Florent Mertens
|
|
8
|
-
Author-email: florent.mertens@gmail.com
|
|
9
|
-
Requires-Python: >=3.10,<4.0
|
|
10
|
-
Classifier: Programming Language :: Python :: 3
|
|
11
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
-
Provides-Extra: dev
|
|
17
|
-
Requires-Dist: asyncssh (>=2.0)
|
|
18
|
-
Requires-Dist: click (>=8.0)
|
|
19
|
-
Requires-Dist: progressbar2 (>=3.40)
|
|
20
|
-
Requires-Dist: pytest (>=8.0) ; extra == "dev"
|
|
21
|
-
Requires-Dist: pytest-cov (>=5.0) ; extra == "dev"
|
|
22
|
-
Requires-Dist: ruff (==0.16.1) ; extra == "dev"
|
|
23
|
-
Requires-Dist: toml (>=0.10)
|
|
24
|
-
Project-URL: Homepage, https://gitlab.com/flomertens/libpipe
|
|
25
|
-
Project-URL: Issues, https://gitlab.com/flomertens/libpipe/-/issues
|
|
26
|
-
Project-URL: Repository, https://gitlab.com/flomertens/libpipe
|
|
27
|
-
Description-Content-Type: text/markdown
|
|
28
|
-
|
|
29
|
-
Libpipe
|
|
30
|
-
=======
|
|
31
|
-
|
|
32
|
-
Common pipeline framework and library
|
|
33
|
-
|
|
34
|
-
Installation
|
|
35
|
-
------------
|
|
36
|
-
|
|
37
|
-
libpipe can be installed via pip:
|
|
38
|
-
|
|
39
|
-
$ pip install libpipe
|
|
40
|
-
|
|
41
|
-
and requires Python 3.6.0 or higher.
|
|
42
|
-
|
libpipe-0.7/README.md
DELETED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|