Skip to content

Commit 6c52458

Browse files
committed
feat(server): refine deleting snapshots
1 parent 6f6d83c commit 6c52458

2 files changed

Lines changed: 91 additions & 4 deletions

File tree

server/opensandbox_server/services/snapshot_service.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,9 +196,6 @@ def delete_snapshot(self, snapshot_id: str) -> None:
196196
},
197197
)
198198

199-
if record.status.state == SnapshotState.DELETING:
200-
return
201-
202199
if record.status.state == SnapshotState.CREATING:
203200
raise HTTPException(
204201
status_code=status.HTTP_409_CONFLICT,
@@ -208,6 +205,11 @@ def delete_snapshot(self, snapshot_id: str) -> None:
208205
},
209206
)
210207

208+
if record.status.state != SnapshotState.DELETING:
209+
record = self._mark_snapshot_deleting(record)
210+
if record is None:
211+
return
212+
211213
self._snapshot_runtime.delete_snapshot(
212214
snapshot_id,
213215
image=record.restore_config.image,
@@ -230,6 +232,43 @@ def _default_pagination():
230232

231233
return PaginationRequest()
232234

235+
def _mark_snapshot_deleting(self, record: SnapshotRecord) -> SnapshotRecord | None:
236+
now = datetime.now(timezone.utc)
237+
deleting_record = SnapshotRecord(
238+
id=record.id,
239+
source_sandbox_id=record.source_sandbox_id,
240+
name=record.name,
241+
description=record.description,
242+
restore_config=record.restore_config,
243+
status=SnapshotStatusRecord(
244+
state=SnapshotState.DELETING,
245+
reason="snapshot_delete_requested",
246+
message="Snapshot deletion requested.",
247+
last_transition_at=now,
248+
),
249+
created_at=record.created_at,
250+
updated_at=now,
251+
)
252+
if self._snapshot_repository.update_if_state(
253+
deleting_record,
254+
record.status.state,
255+
):
256+
return deleting_record
257+
258+
current_record = self._snapshot_repository.get(record.id)
259+
if current_record is None:
260+
return None
261+
if current_record.status.state == SnapshotState.DELETING:
262+
return current_record
263+
264+
raise HTTPException(
265+
status_code=status.HTTP_409_CONFLICT,
266+
detail={
267+
"code": "SNAPSHOT::INVALID_STATE",
268+
"message": f"Snapshot {record.id} changed state and cannot be deleted",
269+
},
270+
)
271+
233272
def _create_snapshot_worker(self, record: SnapshotRecord) -> None:
234273
try:
235274
runtime_status = self._snapshot_runtime.create_snapshot(

server/tests/test_snapshot_service.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,14 @@ def create_snapshot(snapshot_id: str, sandbox_id: str):
381381
runtime.calls.append((snapshot_id, sandbox_id))
382382
return ready_status
383383

384+
def delete_snapshot(snapshot_id: str, image: str | None = None) -> None:
385+
stored = repo.get(snapshot_id)
386+
assert stored is not None
387+
assert stored.status.state == SnapshotState.DELETING
388+
runtime.delete_calls.append((snapshot_id, image))
389+
384390
runtime.create_snapshot = create_snapshot
391+
runtime.delete_snapshot = delete_snapshot
385392
created = service.create_snapshot("sbx-001", CreateSnapshotRequest(name="checkpoint"))
386393

387394
service.delete_snapshot(created.id)
@@ -420,8 +427,49 @@ def delete_snapshot(snapshot_id: str, image: str | None = None) -> None:
420427
with pytest.raises(HTTPException) as exc_info:
421428
service.delete_snapshot("snap-in-use")
422429

430+
stored = repo.get("snap-in-use")
423431
assert exc_info.value.status_code == 409
424-
assert repo.get("snap-in-use") is not None
432+
assert stored is not None
433+
assert stored.status.state == SnapshotState.DELETING
434+
435+
436+
def test_snapshot_service_recovers_delete_after_runtime_cleanup_succeeds(tmp_path) -> None:
437+
repo = SQLiteSnapshotRepository(tmp_path / "snapshots.db")
438+
runtime = StubSnapshotRuntime()
439+
service = PersistedSnapshotService(
440+
repo,
441+
StubSandboxService(),
442+
snapshot_runtime=runtime,
443+
snapshot_executor=ImmediateExecutor(),
444+
)
445+
446+
record = _snapshot_record(
447+
"snap-delete-crash",
448+
SnapshotState.READY,
449+
image="opensandbox-snapshots:snap-delete-crash",
450+
)
451+
repo.create(record)
452+
453+
original_delete = repo.delete
454+
455+
def crash_delete(snapshot_id: str) -> None:
456+
raise RuntimeError("simulated metadata delete crash")
457+
458+
repo.delete = crash_delete
459+
with pytest.raises(RuntimeError, match="simulated metadata delete crash"):
460+
service.delete_snapshot("snap-delete-crash")
461+
462+
stored = repo.get("snap-delete-crash")
463+
assert stored is not None
464+
assert stored.status.state == SnapshotState.DELETING
465+
assert runtime.delete_calls == [("snap-delete-crash", "opensandbox-snapshots:snap-delete-crash")]
466+
467+
repo.delete = original_delete
468+
recovery_runtime = StubSnapshotRuntime()
469+
PersistedSnapshotService(repo, StubSandboxService(), snapshot_runtime=recovery_runtime)
470+
471+
assert recovery_runtime.delete_calls == [("snap-delete-crash", "opensandbox-snapshots:snap-delete-crash")]
472+
assert repo.get("snap-delete-crash") is None
425473

426474

427475
def test_snapshot_service_worker_cleans_up_snapshot_deleted_during_creation(tmp_path) -> None:

0 commit comments

Comments
 (0)