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 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
- self.fd = open(self.output_file, 'w') # noqa: SIM115 - closed in close_log()
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 and hasattr(self.process.process, 'pid'):
150
- if self.process.client.host == localhost_shortname:
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
- line = line.decode()
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
- self.init_log()
198
-
199
- for i in range(self.n_try):
200
- self.timed_out = False
201
-
202
- worker = await client_pool.get_worker(self.run_on_host)
203
- self.status = TaskStatus.RUNNING
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
- if err_msg:
213
- self.log(err_msg, worker.client.host, err=True)
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
- if ret_status and not self.timed_out:
216
- self.status = TaskStatus.SUCCESS
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
- if i + 1 < self.n_try:
221
- self.log(f'Will retry {i + 1} / {self.n_try} ...\n', worker.client.host, err=True)
222
-
223
- self.close_log()
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
- err_msg = f'Task {self.name} has been canceled\n'
404
- return True, err_msg
580
+ self.status = WorkerStatus.IDLE
581
+ raise
405
582
  except Exception as exc:
406
- err_msg = f'Error executing task {self.name}: {exc!s}\n'
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
- err_msg = task.done_callback()
411
- # err_msg = ''
412
- # try:
413
- # loop = asyncio.get_running_loop()
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
- self.ok = (self.n_failed == 0 and self.n_success_nonzero == 0)
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
- if asyncio.get_event_loop().is_closed():
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
- async def get_worker(self, host=None):
518
- while True:
519
- now = time.time()
714
+ def _find_idle_worker(self, host):
715
+ now = time.time()
520
716
 
521
- # hosts not currently in cooldown
522
- good_hosts = {h for h in self.hosts if self.bad_hosts_until.get(h, 0) <= now}
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
- for worker in self.workers:
525
- w_host = worker.client.host
720
+ for worker in self.workers:
721
+ w_host = worker.client.host
526
722
 
527
- # respect run_on_host if requested
528
- if host is not None and w_host != host:
529
- continue
723
+ # respect run_on_host if requested
724
+ if host is not None and w_host != host:
725
+ continue
530
726
 
531
- # if we have at least one good host, avoid bad ones
532
- if host is None and good_hosts and w_host not in good_hosts:
533
- continue
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
- if worker.status is WorkerStatus.IDLE:
536
- worker.status = WorkerStatus.COMMITED
537
- return worker
731
+ if worker.status is WorkerStatus.IDLE:
732
+ return worker
733
+ return None
538
734
 
539
- await asyncio.sleep(0.1)
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
- print(f'Requested run_on_host "{run_on_host}" is not in the hosts list. '
553
- 'Releasing constraint ...')
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
- for worker in self.workers:
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.get_event_loop().call_soon_threadsafe(sys.exit, exit_code)
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 = asyncio.get_event_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()
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "libpipe"
7
- version = "0.7"
7
+ version = "0.8"
8
8
  description = "Pipeline orchestration and node management utilities"
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-or-later"
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
@@ -1,13 +0,0 @@
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.6.0 or higher.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes