Skip to content

Commit 2d83a21

Browse files
authored
Merge pull request #3573 from ekouts/feat/maint_nodes
[feat] Treat nodes in `MAINTENANCE` state as available for flexible allocations on Slurm `MAINT`-flagged reservations
2 parents 2546434 + 52d57d1 commit 2d83a21

File tree

5 files changed

+297
-173
lines changed

5 files changed

+297
-173
lines changed

docs/manpage.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,6 +1032,7 @@ The way the tests are generated and how they interact with the test filtering op
10321032
- ``all``: Tests will run on all the nodes of their respective valid partitions regardless of the node state.
10331033
- ``avail``: Tests will run on all the nodes of their respective valid partitions that are available for running jobs.
10341034
Note that if a node is currently allocated to another job it is still considered as "available."
1035+
Also, for ReFrame partitions using the Slurm backends, if this option is used on a reservation with the ``MAINT`` flag set, then nodes in ``MAINTENANCE`` state will also be considered as available.
10351036
- ``NODESTATE``: Tests will run on all the nodes of their respective valid partitions that are exclusively in state ``NODESTATE``.
10361037
If ``NODESTATE`` is not specified, ``idle`` is assumed.
10371038
- ``NODESTATE*``: Tests will run on all the nodes of their respective valid partitions that are at least in state ``NODESTATE``.
@@ -1060,8 +1061,13 @@ The way the tests are generated and how they interact with the test filtering op
10601061
To achieve the previous behaviour, you should use ``--distribute=idle*``.
10611062

10621063
.. versionchanged:: 4.9
1064+
10631065
``--distribute=NODESTATE`` now allows you to specify multiple valid states using the ``|`` character.
10641066

1067+
.. versionchanged:: 4.10
1068+
1069+
Nodes in ``MAINTENANCE`` state are considered available, if this option is run on a Slurm reservation with the ``MAINT`` flag set.
1070+
10651071
.. option:: -P, --parameterize=[TEST.]VAR=VAL0,VAL1,...
10661072

10671073
Parameterize a test on an existing variable or parameter.

reframe/core/schedulers/__init__.py

Lines changed: 60 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,57 @@ def filternodes(self, job, nodes):
100100
:meta private:
101101
'''
102102

103+
def filternodes_by_state(self, nodelist, state):
104+
'''Filter nodes by their state
105+
106+
:arg nodelist: List of :class:`Node` instances to filter.
107+
:arg state: The state of the nodes.
108+
If ``all``, the initial list is returned untouched.
109+
If ``avail``, only the available nodes will be returned.
110+
All other values are interpreted as a state string.
111+
The pipe character ``|`` can be used as to specify multiple
112+
alternative node states.
113+
State match is exclusive unless the ``*`` is added at the end of the
114+
state string.
115+
When defining multiple states using ``|``, ``*`` has to be added at
116+
the end of each alternative state for which a non-exclusive match is
117+
required.
118+
119+
:returns: the filtered node list
120+
121+
.. versionchanged:: 4.9
122+
123+
Support the ``|`` character to filter according to alternative states.
124+
125+
.. versionchanged:: 4.10
126+
127+
Moved inside the :class:`JobShceduler` class.
128+
'''
129+
if '|' in state:
130+
allowed_states = state.split('|')
131+
final_nodelist = set()
132+
for s in allowed_states:
133+
final_nodelist.update(
134+
self.filternodes_by_state(nodelist, s)
135+
)
136+
137+
nodelist = final_nodelist
138+
elif state == 'avail':
139+
nodelist = {n for n in nodelist if self.is_node_avail(n)}
140+
elif state != 'all':
141+
if state.endswith('*'):
142+
# non-exclusive state match
143+
state = state[:-1]
144+
nodelist = {
145+
n for n in nodelist if n.in_state(state)
146+
}
147+
else:
148+
nodelist = {
149+
n for n in nodelist if n.in_statex(state)
150+
}
151+
152+
return nodelist
153+
103154
@abc.abstractmethod
104155
def submit(self, job):
105156
'''Submit a job.
@@ -153,51 +204,6 @@ def log(self, message, level=DEBUG2):
153204
getlogger().log(level, f'[S] {self.registered_name}: {message}')
154205

155206

156-
def filter_nodes_by_state(nodelist, state):
157-
'''Filter nodes by their state
158-
159-
:arg nodelist: List of :class:`Node` instances to filter.
160-
:arg state: The state of the nodes.
161-
If ``all``, the initial list is returned untouched.
162-
If ``avail``, only the available nodes will be returned.
163-
All other values are interpreted as a state string.
164-
The pipe character ``|`` can be used as to specify multiple
165-
alternative node states.
166-
State match is exclusive unless the ``*`` is added at the end of the
167-
state string.
168-
When defining multiple states using ``|``, ``*`` has to be added at
169-
the end of each alternative state for which a non-exclusive match is
170-
required.
171-
172-
:returns: the filtered node list
173-
174-
.. versionchanged:: 4.9
175-
Support the ``|`` character to filter according to alternative states.
176-
'''
177-
if '|' in state:
178-
allowed_states = state.split('|')
179-
final_nodelist = set()
180-
for s in allowed_states:
181-
final_nodelist.update(filter_nodes_by_state(nodelist, s))
182-
183-
nodelist = final_nodelist
184-
elif state == 'avail':
185-
nodelist = {n for n in nodelist if n.is_avail()}
186-
elif state != 'all':
187-
if state.endswith('*'):
188-
# non-exclusive state match
189-
state = state[:-1]
190-
nodelist = {
191-
n for n in nodelist if n.in_state(state)
192-
}
193-
else:
194-
nodelist = {
195-
n for n in nodelist if n.in_statex(state)
196-
}
197-
198-
return nodelist
199-
200-
201207
class Job(jsonext.JSONSerializable, metaclass=JobMeta):
202208
'''A job descriptor.
203209
@@ -618,19 +624,21 @@ def guess_num_tasks(self):
618624
f'[F] Total available nodes: {len(available_nodes)}'
619625
)
620626

627+
available_nodes = self.scheduler.filternodes(self, available_nodes)
628+
getlogger().debug(
629+
f'[F] Total available after scheduler filter: '
630+
f'{len(available_nodes)}'
631+
)
632+
621633
# Try to guess the number of tasks now
622-
available_nodes = filter_nodes_by_state(
623-
available_nodes, self.sched_flex_alloc_nodes.lower()
634+
available_nodes = self.scheduler.filternodes_by_state(
635+
available_nodes,
636+
self.sched_flex_alloc_nodes.lower()
624637
)
625638
getlogger().debug(
626639
f'[F] Total available in state='
627640
f'{self.sched_flex_alloc_nodes.lower()}: {len(available_nodes)}'
628641
)
629-
available_nodes = self.scheduler.filternodes(self, available_nodes)
630-
getlogger().debug(
631-
f'[F] Total available after scheduler filter: '
632-
f'{len(available_nodes)}'
633-
)
634642
return len(available_nodes) * num_tasks_per_node
635643

636644
def submit(self):
@@ -694,17 +702,6 @@ def in_state(self, state):
694702
:class:`False` otherwise.
695703
'''
696704

697-
@abc.abstractmethod
698-
def is_avail(self):
699-
'''Check whether the node is available for scheduling jobs.'''
700-
701-
def is_down(self):
702-
'''Check whether node is down.
703-
704-
This is the inverse of :func:`is_avail`.
705-
'''
706-
return not self.is_avail()
707-
708705

709706
class AlwaysIdleNode(Node):
710707
def __init__(self, name):
@@ -715,9 +712,6 @@ def __init__(self, name):
715712
def name(self):
716713
return self._name
717714

718-
def is_avail(self):
719-
return True
720-
721715
def in_statex(self, state):
722716
return state.lower() == self._state
723717

reframe/core/schedulers/slurm.py

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,13 @@ def __init__(self):
147147
self._sched_access_in_submit = self.get_option(
148148
'sched_access_in_submit'
149149
)
150+
self._available_states = {
151+
'ALLOCATED',
152+
'COMPLETING',
153+
'IDLE',
154+
'PLANNED',
155+
'RESERVED'
156+
}
150157

151158
def make_job(self, *args, **kwargs):
152159
return _SlurmJob(*args, **kwargs)
@@ -427,21 +434,30 @@ def filternodes(self, job, nodes):
427434

428435
return nodes
429436

430-
def _get_reservation_nodes(self, reservation):
431-
completed = _run_strict('scontrol -a show res %s' % reservation)
432-
node_match = re.search(r'(Nodes=\S+)', completed.stdout)
437+
def _get_reservation_nodes(self, resv):
438+
completed = _run_strict(f'scontrol -a show -o reservations {resv}')
439+
self.log(f'reservation info:\n{completed.stdout}')
440+
441+
node_match = re.search(r'Nodes=(\S+)', completed.stdout)
433442
if node_match:
434443
reservation_nodes = node_match[1]
435444
else:
436-
raise JobSchedulerError("could not extract the node names for "
437-
"reservation '%s'" % reservation)
445+
raise JobSchedulerError('could not extract the node names for '
446+
f'reservation {resv!r}')
447+
448+
flags_match = re.search(r'Flags=(\S+)', completed.stdout)
449+
if flags_match:
450+
if 'MAINT' in flags_match.group(1).split(','):
451+
self._available_states.add('MAINTENANCE')
438452

439-
completed = _run_strict('scontrol -a show -o %s' % reservation_nodes)
453+
completed = _run_strict(
454+
f'scontrol -a show -o nodes {reservation_nodes}'
455+
)
440456
node_descriptions = completed.stdout.splitlines()
441457
return _create_nodes(node_descriptions)
442458

443459
def _get_nodes_by_name(self, nodespec):
444-
completed = osext.run_command('scontrol -a show -o node %s' %
460+
completed = osext.run_command('scontrol -a show -o nodes %s' %
445461
nodespec)
446462
node_descriptions = completed.stdout.splitlines()
447463
return _create_nodes(node_descriptions)
@@ -594,7 +610,7 @@ def _do_cancel_if_blocked(self, job, reason_descr):
594610
self.log(f'Checking if nodes {node_names!r} '
595611
f'are indeed unavailable')
596612
nodes = self._get_nodes_by_name(node_names)
597-
if not any(n.is_down() for n in nodes):
613+
if not any(self.is_node_down(n) for n in nodes):
598614
return
599615

600616
self.cancel(job)
@@ -630,6 +646,12 @@ def cancel(self, job):
630646
def finished(self, job):
631647
return slurm_state_completed(job.state)
632648

649+
def is_node_avail(self, node):
650+
return node.states <= self._available_states
651+
652+
def is_node_down(self, node):
653+
return not self.is_node_avail(node)
654+
633655

634656
@register_scheduler('squeue')
635657
class SqueueJobScheduler(SlurmJobScheduler):
@@ -727,26 +749,16 @@ def __eq__(self, other):
727749
def __hash__(self):
728750
return hash(self.name)
729751

752+
def __repr__(self):
753+
return f'_SlurmNode({self.name!r})'
754+
730755
def in_state(self, state):
731756
return all([self._states >= set(state.upper().split('+')),
732757
self._partitions, self._active_features, self._states])
733758

734759
def in_statex(self, state):
735760
return self._states == set(state.upper().split('+'))
736761

737-
def is_avail(self):
738-
available_states = {
739-
'ALLOCATED',
740-
'COMPLETING',
741-
'IDLE',
742-
'PLANNED',
743-
'RESERVED'
744-
}
745-
return self._states <= available_states
746-
747-
def is_down(self):
748-
return not self.is_avail()
749-
750762
def satisfies(self, slurm_constraint):
751763
# Convert the Slurm constraint to a Python expression and evaluate it,
752764
# but restrict our syntax to accept only AND or OR constraints and

reframe/frontend/testgenerators.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from reframe.core.fields import make_convertible
1515
from reframe.core.logging import getlogger, time_function
1616
from reframe.core.meta import make_test
17-
from reframe.core.schedulers import Job, filter_nodes_by_state
17+
from reframe.core.schedulers import Job
1818
from reframe.frontend.executors import generate_testcases
1919

2020

@@ -38,7 +38,9 @@ def getallnodes(state, jobs_cli_options=None):
3838
f'Total available nodes for {part.name}: {len(available_nodes)}'
3939
)
4040

41-
available_nodes = filter_nodes_by_state(available_nodes, state)
41+
available_nodes = part.scheduler.filternodes_by_state(
42+
available_nodes, state
43+
)
4244
nodes[part.fullname] = [n.name for n in available_nodes]
4345

4446
return nodes

0 commit comments

Comments
 (0)