Skip to content

fix(api): delete snapshot build artifacts on snapshot deletion (stacked on #3797) - #3798

Open
AdaAibaby wants to merge 3 commits into
e2b-dev:mainfrom
AdaAibaby:fix/snapshot-artifact-cleanup
Open

AdaAibaby wants to merge 3 commits into
e2b-dev:mainfrom
AdaAibaby:fix/snapshot-artifact-cleanup

Conversation

@AdaAibaby

@AdaAibaby AdaAibaby commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Deletes the storage artifacts of a snapshot's exclusively-owned builds when the snapshot is deleted (DELETE /sandboxes/{id}), instead of orphaning them in GCS/S3.

A snapshot is backed by its own template, and deleteSnapshot already soft-deletes that template. This reuses the exact template-deletion cleanup for the snapshot's template, so the same guarantees apply.

Stacked on #3797. Review/merge #3797 first. This PR's own delta is one call site in sandbox_kill.go + a DB-backed exclusivity test.

Inherits the reachability guard

Because it reuses #3797's startTemplateArtifactCleanup, the snapshot path inherits the reachability guard, reconciliation metrics, and panic-safe/bounded/best-effort background behavior. The guard is what makes the base-deleted direction safe: if another live build reaches a base layer now DB-exclusive to this snapshot-template, it is kept, not orphaned. teamID is threaded through to scope the live-root set.

Test

DB-backed TestSnapshotArtifactExclusivity: the snapshot's own build and an extra exclusive build are returned for cleanup; a build shared with another template is excluded; the query resolves after soft-delete; every returned build carries the cluster_node_id needed to route DeleteBuild.

GetExclusiveBuildsForTemplateDeletion had no Go caller, so deleting a
template only soft-deleted the DB row and left its build artifacts
(rootfs/kernel/FC snapshots) in storage forever. Fixes e2b-dev#3239.

- SQL: JOIN envs for cluster_id (route DeleteBuild to the right cluster);
  joins raw envs, not active_envs, so it resolves after soft-delete.
- Handler: startTemplateArtifactCleanup runs after the DB commit in a
  detached goroutine that is panic-safe (recover), deadline-bounded
  (2m), and fans out with bounded concurrency (errgroup SetLimit 8).
  Best-effort: nil-node builds skipped, per-build failures never abort
  siblings, outcomes counted via new metrics so orphaned storage is
  alertable. Storage errors never affect the delete response.
- Logging uses the internal logger API (logger.WithError/WithBuildID);
  no raw zap import in the handler (adds logger.WithError helper).
- processExclusiveBuilds is DB-free and unit-tested: nil-node skip,
  error non-fatal, nil-cluster fallback, empty input.

Metrics: api.template.artifacts.deleted / .delete_failed.

Note: generated sql.go hand-updated (ClusterID) since sqlc is not in CI.
Snapshot-deletion artifact cleanup is deliberately a separate follow-up.
… reconciliation metrics

Deleting a template now deletes its GCS/S3 build artifacts safely. Being
DB-exclusive is NOT sufficient: intermediate cache layers are never written to
the DB, so a build that is DB-exclusive to the doomed template can still be a
header-mapping ancestor of another live build. Deleting its prefix orphans that
ancestor and surfaces later as a silent, unrecoverable Input/output error.

Changes:
- Reachability guard folded into processExclusiveBuilds: a candidate is deleted
  only if it is NOT reachable from any live root's memfile/rootfs header closure.
  Fails closed (deletes nothing) when the reader is unconfigured, the live-root
  set can't be queried, or any live root's header can't be read.
- Live-root set completion: GetLiveBuildRootsForTeamExcludingTemplate now UNIONs
  the team's in-progress/pending builds (status_group) on top of other active
  templates' assigned builds, closing the gap where a concurrent build reusing
  the doomed template's exclusive ancestor had no assignment row yet.
- Reconciliation metrics so orphaned storage is derivable, not guessed:
  candidates = deleted + delete_failed + skipped_nil_node + guard_kept (success),
  or fail_closed>0 when nothing was deleted. fail_closed carries a low-cardinality
  reason (reader_unconfigured|live_roots_query|header_unreadable) and is the
  alert signal. deleted/failed carry a cluster attribute. team_id/build_id stay
  on logs/trace, not metric attributes (cardinality).
- Keeps the panic-safe / deadline-bounded / bounded-fan-out background cleanup.

Supersedes the separate reachability-guard PR, which had the guard but lacked
the background hardening and reconciliation metrics.

Tests: processExclusiveBuilds guard/skip/fail paths, fail-closed classification,
and DB-backed live-root tests (in-progress-without-assignment is a root,
soft-deleted/ready-unassigned excluded, team-scoped). go build/vet/gofmt clean.
A snapshot is backed by its own template, which deleteSnapshot already
soft-deletes. Reuse the template-deletion artifact cleanup for the snapshot's
template so its exclusively-owned build artifacts are removed from GCS/S3
instead of being orphaned.

Stacked on the reachability-guarded template cleanup: the snapshot path inherits
the same guard, reconciliation metrics, and panic-safe/bounded/best-effort
background behavior. The guard is what makes the base-deleted direction safe —
if another live build reaches a base layer now DB-exclusive to this
snapshot-template, it is kept, not orphaned. teamID is threaded through to scope
the live-root set the guard consults.

Test: DB-backed TestSnapshotArtifactExclusivity proves the snapshot's own build
and an extra exclusive build are returned for cleanup, a build shared with
another template is excluded, and the query resolves after soft-delete.

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant