Skip to content

Commit 14fff05

Browse files
committed
fix(asg): guard release_instances against double capacity decrement
After commit 641a033 introduced the IN_PROGRESS-then-COMPLETED return flow, ASG partial-return tests started seeing DesiredCapacity drop by 2 (e.g. 4→2) when only 1 instance was returned (expected 4→3). Root cause: release_instances calls detach_instances with ShouldDecrementDesiredCapacity=True, which atomically decrements DesiredCapacity. If the method is invoked a second time for the same instance before the instance is confirmed terminated (possible under the new IN_PROGRESS polling window), the decrement fires again on an already- detached instance. Fix: add _filter_asg_members() to query describe_auto_scaling_instances before detaching. Only instances that are currently attached to the ASG are included in the detach call. Instances already detached are skipped (no capacity decrement), but are still forwarded to terminate_instances_with_fallback so they are cleaned up. Also corrects the fallback live_desired calculation to use len(instances_to_detach) instead of len(instance_ids), keeping the capacity arithmetic consistent when a subset is detached. Adds a moto regression test (test_asg_partial_return_idempotent_capacity_ not_double_decremented) that calls release_hosts twice for the same instance and asserts DesiredCapacity stays at N-1, not N-2.
1 parent 94f39f4 commit 14fff05

2 files changed

Lines changed: 135 additions & 3 deletions

File tree

