Skip to content

Commit ff57be2

Browse files
committed
Add instance-ID fallback matching to handle EC2 eventual consistency
When launching large numbers of instances, DescribeInstances may return instances with missing PrivateIpAddress due to EC2 API eventual consistency. Previously, these instances were discarded, causing clustermgtd to terminate them as unhealthy. Changes: - get_cluster_instances: create EC2Instance with empty IP fields instead of discarding when instance info is incomplete - New get_instance_id_to_node_name_mapping: read (instance_id, node_name) mapping from existing DynamoDB table for fallback matching - _update_slurm_nodes_with_ec2_info: after IP-based matching, attempt fallback matching by instance ID via DynamoDB for unmatched instances - Add compute_node_missing_ip_max_count config (default 10): if an instance is matched via fallback for more than this many consecutive iterations, dissociate it to let health checks handle it - Update test expectations for new behavior
1 parent 56267ba commit ff57be2

3 files changed

Lines changed: 149 additions & 13 deletions

File tree

src/slurm_plugin/clustermgtd.py

Lines changed: 98 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ class ClustermgtdConfig:
151151
"terminate_down_nodes": True,
152152
"orphaned_instance_timeout": 300,
153153
"ec2_instance_missing_max_count": 0,
154+
"compute_node_missing_ip_max_count": 10,
154155
# Health check configs
155156
"disable_ec2_health_check": False,
156157
"disable_scheduled_event_health_check": False,
@@ -310,6 +311,11 @@ def _get_terminate_config(self, config):
310311
"ec2_instance_missing_max_count",
311312
fallback=self.DEFAULTS.get("ec2_instance_missing_max_count"),
312313
)
314+
self.compute_node_missing_ip_max_count = config.getint(
315+
"clustermgtd",
316+
"compute_node_missing_ip_max_count",
317+
fallback=self.DEFAULTS.get("compute_node_missing_ip_max_count"),
318+
)
313319
self.disable_nodes_on_insufficient_capacity = self.insufficient_capacity_timeout > 0
314320

315321
def _get_dns_config(self, config):
@@ -391,6 +397,7 @@ def __init__(self, config):
391397
self._static_nodes_in_replacement = set()
392398
self._partitions_protected_failure_count_map = {}
393399
self._nodes_without_backing_instance_count_map = {}
400+
self._fallback_match_count_map = {}
394401
self._compute_fleet_status = ComputeFleetStatus.RUNNING
395402
self._current_time = None
396403
self._config = None
@@ -551,7 +558,13 @@ def manage_cluster(self):
551558
return
552559
log.debug("Current cluster instances in EC2: %s", cluster_instances)
553560
partitions = list(partitions_name_map.values())
554-
self._update_slurm_nodes_with_ec2_info(nodes, cluster_instances)
561+
self._update_slurm_nodes_with_ec2_info(
562+
nodes,
563+
cluster_instances,
564+
self._instance_manager,
565+
self._fallback_match_count_map,
566+
self._config.compute_node_missing_ip_max_count,
567+
)
555568
self._event_publisher.publish_compute_node_events(nodes, cluster_instances)
556569
# Handle inactive partition and terminate backing instances
557570
self._clean_up_inactive_partition(partitions)
@@ -1144,16 +1157,90 @@ def _parse_scheduler_nodes_data(nodes):
11441157
raise
11451158

