libpipe 0.10__tar.gz → 0.12__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: libpipe
3
- Version: 0.10
3
+ Version: 0.12
4
4
  Summary: Pipeline orchestration and node management utilities
5
5
  License-Expression: GPL-3.0-or-later
6
6
  License-File: LICENSE
@@ -64,7 +64,8 @@ on a file system shared with the compute nodes.
64
64
 
65
65
  With `slurm_nodes`, a pool on a login node requests one allocation of up to that many nodes
66
66
  when its first task starts, runs each task as a job step in it (`srun --jobid --exact`), and
67
- releases it at the end, or after 10 minutes without step. Slurm packs the steps on the nodes
67
+ releases it at the end, or after 10 minutes without step. `slurm_nodes='2-10'` starts it as
68
+ soon as 2 nodes are free, with up to 10 of them, as many as free at that moment. Slurm packs the steps on the nodes
68
69
  from their CPUs and memory, given by the category of the task; a step starts when the
69
70
  allocation has its CPUs free, so that no srun waits polling the controller:
70
71
 
@@ -30,7 +30,8 @@ on a file system shared with the compute nodes.
30
30
 
31
31
  With `slurm_nodes`, a pool on a login node requests one allocation of up to that many nodes
32
32
  when its first task starts, runs each task as a job step in it (`srun --jobid --exact`), and
33
- releases it at the end, or after 10 minutes without step. Slurm packs the steps on the nodes
33
+ releases it at the end, or after 10 minutes without step. `slurm_nodes='2-10'` starts it as
34
+ soon as 2 nodes are free, with up to 10 of them, as many as free at that moment. Slurm packs the steps on the nodes
34
35
  from their CPUs and memory, given by the category of the task; a step starts when the
35
36
  allocation has its CPUs free, so that no srun waits polling the controller:
36
37
 
@@ -32,6 +32,9 @@ localhost_shortname = socket.gethostname().split('.', 1)[0]
32
32
  SLURM_HOST = 'slurm'
33
33
  SLURM_OPTIONS_ENV = 'LIBPIPE_SLURM_OPTIONS'
34
34
  SLURM_MARKER = '[libpipe] slurm job '
35
+ # Lines of srun and slurmstepd naming a node that failed, or the node of a step it ended
36
+ NODE_FAILURE_RE = re.compile(r'Node failure on (\S+)'
37
+ r'|\bON (\S+) CANCELLED AT \S+ DUE TO NODE FAILURE')
35
38
 
36
39
  # CPUs and memory of the task categories, used when the tasks run as job steps of an allocation;
37
40
  # the categories other than heavy are capped at its values
@@ -45,16 +48,54 @@ DEFAULT_RESOURCES = {
45
48
  THREAD_VARS = ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS')
46
49
  # The holding job of an allocation ends after this many minutes without job step
47
50
  ALLOC_IDLE_MINUTES = 10
48
- # Allocations requested in a row before giving up, when they end before running
49
- ALLOC_MAX_REQUESTS = 3
51
+ # Final states of an allocation that stop the pool instead of requesting another one: each new
52
+ # allocation would land on other nodes, and suspend or end other jobs, for nothing
53
+ ALLOC_FAILED_STATES = ('NODE_FAIL', 'BOOT_FAIL', 'FAILED', 'OUT_OF_MEMORY')
54
+ # Launches of a task in an allocation, when srun ends before the command starts (the allocation
55
+ # ended, or is being replaced)
56
+ ALLOC_MAX_LAUNCHES = 3
50
57
  # Workers per node of an allocation: Slurm holds the steps that do not fit yet
51
58
  ALLOC_WORKERS_PER_NODE = 32
52
59
 
53
60
 
61
+ def node_range(slurm_nodes):
62
+ """(min, max) nodes of an allocation from SLURM_NODES: N, or 'MIN-MAX' to start it once MIN
63
+ nodes are free, with up to MAX of them; None without (None, 0)."""
64
+ if slurm_nodes is None or slurm_nodes == 0:
65
+ return None
66
+ if isinstance(slurm_nodes, tuple):
67
+ lo, hi = slurm_nodes
68
+ elif isinstance(slurm_nodes, int) and not isinstance(slurm_nodes, bool):
69
+ lo = hi = slurm_nodes
70
+ elif isinstance(slurm_nodes, str) and re.fullmatch(r'\d+(-\d+)?', slurm_nodes.strip()):
71
+ lo, _, hi = slurm_nodes.strip().partition('-')
72
+ lo, hi = int(lo), int(hi or lo)
73
+ else:
74
+ raise ValueError(f'slurm_nodes: {slurm_nodes!r} is neither a number of nodes nor MIN-MAX')
75
+ if not 1 <= lo <= hi:
76
+ raise ValueError(f'slurm_nodes: {slurm_nodes!r}, MIN-MAX needs 1 <= MIN <= MAX')
77
+ return lo, hi
78
+
79
+
80
+ def nodes_option(nodes):
81
+ """The --nodes value of an allocation of (min, max) nodes."""
82
+ lo, hi = nodes
83
+ return f'{lo}-{hi}' if lo < hi else str(hi)
84
+
85
+
54
86
  def now_str():
55
87
  return datetime.datetime.now().isoformat(sep=' ', timespec='milliseconds')
56
88
 
57
89
 
90
+ def note_node_failure(task, line):
91
+ '''Record on task the node named by a node-failure line (bytes) of srun or slurmstepd.'''
92
+ if b'Node failure' not in line and b'NODE FAILURE' not in line:
93
+ return
94
+ m = NODE_FAILURE_RE.search(line.decode(errors='replace'))
95
+ if m:
96
+ task.failed_nodes.add(m.group(1) or m.group(2))
97
+
98
+
58
99
  def mem_mb(text):
59
100
  '''Memory such as 40GB, 40G or 512MB in MB (GB is 1024 MB, as in Slurm).'''
60
101
  m = re.fullmatch(r'\s*(\d+(?:\.\d+)?)\s*([KMGT]?)(?:i?B)?\s*', str(text), re.IGNORECASE)
@@ -195,6 +236,8 @@ class Task:
195
236
  self.start_time = None
196
237
  self.sigterm_time = None
197
238
  self.slurm_job_id = None
239
+ # Slurm nodes whose failure stopped this task
240
+ self.failed_nodes = set()
198
241
  # Resources of a job step, and the usage reported by libpipe.taskrun
199
242
  self.category = category
200
243
  self.label = label or category
@@ -268,8 +311,9 @@ class Task:
268
311
  if self.fd is not None:
269
312
  self.fd.write(f'[libpipe] {now_str()} {text}\n')
270
313
 
271
- async def log_stream(self, stream, host, err=False, stamp_prefix=None):
272
- """Log the lines of stream; those starting with stamp_prefix get the current time."""
314
+ async def log_stream(self, stream, host, err=False, stamp_prefix=None, on_line=None):
315
+ """Log the lines of stream; those starting with stamp_prefix get the current time.
316
+ on_line is called with each line (bytes)."""
273
317
  while True:
274
318
  try:
275
319
  line = await stream.readline()
@@ -278,6 +322,8 @@ class Task:
278
322
  continue
279
323
  if not line:
280
324
  break
325
+ if on_line is not None:
326
+ on_line(line)
281
327
  if stamp_prefix and line.startswith(stamp_prefix):
282
328
  line = f'{now_str()} '.encode() + line
283
329
  self.log(line, host, err=err)
@@ -333,6 +379,8 @@ class Task:
333
379
  break
334
380
 
335
381
  self.status = TaskStatus.ERROR
382
+ if getattr(worker.client, 'alloc_refused', None):
383
+ break
336
384
  if not ret_status and self.process is not None:
337
385
  # The process had started and may still be running: running the task
338
386
  # again could have two copies write the same outputs
@@ -528,12 +576,20 @@ class LocalClient:
528
576
  pass
529
577
 
530
578
 
579
+ class AllocationRefused(RuntimeError):
580
+ """Slurm refused the allocation request (account, partition, QOS, ...): not retried."""
581
+
582
+
583
+ # sbatch errors worth another request; any other rejection is a configuration error
584
+ SBATCH_TRANSIENT = ('timed out', 'temporarily', 'Unable to contact', 'Connection refused')
585
+
586
+
531
587
  class SlurmClient:
532
588
  """Run each task with srun: a Slurm job of its own when the pool runs on a login node, a
533
- job step when it runs inside an allocation (salloc, sbatch). With alloc_nodes, the client
534
- requests an allocation of that many nodes and runs the tasks as job steps in it, packed on
535
- its nodes from their CPUs and memory; the allocation ends with the pool, or after
536
- ALLOC_IDLE_MINUTES without step.
589
+ job step when it runs inside an allocation (salloc, sbatch). With alloc_nodes (N or
590
+ 'MIN-MAX', see node_range), the client requests an allocation of that many nodes and runs
591
+ the tasks as job steps in it, packed on its nodes from their CPUs and memory; the allocation
592
+ ends with the pool, or after ALLOC_IDLE_MINUTES without step.
537
593
 
538
594
  Options (partition, account, ...) come from slurm_options, or from the LIBPIPE_SLURM_OPTIONS
539
595
  environment variable; they apply to the jobs and the allocation, not to the job steps.
@@ -550,8 +606,10 @@ class SlurmClient:
550
606
  self.max_time = max_time
551
607
  self.exact = False
552
608
  self.steps = False
553
- self.alloc_nodes = alloc_nodes
609
+ self.alloc_nodes = node_range(alloc_nodes)
554
610
  self.alloc_id = None
611
+ self.alloc_refused = None
612
+ self.alloc_ran = False
555
613
  self.alloc_checked = 0
556
614
  self.alloc_lock = asyncio.Lock()
557
615
  # CPUs of the allocation and those free: a step starts when its CPUs are free, instead of
@@ -592,16 +650,21 @@ class SlurmClient:
592
650
  self.cpus_changed.notify_all()
593
651
 
594
652
  async def _acquire_cpus(self, cpus):
653
+ '''CPUs taken, and the job whose CPUs they are (None without CPU count).'''
595
654
  if self.cpus_total is None:
596
- return 0
597
- cpus = min(cpus, self.cpus_total)
655
+ return 0, None
598
656
  async with self.cpus_changed:
599
- await self.cpus_changed.wait_for(lambda: self.cpus_free >= cpus)
657
+ await self.cpus_changed.wait_for(
658
+ lambda: self.cpus_total is None or self.cpus_free >= min(cpus, self.cpus_total))
659
+ if self.cpus_total is None:
660
+ return 0, None
661
+ cpus = min(cpus, self.cpus_total)
600
662
  self.cpus_free -= cpus
601
- return cpus
663
+ return cpus, self.cpus_of
602
664
 
603
- async def _release_cpus(self, cpus):
604
- if cpus and self.cpus_total is not None:
665
+ async def _release_cpus(self, cpus, job_id):
666
+ # CPUs of an allocation that has been replaced are not those of the new one
667
+ if cpus and self.cpus_total is not None and job_id == self.cpus_of:
605
668
  async with self.cpus_changed:
606
669
  self.cpus_free = min(self.cpus_total, self.cpus_free + cpus)
607
670
  self.cpus_changed.notify_all()
@@ -622,13 +685,13 @@ class SlurmClient:
622
685
  has_time = any(o == '-t' or o.startswith(('-t', '--time')) for o in options)
623
686
  return [f'--time={int(-(-self.max_time // 60))}'] if self.max_time and not has_time else []
624
687
 
625
- def srun_command(self, task):
688
+ def srun_command(self, task, alloc_id=None):
626
689
  args = ['srun', f'--job-name={self.job_name}:{task.name}', '--ntasks=1', '--nodes=1',
627
690
  '--unbuffered']
628
691
  command, job_id, options = task.command, '${SLURM_JOB_ID}', self.options
629
692
  if self.steps:
630
- if self.alloc_id:
631
- args.append(f'--jobid={self.alloc_id}')
693
+ if alloc_id:
694
+ args.append(f'--jobid={alloc_id}')
632
695
  if self.exact:
633
696
  args.append('--exact')
634
697
  if task.cpus:
@@ -646,29 +709,48 @@ class SlurmClient:
646
709
  out, err = await process.communicate()
647
710
  return process.returncode, out.decode().strip(), err.decode().strip()
648
711
 
649
- async def ensure_allocation(self):
650
- """Request the allocation, or a new one when it has ended, and wait for it to run."""
712
+ async def ensure_allocation(self, check=False):
713
+ """Request the allocation, or a new one when it has ended after running normally (time
714
+ limit, idle), wait for it to run and return its id. An allocation that ends before
715
+ running, or in a failed state (node failure, ...), stops the pool. check: ask Slurm for
716
+ its state even if it was checked in the last 30 s."""
651
717
  async with self.alloc_lock:
652
- n_requested = 0
718
+ if self.alloc_refused:
719
+ raise AllocationRefused(self.alloc_refused)
720
+ if check:
721
+ self.alloc_checked = 0
653
722
  while True:
654
723
  if self.alloc_id and time.monotonic() - self.alloc_checked < 30:
655
- return
724
+ return self.alloc_id
656
725
  if self.alloc_id is None:
657
- if n_requested == ALLOC_MAX_REQUESTS:
658
- raise RuntimeError(f'{n_requested} allocations ended before running')
659
726
  self.alloc_id = await self._submit_allocation()
660
- n_requested += 1
727
+ self.alloc_ran = False
661
728
  _, state, _ = await self._run('squeue', '-h', '-j', self.alloc_id, '-o', '%T')
662
729
  if state == 'RUNNING':
663
730
  self.alloc_checked = time.monotonic()
731
+ self.alloc_ran = True
664
732
  await self._set_cpus(self.alloc_id)
665
- return
666
- if state not in ('PENDING', 'CONFIGURING'):
667
- print(f'[{self.job_name}] allocation {self.alloc_id} ended '
668
- f'({state or "gone"}): requesting a new one')
669
- self.alloc_id = None
733
+ return self.alloc_id
734
+ if state in ('PENDING', 'CONFIGURING'):
735
+ await asyncio.sleep(5)
670
736
  continue
671
- await asyncio.sleep(5)
737
+ final = await self._final_state(self.alloc_id) or state or 'gone'
738
+ if final.split()[0] in ALLOC_FAILED_STATES or not self.alloc_ran:
739
+ when = '' if self.alloc_ran else ' before running'
740
+ self.alloc_refused = (f'allocation {self.alloc_id} ended{when} ({final}): no '
741
+ f'new allocation is requested; check the nodes and the '
742
+ f'partition (sacct -j {self.alloc_id})')
743
+ print(style(f'[{self.job_name}] {self.alloc_refused}', fg='red'))
744
+ raise AllocationRefused(self.alloc_refused)
745
+ print(f'[{self.job_name}] allocation {self.alloc_id} ended ({final}): '
746
+ 'requesting a new one')
747
+ self.alloc_id = None
748
+
749
+ async def _final_state(self, job_id):
750
+ """State of an ended job from sacct, e.g. NODE_FAIL or COMPLETED; '' if unknown."""
751
+ returncode, out, _ = await self._run('sacct', '-n', '-X', '-P', '-j', job_id, '-o',
752
+ 'State')
753
+ return out.splitlines()[0].strip() if returncode == 0 and out.strip() else ''
672
754
 
673
755
  async def _submit_allocation(self):
674
756
  # The holding job: ends after ALLOC_IDLE_MINUTES without step (other than its own)
@@ -680,15 +762,22 @@ class SlurmClient:
680
762
  if not time_option and not any(o == '-t' or o.startswith(('-t', '--time'))
681
763
  for o in self.options):
682
764
  time_option = ['--time=24:00:00']
683
- args = ['sbatch', '--parsable', f'--nodes={self.alloc_nodes}', '--exclusive',
765
+ # --no-kill: a node that fails ends only the steps on it, not the allocation
766
+ args = ['sbatch', '--parsable', f'--nodes={nodes_option(self.alloc_nodes)}', '--exclusive',
767
+ '--no-kill',
684
768
  f'--job-name={self.job_name}:alloc', '--output=/dev/null', *time_option,
685
769
  *self.options, '--wrap', holder]
686
770
  returncode, out, err = await self._run(*args)
687
771
  if returncode:
772
+ if not any(t in err for t in SBATCH_TRANSIENT):
773
+ # Every task would ask again and be refused the same way: say it once
774
+ self.alloc_refused = f'Slurm refused the allocation: {err}'
775
+ print(style(f'[{self.job_name}] {self.alloc_refused}; no task is run', fg='red'))
776
+ raise AllocationRefused(self.alloc_refused)
688
777
  raise RuntimeError(f'Requesting the allocation failed: {err}')
689
778
  job_id = out.split(';')[0]
690
- print(f'[{self.job_name}] allocation {job_id} of {self.alloc_nodes} node(s): the tasks '
691
- 'run as job steps')
779
+ print(f'[{self.job_name}] allocation {job_id} of {nodes_option(self.alloc_nodes)} '
780
+ 'node(s): the tasks run as job steps')
692
781
  return job_id
693
782
 
694
783
  async def _log_stdout(self, task, stream):
@@ -705,20 +794,35 @@ class SlurmClient:
705
794
  if task.slurm_job_id is None and text.startswith(SLURM_MARKER):
706
795
  task.slurm_job_id = text[len(SLURM_MARKER):].split()[0]
707
796
  task.start_time = time.time()
797
+ note_node_failure(task, line)
708
798
  task.log(line, self.host)
709
799
 
710
800
  async def execute(self, task):
801
+ for launch in range(1, ALLOC_MAX_LAUNCHES + 1):
802
+ await self._launch(task, check=launch > 1)
803
+ # Without the marker line, the command did not start: in an allocation, srun ended
804
+ # because the allocation did (or a new one is not running yet), and the task can run
805
+ # on the next one
806
+ if (not self.alloc_nodes or task.slurm_job_id is not None or task.returncode == 0
807
+ or task.sigterm_time is not None or launch == ALLOC_MAX_LAUNCHES):
808
+ return
809
+ task.log_event(f'the command did not start: launching it again '
810
+ f'({launch + 1}/{ALLOC_MAX_LAUNCHES})')
811
+
812
+ async def _launch(self, task, check=False):
711
813
  task.slurm_job_id = None
712
- if self.alloc_nodes:
713
- await self.ensure_allocation()
714
- cpus = 0
814
+ alloc_id = await self.ensure_allocation(check) if self.alloc_nodes else None
815
+ cpus, cpus_of = 0, None
715
816
  if self.steps and self.cpus_total is not None:
716
817
  task.log_event(f'waiting for {min(task.cpus or 1, self.cpus_total)} CPUs '
717
818
  f'({self.cpus_free} of {self.cpus_total} free)')
718
- cpus = await self._acquire_cpus(task.cpus or 1)
819
+ cpus, cpus_of = await self._acquire_cpus(task.cpus or 1)
820
+ if self.alloc_nodes:
821
+ # The allocation may have been replaced while waiting: the CPUs are of the new one
822
+ alloc_id = cpus_of or alloc_id
719
823
  try:
720
824
  process = await asyncio.create_subprocess_shell(
721
- self.srun_command(task), stdout=asyncio.subprocess.PIPE,
825
+ self.srun_command(task, alloc_id), stdout=asyncio.subprocess.PIPE,
722
826
  stderr=asyncio.subprocess.PIPE, preexec_fn=os.setpgrp, env=self.env)
723
827
  task.log_event(f'srun started{self._cpus_state()}')
724
828
  task.set_process(Process(self, process))
@@ -727,12 +831,13 @@ class SlurmClient:
727
831
 
728
832
  await asyncio.gather(
729
833
  self._log_stdout(task, process.stdout),
730
- task.log_stream(process.stderr, self.host, err=True, stamp_prefix=b'srun: '),
834
+ task.log_stream(process.stderr, self.host, err=True, stamp_prefix=b'srun: ',
835
+ on_line=lambda line: note_node_failure(task, line)),
731
836
  )
732
837
  task.returncode = await process.wait()
733
838
  task.log_event(f'srun exited with code {task.returncode}')
734
839
  finally:
735
- await self._release_cpus(cpus)
840
+ await self._release_cpus(cpus, cpus_of)
736
841
  if cpus:
737
842
  task.log_event(f'{cpus} CPUs released{self._cpus_state()}')
738
843
 
@@ -801,6 +906,11 @@ class Worker:
801
906
  except asyncio.CancelledError:
802
907
  self.status = WorkerStatus.IDLE
803
908
  raise
909
+ except AllocationRefused as exc:
910
+ # Said once on the console by the client: in the task log only
911
+ task.log_event(f'not run: {exc}')
912
+ self.status = WorkerStatus.IDLE
913
+ return False, None
804
914
  except Exception as exc:
805
915
  if isinstance(exc, ConnectionError):
806
916
  err_msg = f'Error connecting client: {exc!s}\n'
@@ -897,6 +1007,22 @@ class WorkerPoolReport:
897
1007
  for label, peak in sorted(peaks.items())
898
1008
  for category, mem in [requests[label]] if peak > mem]
899
1009
 
1010
+ def node_failure_lines(self):
1011
+ '''One line per Slurm node whose failure stopped tasks, with these tasks.'''
1012
+ tasks = collections.defaultdict(list)
1013
+ for t in self.all_tasks:
1014
+ for node in sorted(getattr(t, 'failed_nodes', ())):
1015
+ tasks[node].append(t.name)
1016
+ return [f'{node}: {len(names)} task(s) stopped ({", ".join(names)})'
1017
+ for node, names in sorted(tasks.items())]
1018
+
1019
+ def print_node_failures(self):
1020
+ lines = self.node_failure_lines()
1021
+ if lines:
1022
+ secho('\nSlurm node failures (run these tasks again):', fg='red')
1023
+ print('\n'.join(lines))
1024
+ print()
1025
+
900
1026
  def print_usage(self):
901
1027
  usage = self.usage_lines()
902
1028
  if not usage:
@@ -932,8 +1058,9 @@ class WorkerPool:
932
1058
  '-p cpu -c 16 --mem=64G' (default: $LIBPIPE_SLURM_OPTIONS).
933
1059
  kill_grace_s (int, optional): Time a task past max_time gets to stop after SIGTERM
934
1060
  before SIGKILL.
935
- slurm_nodes (int, optional): For 'slurm' from a login node: run the tasks as job
936
- steps of an allocation of up to that many nodes, instead of one job each.
1061
+ slurm_nodes (int or str, optional): For 'slurm' from a login node: run the tasks as
1062
+ job steps of an allocation of up to that many nodes, instead of one job each;
1063
+ 'MIN-MAX' starts it once MIN nodes are free, with up to MAX of them.
937
1064
  resources (dict, optional): CPUs and memory of the task categories as job steps,
938
1065
  {category: {'cpus': .., 'mem': ..}}, updating DEFAULT_RESOURCES.
939
1066
  """
@@ -960,7 +1087,7 @@ class WorkerPool:
960
1087
  self._waiters = collections.deque()
961
1088
  self._cooldown_timer = None
962
1089
  self.resources = resolve_resources(resources)
963
- self.slurm_nodes = slurm_nodes
1090
+ self.slurm_nodes = node_range(slurm_nodes)
964
1091
  # Wall time, CPU time and peak memory of each task (libpipe.taskrun), with the Python
965
1092
  # of the pool: on this machine or on the nodes of the cluster
966
1093
  self.accounting = all(h in (localhost_shortname, 'localhost', SLURM_HOST) for h in hosts)
@@ -971,9 +1098,9 @@ class WorkerPool:
971
1098
  client = LocalClient(localhost_shortname, force_sync=force_sync)
972
1099
  elif host == SLURM_HOST:
973
1100
  client = SlurmClient(slurm_options, job_name=name, max_time=max_time,
974
- alloc_nodes=slurm_nodes)
975
- if slurm_nodes:
976
- n_workers = max(n_workers, ALLOC_WORKERS_PER_NODE * slurm_nodes)
1101
+ alloc_nodes=self.slurm_nodes)
1102
+ if self.slurm_nodes:
1103
+ n_workers = max(n_workers, ALLOC_WORKERS_PER_NODE * self.slurm_nodes[1])
977
1104
  else:
978
1105
  client = Client(host, user, force_sync=force_sync, password=password,
979
1106
  connect_timeout=connect_timeout)
@@ -1192,7 +1319,8 @@ class WorkerPool:
1192
1319
  # An allocation no larger than the number of tasks
1193
1320
  for client in {w.client for w in self.workers}:
1194
1321
  if isinstance(client, SlurmClient) and client.alloc_nodes:
1195
- client.alloc_nodes = min(self.slurm_nodes, len(self.tasks))
1322
+ hi = min(self.slurm_nodes[1], len(self.tasks))
1323
+ client.alloc_nodes = (min(self.slurm_nodes[0], hi), hi) if hi else None
1196
1324
 
1197
1325
  self.futures = [asyncio.ensure_future(task.run(self)) for task in self.tasks]
1198
1326
  self.futures.append(asyncio.ensure_future(self._monitor_queue()))
@@ -1237,6 +1365,7 @@ class WorkerPool:
1237
1365
  report = self.last_report
1238
1366
  # At the end of the pool, before the next tasks fill the console
1239
1367
  report.print_usage()
1368
+ report.print_node_failures()
1240
1369
 
1241
1370
  if print_summary:
1242
1371
  print("\n=== WorkerPool summary ===")
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "libpipe"
7
- version = "0.10"
7
+ version = "0.12"
8
8
  description = "Pipeline orchestration and node management utilities"
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-or-later"
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