Skip to content

Commit 1fc7b2e

Browse files
committed
Fix azure VM cleanup to handle delays and multiple conditions
1 parent b338eee commit 1fc7b2e

1 file changed

Lines changed: 75 additions & 15 deletions

File tree

  • api/transformerlab/compute_providers

api/transformerlab/compute_providers/azure.py

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@
8383

8484
_VM_CONTRIBUTOR_ROLE_DEFINITION_ID = "9980e02c-c2be-4d73-94e8-173b1dc7cf3c"
8585

86+
_NIC_NAME_PREFIX = "transformerlab-nic-"
87+
_PIP_NAME_PREFIX = "transformerlab-pip-"
88+
89+
# Azure only releases a NIC for deletion once its VM is fully gone, and a public IP
90+
# once its NIC is gone — both report "in use" errors for a while after the parent's
91+
# delete has already completed. Sleep this schedule between attempts.
92+
_NETWORK_DELETE_RETRY_DELAYS_SECONDS: tuple = (5, 10, 15, 30, 30)
93+
8694

8795
def _resolve_gpu_vm_size(accelerators: str) -> str:
8896
"""Map an accelerator spec (e.g. 'A100:8') to an Azure VM size.
@@ -212,18 +220,53 @@ def _get_authorization_client(self) -> Any:
212220

213221
return AuthorizationManagementClient(self._get_credential(), self.subscription_id)
214222

223+
@staticmethod
224+
def _is_not_found_error(exc: Exception) -> bool:
225+
msg = str(exc).lower()
226+
return "resourcenotfound" in msg or "notfound" in msg or "not found" in msg
227+
228+
def _delete_network_resource_with_retries(self, begin_delete: Any, resource_name: str) -> bool:
229+
"""Delete a NIC or public IP, retrying while Azure still reports it in use.
230+
231+
Returns True once the resource is gone (deleted here or already absent).
232+
"""
233+
max_attempts = len(_NETWORK_DELETE_RETRY_DELAYS_SECONDS) + 1
234+
for attempt in range(1, max_attempts + 1):
235+
try:
236+
begin_delete(self.resource_group, resource_name).result()
237+
return True
238+
except Exception as e:
239+
if self._is_not_found_error(e):
240+
return True
241+
if attempt < max_attempts:
242+
delay = _NETWORK_DELETE_RETRY_DELAYS_SECONDS[attempt - 1]
243+
logger.info(
244+
"Delete of %s failed (attempt %d/%d), retrying in %ds: %s",
245+
resource_name,
246+
attempt,
247+
max_attempts,
248+
delay,
249+
e,
250+
)
251+
time.sleep(delay)
252+
else:
253+
logger.warning("Failed to delete %s after %d attempts: %s", resource_name, max_attempts, e)
254+
return False
255+
215256
def _delete_cluster_nic_and_public_ip(self, network_client: Any, cluster_name: str) -> None:
216-
"""Delete per-cluster NIC and public IP (NIC first; tolerates missing resources)."""
217-
nic_name = f"transformerlab-nic-{cluster_name}"
218-
pip_name = f"transformerlab-pip-{cluster_name}"
219-
try:
220-
network_client.network_interfaces.begin_delete(self.resource_group, nic_name).result()
221-
except Exception as e:
222-
logger.warning("Failed to delete NIC %s: %s", nic_name, e)
223-
try:
224-
network_client.public_ip_addresses.begin_delete(self.resource_group, pip_name).result()
225-
except Exception as e:
226-
logger.warning("Failed to delete Public IP %s: %s", pip_name, e)
257+
"""Delete the per-cluster NIC, then its public IP.
258+
259+
Azure requires this order: the NIC can only be deleted once the VM is fully
260+
gone, and the public IP only once the NIC is gone, so each delete retries
261+
across the window where the parent resource is still being released.
262+
"""
263+
nic_name = f"{_NIC_NAME_PREFIX}{cluster_name}"
264+
pip_name = f"{_PIP_NAME_PREFIX}{cluster_name}"
265+
nic_gone = self._delete_network_resource_with_retries(network_client.network_interfaces.begin_delete, nic_name)
266+
if not nic_gone:
267+
logger.warning("Skipping delete of %s because %s still exists and is holding it", pip_name, nic_name)
268+
return
269+
self._delete_network_resource_with_retries(network_client.public_ip_addresses.begin_delete, pip_name)
227270

228271
def _ensure_vm_self_delete_role(self, vm: Any) -> None:
229272
"""Best-effort: grant VM identity rights to delete itself."""
@@ -583,8 +626,8 @@ async def _ensure_and_get_public_key() -> str:
583626
subnet_id, nsg_id = self._ensure_networking(network_client, resource_client)
584627
public_key_str = asyncio.run(_ensure_and_get_public_key())
585628

586-
pip_name = f"transformerlab-pip-{cluster_name}"
587-
nic_name = f"transformerlab-nic-{cluster_name}"
629+
pip_name = f"{_PIP_NAME_PREFIX}{cluster_name}"
630+
nic_name = f"{_NIC_NAME_PREFIX}{cluster_name}"
588631
launch_succeeded = False
589632
try:
590633
pip = network_client.public_ip_addresses.begin_create_or_update(
@@ -599,7 +642,14 @@ async def _ensure_and_get_public_key() -> str:
599642
{
600643
"location": self.location,
601644
"ip_configurations": [
602-
{"name": "ipconfig1", "subnet": {"id": subnet_id}, "public_ip_address": {"id": pip.id}}
645+
{
646+
"name": "ipconfig1",
647+
"subnet": {"id": subnet_id},
648+
# delete_option: Azure deletes the public IP along with the VM
649+
# (the VM self-deletes on job completion, so the control plane
650+
# never gets a chance to clean up in that path).
651+
"public_ip_address": {"id": pip.id, "delete_option": "Delete"},
652+
}
603653
],
604654
"network_security_group": {"id": nsg_id},
605655
},
@@ -684,6 +734,14 @@ async def _ensure_and_get_public_key() -> str:
684734
raise RuntimeError(f"Failed to launch Azure VM: {last_error}") from last_error
685735
finally:
686736
if not launch_succeeded:
737+
# A failed create can still leave a VM resource behind (provisioning
738+
# failed), and the NIC can't be deleted while that VM holds it — so
739+
# remove the VM first.
740+
try:
741+
compute_client.virtual_machines.begin_delete(self.resource_group, cluster_name).result()
742+
except Exception as e:
743+
if not self._is_not_found_error(e):
744+
logger.warning("Failed to delete partially-created VM %s: %s", cluster_name, e)
687745
self._delete_cluster_nic_and_public_ip(network_client, cluster_name)
688746

689747
def stop_cluster(self, cluster_name: str) -> Dict[str, Any]:
@@ -693,7 +751,9 @@ def stop_cluster(self, cluster_name: str) -> Dict[str, Any]:
693751
try:
694752
compute_client.virtual_machines.begin_delete(self.resource_group, cluster_name).result()
695753
except Exception as e:
696-
return {"status": "error", "message": str(e), "cluster_name": cluster_name}
754+
# The VM self-deletes on job completion; still clean up its NIC/public IP.
755+
if not self._is_not_found_error(e):
756+
return {"status": "error", "message": str(e), "cluster_name": cluster_name}
697757

698758
self._delete_cluster_nic_and_public_ip(network_client, cluster_name)
699759

0 commit comments

Comments
 (0)