Skip to content

fix(controller): back off and fail terminally when a claim can never activate#928

Merged
stubbi merged 2 commits into
mainfrom
fix/activate-backoff-terminal-894
Jul 16, 2026
Merged

fix(controller): back off and fail terminally when a claim can never activate#928
stubbi merged 2 commits into
mainfrom
fix/activate-backoff-terminal-894

Conversation

@stubbi

@stubbi stubbi commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Thinking Path

  • Mitos boots Firecracker microVMs and forks them; the controller's Sandbox claim reconciler selects a warm husk pod and activates it (restores the snapshot and hands over env, secrets, and the API token).
  • Every provisioning failure the reconciler already classifies (a missing pool, secret resolution, token minting) either retries with a bounded grace or fails terminally; the husk activation failure was the exception.
  • An activation that can never succeed (a missing or corrupt snapshot artifact, a digest that will never match) re-pended at the flat capacityPendingRequeue cadence forever, with no backoff and no terminal state. A stuck pair produced roughly 1750 futile forkd RPCs and 3500 apiserver writes in an hour.
  • This violates the boring-failure principle (a claim that cannot activate must settle, not hammer) and needs fixing before it can amplify under real tenant load.
  • This pull request anchors the first activation failure on a durable annotation, backs the retry off as the failure persists, and fails the claim terminally after the same bounded grace the capacity wait uses, with the engine's own error surfaced.
  • The benefit is that a stuck claim stops hammering forkd and etcd and lands in a terminal Failed state an operator (or the GC) can act on, while a transient activation fault still recovers within the grace period.

Linked Issues or Issue Description

Closes #894.

What Changed

  • internal/controller/sandboxclaim_controller.go: on husk activation failure, stamp mitos.run/activate-failing-since (RFC3339) on the first failure, then either requeue with activateRetryBackoff(waited) (capacityPendingRequeue doubling to a 30s cap) or, once past maxPendingDuration, transition to terminal Failed with FinishedAt and an actionable ActivateFailed condition carrying the engine error and the node. The stamp is cleared on a successful activation so a later re-activation starts a fresh clock.
  • Added activateRetryBackoff (pure, elapsed-time-derived, stateless across restarts) plus activateFailingSinceAnnotation and activateFailingMaxBackoff.
  • Added activate_backoff_test.go: a table test of the backoff curve and a monotonic-plus-capped property test.

Verification

  • go build ./internal/controller/, go vet ./internal/controller/ clean; gofmt -l clean.
  • go test ./internal/controller/ -run TestActivateRetryBackoff (the new unit tests) pass.
  • go test ./internal/controller/ -run 'TestHuskClaimActivateFailureNotReady|TestHuskClaimActivateFailureJoinsTheTokenSecretWrite|TestHuskClaimActivatesDormantPod|TestHuskClaimActivateCarriesExpectedDigest|TestClaimFailsTerminallyWhenPoolNeverAppears' (envtest) all pass: the activation failure path, happy path, token-write join, and pool-missing terminal are unaffected.

Risks

Low risk. The retry path still releases the husk pod and re-pends within the grace period, so transient faults recover exactly as before, only at a backed-off cadence. The new terminal transition reuses the proven pool-missing grace bound and GC stamping (#630), so a permanently stuck claim is reaped like any other Failed sandbox. One extra metadata write occurs on the first failure and on recovery only.

Model Used

Claude Opus 4.8 (claude-opus-4-8), 1M context, extended thinking.

Summary by CodeRabbit

  • New Features

    • Activation failures now retry with an increasing backoff instead of a fixed interval.
    • Retry attempts are capped by the configured maximum pending duration.
    • Claims that exceed the retry window transition to SandboxFailed with actionable failure details.
    • Successful activation clears the failure timer so future retries start fresh.
  • Bug Fixes

    • Prevented activation retries from exceeding the configured backoff limit.
    • Added safeguards to ensure retry delays never decrease.

…activate

A claim whose husk activation kept failing (a missing or corrupt snapshot
artifact, a digest that will never match) re-pended at the flat
capacityPendingRequeue cadence forever, with no backoff and no terminal
state. In production a stuck pair produced roughly 1750 futile forkd RPCs
and 3500 apiserver writes in an hour.

Anchor the first-failure instant on a durable annotation
(mitos.run/activate-failing-since), mirroring the pool-missing machinery:
the retry backs off as the failure persists (capacityPendingRequeue
doubling to a 30s cap, derived from elapsed time so it is stateless across
controller restarts), and after the same bounded grace as the capacity
wait the claim fails terminally with the engine's own error and an
actionable remediation (issue #28). The stamp is cleared on a successful
activation so a later re-activation (failover, #177) starts a fresh clock.

activateRetryBackoff is a pure function with table and monotonic-plus-capped
unit tests; the existing activate-failure and pool-missing envtest suites
cover the reconcile wiring and confirm no regression.

Closes #894

Signed-off-by: Jannes Stubbemann <jannes@openclaw.rocks>
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The controller replaces flat-cadence husk activation retries with durable failure timing, capped exponential backoff, and terminal failure after max-pending-duration. Successful activation clears the failure timestamp. Unit tests validate exact, monotonic, and capped backoff behavior.

Changes

Activation retry handling

Layer / File(s) Summary
Backoff calculation and validation
internal/controller/sandboxclaim_controller.go, internal/controller/activate_backoff_test.go
Adds the failure timestamp annotation, capped exponential retry calculation, and tests covering thresholds, monotonicity, minimum delay, and maximum cap.
Failure lifecycle and claim state
internal/controller/sandboxclaim_controller.go
Records activation failure time, requeues pending claims with calculated backoff, clears the timestamp after recovery, and transitions claims to SandboxFailed after the configured duration.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Reconciler
  participant Husk
  participant SandboxClaim
  Reconciler->>Husk: Attempt activation
  Husk-->>Reconciler: Return activation failure
  Reconciler->>SandboxClaim: Persist failure timestamp
  Reconciler->>Reconciler: Calculate bounded retry delay
  Reconciler->>SandboxClaim: Requeue as pending or mark failed
  Husk-->>Reconciler: Return successful activation
  Reconciler->>SandboxClaim: Clear failure timestamp
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is a clear conventional commit that matches the main controller behavior change.
Description check ✅ Passed The description follows the template and covers thinking path, linked issue, changes, verification, risks, model, and checklist.
Linked Issues check ✅ Passed The controller now backs off activation retries, clears the stamp on success, and terminally fails after the bounded grace, matching #894.
Out of Scope Changes check ✅ Passed The diff stays focused on controller logic and backoff tests without unrelated feature or refactor changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/activate-backoff-terminal-894

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/controller/sandboxclaim_controller.go`:
- Around line 1147-1154: In the successful activation path around
activateFailingSinceAnnotation, do not return immediately when the annotation
cleanup Update fails. Complete token writing and Ready persistence first, then
remove the annotation with a metadata patch; afterward propagate any cleanup
failure wrapped with fmt.Errorf("context: %w", err), preserving the completed
activation result.
- Around line 1129-1131: Handle the error returned by r.Status().Update in the
husk activation timeout path before returning from the controller. Wrap any
persistence error with fmt.Errorf using a descriptive context and return it so
reconciliation retries; only return the existing empty result when the terminal
status update succeeds.
- Around line 1098-1108: The activation timestamp handling around
activateFailingSinceAnnotation must replace malformed or future timestamps
durably instead of silently resetting failingSince in memory. Update the
annotation to now, persist it with r.Update, and wrap any update failure using
fmt.Errorf with %w; preserve valid, non-future timestamps and ensure the updated
timestamp is used for subsequent wait calculations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: dda09abb-39e0-42c3-935d-ec1e98ee3da0

📥 Commits

Reviewing files that changed from the base of the PR and between 1fb5929 and 606caba.

📒 Files selected for processing (2)
  • internal/controller/activate_backoff_test.go
  • internal/controller/sandboxclaim_controller.go

Comment on lines +1098 to +1108
if stamp := claim.Annotations[activateFailingSinceAnnotation]; stamp != "" {
if parsed, perr := time.Parse(time.RFC3339, stamp); perr == nil {
failingSince = parsed
}
} else {
if claim.Annotations == nil {
claim.Annotations = map[string]string{}
}
claim.Annotations[activateFailingSinceAnnotation] = now.Format(time.RFC3339)
if uerr := r.Update(ctx, claim); uerr != nil {
return ctrl.Result{}, uerr

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Replace invalid activation timestamps instead of restarting the timer in memory.

A malformed annotation remains stored while failingSince resets to now each pass, so waited never reaches maxWait and the claim retries forever. Replace malformed or future timestamps durably, and wrap update errors.

Proposed fix
 		if stamp := claim.Annotations[activateFailingSinceAnnotation]; stamp != "" {
-			if parsed, perr := time.Parse(time.RFC3339, stamp); perr == nil {
+			if parsed, perr := time.Parse(time.RFC3339, stamp); perr == nil && !parsed.After(now) {
 				failingSince = parsed
+			} else {
+				claim.Annotations[activateFailingSinceAnnotation] = now.Format(time.RFC3339)
+				if uerr := r.Update(ctx, claim); uerr != nil {
+					return ctrl.Result{}, fmt.Errorf("reset invalid activation failure timestamp: %w", uerr)
+				}
 			}

As per coding guidelines, Go errors must be wrapped with fmt.Errorf("context: %w", err).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controller/sandboxclaim_controller.go` around lines 1098 - 1108, The
activation timestamp handling around activateFailingSinceAnnotation must replace
malformed or future timestamps durably instead of silently resetting
failingSince in memory. Update the annotation to now, persist it with r.Update,
and wrap any update failure using fmt.Errorf with %w; preserve valid, non-future
timestamps and ensure the updated timestamp is used for subsequent wait
calculations.

Source: Coding guidelines

Comment on lines +1129 to +1131
_ = r.Status().Update(ctx, claim)
logger.Info("claim failed: husk activation did not succeed past bounded wait", "claim", claim.Name, "node", pod.Spec.NodeName, "waited", waited.Round(time.Second), "maxWait", maxWait)
return ctrl.Result{}, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Propagate failure to persist the terminal status.

Ignoring this update error and returning no requeue can leave the claim pending indefinitely even though the grace period expired.

Proposed fix
-			_ = r.Status().Update(ctx, claim)
+			if err := r.Status().Update(ctx, claim); err != nil {
+				return ctrl.Result{}, fmt.Errorf("mark claim failed after activation timeout: %w", err)
+			}

As per coding guidelines, Go errors must be wrapped with fmt.Errorf("context: %w", err).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_ = r.Status().Update(ctx, claim)
logger.Info("claim failed: husk activation did not succeed past bounded wait", "claim", claim.Name, "node", pod.Spec.NodeName, "waited", waited.Round(time.Second), "maxWait", maxWait)
return ctrl.Result{}, nil
if err := r.Status().Update(ctx, claim); err != nil {
return ctrl.Result{}, fmt.Errorf("mark claim failed after activation timeout: %w", err)
}
logger.Info("claim failed: husk activation did not succeed past bounded wait", "claim", claim.Name, "node", pod.Spec.NodeName, "waited", waited.Round(time.Second), "maxWait", maxWait)
return ctrl.Result{}, nil
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controller/sandboxclaim_controller.go` around lines 1129 - 1131,
Handle the error returned by r.Status().Update in the husk activation timeout
path before returning from the controller. Wrap any persistence error with
fmt.Errorf using a descriptive context and return it so reconciliation retries;
only return the existing empty result when the terminal status update succeeds.

Source: Coding guidelines

Comment on lines +1147 to +1154
// A previously failing activation just succeeded: clear the grace stamp so a
// later re-activation (failover, #177) starts a fresh clock. The guard makes
// the common first-try success pay only a map lookup, never a write.
if claim.Annotations[activateFailingSinceAnnotation] != "" {
delete(claim.Annotations, activateFailingSinceAnnotation)
if uerr := r.Update(ctx, claim); uerr != nil {
return ctrl.Result{}, uerr
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Do not let annotation cleanup abort completion after successful activation.

If Update fails here, the function returns before joining the token writer or persisting Ready. The pod is already activated and claim-labeled, while subsequent selection skips claimed pods, potentially orphaning the activation permanently. Complete the token/status path first, then remove the annotation using a metadata patch; propagate any cleanup error with context afterward.

As per coding guidelines, Go errors must be wrapped with fmt.Errorf("context: %w", err).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controller/sandboxclaim_controller.go` around lines 1147 - 1154, In
the successful activation path around activateFailingSinceAnnotation, do not
return immediately when the annotation cleanup Update fails. Complete token
writing and Ready persistence first, then remove the annotation with a metadata
patch; afterward propagate any cleanup failure wrapped with fmt.Errorf("context:
%w", err), preserving the completed activation result.

Source: Coding guidelines

@stubbi
stubbi merged commit 11bb224 into main Jul 16, 2026
32 of 33 checks passed
@stubbi
stubbi deleted the fix/activate-backoff-terminal-894 branch July 16, 2026 11:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A claim that can never activate retry-loops at full cadence with no backoff and no terminal classification

1 participant