-
Notifications
You must be signed in to change notification settings - Fork 359
feat(api): add sandbox fork endpoint #3202
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mishushakov
wants to merge
14
commits into
main
Choose a base branch
from
sandbox-fork-api-method
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
ec563fe
feat(api): add sandbox fork endpoint
mishushakov b9db709
feat(api): resume original and forked sandboxes concurrently
mishushakov 3cd47e7
fix(api): address fork endpoint review comments
mishushakov 2da7045
fix(api): remap resume origin node by the snapshot's sandbox ID
mishushakov f01777b
refactor(api): split resume data fetcher into two methods
mishushakov e1f866b
docs(api): explain default-timeout fallback when TTL expires mid-fork
mishushakov 4d7fe48
fix(api): skip resuming the original sandbox when its TTL expired mid…
mishushakov 2cf6c05
refactor(api): fork sandboxes via in-place checkpoint instead of paus…
mishushakov e173552
fix(api): use fork wording on snapshot lookup errors in fork handler
mishushakov eef6caa
feat(api): fork count parameter to create multiple sandboxes at once
mishushakov 05a603c
feat(api): per-fork results instead of all-or-nothing fork semantics
mishushakov 96e0030
fix(api): validate fork count regardless of whether it was sent
mishushakov fd4bdab
docs(api): explain why a joined checkpoint reports a conflict
mishushakov bf16ec1
fix(api): keep the original sandbox alive when checkpoint fails befor…
mishushakov File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,204 @@ | ||
| package handlers | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "net/http" | ||
| "time" | ||
|
|
||
| "github.com/gin-gonic/gin" | ||
| "github.com/google/uuid" | ||
| "go.opentelemetry.io/otel/trace" | ||
| "go.uber.org/zap" | ||
| "golang.org/x/sync/errgroup" | ||
|
|
||
| "github.com/e2b-dev/infra/packages/api/internal/api" | ||
| "github.com/e2b-dev/infra/packages/api/internal/orchestrator" | ||
| "github.com/e2b-dev/infra/packages/api/internal/sandbox" | ||
| "github.com/e2b-dev/infra/packages/api/internal/utils" | ||
| "github.com/e2b-dev/infra/packages/auth/pkg/auth" | ||
| "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" | ||
| "github.com/e2b-dev/infra/packages/shared/pkg/id" | ||
| sbxlogger "github.com/e2b-dev/infra/packages/shared/pkg/logger/sandbox" | ||
| "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" | ||
| sharedUtils "github.com/e2b-dev/infra/packages/shared/pkg/utils" | ||
| ) | ||
|
|
||
| // maxForkCount caps how many sandboxes a single fork request can create, | ||
| // bounding the parallel boots (and the result allocation) per request. | ||
| const maxForkCount = 100 | ||
|
|
||
| // PostSandboxesSandboxIDFork forks a running sandbox: it checkpoints the | ||
| // sandbox in place (snapshot it and resume it on its node, so the original | ||
| // keeps running with its ID and expiration untouched) and creates count new | ||
| // sandboxes from that snapshot under fresh IDs. Each fork succeeds or fails | ||
| // independently: the response carries one result per requested fork, holding | ||
| // either the created sandbox or the error that prevented it from starting. | ||
| func (a *APIStore) PostSandboxesSandboxIDFork(c *gin.Context, sandboxID api.SandboxID) { | ||
| ctx := c.Request.Context() | ||
|
|
||
| teamInfo := auth.MustGetTeamInfo(c) | ||
| teamID := teamInfo.Team.ID | ||
|
|
||
| sandboxID, err := utils.ShortID(sandboxID) | ||
| if err != nil { | ||
| a.sendAPIStoreError(c, http.StatusBadRequest, "Invalid sandbox ID") | ||
|
|
||
| return | ||
| } | ||
|
|
||
| span := trace.SpanFromContext(ctx) | ||
| span.SetAttributes(telemetry.WithSandboxID(sandboxID)) | ||
|
|
||
| traceID := span.SpanContext().TraceID().String() | ||
| c.Set("traceID", traceID) | ||
|
|
||
| body, err := ginutils.ParseOptionalBody[api.PostSandboxesSandboxIDForkJSONRequestBody](ctx, c) | ||
| if err != nil { | ||
| a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing request: %s", err)) | ||
|
|
||
| return | ||
| } | ||
|
|
||
| forkTimeout := sandbox.SandboxTimeoutDefault | ||
| if body.Timeout != nil { | ||
| forkTimeout = time.Duration(*body.Timeout) * time.Second | ||
|
|
||
| if forkTimeout > time.Duration(teamInfo.Limits.MaxLengthHours)*time.Hour { | ||
| a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Timeout cannot be greater than %d hours", teamInfo.Limits.MaxLengthHours)) | ||
|
|
||
| return | ||
| } | ||
| } | ||
|
|
||
| forkCount := 1 | ||
| if body.Count != nil { | ||
| forkCount = int(*body.Count) | ||
| } | ||
|
|
||
| if forkCount < 1 { | ||
| a.sendAPIStoreError(c, http.StatusBadRequest, "Count must be at least 1") | ||
|
|
||
| return | ||
| } | ||
|
|
||
| if forkCount > maxForkCount { | ||
| a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Count cannot be greater than %d", maxForkCount)) | ||
|
|
||
| return | ||
| } | ||
|
|
||
| // The original sandbox keeps running and holds one slot, so more forks | ||
| // than the concurrency limit can never succeed. | ||
| if int64(forkCount) >= teamInfo.Limits.SandboxConcurrency { | ||
| a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Count must be lower than the maximum number of concurrent sandboxes (%d)", teamInfo.Limits.SandboxConcurrency)) | ||
|
|
||
| return | ||
| } | ||
|
|
||
| original, err := a.orchestrator.GetSandbox(ctx, teamID, sandboxID) | ||
| if err != nil { | ||
| if errors.Is(err, sandbox.ErrNotFound) { | ||
| apiErr := forkHandleNotRunningSandbox(ctx, a, sandboxID, teamID) | ||
| a.sendAPIStoreError(c, apiErr.Code, apiErr.ClientMsg) | ||
|
|
||
| return | ||
| } | ||
|
|
||
| telemetry.ReportError(ctx, "error getting sandbox for fork", err, telemetry.WithSandboxID(sandboxID)) | ||
| a.sendAPIStoreError(c, http.StatusInternalServerError, "Error forking sandbox") | ||
|
|
||
| return | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| if err := sharedUtils.CheckEnvdVersionForSnapshot(original.EnvdVersion); err != nil { | ||
| a.sendAPIStoreError(c, http.StatusBadRequest, err.Error()) | ||
|
|
||
| return | ||
| } | ||
|
|
||
| // Checkpoint the sandbox in place: it is briefly paused on its node, | ||
| // snapshotted, and resumed under the same execution ID, so the original | ||
| // keeps its ID, expiration, and concurrency slot. | ||
| err = a.orchestrator.CheckpointSandbox(ctx, teamID, sandboxID) | ||
| var transErr *sandbox.InvalidStateTransitionError | ||
|
|
||
| switch { | ||
| case err == nil: | ||
| case errors.Is(err, sandbox.ErrNotFound): | ||
| apiErr := forkHandleNotRunningSandbox(ctx, a, sandboxID, teamID) | ||
| a.sendAPIStoreError(c, apiErr.Code, apiErr.ClientMsg) | ||
|
|
||
| return | ||
| case errors.As(err, &transErr): | ||
| a.sendAPIStoreError(c, http.StatusConflict, fmt.Sprintf("Sandbox '%s' cannot be forked while in '%s' state", sandboxID, transErr.CurrentState)) | ||
|
|
||
| return | ||
| case errors.Is(err, orchestrator.PauseQueueExhaustedError{}): | ||
| a.sendAPIStoreError(c, http.StatusServiceUnavailable, fmt.Sprintf("Sandbox '%s' cannot be forked right now because its node is busy, please retry", sandboxID)) | ||
|
|
||
| return | ||
| default: | ||
| telemetry.ReportError(ctx, "error checkpointing sandbox for fork", err, telemetry.WithSandboxID(sandboxID)) | ||
| a.sendAPIStoreError(c, http.StatusInternalServerError, "Error forking sandbox") | ||
|
|
||
| return | ||
| } | ||
|
|
||
| sbxlogger.E(&sbxlogger.SandboxMetadata{ | ||
| SandboxID: sandboxID, | ||
| TemplateID: original.TemplateID, | ||
| TeamID: teamID.String(), | ||
| }).Debug(ctx, "Creating forked sandboxes from snapshot", zap.Int("count", forkCount)) | ||
|
|
||
| // All forks boot in parallel from the same immutable snapshot, each | ||
| // succeeding or failing independently. | ||
| results := make([]api.SandboxForkResult, forkCount) | ||
|
|
||
| wg := errgroup.Group{} | ||
| for i := range forkCount { | ||
| wg.Go(func() error { | ||
| forkedSandboxID := InstanceIDPrefix + id.Generate() | ||
|
|
||
| forkedSbx, createErr := a.startSandbox( | ||
| ctx, | ||
| forkedSandboxID, | ||
| forkTimeout, | ||
| teamInfo, | ||
| a.buildResumeSandboxDataFromSnapshot(sandboxID, forkedSandboxID, nil), | ||
| &c.Request.Header, | ||
| true, | ||
| nil, // mcp | ||
| ) | ||
| if createErr != nil { | ||
| telemetry.ReportError(ctx, "error creating forked sandbox", createErr.Err, telemetry.WithSandboxID(forkedSandboxID)) | ||
| results[i] = api.SandboxForkResult{Error: &api.Error{Code: int32(createErr.Code), Message: createErr.ClientMsg}} | ||
|
|
||
| //nolint:nilerr // per-fork errors are reported in the result entry, not propagated | ||
| return nil | ||
| } | ||
|
|
||
| results[i] = api.SandboxForkResult{Sandbox: forkedSbx} | ||
|
|
||
| return nil | ||
| }) | ||
| } | ||
| _ = wg.Wait() | ||
|
|
||
| c.JSON(http.StatusCreated, results) | ||
| } | ||
|
|
||
| // forkHandleNotRunningSandbox classifies a fork request for a sandbox that is | ||
| // not running: 409 if it is paused (a snapshot exists), 404 otherwise. | ||
| func forkHandleNotRunningSandbox(ctx context.Context, a *APIStore, sandboxID string, teamID uuid.UUID) api.APIError { | ||
| apiErr := pauseHandleNotRunningSandbox(ctx, a.snapshotCache, sandboxID, teamID) | ||
| switch apiErr.Code { | ||
| case http.StatusConflict: | ||
| apiErr.ClientMsg = fmt.Sprintf("Sandbox '%s' is paused and cannot be forked; resume it first", sandboxID) | ||
| case http.StatusInternalServerError: | ||
| apiErr.ClientMsg = "Error forking sandbox" | ||
| } | ||
|
|
||
| return apiErr | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
130 changes: 130 additions & 0 deletions
130
packages/api/internal/orchestrator/checkpoint_instance.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| package orchestrator | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "sync" | ||
| "time" | ||
|
|
||
| "github.com/gogo/status" | ||
| "github.com/google/uuid" | ||
| "google.golang.org/grpc/codes" | ||
|
|
||
| "github.com/e2b-dev/infra/packages/api/internal/sandbox" | ||
| "github.com/e2b-dev/infra/packages/db/pkg/types" | ||
| "github.com/e2b-dev/infra/packages/db/queries" | ||
| "github.com/e2b-dev/infra/packages/shared/pkg/grpc/orchestrator" | ||
| "github.com/e2b-dev/infra/packages/shared/pkg/storage/storageopts" | ||
| "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" | ||
| ) | ||
|
|
||
| // CheckpointSandbox snapshots a running sandbox in place: the sandbox is | ||
| // briefly paused on its node, snapshotted, and resumed under the same | ||
| // execution ID, so it keeps running with its ID, expiration, and reservation | ||
| // untouched. The snapshot is written to the sandbox's own snapshots row, so | ||
| // it can immediately be resumed or forked from. | ||
| func (o *Orchestrator) CheckpointSandbox(ctx context.Context, teamID uuid.UUID, sandboxID string) error { | ||
| ctx, span := tracer.Start(ctx, "checkpoint-sandbox") | ||
| defer span.End() | ||
|
|
||
| sbx, alreadyDone, finishSnapshotting, err := o.sandboxStore.StartRemoving(ctx, teamID, sandboxID, sandbox.RemoveOpts{Action: sandbox.StateActionSnapshot}) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to start snapshotting: %w", err) | ||
| } | ||
|
|
||
| // alreadyDone conflates joining a concurrent checkpoint that just | ||
| // succeeded with finding the sandbox stuck in Snapshotting after a failed | ||
| // one, where no fresh snapshot exists. Treating it as success could fork | ||
| // stale state, so report a conflict and let the caller retry (same | ||
| // behavior as CreateSnapshotTemplate). | ||
| if alreadyDone { | ||
| return &sandbox.InvalidStateTransitionError{ | ||
| CurrentState: sandbox.StateSnapshotting, | ||
| TargetState: sandbox.StateSnapshotting, | ||
| } | ||
|
mishushakov marked this conversation as resolved.
|
||
| } | ||
|
|
||
| // finish completes the snapshotting transition exactly once. | ||
| // On success (nil) it restores the sandbox to Running. | ||
| // On error it leaves the state as Snapshotting so that | ||
| // RemoveSandbox can transition directly to Killing. | ||
| var once sync.Once | ||
| finish := func(err error) { | ||
| once.Do(func() { | ||
| finishSnapshotting(context.WithoutCancel(ctx), err) | ||
| }) | ||
| } | ||
| defer finish(nil) | ||
|
|
||
| node := o.getOrConnectNode(ctx, sbx.ClusterID, sbx.NodeID) | ||
| if node == nil { | ||
| return fmt.Errorf("node '%s' not found", sbx.NodeID) | ||
| } | ||
|
|
||
| upsertResult, err := o.throttledUpsertSnapshot(ctx, buildUpsertSnapshotParams(sbx, node, false)) | ||
| if err != nil { | ||
| return fmt.Errorf("error upserting snapshot: %w", err) | ||
| } | ||
|
|
||
| // Checkpoint pauses the sandbox, snapshots it, and resumes it on the | ||
| // orchestrator with the same ExecutionID. Once the pause has started, the | ||
| // orchestrator stops the old sandbox itself on error; RemoveSandbox is | ||
| // still needed to clean up API-side state (store, routing, analytics). | ||
| client, childCtx := node.GetClient(ctx) | ||
| _, err = client.Sandbox.Checkpoint(childCtx, &orchestrator.SandboxCheckpointRequest{ | ||
| SandboxId: sbx.SandboxID, | ||
| BuildId: upsertResult.BuildID.String(), | ||
| Metadata: map[string]string{storageopts.ObjectMetadataTemplateID: upsertResult.TemplateID}, | ||
| }) | ||
| if err != nil { | ||
| // Cleanup must run even when the checkpoint failed because this | ||
| // request's context was cancelled (e.g. client disconnect mid-fork). | ||
| cleanupCtx := context.WithoutCancel(ctx) | ||
|
|
||
| o.failSnapshotBuild(cleanupCtx, upsertResult.BuildID, err) | ||
|
|
||
| // The orchestrator rejects these before pausing the VM (envd too old, | ||
| // starting-sandboxes queue full), so the sandbox is still running | ||
| // healthy on its node: restore it to Running instead of killing it. | ||
| if st, ok := status.FromError(err); ok { | ||
| switch st.Code() { | ||
| case codes.FailedPrecondition: | ||
| finish(nil) | ||
|
|
||
| return fmt.Errorf("checkpoint rejected: %w", err) | ||
| case codes.ResourceExhausted: | ||
| finish(nil) | ||
|
|
||
| return PauseQueueExhaustedError{} | ||
| } | ||
| } | ||
|
|
||
| // Complete the snapshotting transition with error — leaves state as | ||
| // Snapshotting (no restore to Running) and clears the transition key | ||
| // so RemoveSandbox can proceed without deadlock. | ||
| finish(err) | ||
|
|
||
| if killErr := o.RemoveSandbox(cleanupCtx, teamID, sandboxID, sandbox.RemoveOpts{Action: sandbox.StateActionKill}); killErr != nil { | ||
| telemetry.ReportError(cleanupCtx, "error killing sandbox after failed checkpoint", killErr) | ||
| } | ||
|
|
||
| return fmt.Errorf("checkpoint failed: %w", err) | ||
| } | ||
|
|
||
| now := time.Now() | ||
| err = o.sqlcDB.UpdateEnvBuildStatus(ctx, queries.UpdateEnvBuildStatusParams{ | ||
| Status: types.BuildStatusSuccess, | ||
| FinishedAt: &now, | ||
| Reason: types.BuildReason{}, | ||
| BuildID: upsertResult.BuildID, | ||
| }) | ||
| if err != nil { | ||
| return fmt.Errorf("error updating build status: %w", err) | ||
| } | ||
|
|
||
| o.snapshotCache.Invalidate(context.WithoutCancel(ctx), sandboxID) | ||
|
mishushakov marked this conversation as resolved.
|
||
|
|
||
| telemetry.ReportEvent(ctx, "Checkpointed sandbox") | ||
|
|
||
| return nil | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.