aws-parallelcluster-node 3.15.0__tar.gz → 3.16.0__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.
Files changed (42) hide show
  1. {aws_parallelcluster_node-3.15.0/src/aws_parallelcluster_node.egg-info → aws_parallelcluster_node-3.16.0}/PKG-INFO +1 -1
  2. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/setup.py +1 -1
  3. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0/src/aws_parallelcluster_node.egg-info}/PKG-INFO +1 -1
  4. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/common/schedulers/slurm_commands.py +211 -102
  5. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/common/schedulers/slurm_reservation_commands.py +30 -37
  6. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/slurm_plugin/clustermgtd.py +43 -17
  7. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/slurm_plugin/fleet_manager.py +31 -4
  8. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/slurm_plugin/fleet_status_manager.py +6 -1
  9. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/slurm_plugin/instance_manager.py +17 -3
  10. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/slurm_plugin/resume.py +8 -0
  11. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/slurm_plugin/slurm_resources.py +25 -5
  12. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/LICENSE.txt +0 -0
  13. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/NOTICE.txt +0 -0
  14. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/README.md +0 -0
  15. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/setup.cfg +0 -0
  16. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/aws/__init__.py +0 -0
  17. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/aws/common.py +0 -0
  18. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/aws/ec2.py +0 -0
  19. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/aws_parallelcluster_node.egg-info/SOURCES.txt +0 -0
  20. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/aws_parallelcluster_node.egg-info/dependency_links.txt +0 -0
  21. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/aws_parallelcluster_node.egg-info/entry_points.txt +0 -0
  22. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/aws_parallelcluster_node.egg-info/not-zip-safe +0 -0
  23. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/aws_parallelcluster_node.egg-info/requires.txt +0 -0
  24. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/aws_parallelcluster_node.egg-info/top_level.txt +0 -0
  25. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/common/__init__.py +0 -0
  26. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/common/ec2_utils.py +0 -0
  27. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/common/schedulers/__init__.py +0 -0
  28. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/common/time_utils.py +0 -0
  29. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/common/utils.py +0 -0
  30. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/slurm_plugin/__init__.py +0 -0
  31. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/slurm_plugin/capacity_block_manager.py +0 -0
  32. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/slurm_plugin/cluster_event_publisher.py +0 -0
  33. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/slurm_plugin/common.py +0 -0
  34. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/slurm_plugin/computemgtd.py +0 -0
  35. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/slurm_plugin/console_logger.py +0 -0
  36. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/slurm_plugin/logging/parallelcluster_clustermgtd_logging.conf +0 -0
  37. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/slurm_plugin/logging/parallelcluster_computemgtd_logging.conf +0 -0
  38. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/slurm_plugin/logging/parallelcluster_fleet_status_manager_logging.conf +0 -0
  39. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/slurm_plugin/logging/parallelcluster_resume_logging.conf +0 -0
  40. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/slurm_plugin/logging/parallelcluster_suspend_logging.conf +0 -0
  41. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/slurm_plugin/suspend.py +0 -0
  42. {aws_parallelcluster_node-3.15.0 → aws_parallelcluster_node-3.16.0}/src/slurm_plugin/task_executor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-parallelcluster-node
3
- Version: 3.15.0
3
+ Version: 3.16.0
4
4
  Summary: aws-parallelcluster-node provides the scripts for an AWS ParallelCluster node.
5
5
  Home-page: https://github.com/aws/aws-parallelcluster-node
6
6
  Author: Amazon Web Services
@@ -32,7 +32,7 @@ console_scripts = [
32
32
  "clustermgtd = slurm_plugin.clustermgtd:main",
33
33
  "computemgtd = slurm_plugin.computemgtd:main",
34
34
  ]
35
- version = "3.15.0"
35
+ version = "3.16.0"
36
36
  requires = ["boto3>=1.7.55", "retrying>=1.3.3", "setuptools"]
37
37
 
38
38
  setup(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-parallelcluster-node
3
- Version: 3.15.0
3
+ Version: 3.16.0
4
4
  Summary: aws-parallelcluster-node provides the scripts for an AWS ParallelCluster node.
5
5
  Home-page: https://github.com/aws/aws-parallelcluster-node
6
6
  Author: Amazon Web Services
@@ -13,6 +13,11 @@ import json
13
13
  import logging
14
14
  import os
15
15
  import re
16
+ import shlex
17
+
18
+ # A nosec comment is appended to the following line in order to disable the B404 check.
19
+ # In this file the input of the module subprocess is trusted.
20
+ import subprocess # nosec B404
16
21
  from datetime import datetime, timezone
17
22
  from typing import Dict, List
18
23
 
@@ -60,10 +65,25 @@ SLURM_CONF_DIR = os.path.join(os.path.split(SLURM_BINARIES_DIR)[0], "etc")
60
65
  SCONTROL = f"sudo {SLURM_BINARIES_DIR}/scontrol"
61
66
  SINFO = f"{SLURM_BINARIES_DIR}/sinfo"
62
67
 
63
- SCONTROL_OUTPUT_AWK_PARSER = (
64
- 'awk \'BEGIN{{RS="\\n\\n" ; ORS="######\\n";}} {{print}}\' | '
65
- + "grep -oP '^(NodeName=\\S+)|(NodeAddr=\\S+)|(NodeHostName=\\S+)|(?<!Next)(State=\\S+)|"
66
- + "(Partitions=\\S+)|(SlurmdStartTime=\\S+)|(LastBusyTime=\\S+)|(ReservationName=\\S+)|(Reason=.*)|(######)'"
68
+ SCONTROL_NODE_INFO_FIELD_REGEX = re.compile(
69
+ r"^(NodeName=\S+)"
70
+ r"|(NodeAddr=\S+)"
71
+ r"|(NodeHostName=\S+)"
72
+ r"|(?<!Next)(State=\S+)"
73
+ r"|(Partitions=\S+)"
74
+ r"|(SlurmdStartTime=\S+)"
75
+ r"|(LastBusyTime=\S+)"
76
+ r"|(ReservationName=\S+)"
77
+ r"|(InstanceId=\S+)"
78
+ r"|(Reason=.*)",
79
+ re.MULTILINE,
80
+ )
81
+
82
+ # Fields extracted from `scontrol show partitions` output. `(?<!Next)` ensures `State` is matched but
83
+ # `NextState` is not.
84
+ SCONTROL_PARTITION_INFO_FIELD_REGEX = re.compile(
85
+ r"^(PartitionName=\S+)" r"|(?<!Next)(State=\S+)",
86
+ re.MULTILINE,
67
87
  )
68
88
 
69
89
  # Set default timeouts for running different slurm commands.
@@ -129,6 +149,7 @@ def update_nodes(
129
149
  nodes,
130
150
  nodeaddrs=None,
131
151
  nodehostnames=None,
152
+ instance_ids=None,
132
153
  state=None,
133
154
  reason=None,
134
155
  raise_on_error=True,
@@ -150,8 +171,11 @@ def update_nodes(
150
171
  For example, if updating a state cause failure, but updating nodeaddr cause no failure.
151
172
  if we run scontrol update state=fail_state nodeaddr=good_addr nodename=name,
152
173
  the scontrol command will fail but nodeaddr will be updated to good_addr.
174
+
175
+ InstanceId is set in the same batched command as NodeAddr so that the node and its backing
176
+ instance are associated atomically.
153
177
  """
154
- batched_node_info = _batch_node_info(nodes, nodeaddrs, nodehostnames, batch_size=100)
178
+ batched_node_info = _batch_node_info(nodes, nodeaddrs, nodehostnames, instance_ids, batch_size=100)
155
179
 
156
180
  update_cmd = f"{SCONTROL} update"
157
181
  if state:
@@ -160,7 +184,7 @@ def update_nodes(
160
184
  if reason:
161
185
  validate_subprocess_argument(reason)
162
186
  update_cmd += f' reason="{reason}"'
163
- for nodenames, addrs, hostnames in batched_node_info:
187
+ for nodenames, addrs, hostnames, ids in batched_node_info:
164
188
  validate_subprocess_argument(nodenames)
165
189
  node_info = f"nodename={nodenames}"
166
190
  if addrs:
@@ -169,6 +193,9 @@ def update_nodes(
169
193
  if hostnames:
170
194
  validate_subprocess_argument(hostnames)
171
195
  node_info += f" nodehostname={hostnames}"
196
+ if ids:
197
+ validate_subprocess_argument(ids)
198
+ node_info += f" instanceid={ids}"
172
199
  # It's safe to use the function affected by B604 since the command is fully built in this code
173
200
  run_command( # nosec B604
174
201
  f"{update_cmd} {node_info}", raise_on_error=raise_on_error, timeout=command_timeout, shell=True
@@ -213,6 +240,44 @@ def update_all_partitions(state, reset_node_addrs_hostname):
213
240
  return False
214
241
 
215
242
 
243
+ def reset_nodes_in_inactive_partitions():
244
+ """
245
+ Reset nodeaddr/nodehostname and set to down the nodes belonging to INACTIVE partitions.
246
+
247
+ This is meant to be called when starting the compute fleet, before bringing partitions back UP, to clean up nodes
248
+ that an INACTIVE partition may have left behind (e.g. dynamic nodes that were powering up without a backing
249
+ instance when protected mode disabled the partition). If not reset, those nodes would be detected as bootstrap
250
+ failures right after start and send the cluster back into protected mode. Only INACTIVE partitions are considered,
251
+ so nodes of active partitions (which may be running jobs) are not affected.
252
+ """
253
+ try:
254
+ # Collect the node names of INACTIVE partitions, skipping partitions with no nodes to avoid building a
255
+ # malformed (e.g. empty or with consecutive commas) node list.
256
+ inactive_partition_nodes = [
257
+ part.nodenames
258
+ for part in get_partitions_info()
259
+ if PartitionStatus(part.state) == PartitionStatus.INACTIVE and part.nodenames
260
+ ]
261
+ if not inactive_partition_nodes:
262
+ log.info("No nodes in INACTIVE partitions, nothing to reset.")
263
+ return
264
+ # Reset only the nodes that actually need it (e.g. nodeaddr still set), mirroring clustermgtd's inactive
265
+ # cleanup.
266
+ nodes_to_reset = {
267
+ node.name for node in get_nodes_info(",".join(inactive_partition_nodes)) if node.needs_reset_when_inactive()
268
+ }
269
+ if nodes_to_reset:
270
+ log.info(
271
+ "Resetting nodeaddr/nodehostname and setting to down the following nodes of INACTIVE partitions: %s",
272
+ sorted(nodes_to_reset),
273
+ )
274
+ reset_nodes(nodes_to_reset, state="down", reason="inactive partition", raise_on_error=False)
275
+ else:
276
+ log.info("No nodes of INACTIVE partitions need to be reset.")
277
+ except Exception as e:
278
+ log.error("Failed when resetting nodes of INACTIVE partitions with error %s", e)
279
+
280
+
216
281
  def _batch_attribute(attribute, batch_size, expected_length=None):
217
282
  """Parse an attribute into batches."""
218
283
  if type(attribute) is str:
@@ -223,29 +288,30 @@ def _batch_attribute(attribute, batch_size, expected_length=None):
223
288
  return [",".join(batch) for batch in grouper(attribute, batch_size)]
224
289
 
225
290
 
226
- def _batch_node_info(nodenames, nodeaddrs, nodehostnames, batch_size):
227
- """Group nodename, nodeaddrs, nodehostnames into batches."""
291
+ def _batch_optional_attribute(attribute, attribute_label, nodenames, default_batch, batch_size):
292
+ """Batch an optional per-node attribute, raising if its entry count does not match the nodes."""
293
+ if not attribute:
294
+ return default_batch
295
+ try:
296
+ return _batch_attribute(attribute, batch_size, expected_length=len(nodenames))
297
+ except ValueError:
298
+ log.error("Nodename %s and %s %s contain different number of entries", nodenames, attribute_label, attribute)
299
+ raise
300
+
301
+
302
+ def _batch_node_info(nodenames, nodeaddrs, nodehostnames, instance_ids, batch_size):
303
+ """Group nodename, nodeaddrs, nodehostnames, instance_ids into batches."""
228
304
  if type(nodenames) is str:
229
305
  # Only split on , if there is ] before
230
306
  # For ex. "node-[1,3,4-5],node-[20,30]" should split into ["node-[1,3,4-5]","node-[20,30]"]
231
307
  nodenames = re.split("(?<=]),", nodenames)
232
308
  nodename_batch = _batch_attribute(nodenames, batch_size)
233
- nodeaddrs_batch = [None] * len(nodename_batch)
234
- nodehostnames_batch = [None] * len(nodename_batch)
235
- if nodeaddrs:
236
- try:
237
- nodeaddrs_batch = _batch_attribute(nodeaddrs, batch_size, expected_length=len(nodenames))
238
- except ValueError:
239
- log.error("Nodename %s and NodeAddr %s contain different number of entries", nodenames, nodeaddrs)
240
- raise
241
- if nodehostnames:
242
- try:
243
- nodehostnames_batch = _batch_attribute(nodehostnames, batch_size, expected_length=len(nodenames))
244
- except ValueError:
245
- log.error("Nodename %s and NodeHostname %s contain different number of entries", nodenames, nodehostnames)
246
- raise
309
+ default_batch = [None] * len(nodename_batch)
310
+ nodeaddrs_batch = _batch_optional_attribute(nodeaddrs, "NodeAddr", nodenames, default_batch, batch_size)
311
+ nodehostnames_batch = _batch_optional_attribute(nodehostnames, "NodeHostName", nodenames, default_batch, batch_size)
312
+ instance_ids_batch = _batch_optional_attribute(instance_ids, "InstanceId", nodenames, default_batch, batch_size)
247
313
 
248
- return zip(nodename_batch, nodeaddrs_batch, nodehostnames_batch)
314
+ return zip(nodename_batch, nodeaddrs_batch, nodehostnames_batch, instance_ids_batch)
249
315
 
250
316
 
251
317
  def set_nodes_down(nodes, reason):
@@ -289,6 +355,83 @@ def set_nodes_idle(nodes, reason=None, reset_node_addrs_hostname=False):
289
355
  update_nodes(nodes=nodes, state="resume", reason=reason, raise_on_error=False)
290
356
 
291
357
 
358
+ def _run_scontrol_command(
359
+ scontrol_args: str,
360
+ command_timeout: int = DEFAULT_GET_INFO_COMMAND_TIMEOUT,
361
+ raise_on_error: bool = True,
362
+ ) -> str:
363
+ """
364
+ Run a `scontrol <args>` command as a standalone subprocess and return its stdout.
365
+
366
+ scontrol's exit code, stderr and raw stdout are logged. When scontrol exits non-zero but still returns
367
+ output (e.g. one of the requested nodes does not exist: Slurm prints "Node <x> not found" and exits 1
368
+ while still returning the other nodes), the output is returned and a warning is logged. When it exits
369
+ non-zero with no output, an error is logged and, when raise_on_error, a CalledProcessError is raised.
370
+ """
371
+ command = f"{SCONTROL} {scontrol_args}"
372
+ log.debug("Executing scontrol command: %s", command)
373
+ try:
374
+ # nosec B603: command is built from trusted/validated input and run without a shell.
375
+ result = subprocess.run( # nosec B603
376
+ shlex.split(command),
377
+ timeout=command_timeout,
378
+ check=False,
379
+ stdout=subprocess.PIPE,
380
+ stderr=subprocess.PIPE,
381
+ encoding="utf-8",
382
+ )
383
+ except subprocess.TimeoutExpired:
384
+ log.error("scontrol command timed out after %s seconds: '%s'", command_timeout, command)
385
+ raise
386
+ except OSError as e:
387
+ log.error("Unable to execute scontrol command '%s'. Failed with exception: %s", command, e)
388
+ raise
389
+
390
+ stdout = result.stdout or ""
391
+ stderr = (result.stderr or "").strip()
392
+ # Raw scontrol output is logged at DEBUG only, so it is not emitted at the default INFO level.
393
+ log.debug("scontrol command '%s' exited with code %s. Raw output:\n%s", command, result.returncode, stdout)
394
+
395
+ if result.returncode != 0:
396
+ if stdout.strip():
397
+ log.warning(
398
+ "scontrol command '%s' returned non-zero exit code %s but produced output; proceeding with "
399
+ "the output. stderr: '%s'",
400
+ command,
401
+ result.returncode,
402
+ stderr,
403
+ )
404
+ else:
405
+ log.error(
406
+ "scontrol command '%s' failed with exit code %s and produced no output. stderr: '%s'",
407
+ command,
408
+ result.returncode,
409
+ stderr,
410
+ )
411
+ if raise_on_error:
412
+ raise subprocess.CalledProcessError(result.returncode, command, output=stdout, stderr=result.stderr)
413
+
414
+ return stdout
415
+
416
+
417
+ def _extract_scontrol_records(raw_output: str, field_regex) -> List[Dict[str, str]]:
418
+ """
419
+ Split raw scontrol output into records and extract the relevant `key=value` fields of each record.
420
+
421
+ Records are separated by blank lines. For each record, only the fields matched by field_regex are kept,
422
+ returned as a dict mapping the Slurm field name to its value. Records with no matching field are skipped.
423
+ """
424
+ records = []
425
+ for record in re.split(r"\n\n+", raw_output.strip()):
426
+ fields = {}
427
+ for match in field_regex.finditer(record):
428
+ key, _, value = match.group(0).partition("=")
429
+ fields[key] = value
430
+ if fields:
431
+ records.append(fields)
432
+ return records
433
+
434
+
292
435
  def get_nodes_info(nodes: str = "", command_timeout=DEFAULT_GET_INFO_COMMAND_TIMEOUT) -> List[SlurmNode]:
293
436
  """
294
437
  Retrieve SlurmNode list from slurm nodelist notation.
@@ -304,15 +447,11 @@ def get_nodes_info(nodes: str = "", command_timeout=DEFAULT_GET_INFO_COMMAND_TIM
304
447
  if nodes == "":
305
448
  nodes = _get_all_partition_nodes(",".join(PartitionNodelistMapping.instance().get_partitions()))
306
449
 
307
- # Validation to sanitize the input argument and make it safe to use the function affected by B604
308
450
  validate_subprocess_argument(nodes)
309
451
 
310
- # awk is used to replace the \n\n record separator with '######\n'
311
452
  # Note: In case the node does not belong to any partition the Partitions field is missing from Slurm output
312
- show_node_info_command = f"{SCONTROL} show nodes {nodes} | {SCONTROL_OUTPUT_AWK_PARSER}"
313
- nodeinfo_str = check_command_output(show_node_info_command, timeout=command_timeout, shell=True) # nosec B604
314
-
315
- return _parse_nodes_info(nodeinfo_str)
453
+ raw_node_info = _run_scontrol_command(f"show nodes {nodes}", command_timeout=command_timeout)
454
+ return _parse_nodes_info(_extract_scontrol_records(raw_node_info, SCONTROL_NODE_INFO_FIELD_REGEX))
316
455
 
317
456
 
318
457
  def get_partitions_info(command_timeout=DEFAULT_GET_INFO_COMMAND_TIMEOUT) -> List[SlurmPartition]:
@@ -321,16 +460,10 @@ def get_partitions_info(command_timeout=DEFAULT_GET_INFO_COMMAND_TIMEOUT) -> Lis
321
460
 
322
461
  This function considers only partitions managed by ParallelCluster.
323
462
  """
324
- partitions = list(PartitionNodelistMapping.instance().get_partitions())
325
- grep_filter = _get_partition_grep_filter(partitions)
326
- show_partition_info_command = (
327
- f"{SCONTROL} show partitions -o {grep_filter} " + '| grep -oP "^PartitionName=\\K(\\S+)| State=\\K(\\S+)"'
328
- )
329
- # It's safe to use the function affected by B604 since the command is fully built in this code
330
- partition_info_str = check_command_output(
331
- show_partition_info_command, timeout=command_timeout, shell=True # nosec B604
332
- )
333
- partitions_info = _parse_partition_name_and_state(partition_info_str)
463
+ partitions = set(PartitionNodelistMapping.instance().get_partitions())
464
+ raw_partition_info = _run_scontrol_command("show partitions", command_timeout=command_timeout)
465
+ partition_records = _extract_scontrol_records(raw_partition_info, SCONTROL_PARTITION_INFO_FIELD_REGEX)
466
+ partitions_info = _parse_partitions_info(partition_records, managed_partitions=partitions or None)
334
467
  return [
335
468
  SlurmPartition(
336
469
  partition_name,
@@ -341,15 +474,6 @@ def get_partitions_info(command_timeout=DEFAULT_GET_INFO_COMMAND_TIMEOUT) -> Lis
341
474
  ]
342
475
 
343
476
 
344
- def _get_partition_grep_filter(partitions: List[str]) -> str:
345
- grep_filter = ""
346
- if partitions:
347
- grep_filter += " | grep"
348
- for partition in partitions:
349
- grep_filter += f' -e "PartitionName={partition}"'
350
- return grep_filter
351
-
352
-
353
477
  def resume_powering_down_nodes():
354
478
  """Resume nodes that are powering_down so that are set in power state right away."""
355
479
  # TODO: This function was added due to Slurm ticket 12915. The bug is not reproducible and the ticket was then
@@ -359,9 +483,23 @@ def resume_powering_down_nodes():
359
483
  update_nodes(nodes=powering_down_nodes, state="resume", raise_on_error=False)
360
484
 
361
485
 
362
- def _parse_partition_name_and_state(partition_info):
363
- """Parse partition name and state from scontrol output."""
364
- return grouper(partition_info.splitlines(), 2)
486
+ def _parse_partitions_info(partition_records, managed_partitions=None):
487
+ """
488
+ Extract (partition_name, partition_state) tuples from scontrol partition records.
489
+
490
+ Only partitions in managed_partitions are returned; if managed_partitions is None, all partitions are
491
+ returned. Records missing the name or state are skipped.
492
+ """
493
+ partitions_info = []
494
+ for fields in partition_records:
495
+ partition_name = fields.get("PartitionName")
496
+ partition_state = fields.get("State")
497
+ if not partition_name or not partition_state:
498
+ continue
499
+ if managed_partitions is not None and partition_name not in managed_partitions:
500
+ continue
501
+ partitions_info.append((partition_name, partition_state))
502
+ return partitions_info
365
503
 
366
504
 
367
505
  def _get_all_partition_nodes(partition_name, command_timeout=DEFAULT_GET_INFO_COMMAND_TIMEOUT):
@@ -386,43 +524,14 @@ def _get_slurm_nodes(states=None, partition_name=None, command_timeout=DEFAULT_G
386
524
  return check_command_output(sinfo_command, timeout=command_timeout, shell=True).splitlines() # nosec B604
387
525
 
388
526
 
389
- def _parse_nodes_info(slurm_node_info: str) -> List[SlurmNode]:
390
- """Parse slurm node info into SlurmNode objects."""
391
- # [ec2-user@ip-10-0-0-58 ~]$ /opt/slurm/bin/scontrol show nodes compute-dy-c5xlarge-[1-3],compute-dy-c5xlarge-50001\
392
- # | awk 'BEGIN{{RS="\n\n" ; ORS="######\n";}} {{print}}' | grep -oP "^(NodeName=\S+)|(NodeAddr=\S+)
393
- # |(NodeHostName=\S+)|(?<!Next)(State=\S+)|(Partitions=\S+)|(SlurmdStartTime=\S+)|(LastBusyTime=\\S+)
394
- # |(ReservationName=\S+)|(Reason=.*)|(######)"
395
- # NodeName=compute-dy-c5xlarge-1
396
- # NodeAddr=1.2.3.4
397
- # NodeHostName=compute-dy-c5xlarge-1
398
- # State=IDLE+CLOUD+POWER
399
- # Partitions=compute,compute2
400
- # SlurmdStartTime=2023-01-26T09:57:15
401
- # Reason=some reason
402
- # ReservationName=root_1
403
- # ######
404
- # NodeName=compute-dy-c5xlarge-2
405
- # NodeAddr=1.2.3.4
406
- # NodeHostName=compute-dy-c5xlarge-2
407
- # State=IDLE+CLOUD+POWER
408
- # Partitions=compute,compute2
409
- # SlurmdStartTime=2023-01-26T09:57:15
410
- # Reason=(Code:InsufficientInstanceCapacity)Failure when resuming nodes
411
- # ######
412
- # NodeName=compute-dy-c5xlarge-3
413
- # NodeAddr=1.2.3.4
414
- # NodeHostName=compute-dy-c5xlarge-3
415
- # State=IDLE+CLOUD+POWER
416
- # Partitions=compute,compute2
417
- # SlurmdStartTime=2023-01-26T09:57:15
418
- # ######
419
- # NodeName=compute-dy-c5xlarge-50001
420
- # NodeAddr=1.2.3.4
421
- # NodeHostName=compute-dy-c5xlarge-50001
422
- # State=IDLE+CLOUD+POWER
423
- # SlurmdStartTime=None
424
- # ######
527
+ def _parse_nodes_info(node_records: List[Dict[str, str]]) -> List[SlurmNode]:
528
+ """
529
+ Build SlurmNode objects from scontrol node records extracted by _extract_scontrol_records.
425
530
 
531
+ Each record is a dict mapping Slurm field names to their values, e.g.:
532
+ {"NodeName": "compute-dy-c5xlarge-1", "NodeAddr": "1.2.3.4", "NodeHostName": "compute-dy-c5xlarge-1",
533
+ "State": "IDLE+CLOUD+POWER", "Partitions": "compute,compute2", "SlurmdStartTime": "2023-01-26T09:57:15"}
534
+ """
426
535
  map_slurm_key_to_arg = {
427
536
  "NodeName": "name",
428
537
  "NodeAddr": "nodeaddr",
@@ -433,32 +542,32 @@ def _parse_nodes_info(slurm_node_info: str) -> List[SlurmNode]:
433
542
  "SlurmdStartTime": "slurmdstarttime",
434
543
  "LastBusyTime": "lastbusytime",
435
544
  "ReservationName": "reservation_name",
545
+ "InstanceId": "instance_id",
436
546
  }
437
547
 
438
548
  date_fields = ["SlurmdStartTime", "LastBusyTime"]
439
549
 
440
- node_info = slurm_node_info.split("######\n")
441
550
  slurm_nodes = []
442
- for node in node_info:
443
- lines = node.splitlines()
551
+ for fields in node_records:
552
+ if "NodeName" not in fields:
553
+ continue
444
554
  kwargs = {}
445
- for line in lines:
446
- key, value = line.split("=", 1)
555
+ for key, value in fields.items():
447
556
  if key in date_fields:
448
557
  if value not in ["None", "Unknown"]:
449
558
  value = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S").astimezone(tz=timezone.utc)
450
559
  else:
451
560
  value = None
561
+ elif key == "InstanceId" and value == "(null)":
562
+ # Slurm reports an unset InstanceId as "(null)"
563
+ value = None
452
564
  kwargs[map_slurm_key_to_arg[key]] = value
453
- if lines:
454
- try:
455
- if is_static_node(kwargs["name"]):
456
- node = StaticNode(**kwargs)
457
- slurm_nodes.append(node)
458
- else:
459
- node = DynamicNode(**kwargs)
460
- slurm_nodes.append(node)
461
- except InvalidNodenameError:
462
- log.warning("Ignoring node %s because it has an invalid name", kwargs["name"])
565
+ try:
566
+ if is_static_node(kwargs["name"]):
567
+ slurm_nodes.append(StaticNode(**kwargs))
568
+ else:
569
+ slurm_nodes.append(DynamicNode(**kwargs))
570
+ except InvalidNodenameError:
571
+ log.warning("Ignoring node %s because it has an invalid name", kwargs["name"])
463
572
 
464
573
  return slurm_nodes
@@ -9,6 +9,7 @@
9
9
  # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions and
10
10
  # limitations under the License.
11
11
  import logging
12
+ import re
12
13
 
13
14
  # A nosec comment is appended to the following line in order to disable the B404 check.
14
15
  # In this file the input of the module subprocess is trusted.
@@ -16,7 +17,12 @@ import subprocess # nosec B404
16
17
  from datetime import datetime
17
18
  from typing import List, Union
18
19
 
19
- from common.schedulers.slurm_commands import DEFAULT_SCONTROL_COMMAND_TIMEOUT, SCONTROL
20
+ from common.schedulers.slurm_commands import (
21
+ DEFAULT_SCONTROL_COMMAND_TIMEOUT,
22
+ SCONTROL,
23
+ _extract_scontrol_records,
24
+ _run_scontrol_command,
25
+ )
20
26
  from common.utils import (
21
27
  SlurmCommandError,
22
28
  SlurmCommandErrorHandler,
@@ -30,9 +36,11 @@ from slurm_plugin.slurm_resources import SlurmReservation
30
36
  logger = logging.getLogger(__name__)
31
37
 
32
38
 
33
- SCONTROL_SHOW_RESERVATION_OUTPUT_AWK_PARSER = (
34
- 'awk \'BEGIN{{RS="\\n\\n" ; ORS="######\\n";}} {{print}}\' | '
35
- + "grep -oP '^(ReservationName=\\S+)|(?<!Next)(State=\\S+)|(Users=\\S+)|(Nodes=\\S+)|(######)'"
39
+ # Fields extracted from raw `scontrol show reservations` output. Only `ReservationName` is anchored at the
40
+ # start of a line; `(?<!Next)` ensures `State` is matched but `NextState` is not.
41
+ SCONTROL_RESERVATION_INFO_FIELD_REGEX = re.compile(
42
+ r"^(ReservationName=\S+)" r"|(?<!Next)(State=\S+)" r"|(Users=\S+)" r"|(Nodes=\S+)",
43
+ re.MULTILINE,
36
44
  )
37
45
 
38
46
 
@@ -256,41 +264,26 @@ def get_slurm_reservations_info(
256
264
 
257
265
  Official documentation is https://slurm.schedmd.com/reservations.html
258
266
  """
259
- # awk is used to replace the \n\n record separator with '######\n'
260
- show_reservations_command = f"{SCONTROL} show reservations | {SCONTROL_SHOW_RESERVATION_OUTPUT_AWK_PARSER}"
261
- slurm_reservations_info = check_command_output(
262
- show_reservations_command, raise_on_error=raise_on_error, timeout=command_timeout, shell=True
263
- ) # nosec B604
264
-
265
- return _parse_reservations_info(slurm_reservations_info)
266
-
267
-
268
- def _parse_reservations_info(slurm_reservations_info: str) -> List[SlurmReservation]:
269
- """Parse slurm reservations info into SlurmReservation objects."""
270
- # $ /opt/slurm/bin/scontrol show reservations awk 'BEGIN{{RS="\n\n" ; ORS="######\n";}} {{print}}' |
271
- # grep -oP '^(ReservationName=\S+)|(?<!Next)(State=\S+)|(Users=\S+)|(Nodes=\S+)|(######)'
272
- # ReservationName=root_8
273
- # Nodes=queuep4d-dy-crp4d-[1-5]
274
- # Users=root
275
- # State=ACTIVE
276
- # ######
277
- # ReservationName=root_9
278
- # Nodes=queue1-st-crt2micro-1
279
- # Users=root
280
- # State=ACTIVE
281
- # ######
267
+ raw_reservations_info = _run_scontrol_command(
268
+ "show reservations", command_timeout=command_timeout, raise_on_error=raise_on_error
269
+ )
270
+ reservation_records = _extract_scontrol_records(raw_reservations_info, SCONTROL_RESERVATION_INFO_FIELD_REGEX)
271
+
272
+ return _parse_reservations_info(reservation_records)
273
+
274
+
275
+ def _parse_reservations_info(reservation_records: List[dict]) -> List[SlurmReservation]:
276
+ """
277
+ Build SlurmReservation objects from scontrol reservation records extracted by _extract_scontrol_records.
278
+
279
+ Each record is a dict mapping Slurm field names to their values, e.g.:
280
+ {"ReservationName": "root_8", "Nodes": "queuep4d-dy-crp4d-[1-5]", "Users": "root", "State": "ACTIVE"}
281
+ """
282
282
  map_slurm_key_to_arg = {"ReservationName": "name", "Nodes": "nodes", "Users": "users", "State": "state"}
283
283
 
284
- reservation_info = slurm_reservations_info.split("######\n")
285
284
  slurm_reservations = []
286
- for reservation in reservation_info:
287
- lines = reservation.splitlines()
288
- kwargs = {}
289
- for line in lines:
290
- key, value = line.split("=")
291
- kwargs[map_slurm_key_to_arg[key]] = value
292
- if lines:
293
- reservation = SlurmReservation(**kwargs)
294
- slurm_reservations.append(reservation)
285
+ for fields in reservation_records:
286
+ kwargs = {map_slurm_key_to_arg[key]: value for key, value in fields.items()}
287
+ slurm_reservations.append(SlurmReservation(**kwargs))
295
288
 
296
289
  return slurm_reservations
@@ -44,6 +44,7 @@ from slurm_plugin.capacity_block_manager import CapacityBlockManager
44
44
  from slurm_plugin.cluster_event_publisher import ClusterEventPublisher
45
45
  from slurm_plugin.common import TIMESTAMP_FORMAT, ScalingStrategy, log_exception, print_with_count
46
46
  from slurm_plugin.console_logger import ConsoleLogger
47
+ from slurm_plugin.fleet_manager import INSTANCE_INFO_RETRIEVAL_TIMEOUT_DEFAULT
47
48
  from slurm_plugin.instance_manager import InstanceManager
48
49
  from slurm_plugin.slurm_resources import (
49
50
  CONFIG_FILE_DIR,
@@ -97,14 +98,30 @@ class ComputeFleetStatusManager:
97
98
  COMPUTE_FLEET_STATUS_ATTRIBUTE = "status"
98
99
  COMPUTE_FLEET_LAST_UPDATED_TIME_ATTRIBUTE = "lastStatusUpdatedTime"
99
100
 
101
+ # Retry is sized to outlast the transient IMDS unavailability windows observed in the fleet, which are in the
102
+ # order of a few seconds: waits are 1s, 2s and 4s, plus a jitter of up to 1s each to avoid retrying in lockstep
103
+ # with the other daemons polling IMDS. The overall wait is kept well below the clustermgtd loop time, because
104
+ # the fleet status is retrieved synchronously at the beginning of every loop.
105
+ @staticmethod
106
+ @retry(
107
+ wait_exponential_multiplier=500,
108
+ wait_exponential_max=seconds(4),
109
+ wait_jitter_max=seconds(1),
110
+ stop_max_attempt_number=4,
111
+ )
112
+ def _get_fleet_status():
113
+ # Failures are logged by get_status only once the retries are exhausted, so that the ones recovered by the
114
+ # retries do not pollute the log with errors that have no impact on the cluster.
115
+ compute_fleet_raw_data = check_command_output("get-compute-fleet-status.sh", log_error=False)
116
+ log.debug("Retrieved compute fleet data: %s", compute_fleet_raw_data)
117
+ return ComputeFleetStatus(
118
+ json.loads(compute_fleet_raw_data).get(ComputeFleetStatusManager.COMPUTE_FLEET_STATUS_ATTRIBUTE)
119
+ )
120
+
100
121
  @staticmethod
101
122
  def get_status(fallback=None):
102
123
  try:
103
- compute_fleet_raw_data = check_command_output("get-compute-fleet-status.sh")
104
- log.debug("Retrieved compute fleet data: %s", compute_fleet_raw_data)
105
- return ComputeFleetStatus(
106
- json.loads(compute_fleet_raw_data).get(ComputeFleetStatusManager.COMPUTE_FLEET_STATUS_ATTRIBUTE)
107
- )
124
+ return ComputeFleetStatusManager._get_fleet_status()
108
125
  except Exception as e:
109
126
  if isinstance(e, CalledProcessError):
110
127
  error = e.stdout.rstrip()
@@ -115,7 +132,6 @@ class ComputeFleetStatusManager:
115
132
  error,
116
133
  fallback,
117
134
  )
118
-
119
135
  return fallback
120
136
 
121
137
  @staticmethod
@@ -143,6 +159,7 @@ class ClustermgtdConfig:
143
159
  "run_instances_overrides": "/opt/slurm/etc/pcluster/run_instances_overrides.json",
144
160
  "create_fleet_overrides": "/opt/slurm/etc/pcluster/create_fleet_overrides.json",
145
161
  "fleet_config_file": "/etc/parallelcluster/slurm_plugin/fleet-config.json",
162
+ "instance_info_retrieval_timeout": INSTANCE_INFO_RETRIEVAL_TIMEOUT_DEFAULT,
146
163
  # Terminate configs
147
164
  "terminate_max_batch_size": 1000,
148
165
  # Timeout to wait for node initialization, should be the same as ResumeTimeout
@@ -253,6 +270,11 @@ class ClustermgtdConfig:
253
270
  "clustermgtd", "create_fleet_overrides", fallback=self.DEFAULTS.get("create_fleet_overrides")
254
271
  )
255
272
  self.create_fleet_overrides = read_json(create_fleet_overrides_file, default={})
273
+ self.instance_info_retrieval_timeout = config.getint(
274
+ "clustermgtd",
275
+ "instance_info_retrieval_timeout",
276
+ fallback=self.DEFAULTS.get("instance_info_retrieval_timeout"),
277
+ )
256
278
 
257
279
  def _get_health_check_config(self, config):
258
280
  self.disable_ec2_health_check = config.getboolean(
@@ -448,6 +470,7 @@ class ClusterManager:
448
470
  run_instances_overrides=config.run_instances_overrides,
449
471
  create_fleet_overrides=config.create_fleet_overrides,
450
472
  fleet_config=config.fleet_config,
473
+ instance_info_retrieval_timeout=config.instance_info_retrieval_timeout,
451
474
  )
452
475
 
453
476
  def _initialize_executor(self, config):
@@ -729,13 +752,13 @@ class ClusterManager:
729
752
  return is_instance_status_unhealthy
730
753
 
731
754
  def _update_static_nodes_in_replacement(self, slurm_nodes: List[SlurmNode]):
732
- """Remove from self.static_nodes_in_replacement nodes that are up or that are in maintenance."""
755
+ """Remove from self.static_nodes_in_replacement nodes that are up."""
733
756
  nodename_to_slurm_nodes_map = {node.name: node for node in slurm_nodes}
734
757
  nodes_still_in_replacement = set()
735
758
  for nodename in self._static_nodes_in_replacement:
736
759
  node = nodename_to_slurm_nodes_map.get(nodename)
737
- # Consider nodename still in replacement if node is not up and not in maintenance
738
- if node and not node.is_up() and not node.is_in_maintenance():
760
+ # Consider nodename still in replacement if node is not up
761
+ if node and not node.is_up():
739
762
  nodes_still_in_replacement.add(nodename)
740
763
 
741
764
  # override self._static_nodes_in_replacement with updated list
@@ -949,7 +972,11 @@ class ClusterManager:
949
972
  log.info("Found the following unhealthy dynamic nodes: %s", print_with_count(unhealthy_dynamic_nodes))
950
973
  self._handle_unhealthy_dynamic_nodes(unhealthy_dynamic_nodes)
951
974
  if unhealthy_static_nodes:
952
- log.info("Found the following unhealthy static nodes: %s", print_with_count(unhealthy_static_nodes))
975
+ # Include the node states, which carry the reason the nodes are considered unhealthy.
976
+ log.info(
977
+ "Found the following unhealthy static nodes: %s",
978
+ print_with_count([f"{node} in state {node.state_string}" for node in unhealthy_static_nodes]),
979
+ )
953
980
  self._handle_unhealthy_static_nodes(unhealthy_static_nodes)
954
981
 
955
982
  # evaluate partitions to put in protected mode and ICEs nodes to terminate
@@ -1145,15 +1172,14 @@ class ClusterManager:
1145
1172
 
1146
1173
  @staticmethod
1147
1174
  def _update_slurm_nodes_with_ec2_info(nodes, cluster_instances):
1175
+ """Associate EC2 instances with Slurm nodes by matching on instance ID."""
1148
1176
  if cluster_instances:
1149
- ip_to_slurm_node_map = {node.nodeaddr: node for node in nodes}
1177
+ instance_id_to_slurm_node_map = {node.instance_id: node for node in nodes if node.instance_id}
1150
1178
  for instance in cluster_instances:
1151
- for private_ip in instance.all_private_ips:
1152
- if private_ip in ip_to_slurm_node_map:
1153
- slurm_node = ip_to_slurm_node_map.get(private_ip)
1154
- slurm_node.instance = instance
1155
- instance.slurm_node = slurm_node
1156
- break
1179
+ if instance.id in instance_id_to_slurm_node_map:
1180
+ slurm_node = instance_id_to_slurm_node_map[instance.id]
1181
+ slurm_node.instance = instance
1182
+ instance.slurm_node = slurm_node
1157
1183
 
1158
1184
  @staticmethod
1159
1185
  def get_instance_id_to_active_node_map(partitions: List[SlurmPartition]) -> Dict:
@@ -24,6 +24,12 @@ from slurm_plugin.common import print_with_count
24
24
 
25
25
  logger = logging.getLogger(__name__)
26
26
 
27
+ # Total time budget (seconds) and per-attempt backoff cap for retrying DescribeInstances after a CreateFleet
28
+ # launch, to tolerate EC2 API eventual consistency.
29
+ # See https://docs.aws.amazon.com/ec2/latest/devguide/eventual-consistency.html
30
+ INSTANCE_INFO_RETRIEVAL_TIMEOUT_DEFAULT = 90
31
+ INSTANCE_INFO_RETRIEVAL_MAX_BACKOFF = 30
32
+
27
33
 
28
34
  class EC2Instance:
29
35
  def __init__(self, id, private_ip, hostname, all_private_ips, launch_time):
@@ -94,6 +100,7 @@ class FleetManagerFactory:
94
100
  all_or_nothing,
95
101
  run_instances_overrides,
96
102
  create_fleet_overrides,
103
+ instance_info_retrieval_timeout=INSTANCE_INFO_RETRIEVAL_TIMEOUT_DEFAULT,
97
104
  ):
98
105
  try:
99
106
  queue_config = fleet_config[queue]
@@ -120,6 +127,7 @@ class FleetManagerFactory:
120
127
  compute_resource_config,
121
128
  all_or_nothing,
122
129
  create_fleet_overrides.get(queue, {}).get(compute_resource, {}),
130
+ instance_info_retrieval_timeout=instance_info_retrieval_timeout,
123
131
  )
124
132
  elif api == "run-instances":
125
133
  return Ec2RunInstancesManager(
@@ -272,6 +280,7 @@ class Ec2CreateFleetManager(FleetManager):
272
280
  compute_resource_config,
273
281
  all_or_nothing,
274
282
  launch_overrides,
283
+ instance_info_retrieval_timeout=INSTANCE_INFO_RETRIEVAL_TIMEOUT_DEFAULT,
275
284
  ):
276
285
  super().__init__(
277
286
  cluster_name,
@@ -283,6 +292,7 @@ class Ec2CreateFleetManager(FleetManager):
283
292
  all_or_nothing,
284
293
  launch_overrides,
285
294
  )
295
+ self._instance_info_retrieval_timeout = instance_info_retrieval_timeout
286
296
 
287
297
  def _evaluate_template_overrides(self) -> list:
288
298
  """Build and return the list of Launch Template Overrides to be applied in the CreateFleet request.
@@ -436,22 +446,39 @@ class Ec2CreateFleetManager(FleetManager):
436
446
  """
437
447
  Describe instances to retrieve info not available from create-fleet response.
438
448
 
449
+ Right after a CreateFleet launch, DescribeInstances may return instances with missing info
450
+ (e.g. PrivateIpAddress) or even InvalidInstanceID.NotFound due to EC2 API eventual consistency.
451
+ Retry with exponential backoff (capped per attempt) until the configured total timeout is reached,
452
+ as recommended at https://docs.aws.amazon.com/ec2/latest/devguide/eventual-consistency.html
453
+
439
454
  :raises ClientError in case of boto3 failure
440
455
  :return list of instances with complete information and list of IDs for instances with incomplete information
441
456
  """
442
457
  instances = []
443
458
  partial_instance_ids = instance_ids
444
459
 
445
- retries = 5
446
460
  attempt_count = 0
461
+ # Budget is tracked against the un-jittered backoff; jitter is added only to the actual sleep.
462
+ elapsed_backoff = 0
447
463
  # Wait for instances to be available in EC2
448
464
  time.sleep(0.1)
449
- while attempt_count < retries and partial_instance_ids:
465
+ while partial_instance_ids:
450
466
  complete_instances, partial_instance_ids = self._retrieve_instances_info_from_ec2(partial_instance_ids)
451
467
  instances.extend(complete_instances)
468
+ if not partial_instance_ids:
469
+ break
470
+ base_backoff = min(0.3 * 2 ** (attempt_count + 1), INSTANCE_INFO_RETRIEVAL_MAX_BACKOFF)
471
+ if elapsed_backoff + base_backoff > self._instance_info_retrieval_timeout:
472
+ logger.warning(
473
+ "Unable to retrieve complete info for instances %s within %s seconds, giving up after %s attempts.",
474
+ print_with_count(partial_instance_ids),
475
+ self._instance_info_retrieval_timeout,
476
+ attempt_count + 1,
477
+ )
478
+ break
479
+ elapsed_backoff += base_backoff
452
480
  attempt_count += 1
453
- if attempt_count < retries:
454
- time.sleep(0.3 * 2**attempt_count + (secrets.randbelow(500) / 1000))
481
+ time.sleep(base_backoff + (secrets.randbelow(500) / 1000))
455
482
 
456
483
  return instances, partial_instance_ids
457
484
 
@@ -19,7 +19,11 @@ from configparser import ConfigParser
19
19
  from logging.config import fileConfig
20
20
 
21
21
  from botocore.config import Config
22
- from common.schedulers.slurm_commands import resume_powering_down_nodes, update_all_partitions
22
+ from common.schedulers.slurm_commands import (
23
+ reset_nodes_in_inactive_partitions,
24
+ resume_powering_down_nodes,
25
+ update_all_partitions,
26
+ )
23
27
  from slurm_plugin.clustermgtd import ComputeFleetStatus, ComputeFleetStatusManager
24
28
  from slurm_plugin.common import log_exception
25
29
  from slurm_plugin.instance_manager import InstanceManager
@@ -91,6 +95,7 @@ def _manage_fleet_status_transition(config, computefleet_status_data_path):
91
95
 
92
96
  def _start_partitions():
93
97
  log.info("Setting slurm partitions to UP and resuming nodes...")
98
+ reset_nodes_in_inactive_partitions()
94
99
  update_all_partitions(PartitionStatus.UP, reset_node_addrs_hostname=False)
95
100
  # TODO: This function was added due to Slurm ticket 12915. The bug is not reproducible and the ticket was then
96
101
  # closed. This operation may now be useless: we need to check this.
@@ -25,7 +25,11 @@ from common.ec2_utils import get_private_ip_address_and_dns_name
25
25
  from common.schedulers.slurm_commands import get_nodes_info, update_nodes
26
26
  from common.utils import grouper, setup_logging_filter
27
27
  from slurm_plugin.common import ComputeInstanceDescriptor, ScalingStrategy, log_exception, print_with_count
28
- from slurm_plugin.fleet_manager import EC2Instance, FleetManagerFactory
28
+ from slurm_plugin.fleet_manager import (
29
+ INSTANCE_INFO_RETRIEVAL_TIMEOUT_DEFAULT,
30
+ EC2Instance,
31
+ FleetManagerFactory,
32
+ )
29
33
  from slurm_plugin.slurm_resources import (
30
34
  EC2_HEALTH_STATUS_UNHEALTHY_STATES,
31
35
  EC2_INSTANCE_ALIVE_STATES,
@@ -85,6 +89,7 @@ class InstanceManager:
85
89
  run_instances_overrides: dict = None,
86
90
  create_fleet_overrides: dict = None,
87
91
  job_level_scaling: bool = False,
92
+ instance_info_retrieval_timeout: int = INSTANCE_INFO_RETRIEVAL_TIMEOUT_DEFAULT,
88
93
  ):
89
94
  """Initialize InstanceLauncher with required attributes."""
90
95
  self._region = region
@@ -101,6 +106,7 @@ class InstanceManager:
101
106
  self._fleet_config = fleet_config
102
107
  self._run_instances_overrides = run_instances_overrides or {}
103
108
  self._create_fleet_overrides = create_fleet_overrides or {}
109
+ self._instance_info_retrieval_timeout = instance_info_retrieval_timeout
104
110
  self._boto3_resource_factory = lambda resource_name: boto3.session.Session().resource(
105
111
  resource_name, region_name=region, config=boto3_config
106
112
  )
@@ -262,7 +268,8 @@ class InstanceManager:
262
268
  """
263
269
  Get instances that are associated with the cluster.
264
270
 
265
- Instances without all the info set are ignored and not returned
271
+ Instances with missing info (e.g. PrivateIpAddress due to EC2 eventual consistency) are kept with
272
+ empty IP fields so that clustermgtd can still match them to Slurm nodes by instance ID.
266
273
  """
267
274
  ec2_client = boto3.client("ec2", region_name=self._region, config=self._boto3_config)
268
275
  paginator = ec2_client.get_paginator("describe_instances")
@@ -290,12 +297,17 @@ class InstanceManager:
290
297
  )
291
298
  )
292
299
  except Exception as e:
300
+ # Keep the instance with empty IP info so it can still be matched by instance ID in clustermgtd.
293
301
  logger.warning(
294
- "Ignoring instance %s because not all EC2 info are available, exception: %s, message: %s",
302
+ "Incomplete EC2 info for instance %s, keeping it for instance-ID matching, "
303
+ "exception: %s, message: %s",
295
304
  instance_info["InstanceId"],
296
305
  type(e).__name__,
297
306
  e,
298
307
  )
308
+ instances.append(
309
+ EC2Instance(instance_info["InstanceId"], "", "", set(), instance_info.get("LaunchTime"))
310
+ )
299
311
 
300
312
  return instances
301
313
 
@@ -1008,6 +1020,7 @@ class InstanceManager:
1008
1020
  all_or_nothing=all_or_nothing_batch,
1009
1021
  run_instances_overrides=self._run_instances_overrides,
1010
1022
  create_fleet_overrides=self._create_fleet_overrides,
1023
+ instance_info_retrieval_timeout=self._instance_info_retrieval_timeout,
1011
1024
  )
1012
1025
  return fleet_manager
1013
1026
 
@@ -1077,6 +1090,7 @@ class InstanceManager:
1077
1090
  slurm_nodes,
1078
1091
  nodeaddrs=[instance.private_ip for instance in launched_instances],
1079
1092
  nodehostnames=node_hostnames,
1093
+ instance_ids=[instance.id for instance in launched_instances],
1080
1094
  )
1081
1095
  logger.info(
1082
1096
  "Nodes are now configured with instances %s",
@@ -22,6 +22,7 @@ from common.schedulers.slurm_commands import get_nodes_info, set_nodes_down
22
22
  from common.utils import read_json
23
23
  from slurm_plugin.cluster_event_publisher import ClusterEventPublisher
24
24
  from slurm_plugin.common import ScalingStrategy, is_clustermgtd_heartbeat_valid, print_with_count
25
+ from slurm_plugin.fleet_manager import INSTANCE_INFO_RETRIEVAL_TIMEOUT_DEFAULT
25
26
  from slurm_plugin.instance_manager import InstanceManager
26
27
  from slurm_plugin.slurm_resources import CONFIG_FILE_DIR
27
28
 
@@ -47,6 +48,7 @@ class SlurmResumeConfig:
47
48
  "fleet_config_file": "/etc/parallelcluster/slurm_plugin/fleet-config.json",
48
49
  "job_level_scaling": True,
49
50
  "scaling_strategy": "all-or-nothing",
51
+ "instance_info_retrieval_timeout": INSTANCE_INFO_RETRIEVAL_TIMEOUT_DEFAULT,
50
52
  }
51
53
 
52
54
  def __init__(self, config_file_path):
@@ -96,6 +98,11 @@ class SlurmResumeConfig:
96
98
  self.job_level_scaling = config.getboolean(
97
99
  "slurm_resume", "job_level_scaling", fallback=self.DEFAULTS.get("job_level_scaling")
98
100
  )
101
+ self.instance_info_retrieval_timeout = config.getint(
102
+ "slurm_resume",
103
+ "instance_info_retrieval_timeout",
104
+ fallback=self.DEFAULTS.get("instance_info_retrieval_timeout"),
105
+ )
99
106
  fleet_config_file = config.get(
100
107
  "slurm_resume", "fleet_config_file", fallback=self.DEFAULTS.get("fleet_config_file")
101
108
  )
@@ -206,6 +213,7 @@ def _resume(arg_nodes, resume_config, slurm_resume):
206
213
  run_instances_overrides=resume_config.run_instances_overrides,
207
214
  create_fleet_overrides=resume_config.create_fleet_overrides,
208
215
  job_level_scaling=resume_config.job_level_scaling,
216
+ instance_info_retrieval_timeout=resume_config.instance_info_retrieval_timeout,
209
217
  )
210
218
  instance_manager.add_instances(
211
219
  slurm_resume=slurm_resume,
@@ -203,7 +203,11 @@ class SlurmNode(metaclass=ABCMeta):
203
203
  SLURM_SCONTROL_POWER_UP_STATE = "POWERING_UP"
204
204
  SLURM_SCONTROL_ONLINE_STATES = {"IDLE+CLOUD", "MIXED+CLOUD", "ALLOCATED+CLOUD", "COMPLETING+CLOUD"}
205
205
  SLURM_SCONTROL_POWER_WITH_JOB_STATE = {"MIXED", "CLOUD", "POWERED_DOWN"}
206
- SLURM_SCONTROL_RESUME_FAILED_STATE = {"DOWN", "CLOUD", "POWERED_DOWN", "NOT_RESPONDING"}
206
+ # In Slurm 25.11:
207
+ # When ResumeTimeout fires on a cloud node, Slurm explicitly clears NODE_STATE_NO_RESPOND (along with DRAIN,
208
+ # POWER_DOWN, POWERING_UP), adds POWERED_DOWN, and then set node to DOWN with reason "ResumeTimeout reached".
209
+ # The resulting state is therefore DOWN+CLOUD+POWERED_DOWN and does NOT include NOT_RESPONDING.
210
+ SLURM_SCONTROL_RESUME_FAILED_STATE = {"DOWN", "CLOUD", "POWERED_DOWN"}
207
211
  SLURM_SCONTROL_NODE_DOWN_NOT_RESPONDING_STATE = {"DOWN", "CLOUD", "NOT_RESPONDING"}
208
212
  # Due to a bug in Slurm a powered down node can enter IDLE+CLOUD+POWER_DOWN+POWERED_DOWN state
209
213
  SLURM_SCONTROL_POWER_STATES = [{"IDLE", "CLOUD", "POWERED_DOWN"}, {"IDLE", "CLOUD", "POWERED_DOWN", "POWER_DOWN"}]
@@ -239,6 +243,7 @@ class SlurmNode(metaclass=ABCMeta):
239
243
  slurmdstarttime: datetime = None,
240
244
  lastbusytime: datetime = None,
241
245
  reservation_name: str = None,
246
+ instance_id: str = None,
242
247
  ):
243
248
  """Initialize slurm node with attributes."""
244
249
  self.name = name
@@ -249,6 +254,7 @@ class SlurmNode(metaclass=ABCMeta):
249
254
  self.partitions = partitions.strip().split(",") if partitions else None
250
255
  self.reason = reason
251
256
  self.instance = instance
257
+ self.instance_id = instance_id
252
258
  self.slurmdstarttime = slurmdstarttime
253
259
  self.lastbusytime = lastbusytime
254
260
  self.reservation_name = reservation_name
@@ -529,6 +535,7 @@ class StaticNode(SlurmNode):
529
535
  slurmdstarttime=None,
530
536
  lastbusytime=None,
531
537
  reservation_name=None,
538
+ instance_id=None,
532
539
  ):
533
540
  """Initialize slurm node with attributes."""
534
541
  super().__init__(
@@ -542,6 +549,7 @@ class StaticNode(SlurmNode):
542
549
  slurmdstarttime,
543
550
  lastbusytime=lastbusytime,
544
551
  reservation_name=reservation_name,
552
+ instance_id=instance_id,
545
553
  )
546
554
 
547
555
  def is_healthy(
@@ -581,20 +589,30 @@ class StaticNode(SlurmNode):
581
589
  return True
582
590
  else:
583
591
  if log_warn_if_unhealthy:
584
- logger.warning("Node state check: node %s in DRAINED, node state: %s", self, self.state_string)
592
+ logger.warning(
593
+ "Node state check: node %s in DRAINED and not within the replacement protection window, "
594
+ "it is considered unhealthy and will be replaced, node state: %s",
595
+ self,
596
+ self.state_string,
597
+ )
585
598
  return False
586
599
  # Check to see if node is in DOWN, ignoring any node currently being replaced
587
600
  elif self.is_down() and consider_down_as_unhealthy:
588
601
  if self.is_being_replaced:
589
602
  logger.debug(
590
- "Node state check: node %s in DOWN but is currently being replaced, ignoring. Node state: ",
603
+ "Node state check: node %s in DOWN but is currently being replaced, ignoring, node state: %s",
591
604
  self,
592
605
  self.state_string,
593
606
  )
594
607
  return True
595
608
  else:
596
609
  if log_warn_if_unhealthy:
597
- logger.warning("Node state check: node %s in DOWN, node state: %s", self, self.state_string)
610
+ logger.warning(
611
+ "Node state check: node %s in DOWN and not within the replacement protection window, "
612
+ "it is considered unhealthy and will be replaced, node state: %s",
613
+ self,
614
+ self.state_string,
615
+ )
598
616
  return False
599
617
  return True
600
618
 
@@ -663,6 +681,7 @@ class DynamicNode(SlurmNode):
663
681
  slurmdstarttime=None,
664
682
  lastbusytime=None,
665
683
  reservation_name=None,
684
+ instance_id=None,
666
685
  ):
667
686
  """Initialize slurm node with attributes."""
668
687
  super().__init__(
@@ -676,6 +695,7 @@ class DynamicNode(SlurmNode):
676
695
  slurmdstarttime,
677
696
  lastbusytime=lastbusytime,
678
697
  reservation_name=reservation_name,
698
+ instance_id=instance_id,
679
699
  )
680
700
 
681
701
  def is_state_healthy(self, consider_drain_as_unhealthy, consider_down_as_unhealthy, log_warn_if_unhealthy=True):
@@ -730,7 +750,7 @@ class DynamicNode(SlurmNode):
730
750
  self.state_string,
731
751
  )
732
752
  return True
733
- # Dynamic node in DOWN+CLOUD+POWERED_DOWN+NOT_RESPONDING state
753
+ # Dynamic node in SLURM_SCONTROL_RESUME_FAILED_STATE.
734
754
  elif self.is_bootstrap_timeout():
735
755
  # We need to check if nodeaddr is set to avoid counting powering up nodes as bootstrap failure nodes during
736
756
  # cluster start/stop.