Skip to content

Commit eef64c7

Browse files
dobraccursoragentgithub-actions[bot]
authored andcommitted
fix(api): stop retrying pause on already-killing leftovers
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Jakub Dobry <dobrac@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> GitOrigin-RevId: df365d410f5f648ce8b39d1f1b40449998bc5ea7
1 parent 1fe4959 commit eef64c7

4 files changed

Lines changed: 145 additions & 10 deletions

File tree

‎packages/api/internal/orchestrator/evictor/evict.go‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -158,12 +158,14 @@ func (e *Evictor) refreshConcurrencyLimit(ctx context.Context) {
158158

159159
func (e *Evictor) evictSandbox(ctx context.Context, sbx sandbox.Sandbox) {
160160
action := sandbox.StateActionKill
161-
if sbx.AutoPause {
161+
if sbx.AutoPause && canTake(sbx.State, sandbox.StateActionPause) {
162162
action = sandbox.StateActionPause
163163
pause.LogInitiated(ctx, sbx.SandboxID, sbx.TeamID.String(), pause.ReasonTimeout, sbx.AutoPauseFilesystemOnly)
164164
}
165165

166-
opts := sandbox.RemoveOpts{Action: action, Eviction: true}
166+
// The action was chosen from a scanned record. Pin the removal to that
167+
// execution so a resume landing in between is refused, not acted on.
168+
opts := sandbox.RemoveOpts{Action: action, Eviction: true, ExpectExecutionID: sbx.ExecutionID}
167169
switch action {
168170
case sandbox.StateActionKill:
169171
opts.Reason = sandbox.KillReasonTimeout
@@ -184,12 +186,14 @@ func (e *Evictor) evictSandbox(ctx context.Context, sbx sandbox.Sandbox) {
184186
switch {
185187
case isNotEvictableError(err):
186188
pause.LogSkipped(ctx, sbx.SandboxID, sbx.TeamID.String(), pause.ReasonTimeout, pause.SkipReasonNotEvictable, opts.FilesystemOnly)
187-
case errors.Is(err, sandbox.ErrNotFound):
189+
case isGone(err):
188190
pause.LogSkipped(ctx, sbx.SandboxID, sbx.TeamID.String(), pause.ReasonTimeout, pause.SkipReasonNotFound, opts.FilesystemOnly)
191+
case isStaleDecision(err, sbx.State):
192+
pause.LogSkipped(ctx, sbx.SandboxID, sbx.TeamID.String(), pause.ReasonTimeout, pause.SkipReasonStateChanged, opts.FilesystemOnly)
189193
default:
190194
pause.LogFailure(ctx, sbx.SandboxID, sbx.TeamID.String(), pause.ReasonTimeout, opts.FilesystemOnly, err)
191195
}
192-
} else if !isKnownEvictionError(err) {
196+
} else if !isKnownEvictionError(err, sbx.State) {
193197
logger.L().Debug(ctx, "Evicting sandbox failed",
194198
zap.Error(err),
195199
logger.WithSandboxID(sbx.SandboxID),
@@ -211,10 +215,29 @@ func (e *Evictor) evictSandbox(ctx context.Context, sbx sandbox.Sandbox) {
211215
}
212216
}
213217

218+
func canTake(state sandbox.State, action sandbox.StateAction) bool {
219+
return state == action.TargetState || sandbox.AllowedTransitions[state][action.TargetState]
220+
}
221+
214222
func isNotEvictableError(err error) bool {
215223
return errors.Is(err, sandbox.ErrEvictionInProgress) || errors.Is(err, sandbox.ErrEvictionNotNeeded)
216224
}
217225

218-
func isKnownEvictionError(err error) bool {
219-
return isNotEvictableError(err) || errors.Is(err, sandbox.ErrNotFound)
226+
// isGone reports the scanned sandbox is no longer there: removed, or replaced
227+
// by a new execution under the same ID.
228+
func isGone(err error) bool {
229+
return errors.Is(err, sandbox.ErrNotFound) || errors.Is(err, sandbox.ErrExecutionMismatch)
230+
}
231+
232+
// isStaleDecision reports a refusal explained by the sandbox moving between
233+
// the expired-set read and StartRemoving. A refusal from the very state the
234+
// action was chosen for is a real failure and stays one.
235+
func isStaleDecision(err error, observed sandbox.State) bool {
236+
var transErr *sandbox.InvalidStateTransitionError
237+
238+
return errors.As(err, &transErr) && transErr.CurrentState != observed
239+
}
240+
241+
func isKnownEvictionError(err error, observed sandbox.State) bool {
242+
return isNotEvictableError(err) || isGone(err) || isStaleDecision(err, observed)
220243
}

‎packages/api/internal/orchestrator/evictor/evict_test.go‎

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func TestEvictSandbox_ReasonByAction(t *testing.T) {
3030
counter, err := telemetry.GetCounter(noop.NewMeterProvider().Meter("github.com/e2b-dev/infra/packages/api/internal/orchestrator/evictor"), telemetry.ApiEvictorFsOnlyAutoPause)
3131
require.NoError(t, err)
3232

33-
runOn := func(autoPause, autoPauseFilesystemOnly bool, fcVersion string) sandbox.RemoveOpts {
33+
runOn := func(state sandbox.State, autoPause, autoPauseFilesystemOnly bool, fcVersion string) sandbox.RemoveOpts {
3434
var got sandbox.RemoveOpts
3535
called := false
3636
e := &Evictor{
@@ -52,6 +52,8 @@ func TestEvictSandbox_ReasonByAction(t *testing.T) {
5252
e.evictSandbox(t.Context(), sandbox.Sandbox{
5353
SandboxID: "sbx",
5454
TeamID: uuid.New(),
55+
ExecutionID: "exec-1",
56+
State: state,
5557
AutoPause: autoPause,
5658
AutoPauseFilesystemOnly: autoPauseFilesystemOnly,
5759
FirecrackerVersion: fcVersion,
@@ -63,7 +65,7 @@ func TestEvictSandbox_ReasonByAction(t *testing.T) {
6365
return got
6466
}
6567
run := func(autoPause, autoPauseFilesystemOnly bool) sandbox.RemoveOpts {
66-
return runOn(autoPause, autoPauseFilesystemOnly, "v1.14-0.2.0")
68+
return runOn(sandbox.StateRunning, autoPause, autoPauseFilesystemOnly, "v1.14-0.2.0")
6769
}
6870

6971
t.Run("kill carries timeout reason", func(t *testing.T) {
@@ -76,6 +78,13 @@ func TestEvictSandbox_ReasonByAction(t *testing.T) {
7678
assert.Equal(t, sandbox.KillReasonTimeout, got.Reason)
7779
})
7880

81+
t.Run("removal is pinned to the scanned execution", func(t *testing.T) {
82+
t.Parallel()
83+
84+
assert.Equal(t, "exec-1", run(false, false).ExpectExecutionID)
85+
assert.Equal(t, "exec-1", run(true, false).ExpectExecutionID)
86+
})
87+
7988
t.Run("kill ignores the auto-pause snapshot kind", func(t *testing.T) {
8089
t.Parallel()
8190

@@ -120,7 +129,7 @@ func TestEvictSandbox_ReasonByAction(t *testing.T) {
120129
t.Run("filesystem-only auto-pause is honored on a legacy release", func(t *testing.T) {
121130
t.Parallel()
122131

123-
got := runOn(true, true, "v1.14.1_431f1fc")
132+
got := runOn(sandbox.StateRunning, true, true, "v1.14.1_431f1fc")
124133

125134
assert.Equal(t, sandbox.StateActionPause, got.Action)
126135
assert.True(t, got.FilesystemOnly)
@@ -129,9 +138,88 @@ func TestEvictSandbox_ReasonByAction(t *testing.T) {
129138
t.Run("filesystem-only auto-pause is honored on an unparsable version", func(t *testing.T) {
130139
t.Parallel()
131140

132-
got := runOn(true, true, "")
141+
got := runOn(sandbox.StateRunning, true, true, "")
133142

134143
assert.Equal(t, sandbox.StateActionPause, got.Action)
135144
assert.True(t, got.FilesystemOnly)
136145
})
146+
147+
t.Run("auto-pause leftover in killing is killed not paused", func(t *testing.T) {
148+
t.Parallel()
149+
150+
got := runOn(sandbox.StateKilling, true, true, "v1.14-0.2.0")
151+
152+
assert.Equal(t, sandbox.StateActionKill, got.Action)
153+
assert.True(t, got.Eviction)
154+
assert.Equal(t, sandbox.KillReasonTimeout, got.Reason)
155+
assert.False(t, got.FilesystemOnly)
156+
})
157+
158+
t.Run("auto-pause leftover that can still pause is paused", func(t *testing.T) {
159+
t.Parallel()
160+
161+
for _, state := range []sandbox.State{sandbox.StatePausing, sandbox.StateSnapshotting} {
162+
t.Run(string(state), func(t *testing.T) {
163+
t.Parallel()
164+
165+
got := runOn(state, true, true, "v1.14-0.2.0")
166+
167+
assert.Equal(t, sandbox.StateActionPause, got.Action)
168+
assert.True(t, got.FilesystemOnly)
169+
})
170+
}
171+
})
172+
}
173+
174+
func TestCanTake(t *testing.T) {
175+
t.Parallel()
176+
177+
assert.True(t, canTake(sandbox.StateRunning, sandbox.StateActionPause))
178+
assert.True(t, canTake(sandbox.StatePausing, sandbox.StateActionPause))
179+
assert.True(t, canTake(sandbox.StateSnapshotting, sandbox.StateActionPause))
180+
assert.False(t, canTake(sandbox.StateKilling, sandbox.StateActionPause))
181+
assert.True(t, canTake(sandbox.StateKilling, sandbox.StateActionKill))
182+
}
183+
184+
func TestIsStaleDecision(t *testing.T) {
185+
t.Parallel()
186+
187+
t.Run("state moved since the scan", func(t *testing.T) {
188+
t.Parallel()
189+
190+
err := &sandbox.InvalidStateTransitionError{
191+
CurrentState: sandbox.StateKilling,
192+
TargetState: sandbox.StatePausing,
193+
}
194+
assert.True(t, isStaleDecision(err, sandbox.StateRunning))
195+
assert.True(t, isKnownEvictionError(err, sandbox.StateRunning))
196+
})
197+
198+
t.Run("refusal from the scanned state stays a failure", func(t *testing.T) {
199+
t.Parallel()
200+
201+
// An unknown state is refused from the same state it was scanned in.
202+
// Nothing moved; the record is broken.
203+
err := &sandbox.InvalidStateTransitionError{
204+
CurrentState: "",
205+
TargetState: sandbox.StateKilling,
206+
}
207+
assert.False(t, isStaleDecision(err, ""))
208+
assert.False(t, isKnownEvictionError(err, ""))
209+
})
210+
211+
t.Run("other errors are not stale decisions", func(t *testing.T) {
212+
t.Parallel()
213+
214+
assert.False(t, isStaleDecision(sandbox.ErrNotFound, sandbox.StateRunning))
215+
})
216+
}
217+
218+
func TestIsGone(t *testing.T) {
219+
t.Parallel()
220+
221+
assert.True(t, isGone(sandbox.ErrNotFound))
222+
assert.True(t, isGone(sandbox.ErrExecutionMismatch))
223+
assert.True(t, isKnownEvictionError(sandbox.ErrExecutionMismatch, sandbox.StateRunning))
224+
assert.False(t, isGone(sandbox.ErrEvictionNotNeeded))
137225
}

‎packages/api/internal/pause/log.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ const (
2121
SkipReasonAlreadyPaused SkipReason = "already_paused"
2222
SkipReasonNotEvictable SkipReason = "not_evictable"
2323
SkipReasonNotFound SkipReason = "not_found"
24+
// SkipReasonStateChanged: the sandbox moved, between the expiry scan and
25+
// the removal, into a state pause cannot start from.
26+
SkipReasonStateChanged SkipReason = "state_changed"
2427
)
2528

2629
// fsOnly rides on every pause event so the snapshot kind can be joined to the

‎packages/api/internal/sandbox/storage/redis/expiration_index_test.go‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,27 @@ func TestExpiredItems_ReturnsExpiredRunningSandbox(t *testing.T) {
254254
require.Equal(t, sbx.ExecutionID, items[0].ExecutionID)
255255
}
256256

257+
func TestExpiredItems_ResidualKillingAfterStaleCutoff(t *testing.T) {
258+
t.Parallel()
259+
260+
storage, _ := setupTestStorage(t)
261+
262+
teamID := uuid.New()
263+
stale := makeIndexedSandbox(teamID, "sbx-stale-killing", uuid.NewString(), time.Now().Add(-time.Hour), time.Now().Add(-sandboxtypes.StaleCutoff-time.Minute))
264+
stale.State = sandboxtypes.StateKilling
265+
require.NoError(t, storage.Add(t.Context(), stale))
266+
267+
young := makeIndexedSandbox(teamID, "sbx-young-killing", uuid.NewString(), time.Now().Add(-time.Hour), time.Now().Add(-time.Minute))
268+
young.State = sandboxtypes.StateKilling
269+
require.NoError(t, storage.Add(t.Context(), young))
270+
271+
items, err := storage.ExpiredItems(t.Context())
272+
require.NoError(t, err)
273+
require.Len(t, items, 1)
274+
require.Equal(t, stale.SandboxID, items[0].SandboxID)
275+
require.Equal(t, sandboxtypes.StateKilling, items[0].State)
276+
}
277+
257278
// TestHeal_RestoresMissingMember reproduces the production incident: a
258279
// sandbox present in team storage but missing from the global expiration
259280
// index is invisible to the evictor and would live forever.

0 commit comments

Comments
 (0)