Skip to content

Commit e98339d

Browse files
borisurbanikclaude
andcommitted
Fix status reconciler requeue logic: RequeueAfter and skip equal-status write
Two issues in the previous requeue logic: 1. The guard `equalStatus && newAvailable` caused the unavailable-but-equal case to fall through to a status write even when nothing had changed, producing a no-op write on every reconcile while the instance remained unavailable. The new `if equalStatus` guard short-circuits both the available and unavailable steady states, avoiding the unnecessary write. 2. Both unavailable return paths used `Requeue: true` (immediate requeue), which causes tight-loop reconciliation against an instance that is still unavailable. Switching to `RequeueAfter: 30s` gives components time to recover between checks and avoids extending backoff counter. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 18b6909 commit e98339d

3 files changed

Lines changed: 109 additions & 51 deletions

File tree

controllers/apps/apimanager_controller.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ func (r *APIManagerReconciler) Reconcile(ctx context.Context, req ctrl.Request)
108108
if statusErr != nil {
109109
return ctrl.Result{}, statusErr
110110
}
111-
if statusResult.Requeue {
111+
if statusResult.Requeue || statusResult.RequeueAfter > 0 {
112112
logger.Info("Reconciling not finished. Requeueing.")
113113
return statusResult, nil
114114
}
@@ -138,7 +138,7 @@ func (r *APIManagerReconciler) Reconcile(ctx context.Context, req ctrl.Request)
138138
if statusErr != nil {
139139
return ctrl.Result{}, statusErr
140140
}
141-
if statusResult.Requeue {
141+
if statusResult.Requeue || statusResult.RequeueAfter > 0 {
142142
logger.Info("Reconciling not finished. Requeueing.")
143143
return statusResult, nil
144144
}
@@ -175,7 +175,7 @@ func (r *APIManagerReconciler) Reconcile(ctx context.Context, req ctrl.Request)
175175
return specResult, nil
176176
}
177177

178-
if statusResult.Requeue {
178+
if statusResult.Requeue || statusResult.RequeueAfter > 0 {
179179
logger.Info("Reconciling not finished. Requeueing.")
180180
return statusResult, nil
181181
}

controllers/apps/apimanager_status_reconciler.go

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"sort"
77
"strings"
8+
"time"
89

910
appsv1alpha1 "github.com/3scale/3scale-operator/apis/apps/v1alpha1"
1011
subController "github.com/3scale/3scale-operator/controllers/subscription"
@@ -49,33 +50,28 @@ func (s *APIManagerStatusReconciler) Reconcile() (reconcile.Result, error) {
4950
return reconcile.Result{}, fmt.Errorf("failed to calculate status: %w", err)
5051
}
5152

52-
// Read availability from the newly computed status, not the stale pre-reconcile snapshot.
53-
// Using old status caused a True-to-False requeue gap: old=Available=True would suppress
54-
// the requeue even after writing Available=False to the API server (THREESCALE-10754).
55-
newAvailable := newStatus.Conditions.IsTrueFor(appsv1alpha1.APIManagerAvailableConditionType)
56-
5753
equalStatus := s.apimanagerResource.Status.Equals(newStatus, s.logger)
5854
s.logger.V(1).Info("Status", "status is different", !equalStatus)
59-
if equalStatus && newAvailable {
60-
// Steady state
61-
s.logger.V(1).Info("Status was not updated")
62-
return reconcile.Result{}, nil
63-
}
64-
65-
s.apimanagerResource.Status = *newStatus
66-
updateErr := s.Client().Status().Update(s.Context(), s.apimanagerResource)
67-
if updateErr != nil {
68-
// Ignore conflicts, resource might just be outdated.
69-
if errors.IsConflict(updateErr) {
70-
s.logger.Info("Failed to update status: resource might just be outdated")
71-
return reconcile.Result{Requeue: true}, nil
55+
if !equalStatus {
56+
s.apimanagerResource.Status = *newStatus
57+
updateErr := s.Client().Status().Update(s.Context(), s.apimanagerResource)
58+
if updateErr != nil {
59+
// Ignore conflicts, resource might just be outdated.
60+
if errors.IsConflict(updateErr) {
61+
s.logger.Info("Failed to update status: resource might just be outdated")
62+
return reconcile.Result{Requeue: true}, nil
63+
}
64+
return reconcile.Result{}, fmt.Errorf("failed to update status: %w", updateErr)
7265
}
73-
74-
return reconcile.Result{}, fmt.Errorf("failed to update status: %w", updateErr)
66+
} else {
67+
s.logger.V(1).Info("Status was not updated")
7568
}
7669

70+
// Re-check status periodically specifically only when availability is failing; this
71+
// is an optimization - once we're available we rely only on watch events + re-sync interval
72+
newAvailable := newStatus.Conditions.IsTrueFor(appsv1alpha1.APIManagerAvailableConditionType)
7773
if !newAvailable {
78-
return reconcile.Result{Requeue: true}, nil
74+
return reconcile.Result{RequeueAfter: 30 * time.Second}, nil
7975
}
8076

8177
return reconcile.Result{}, nil

controllers/apps/apimanager_status_reconciler_test.go

Lines changed: 89 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -609,38 +609,100 @@ func TestAPIManagerStatusReconciler_Reconcile_statusConditions(t *testing.T) {
609609
}
610610
}
611611

612-
// TestAPIManagerStatusReconciler_Reconcile_requeueOnTrueToFalseTransition is a regression
613-
// test for the stale-read requeue bug: when Available transitions from True to False the
614-
// reconciler must requeue, not settle silently at Available=False.
615-
func TestAPIManagerStatusReconciler_Reconcile_requeueOnTrueToFalseTransition(t *testing.T) {
612+
// TestAPIManagerStatusReconciler_Reconcile_requeueBehaviour verifies the requeue semantics
613+
// under three scenarios:
614+
//
615+
// - True→False transition: status changes; RequeueAfter is set
616+
// - Unavailable steady-state: status is already equal (False); no write, RequeueAfter still set
617+
// - Available steady-state: status is already equal (True); no write, RequeueAfter is zero
618+
func TestAPIManagerStatusReconciler_Reconcile_requeueBehaviour(t *testing.T) {
616619
namespace := "test-namespace"
617620
t.Setenv("PREFLIGHT_CHECKS_BYPASS", "true")
618621

619-
// Seed the CR with Available=True in its current status (old state).
620-
am := getTestAPIManager(namespace)
621-
am.Status.Conditions = common.Conditions{
622-
{Type: appsv1alpha1.APIManagerAvailableConditionType, Status: corev1.ConditionTrue},
622+
tests := []struct {
623+
name string
624+
deploymentsUp bool
625+
steadyState bool // if true, pre-seed exact status so equalStatus==true
626+
seedAvailable corev1.ConditionStatus // Available condition _before_ Reconcile - only used if steadyState == false
627+
wantRequeueAfter bool
628+
}{
629+
{
630+
name: "unavailable transition (True to False)",
631+
deploymentsUp: false,
632+
steadyState: false,
633+
seedAvailable: corev1.ConditionTrue,
634+
wantRequeueAfter: true,
635+
},
636+
{
637+
name: "unavailable steady-state (False to False, equal)",
638+
deploymentsUp: false,
639+
steadyState: true,
640+
wantRequeueAfter: true,
641+
},
642+
{
643+
name: "available transition (False to True)",
644+
deploymentsUp: true,
645+
steadyState: false,
646+
seedAvailable: corev1.ConditionFalse,
647+
wantRequeueAfter: false,
648+
},
649+
{
650+
name: "available steady-state (True to True, equal)",
651+
deploymentsUp: true,
652+
steadyState: true,
653+
wantRequeueAfter: false,
654+
},
623655
}
624656

625-
// Cluster state: deployments not available, so calculateStatus() will return Available=False.
626-
objects := concat(
627-
getAllStandardDeployments(namespace, string(am.UID), false),
628-
getRequiredSecrets(namespace),
629-
getRequiredRoutes(namespace, testWildcardDomain, testTenantName),
630-
[]runtime.Object{am},
631-
)
632-
633-
s := &APIManagerStatusReconciler{
634-
BaseReconciler: getAPIManagerBaseReconciler(objects...),
635-
apimanagerResource: am,
636-
logger: logr.Discard(),
637-
}
657+
for _, tt := range tests {
658+
t.Run(tt.name, func(t *testing.T) {
659+
am := getTestAPIManager(namespace)
638660

639-
result, err := s.Reconcile()
640-
if err != nil {
641-
t.Fatalf("Reconcile() unexpected error: %v", err)
642-
}
643-
if !result.Requeue {
644-
t.Errorf("Reconcile() Requeue = false, want true on Available True-to-False transition")
661+
objects := concat(
662+
getAllStandardDeployments(namespace, string(am.UID), tt.deploymentsUp),
663+
getRequiredSecrets(namespace),
664+
getRequiredRoutes(namespace, testWildcardDomain, testTenantName),
665+
[]runtime.Object{am},
666+
)
667+
668+
s := &APIManagerStatusReconciler{
669+
BaseReconciler: getAPIManagerBaseReconciler(objects...),
670+
apimanagerResource: am,
671+
logger: logr.Discard(),
672+
}
673+
674+
if tt.steadyState {
675+
// Pre-seed am.Status with the exact value calculateStatus() will compute so
676+
// that Equals() returns true and the equalStatus short-circuit is exercised.
677+
computed, err := s.calculateStatus()
678+
if err != nil {
679+
t.Fatalf("calculateStatus() unexpected error: %v", err)
680+
}
681+
am.Status = *computed
682+
} else {
683+
am.Status.Conditions = common.Conditions{
684+
{Type: appsv1alpha1.APIManagerAvailableConditionType, Status: tt.seedAvailable},
685+
}
686+
}
687+
688+
result, err := s.Reconcile()
689+
if err != nil {
690+
t.Fatalf("Reconcile() unexpected error: %v", err)
691+
}
692+
693+
if result.Requeue {
694+
t.Errorf("Requeue = true, normal (non-error) reconcile should only return requeueAfter")
695+
}
696+
697+
if tt.wantRequeueAfter {
698+
if result.RequeueAfter == 0 {
699+
t.Errorf("RequeueAfter = 0, want non-zero when unavailable")
700+
}
701+
} else {
702+
if result.RequeueAfter != 0 {
703+
t.Errorf("RequeueAfter = %v, want 0 when available", result.RequeueAfter)
704+
}
705+
}
706+
})
645707
}
646708
}

0 commit comments

Comments
 (0)