Skip to content

Commit 4319b6c

Browse files
committed
feat(project-cleanup): wait for volumes to detach between phases
After the server-delete phase, Cinder moves attached volumes through ``in-use → detaching → available`` asynchronously. The volume-delete phase hit these mid-transition and received HTTP 400 ``status must be available`` for each one — the 12-of-30 red wave in the 2026-04-23 run. Introduce a settle window right before the volume phase: re-list the project's volumes and poll until none remain in ``detaching`` or ``in-use``, bounded by a conservative 30 s timeout with a 2 s interval. Module-level constants (``_VOLUME_SETTLE_TIMEOUT``, ``_VOLUME_SETTLE_INTERVAL``) keep the values tunable per test without leaking global state. The refresh step doubles as a second-pass filter: volumes cascaded by the server delete disappear from the list entirely, so the downstream ``_delete_one`` sees fewer 404s. Remaining stuck volumes are returned on timeout and surface normally as FAILED — silence would hide them.
1 parent 4c71cd3 commit 4319b6c

2 files changed

Lines changed: 141 additions & 0 deletions

File tree

orca_cli/commands/project.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import time
56
from datetime import datetime
67
from enum import Enum
78

@@ -188,6 +189,48 @@ def _before_cutoff(resource: dict, cutoff: datetime | None) -> bool:
188189
return True
189190

190191

192+
# Short settle window for volumes freshly detached by a server delete.
193+
# Kept module-level so tests can monkeypatch them to zero without global state
194+
# leaking into production. Values are deliberately conservative: 30 s is long
195+
# enough to cover typical Cinder detach latency on a loaded cloud, 2 s limits
196+
# the polling to ~15 round-trips in the worst case.
197+
_VOLUME_SETTLE_TIMEOUT = 30.0
198+
_VOLUME_SETTLE_INTERVAL = 2.0
199+
_VOLUME_TRANSIENT_STATUSES = frozenset({"detaching", "in-use"})
200+
201+
202+
def _refresh_and_wait_volumes(
203+
client,
204+
proj_id: str,
205+
cutoff: datetime | None,
206+
) -> list[tuple[str, str]]:
207+
"""Re-list project volumes and wait until none are in a transient state.
208+
209+
Called between the server-delete and volume-delete phases. Server
210+
deletion releases attached volumes asynchronously — they transition
211+
``in-use → detaching → available`` in Cinder, and deleting a volume
212+
mid-detach raises HTTP 400. Polling here lets us meet the volume
213+
phase with a clean set instead of firing 400s the user has to ignore.
214+
215+
On timeout we return whatever we last observed; a lingering DELETE
216+
will surface as FAILED via ``_delete_one``, which is the right
217+
signal — silence would hide a stuck volume.
218+
"""
219+
deadline = time.monotonic() + _VOLUME_SETTLE_TIMEOUT
220+
while True:
221+
try:
222+
vols: list = list(VolumeService(client).find(
223+
params={"project_id": proj_id},
224+
))
225+
except Exception:
226+
vols = []
227+
vols = [v for v in vols if _before_cutoff(v, cutoff)]
228+
transient = [v for v in vols if v.get("status") in _VOLUME_TRANSIENT_STATUSES]
229+
if not transient or time.monotonic() >= deadline:
230+
return [(v["id"], v.get("name") or "—") for v in vols]
231+
time.sleep(_VOLUME_SETTLE_INTERVAL)
232+
233+
191234
# device_owner values for ports attached to a router that need to be detached
192235
# via remove_router_interface before the router can be deleted. Router gateway
193236
# ports (network:router_gateway, network:router_centralized_snat) are released
@@ -585,6 +628,15 @@ def add(rtype: str, items: list, name_key: str = "name") -> None:
585628

586629
tally: dict[Outcome, int] = {o: 0 for o in Outcome}
587630
for rtype in DELETION_ORDER:
631+
if rtype == "volume" and by_type.get("volume"):
632+
# Server deletion releases attached volumes asynchronously; wait
633+
# for them to settle before attempting the volume-phase DELETEs.
634+
with console.status(
635+
"[bold cyan]Waiting for volumes to detach…[/bold cyan]"
636+
):
637+
by_type["volume"] = _refresh_and_wait_volumes(
638+
client, proj_id, cutoff,
639+
)
588640
for rid, rname in by_type.get(rtype, []):
589641
tally[_delete_one(client, rtype, rid, rname)] += 1
590642