11461159
@staticmethod
1147-
def _update_slurm_nodes_with_ec2_info(nodes, cluster_instances):
1148-
if cluster_instances:
1149-
ip_to_slurm_node_map = {node.nodeaddr: node for node in nodes}
1150-
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
1160+
def _update_slurm_nodes_with_ec2_info( # noqa: C901
1161+
nodes,
1162+
cluster_instances,
1163+
instance_manager=None,
1164+
fallback_match_count_map=None,
1165+
compute_node_missing_ip_max_count=10,
1166+
):
1167+
"""
1168+
Associate EC2 instances with Slurm nodes.
1169+
1170+
Primary matching is by IP address. If instances have empty IPs (due to EC2 eventual consistency),
1171+
a fallback matching by instance ID is attempted using the DynamoDB node-name-to-instance-id mapping.
1172+
1173+
Args:
1174+
fallback_match_count_map: dict tracking consecutive fallback match counts per node name.
1175+
If a node is matched via fallback for more than compute_node_missing_ip_max_count
1176+
consecutive iterations, the association is removed so that existing health check
1177+
mechanisms can handle it.
1178+
compute_node_missing_ip_max_count: max consecutive iterations to tolerate missing IP
1179+
before dissociating the instance. Configurable via clustermgtd conf file.
1180+
"""
1181+
if not cluster_instances:
1182+
return
1183+
1184+
# First pass: match by IP address (primary method)
1185+
ip_to_slurm_node_map = {node.nodeaddr: node for node in nodes}
1186+
unmatched_instances = []
1187+
for instance in cluster_instances:
1188+
matched = False
1189+
for private_ip in instance.all_private_ips:
1190+
if private_ip in ip_to_slurm_node_map:
1191+
slurm_node = ip_to_slurm_node_map.get(private_ip)
1192+
slurm_node.instance = instance
1193+
instance.slurm_node = slurm_node
1194+
matched = True
1195+
break
1196+
if not matched and not instance.all_private_ips:
1197+
unmatched_instances.append(instance)
1198+
1199+
# Second pass: fallback matching by instance ID for instances with missing IPs
1200+
if unmatched_instances and instance_manager:
1201+
log.info(
1202+
"Found %d instances with missing IPs, attempting fallback matching by instance ID",
1203+
len(unmatched_instances),
1204+
)
1205+
try:
1206+
instance_id_to_node_name = instance_manager.get_instance_id_to_node_name_mapping()
1207+
except Exception as e:
1208+
log.warning("Failed to read instance-ID-to-node-name mapping for fallback matching: %s", e)
1209+
instance_id_to_node_name = {}
1210+
1211+
if instance_id_to_node_name:
1212+
name_to_slurm_node_map = {node.name: node for node in nodes}
1213+
for instance in unmatched_instances:
1214+
node_name = instance_id_to_node_name.get(instance.id)
1215+
if node_name and node_name in name_to_slurm_node_map:
1216+
slurm_node = name_to_slurm_node_map[node_name]
1217+
if not slurm_node.instance:
1218+
# Track consecutive fallback match count
1219+
if fallback_match_count_map is not None:
1220+
count = fallback_match_count_map.get(node_name, 0) + 1
1221+
fallback_match_count_map[node_name] = count
1222+
if count > compute_node_missing_ip_max_count:
1223+
log.warning(
1224+
"Instance %s matched to node %s via fallback for %d consecutive iterations "
1225+
"(IP never appeared). Dissociating to let health checks handle it.",
1226+
instance.id,
1227+
node_name,
1228+
count,
1229+
)
1230+
continue
1231+
log.info(
1232+
"Matched instance %s to node %s via instance ID fallback (IP not yet available)",
1233+
instance.id,
1234+
node_name,
1235+
)
1236+
slurm_node.instance = instance
1237+
instance.slurm_node = slurm_node
1238+
1239+
# Clean up fallback counts for nodes no longer needing fallback matching
1240+
# (either IP appeared and they matched via primary path, or the instance no longer exists)
1241+
if fallback_match_count_map is not None and not unmatched_instances:
1242+
# No unmatched instances this iteration — all previously tracked nodes have resolved
1243+
fallback_match_count_map.clear()
11571244

11581245
@staticmethod
11591246
def get_instance_id_to_active_node_map(partitions: List[SlurmPartition]) -> Dict:

