fix(trafficrouting): do not block abort/progressDeadline when Istio delays DestinationRule switch. Fixes #4626#4823
Conversation
…elays DestinationRule switch. Fixes argoproj#4626 When subset-level Istio traffic routing delays the DestinationRule switch because a traffic-receiving ReplicaSet is not fully available (behavior introduced by argoproj#4612), UpdateHash returns a plain error. rolloutCanary() treated it as fatal and returned before syncRolloutStatusCanary(), so evaluateProgressDeadlineAbort(), Progressing-condition timeout evaluation, and ReplicaSet cleanup never ran. A rollout whose canary pods never become available therefore could not time out, turn Degraded, or auto-abort: the controller retried the same reconcile forever and the rollout stayed Progressing indefinitely. The condition that should trigger the abort (unavailable ReplicaSet) was exactly the condition that blocked the abort code path. Fix it in three parts: 1. Wrap the delay with a sentinel error (istio.ErrDestinationRuleUpdateDelayed) so callers can distinguish this transient, expected condition from fatal errors via errors.Is. All other errors keep the existing propagate-and-requeue behavior. 2. In reconcileTrafficRouting(), when the sentinel is detected, skip SetWeight for the round (preserving the ordering guarantee from argoproj#4612) but return nil so the rest of the reconcile still runs: abort/progressDeadline evaluation, conditions/phase updates, and ReplicaSet cleanup. 3. Flag the delay on the rollout context so completedCurrentCanaryStep() does not advance the current step while the desired traffic weight has not actually been applied, keeping the progression backpressure the fatal error used to provide. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: changhwanK <devchanghwan@gmail.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #4823 +/- ##
==========================================
- Coverage 85.04% 84.94% -0.10%
==========================================
Files 166 166
Lines 19136 19142 +6
==========================================
- Hits 16275 16261 -14
- Misses 2009 2020 +11
- Partials 852 861 +9
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Published E2E Test Results 4 files 4 suites 3h 48m 16s ⏱️ For more details on these failures, see this check. Results for commit 7c30ebb. ♻️ This comment has been updated with latest results. |
Published Unit Test Results2 518 tests 2 518 ✅ 3m 24s ⏱️ Results for commit 7c30ebb. ♻️ This comment has been updated with latest results. |
|
|
Hi @zachaller / @kostis-codefresh, would appreciate a look at this when you have bandwidth. This fixes a bug we've hit three times in production (2026-05-07, 2026-05-29, 2026-06-23 on v1.8.2) where canary rollouts get permanently stuck The fix is small and scoped (sentinel error to distinguish the transient Istio DestinationRule delay from fatal errors), all CI is green, and it includes a new test that reproduces the exact stuck-forever scenario end-to-end and fails on master without the fix. Happy to address any review feedback quickly. |



Fixes #4626
Summary
When subset-level Istio traffic routing delays the DestinationRule switch because a traffic-receiving ReplicaSet is not fully available (behavior introduced by #4612), the resulting error propagated as fatal and short-circuited
rolloutCanary()before it ever reachedsyncRolloutStatusCanary(). As a result, a rollout whose canary pods never become available could not time out, turnDegraded, or auto-abort: the controller retried the same reconcile forever (rate-limited requeue) and the rollout stayedProgressingindefinitely, requiring manual intervention (and in the worst observed case, a controller restart) to recover.The core of the bug is a circular blocking condition: the condition that should trigger the progress-deadline abort (an unavailable ReplicaSet) is exactly the condition that blocked the code path that evaluates the abort.
Root cause
Call path (all line refs at current master):
rollout/trafficrouting/istio/istio.goUpdateHash()intentionally skips the DestinationRule switch and returns a plainerrorwhenshouldDelayDestinationRuleUpdate()is true. This is an expected, transient condition (logged atInfolevel), but it is indistinguishable from a fatal error to callers.rollout/trafficrouting.goreconcileTrafficRouting()propagates it unchanged.rollout/canary.go:67-69rolloutCanary()returns early, skipping everything after it: experiments, analysis runs, ReplicaSet scaling/cleanup, and criticallysyncRolloutStatusCanary()→persistRolloutStatus(), which is whereevaluateProgressDeadlineAbort(),Progressing-condition timeout evaluation, andrequeueStuckRollout()live.Since the delay error recurs on every reconcile as long as the ReplicaSet stays unavailable (e.g. crashlooping pods), the abort/timeout evaluation is blocked permanently, not transiently. Note that abort is also the natural exit for this loop:
shouldDelayDestinationRuleUpdate()already exempts the canary ReplicaSet during an abort, so an abort would make the delay condition disappear and let the system converge — it just could never fire.Fix
Three small pieces:
rollout/trafficrouting/istio/istio.go: wrap the delay error with a sentinel (ErrDestinationRuleUpdateDelayed,%w) so callers can distinguish this expected condition viaerrors.Is. The error message is unchanged. All other errors (API failures etc.) are untouched and keep the existing propagate-and-requeue behavior.rollout/trafficrouting.goreconcileTrafficRouting(): when the sentinel is detected afterUpdateHash(), skipSetWeight()for this round (preserving the ordering guarantee from fix(trafficrouting): ensure DestinationRule is updated before SetWeight on rollback #4612: never set weight while the DestinationRule switch is pending) but returnnilso the rest of the reconcile still runs — abort/progressDeadline evaluation, conditions/phase updates, ReplicaSet cleanup, and the deadline-based requeue.rollout/canary.gocompletedCurrentCanaryStep()+ atrafficRoutingDelayedflag onrolloutContext: while the switch is delayed, the current step is not treated as completed, socurrentStepIndexcannot advance while the desired traffic weight has not actually been applied. This keeps the progression backpressure that the fatal error used to provide.Behavior before/after while the DestinationRule switch is delayed:
SetWeight()calledProgressing→TimedOut, phaseDegradedTesting
TestCanaryProgressDeadlineAbortNotBlockedByDelayedDestinationRuleSwitchreproduces the reported symptom end-to-end at the controller level: a canary rollout withprogressDeadlineAbort: true, stuck past its deadline with a partially-available canary ReplicaSet, must still auto-abort whileUpdateHashreports the delay. It fails on master (no abort patch is produced) and passes with this fix.TestReconcileTrafficRoutingUpdateHashDelayedErrverifies the delayed error is absorbed,SetWeightis not called (unstubbed on the mock, so a regression panics), the step is not completed (currentStepIndexdoes not advance, noRolloutStepCompletedevent).TestRollbackDestinationRuleBeforeSetWeight(the fix(trafficrouting): ensure DestinationRule is updated before SetWeight on rollback #4612 scenario test) still assertsSetWeightis not called on delay, and now also asserts reconciliation continues (stale canary RS cleanup proceeds in the same sync instead of the sync erroring out).TestUpdateHashNoReadyReplicaSetsadditionally pins theerrors.Iscontract on the sentinel.go build ./..., fullgo test ./...(0 failures), andgolangci-lint run ./rollout/...(0 issues) pass locally.Production context
This was hit three times in production (2026-05-07, 2026-05-29, 2026-06-23) on v1.8.2; details posted in #4626. The third recurrence affected 8 rollouts simultaneously and required a controller restart to recover. In all cases the trigger was canary pods that could never become available, and the rollouts stayed
Progressingwith no abort, noDegradedphase, and no status updates until manual intervention.Checklist:
"fix(controller): Updates such and such. Fixes #1234".TestIstioSuite) was not run locally and relies on CI.🤖 Generated with Claude Code