tests/test_project_cleanup.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,95 @@ def test_unexpected_exception_is_failed(self, monkeypatch):
240240
assert out is Outcome.FAILED
241241

242242

243+
class TestRefreshAndWaitVolumes:
244+
"""Volume settle window between server-delete and volume-delete phases.
245+
246+
Tests pin the settle timings to near-zero so the suite stays fast.
247+
"""
248+
249+
def _zero_timing(self, monkeypatch):
250+
monkeypatch.setattr(proj_mod, "_VOLUME_SETTLE_INTERVAL", 0.0)
251+
monkeypatch.setattr(proj_mod, "_VOLUME_SETTLE_TIMEOUT", 0.01)
252+
monkeypatch.setattr(proj_mod.time, "sleep", lambda _s: None)
253+
254+
def test_returns_immediately_when_no_transient(self, monkeypatch):
255+
self._zero_timing(monkeypatch)
256+
svc = MagicMock()
257+
svc.find.return_value = [
258+
{"id": "v1", "name": "n1", "status": "available"},
259+
{"id": "v2", "name": "n2", "status": "error"},
260+
]
261+
monkeypatch.setattr(proj_mod, "VolumeService", lambda _c: svc)
262+
263+
result = proj_mod._refresh_and_wait_volumes(object(), "p-1", None)
264+
265+
assert result == [("v1", "n1"), ("v2", "n2")]
266+
svc.find.assert_called_once()
267+
268+
def test_polls_until_transient_clears(self, monkeypatch):
269+
self._zero_timing(monkeypatch)
270+
svc = MagicMock()
271+
# First call: one volume still detaching. Second call: both settled.
272+
svc.find.side_effect = [
273+
[
274+
{"id": "v1", "name": "n1", "status": "detaching"},
275+
{"id": "v2", "name": "n2", "status": "available"},
276+
],
277+
[
278+
{"id": "v1", "name": "n1", "status": "available"},
279+
{"id": "v2", "name": "n2", "status": "available"},
280+
],
281+
]
282+
monkeypatch.setattr(proj_mod, "VolumeService", lambda _c: svc)
283+
284+
result = proj_mod._refresh_and_wait_volumes(object(), "p-1", None)
285+
286+
assert result == [("v1", "n1"), ("v2", "n2")]
287+
assert svc.find.call_count == 2
288+
289+
def test_returns_last_seen_on_timeout(self, monkeypatch):
290+
"""Timeout must not raise — the stuck volume surfaces as FAILED later."""
291+
self._zero_timing(monkeypatch)
292+
svc = MagicMock()
293+
svc.find.return_value = [
294+
{"id": "stuck", "name": "s", "status": "detaching"},
295+
]
296+
monkeypatch.setattr(proj_mod, "VolumeService", lambda _c: svc)
297+
298+
result = proj_mod._refresh_and_wait_volumes(object(), "p-1", None)
299+
300+
assert result == [("stuck", "s")]
301+
302+
def test_missing_service_is_tolerated(self, monkeypatch):
303+
self._zero_timing(monkeypatch)
304+
svc = MagicMock()
305+
svc.find.side_effect = RuntimeError("cinder down")
306+
monkeypatch.setattr(proj_mod, "VolumeService", lambda _c: svc)
307+
308+
result = proj_mod._refresh_and_wait_volumes(object(), "p-1", None)
309+
310+
assert result == []
311+
312+
def test_cutoff_is_applied_on_refresh(self, monkeypatch):
313+
"""Fresh list honors --created-before, same as the initial scan."""
314+
from datetime import datetime, timezone
315+
316+
self._zero_timing(monkeypatch)
317+
svc = MagicMock()
318+
svc.find.return_value = [
319+
{"id": "old", "name": "o", "status": "available",
320+
"created_at": "2020-01-01T00:00:00+00:00"},
321+
{"id": "new", "name": "n", "status": "available",
322+
"created_at": "2099-01-01T00:00:00+00:00"},
323+
]
324+
monkeypatch.setattr(proj_mod, "VolumeService", lambda _c: svc)
325+
326+
cutoff = datetime(2025, 1, 1, tzinfo=timezone.utc)
327+
result = proj_mod._refresh_and_wait_volumes(object(), "p-1", cutoff)
328+
329+
assert result == [("old", "o")]
330+
331+
243332
class TestSummaryRendering:
244333
"""End-to-end through the Click command: the summary line must aggregate
245334
the four outcomes independently — a mix of 404s, 409s and real failures

0 commit comments

Comments
 (0)