src/slurm_plugin/instance_manager.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,8 @@ def get_cluster_instances(self, include_head_node=False, alive_states_only=True)
262262
"""
263263
Get instances that are associated with the cluster.
264264
265-
Instances without all the info set are ignored and not returned
265+
Instances with missing EC2 info (e.g., PrivateIpAddress due to EC2 eventual consistency) are included
266+
with empty IP fields to allow instance-ID-based fallback matching in clustermgtd.
266267
"""
267268
ec2_client = boto3.client("ec2", region_name=self._region, config=self._boto3_config)
268269
paginator = ec2_client.get_paginator("describe_instances")
@@ -290,11 +291,25 @@ def get_cluster_instances(self, include_head_node=False, alive_states_only=True)
290291
)
291292
)
292293
except Exception as e:
294+
required_fields = {"PrivateIpAddress", "PrivateDnsName", "NetworkInterfaces"}
295+
missing_fields = required_fields - set(instance_info.keys())
293296
logger.warning(
294-
"Ignoring instance %s because not all EC2 info are available, exception: %s, message: %s",
297+
"Instance %s missing some EC2 info, exception: %s, message: %s. "
298+
"Missing top-level fields: %s. "
299+
"Adding with instance ID only to allow fallback matching.",
295300
instance_info["InstanceId"],
296301
type(e).__name__,
297302
e,
303+
missing_fields if missing_fields else "none",
304+
)
305+
instances.append(
306+
EC2Instance(
307+
instance_info["InstanceId"],
308+
"",
309+
"",
310+
set(),
311+
instance_info.get("LaunchTime"),
312+
)
298313
)
299314

300315
return instances
@@ -311,6 +326,39 @@ def terminate_all_compute_nodes(self, terminate_batch_size):
311326
logger.error("Failed when terminating compute fleet with error %s", e)
312327
return False
313328

329+
def get_instance_id_to_node_name_mapping(self):
330+
"""Read instance-ID-to-node-name mapping from DynamoDB.
331+
332+
Returns a dict mapping instance_id -> node_name.
333+
Used by clustermgtd for fallback matching when PrivateIpAddress is missing.
334+
"""
335+
if not self._table:
336+
logger.warning("DynamoDB table not configured, cannot read instance-ID-to-node-name mapping")
337+
return {}
338+
try:
339+
mapping = {}
340+
response = self._table.scan(ProjectionExpression="Id, InstanceId")
341+
for item in response.get("Items", []):
342+
instance_id = item.get("InstanceId")
343+
node_name = item.get("Id")
344+
if instance_id and node_name:
345+
mapping[instance_id] = node_name
346+
# Handle pagination
347+
while "LastEvaluatedKey" in response:
348+
response = self._table.scan(
349+
ProjectionExpression="Id, InstanceId",
350+
ExclusiveStartKey=response["LastEvaluatedKey"],
351+
)
352+
for item in response.get("Items", []):
353+
instance_id = item.get("InstanceId")
354+
node_name = item.get("Id")
355+
if instance_id and node_name:
356+
mapping[instance_id] = node_name
357+
return mapping
358+
except Exception as e:
359+
logger.warning("Failed to read instance-ID-to-node-name mapping from DynamoDB: %s", e)
360+
return {}
361+
314362
def _update_failed_nodes(self, nodeset, error_code="Exception", override=True):
315363
"""Update failed nodes dict with error code as key and nodeset value."""
316364
if not override:

tests/slurm_plugin/test_instance_manager.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -907,6 +907,7 @@ def get_unhealthy_cluster_instance_status(
907907
generate_error=False,
908908
),
909909
[
910+
EC2Instance("i-1", "", "", set(), datetime(2020, 1, 1, tzinfo=timezone.utc)),
910911
EC2Instance("i-2", "ip-2", "hostname", {"ip-2"}, datetime(2020, 1, 1, tzinfo=timezone.utc)),
911912
],
912913
False,

0 commit comments

Comments
 (0)