Skip to content

fix(server): prevent unrelated sandbox deletes from blocking deletion events#2340

Draft
pimlock wants to merge 3 commits into
mainfrom
2326-unblock-sandbox-delete-events/pimlock
Draft

fix(server): prevent unrelated sandbox deletes from blocking deletion events#2340
pimlock wants to merge 3 commits into
mainfrom
2326-unblock-sandbox-delete-events/pimlock

Conversation

@pimlock

@pimlock pimlock commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

🏗️ build-from-issue-agent

Summary

Prevent one slow sandbox deletion from blocking unrelated watcher events or deletes. The gateway now serializes duplicate deletes per stable sandbox ID, persists and recovers deletion transitions with version checks, and performs backend calls outside the gateway-wide state guard.

Related Issue

Closes #2326

Changes

  • Add weak, per-sandbox delete gates so duplicate requests share one driver call without blocking other sandbox IDs.
  • Bind a delete to the ID resolved at request start, preserve the workflow after client cancellation, and never retarget a reused sandbox name.
  • Make the delete-gate -> global-lock order explicit: delete-path global-lock acquisitions require a SandboxDeleteGuard proving that the ID-scoped gate is held.
  • Make watcher reconciliation deletion-aware and ignore unknown backend snapshots while logging the dropped event.
  • Recover failed driver deletes from an observed backend snapshot, remove an absent backend row, or roll back the exact deleting version when the backend state is inconclusive.
  • Delete sandbox rows by stable ID and resource version while retaining the existing best-effort name-based settings cleanup.
  • Extend concurrency, cancellation, name-reuse, cleanup, and recovery regression coverage; harden lifecycle E2E polling and diagnostics.
  • Document the stable deletion invariants and the current gateway-restart limitation.

The delete guard makes the intended lock order visible and harder to violate within the delete workflow, but it does not prevent a future caller from acquiring the global lock before requesting a delete gate. Enforcing that globally would require ranked-lock tracking or a broader refactor that restricts all direct lock acquisition, which is outside this PR's scope.

Deviations from Plan

Settings cleanup intentionally retains its existing best-effort name-based behavior. An earlier atomic persistence extension was removed to keep this PR scoped to the deletion concurrency issue.

Testing

  • mise run pre-commit passes
  • Unit tests added/updated
  • E2E tests added/updated (execution delegated to the labeled Branch E2E Checks workflow)

Tests added or updated:

  • Compute: 25 regression tests covering lock isolation, duplicate deletes, cancellation, name reuse, watcher races, recovery, and cleanup.
  • E2E: 2 sandbox lifecycle cases now use deadline-based polling with final-state diagnostics.

Checklist

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
@pimlock pimlock added the test:e2e Requires end-to-end coverage label Jul 17, 2026
@copy-pr-bot

copy-pr-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@github-actions

Copy link
Copy Markdown

Label test:e2e applied, but pull-request/2340 is at {"messa while the PR head is 0f18d50. A maintainer needs to comment /ok to test 0f18d50c78cdb7ae46861508c6641342028ad665 to refresh the mirror. Once the mirror catches up, re-run Branch E2E Checks from the Actions tab.

@pimlock

pimlock commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

/ok to test 0f18d50

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
@pimlock

pimlock commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

/ok to test 8283eec

self.cleanup_sandbox_owned_records(&sandbox).await;
self.sandbox_index.update_from_sandbox(&transition.deleting);
self.sandbox_watch_bus.notify(&candidate_id);
drop(guard);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does returning before we call drop(guard) mean that we run the risk of keeping the lock? (Does Rust have an equivalent to a defer drop(guard)?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, the guard is dropped explicitly, so it's released before the function returns. Without that, it happens automatically when the value goes out of scope (e.g. function returns). They refer to it as RAII.
Btw using it after drop would fail the compilation, as drop takes the ownership.

I don't know how common this approach of dropping explicitly is, alternatively the part that needs the lock could be a separate function and then the explicit drop wouldn't be needed.

This was first pass the agent took (after iterating on the plan), so it may need some tweaking. One thing I'd like to have is that the sandbox delete lock cannot be acquired if the global lock is acquired, otherwise deadlocks could happen. I think there should be a way of representing it with the type system to make that enforced, but need to spend some more time on this.

.ok_or_else(|| Status::not_found("sandbox not found"))?;
let candidate_id = candidate.object_id().to_string();
let delete_gate = self.delete_gates.gate_for(&candidate_id);
let _delete_guard = delete_gate.lock().await;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where do we drop this again?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is dropped when the variable goes out of scope, so right before this function returns.

Comment on lines +3872 to +3873
let delete_gate = runtime.delete_gates.gate_for(original.object_id());
let delete_guard = delete_gate.lock().await;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under which condition is it required to take a lock? Some code (possibly only tests) call gate_for without a subsequent .lock().await.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lock ensures only one delete operation is happening at the same time for a given sandbox. Having multiple run at the same time introduces issues related to how the cleanup is performed and could cause inconsistent state in DB.

Before this change, we'd acquire the global lock for the whole deletion process, so the limitation was that only one sandbox operation (deletion or otherwise) could be happening at the same time. That included invoking driver.delete, which may be slow (waiting for the process to respond to sigterm/sigkill).

This applies the global lock more narrowly and uses local lock for the deletion process (the deletions of unrelated sandbox are independent).

This was the simplest solution we found with the agent, it's not bullet proof, of course it works only for a single gateway, if the mechanism relied on DB for leasing the deletion process, that would work if we have multiple gateways, but that seemed too complex for this fix. But it maybe worth exploring other options.

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
@pimlock

pimlock commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

/ok to test 6a15fd9

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test:e2e Requires end-to-end coverage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(server): prevent unrelated sandbox deletes from blocking deletion events

2 participants