Skip to content

[disruption]: pipelined disruption budgets - #51

Open
lukeraphael wants to merge 1 commit into
mainfrom
luke/pipelined-budgets
Open

[disruption]: pipelined disruption budgets#51
lukeraphael wants to merge 1 commit into
mainfrom
luke/pipelined-budgets

Conversation

@lukeraphael

@lukeraphael lukeraphael commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds an opt-in PIPELINED_DISRUPTION_BUDGETS (--pipelined-disruption-budgets) mode, default off.
  • Splits a NodePool's disruption budget into two gates:
    • Start gate (BuildDisruptionBudgetMapping): allowed − nodes marked for deletion and waiting on replacements. Draining nodes no longer count here when pipelined.
    • Termination gate (BuildTerminationBudgetMapping, checked in the disruption queue once a command's replacements are Initialized, right before its candidates are deleted): allowed − nodes terminating or NotReady.
  • A command blocked on the termination gate requeues every 10s; that wait does not count toward the command timeout.
  • New gauges karpenter_nodepools_nodes_pending_replacement and karpenter_nodepools_nodes_terminating split the existing karpenter_nodepools_nodes_consuming_budgets; new counter karpenter_voluntary_disruption_queue_termination_waits_total{nodepool,reason}.
  • Behaviour with the flag off is unchanged.

Test plan

  • go test ./pkg/controllers/disruption/ (full suite, 361 specs) and ./pkg/operator/options/
  • New queue specs: termination-gate wait + release, and no wait when the flag is off
  • Roll out behind the flag on one cluster and compare queue_termination_waits_total / nodes_terminating against the NodePool budget

Open in Devin Review

Add an opt-in PIPELINED_DISRUPTION_BUDGETS mode that splits a NodePool's
disruption budget into two gates. Starting a command only counts nodes that
are marked and waiting on replacements, so booting replacements no longer
block new commands. Before the queue deletes a command's candidates it checks
a second gate, allowed minus nodes that are already terminating or NotReady,
so the budget bounds how many nodes are draining at once. Budget waits poll
every 10s and are not counted toward the command timeout.

New gauges karpenter_nodepools_nodes_pending_replacement and
karpenter_nodepools_nodes_terminating split the existing consuming-budgets
gauge; karpenter_voluntary_disruption_queue_termination_waits_total counts
termination-gate waits.

Assisted-by: Claude Code:claude-marshmallow-eap

@exa-heron-staging exa-heron-staging 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.

Heron review

global-review-orchestrator-guardian

Verdict: ✅ Approve · 2 findings · head 7bbf8b91e7 · openai/gpt-5.6-luna · 4m 22s · session

Severity Rule Location Finding
⚠️ advisory high-confidence-regression pkg/controllers/disruption/helpers.go:448 Initialized replacement commands still consume the pipeline start budget
⚠️ advisory unsafe-side-effect pkg/controllers/disruption/queue.go:308-321 Termination-budget retry can deadlock after a partial multi-node delete

Last updated 2026-08-24 03:31 UTC · reply @heron dismiss <reason> to dismiss on its comment thread

switch {
case nodeutils.GetCondition(node.Node, corev1.NodeReady).Status != corev1.ConditionTrue || node.Deleted():
c.terminating++
case node.MarkedForDeletion():

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Initialized replacement commands still consume the pipeline start budget

⚠️ advisory · rule high-confidence-regression · confidence 0.98

countBudgetConsumers classifies every initialized, ready node with node.MarkedForDeletion() as pending, and BuildDisruptionBudgetMapping subtracts that count when pipelining is enabled (consuming = c.pending at line 366). However, StartCommand marks candidates for deletion immediately after launching replacements (queue.go:425), while replacement readiness is tracked only in the command (cmd.Replacements[i].Initialized = true, queue.go:266); the cluster state is not changed when a replacement becomes initialized. Consequently a command whose replacement is ready but waiting at the new termination gate remains counted as a pending replacement, so it blocks admission of further commands until its candidate is deleted. This defeats the PR's advertised decoupling of replacement boot time from disruption throughput (and makes the pending-replacement metric inaccurate). Track replacement readiness when calculating the start-stage budget (or otherwise release the command's pending reservation once its replacements initialize), while retaining the termination-stage reservation.

Suggested fix:

Make the start-stage budget use actual queue command/replacement readiness rather than treating all cluster MarkedForDeletion nodes as pending, or update the state used by the budget mapping when a command's replacements become initialized.

Heron review global-review-orchestrator-guardian · fingerprint 30eaaf249de1 · reply @heron dismiss <reason> to dismiss

Comment on lines +308 to +321
if c.Deleted() {
continue
}
needed[c.NodePool.Name]++
}
if len(needed) == 0 {
return nil
}
terminationBudget, err := BuildTerminationBudgetMapping(ctx, q.cluster, q.clock, q.kubeClient, q.cloudProvider, cmd.Reason())
if err != nil {
return fmt.Errorf("building termination budgets, %w", err)
}
for nodePool, n := range needed {
if available := terminationBudget[nodePool]; available < n {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Termination-budget retry can deadlock after a partial multi-node delete

⚠️ advisory · rule unsafe-side-effect · confidence 0.96

The new termination gate attempts to exclude candidates deleted by a prior attempt with c.Deleted() (lines 308-310), but candidates are built from cluster.DeepCopyNodes() in GetCandidatesWithTotals and are retained as those deep-copied StateNodes in the command. StartCommand only marks the live cluster state for deletion (queue.go:425); a successful API delete does not update the command's copied candidate, so c.Deleted() remains false on a retry. If a multi-candidate command deletes one NodeClaim successfully and another delete fails, the retry still sets needed to the full original candidate set while BuildTerminationBudgetMapping sees the first NodeClaim as terminating and subtracts it from availability. For a pool budget of 2 this becomes needed=2, available=1, so the command waits forever (even after the deleted node disappears, the remaining budget is at most 1). This changes the existing partial-delete retry path from retrying the failed candidate to an unrecoverable stuck command. Record/refetch successful deletions and exclude them from needed before rechecking the gate.

Suggested fix:

Maintain per-candidate deletion success in the Command or refresh candidate NodeClaims from the API/cluster before constructing needed; only candidates that still require deletion should be compared with the termination budget.

Heron review global-review-orchestrator-guardian · fingerprint 540848bf6ce0 · reply @heron dismiss <reason> to dismiss

@devin-ai-integration devin-ai-integration 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.

Devin Review found 1 potential issue.

View 1 additional finding in Devin Review. (Configure)

Open in Devin Review

Comment on lines +232 to 237
if IsTerminationBudgetError(err) {
return
}
if q.clock.Since(cmd.CreationTimestamp) > retryDuration {
err = NewUnrecoverableError(serrors.Wrap(fmt.Errorf("command reached timeout, %w", err), "duration", q.clock.Since(cmd.CreationTimestamp)))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Successful pipelined disruption reported as a timeout failure

When a command waits past retryDuration at the termination gate and then deletes its candidates, waitOrTerminate returns nil, but the deferred timeout check still wraps that nil into an UnrecoverableError; the new guard only exempts TerminationBudgetError, not eventual success. Reconcile then runs the failure branch (queue.go), untainting the draining candidates, clearing their disruption condition, counting a queue failure, and skipping the realized-savings metrics. Pipelined waits are unbounded while retryDuration caps at one hour, so this hits any command delayed at the gate.

Suggested change
if IsTerminationBudgetError(err) {
return
}
if q.clock.Since(cmd.CreationTimestamp) > retryDuration {
err = NewUnrecoverableError(serrors.Wrap(fmt.Errorf("command reached timeout, %w", err), "duration", q.clock.Since(cmd.CreationTimestamp)))
}
if IsTerminationBudgetError(err) {
return
}
if err != nil && q.clock.Since(cmd.CreationTimestamp) > retryDuration {
err = NewUnrecoverableError(serrors.Wrap(fmt.Errorf("command reached timeout, %w", err), "duration", q.clock.Since(cmd.CreationTimestamp)))
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

1 participant