src/orb/providers/aws/infrastructure/handlers/asg/capacity_manager.py

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,35 @@ def release_instances(
172172
self._cleanup_on_zero_capacity("asg", asg_name)
173173
return
174174

175+
# Guard against double-execution: only detach instances that are currently
176+
# members of this ASG. If release_instances is called a second time for the
177+
# same set (e.g. due to an IN_PROGRESS status retry or a race), the instances
178+
# are already detached and this check prevents a second DesiredCapacity decrement.
179+
instances_to_detach = self._filter_asg_members(asg_name, instance_ids)
180+
if not instances_to_detach:
181+
self._logger.info(
182+
"ASG %s: all %d instance(s) already detached — skipping detach and capacity decrement",
183+
asg_name,
184+
len(instance_ids),
185+
)
186+
# Instances may still be running (standalone after a prior partial detach);
187+
# terminate them directly so the return request can complete.
188+
self._aws_ops.terminate_instances_with_fallback(
189+
instance_ids, self._request_adapter, f"ASG {asg_name} instances (already detached)"
190+
)
191+
return
192+
193+
skipped = [i for i in instance_ids if i not in instances_to_detach]
194+
if skipped:
195+
self._logger.info(
196+
"ASG %s: %d instance(s) already detached (skipping): %s",
197+
asg_name,
198+
len(skipped),
199+
skipped,
200+
)
201+
175202
# Detach instances (API limit: 50 per call; use 20 for safety)
176-
for chunk in self._chunk_list(instance_ids, 20):
203+
for chunk in self._chunk_list(instances_to_detach, 20):
177204
self._retry_with_backoff(
178205
self._aws_client.autoscaling_client.detach_instances,
179206
operation_type="critical",
@@ -182,7 +209,7 @@ def release_instances(
182209
ShouldDecrementDesiredCapacity=True,
183210
)
184211
self._logger.debug("Detached chunk from ASG %s: %s", asg_name, chunk)
185-
self._logger.info("Detached instances from ASG %s: %s", asg_name, instance_ids)
212+
self._logger.info("Detached instances from ASG %s: %s", asg_name, instances_to_detach)
186213

187214
# Re-describe the ASG to get the live DesiredCapacity after detach, since
188215
# ShouldDecrementDesiredCapacity=True may have already decremented it in AWS
@@ -201,7 +228,7 @@ def release_instances(
201228
asg_name,
202229
exc,
203230
)
204-
live_desired = max(0, asg_details["DesiredCapacity"] - len(instance_ids))
231+
live_desired = max(0, asg_details["DesiredCapacity"] - len(instances_to_detach))
205232

206233
new_capacity = max(0, live_desired)
207234

@@ -228,6 +255,40 @@ def release_instances(
228255
# Private helpers
229256
# ------------------------------------------------------------------
230257

258+
def _filter_asg_members(self, asg_name: str, instance_ids: list[str]) -> list[str]:
259+
"""Return the subset of instance_ids that are currently attached to asg_name.
260+
261+
Used as an idempotency guard in release_instances: if an instance has already
262+
been detached (e.g. by a prior call), it is excluded so that
263+
ShouldDecrementDesiredCapacity=True is not applied a second time.
264+
"""
265+
if not instance_ids:
266+
return []
267+
try:
268+
response = self._retry_with_backoff(
269+
self._aws_client.autoscaling_client.describe_auto_scaling_instances,
270+
operation_type="read_only",
271+
InstanceIds=instance_ids,
272+
)
273+
attached_ids = {
274+
entry["InstanceId"]
275+
for entry in response.get("AutoScalingInstances", [])
276+
if entry.get("AutoScalingGroupName") == asg_name
277+
}
278+
return [iid for iid in instance_ids if iid in attached_ids]
279+
except Exception as exc:
280+
self._logger.warning(
281+
"Failed to verify ASG %s membership for instances %s; "
282+
"proceeding with all instances to avoid leaving them running: %s",
283+
asg_name,
284+
instance_ids,
285+
exc,
286+
)
287+
# On error, be conservative: attempt detach for all instances.
288+
# A "not a member" error from detach_instances is better than
289+
# skipping a needed capacity decrement.
290+
return list(instance_ids)
291+
231292
def _call_delete_asg(self, asg_name: str) -> None:
232293
"""Invoke the registered delete-ASG callback, or fall back to direct deletion."""
233294
if self._delete_asg_fn is not None:

tests/providers/aws/moto/test_partial_return.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,77 @@ def test_asg_partial_return_decrements_desired_capacity(self, moto_vpc_resources
467467
assert resp["AutoScalingGroups"], "ASG should still exist after partial release"
468468
assert resp["AutoScalingGroups"][0]["DesiredCapacity"] == 1
469469

470+
def test_asg_partial_return_idempotent_capacity_not_double_decremented(self, moto_vpc_resources):
471+
"""Calling release_hosts twice for the same instance must NOT decrement capacity twice.
472+
473+
Regression guard for the IN_PROGRESS status flow introduced in commit 641a03332:
474+
with the return request staying IN_PROGRESS after termination is accepted, a second
475+
release_hosts call (any re-entrant path) must leave DesiredCapacity at N-1, not N-2.
476+
"""
477+
subnet_id = moto_vpc_resources["subnet_ids"][0]
478+
sg_id = moto_vpc_resources["sg_id"]
479+
480+
aws_client = _make_capacity_aws_client()
481+
logger = _make_logger()
482+
config_port = _make_capacity_config_port()
483+
lt_manager = _make_capacity_lt_manager(aws_client)
484+
aws_ops = AWSOperations(aws_client, logger, config_port)
485+
handler = ASGHandler(
486+
aws_client=aws_client,
487+
logger=logger,
488+
aws_ops=aws_ops,
489+
launch_template_manager=lt_manager,
490+
config_port=config_port,
491+
)
492+
493+
template = AWSTemplate(
494+
template_id="tpl-asg-idempotent",
495+
name="test-asg-idempotent",
496+
provider_api="ASG",
497+
machine_types={"t3.micro": 1},
498+
image_id="ami-12345678",
499+
max_instances=4,
500+
price_type="ondemand",
501+
subnet_ids=[subnet_id],
502+
security_group_ids=[sg_id],
503+
)
504+
request = _make_capacity_request("req-asg-idempotent-001", requested_count=4)
505+
result = handler.acquire_hosts(request, template)
506+
assert result["success"] is True
507+
asg_name = result["resource_ids"][0]
508+
509+
# Provision 4 instances and attach them to the ASG
510+
ec2 = aws_client.ec2_client
511+
asg = aws_client.autoscaling_client
512+
run_resp = ec2.run_instances(
513+
ImageId="ami-12345678",
514+
MinCount=4,
515+
MaxCount=4,
516+
InstanceType="t3.micro",
517+
SubnetId=subnet_id,
518+
)
519+
instance_ids = [i["InstanceId"] for i in run_resp["Instances"]]
520+
asg.attach_instances(InstanceIds=instance_ids, AutoScalingGroupName=asg_name)
521+
asg.update_auto_scaling_group(AutoScalingGroupName=asg_name, DesiredCapacity=4)
522+
523+
resource_mapping = {instance_ids[0]: (asg_name, 4)}
524+
525+
# First call — normal deprovisioning: capacity 4 → 3
526+
handler.release_hosts([instance_ids[0]], resource_mapping=resource_mapping)
527+
resp = asg.describe_auto_scaling_groups(AutoScalingGroupNames=[asg_name])
528+
assert resp["AutoScalingGroups"][0]["DesiredCapacity"] == 3, (
529+
"First release_hosts call should decrement capacity from 4 to 3"
530+
)
531+
532+
# Second call — simulates re-entrancy (IN_PROGRESS retry scenario):
533+
# instance is already detached; capacity must NOT drop to 2.
534+
handler.release_hosts([instance_ids[0]], resource_mapping=resource_mapping)
535+
resp = asg.describe_auto_scaling_groups(AutoScalingGroupNames=[asg_name])
536+
assert resp["AutoScalingGroups"][0]["DesiredCapacity"] == 3, (
537+
"Second release_hosts call for already-detached instance must NOT decrement "
538+
"capacity again (expected 3, idempotency guard should prevent 3 → 2)"
539+
)
540+
470541
def test_ec2_fleet_maintain_partial_return_decrements_target_capacity(self, moto_vpc_resources):
471542
"""release_hosts with resource_mapping for 1 unit calls modify_fleet with TotalTargetCapacity=1.
472543

0 commit comments

Comments
 (0)