Skip to content

Commit bf73355

Browse files
scotwellsclaude
andcommitted
fix: remove Quota scheduling gate in the same reconcile pass as status update
After quota is granted, the Quota scheduling gate was never removed from spec.controller.schedulingGates, leaving instances stuck "Pending (SchedulingGatesPresent)" even though the workload was running. Root cause: Reconcile returned early after writing QuotaGranted=True to status (statusChanged=true path), before reaching removeQuotaSchedulingGate. Because ResourceClaims are immutable after creation and local Instances are not watched (WithEngageWithLocalCluster(false)), no subsequent event would re-enqueue the instance — the gate was stranded forever. Fix: on the success path (quotaErr==nil), fall through to removeQuotaSchedulingGate after persisting the status update rather than returning early. Only return early with quotaErr when it is non-nil, which preserves the transient-failure backoff-requeue behavior. Also updates existing tests that previously required two reconciles to clear the gate (the second of which could never arrive in production), and adds TestQuotaGateRemovedInSingleReconcile as a regression test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 933f7f7 commit bf73355

2 files changed

Lines changed: 215 additions & 37 deletions

File tree

internal/controller/instance_controller.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -213,15 +213,19 @@ func (r *InstanceReconciler) Reconcile(ctx context.Context, req mcreconcile.Requ
213213
if err := cl.GetClient().Status().Update(ctx, &instance); err != nil {
214214
return ctrl.Result{}, err
215215
}
216-
if err := r.writeBackToUpstream(ctx, req.ClusterName, &instance); err != nil {
217-
return ctrl.Result{}, err
216+
// Return with the quota error (nil or transient) so controller-runtime
217+
// requeues with backoff on failures. On the success path (quotaErr==nil)
218+
// we fall through to removeQuotaSchedulingGate below instead of returning
219+
// early, so the gate is cleared in the same reconcile pass rather than
220+
// waiting for a requeue that may never come (ResourceClaim is immutable
221+
// and local Instances are not watched).
222+
if quotaErr != nil {
223+
if err := r.writeBackToUpstream(ctx, req.ClusterName, &instance); err != nil {
224+
return ctrl.Result{}, err
225+
}
226+
return ctrl.Result{}, quotaErr
218227
}
219-
// Return after the status update. If there was a quota error, return it
220-
// so controller-runtime requeues with backoff for transient failures.
221-
return ctrl.Result{}, quotaErr
222-
}
223-
224-
if quotaErr != nil {
228+
} else if quotaErr != nil {
225229
// No status change but quota evaluation failed — return error to requeue.
226230
return ctrl.Result{}, quotaErr
227231
}

internal/controller/instance_controller_test.go

Lines changed: 203 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -613,7 +613,7 @@ func TestReconcileQuota(t *testing.T) {
613613
return r, projectClient, quotaClient
614614
}
615615

616-
t.Run("quota granted flow: claim granted removes gate and sets QuotaGranted=True", func(t *testing.T) {
616+
t.Run("quota granted flow: claim granted removes gate and sets QuotaGranted=True in single reconcile", func(t *testing.T) {
617617
s := newTestScheme(t)
618618
instance := makeInstance(s,
619619
computev1alpha.SchedulingGate{Name: instancecontrol.NetworkSchedulingGate.String()},
@@ -623,7 +623,10 @@ func TestReconcileQuota(t *testing.T) {
623623

624624
r, projectClient, _ := newReconciler(t, []client.Object{instance, makeDeployment()}, []client.Object{claim})
625625

626-
// First reconcile: sets QuotaGranted=True in status, returns early.
626+
// Single reconcile: sets QuotaGranted=True in status AND removes the
627+
// Quota scheduling gate in the same pass. The early-return-before-gate-
628+
// removal bug required a second reconcile that never arrived because
629+
// ResourceClaims are immutable and local Instances are not watched.
627630
_, err := r.Reconcile(context.Background(), mcreconcile.Request{Request: reconcile.Request{NamespacedName: types.NamespacedName{Namespace: namespace, Name: instanceName}}, ClusterName: clusterName})
628631
require.NoError(t, err)
629632

@@ -635,19 +638,13 @@ func TestReconcileQuota(t *testing.T) {
635638
assert.Equal(t, metav1.ConditionTrue, quotaCond.Status)
636639
assert.Equal(t, computev1alpha.InstanceQuotaGrantedReasonQuotaAvailable, quotaCond.Reason)
637640

638-
// Second reconcile: status is already set, so removes the scheduling gate.
639-
_, err = r.Reconcile(context.Background(), mcreconcile.Request{Request: reconcile.Request{NamespacedName: types.NamespacedName{Namespace: namespace, Name: instanceName}}, ClusterName: clusterName})
640-
require.NoError(t, err)
641-
642-
require.NoError(t, projectClient.Get(context.Background(), types.NamespacedName{Namespace: namespace, Name: instanceName}, &updated))
643-
644641
hasQuotaGate := false
645642
for _, g := range updated.Spec.Controller.SchedulingGates {
646643
if g.Name == instancecontrol.QuotaSchedulingGate.String() {
647644
hasQuotaGate = true
648645
}
649646
}
650-
assert.False(t, hasQuotaGate, "QuotaSchedulingGate should have been removed")
647+
assert.False(t, hasQuotaGate, "QuotaSchedulingGate must be removed in the same reconcile pass as the status update")
651648
})
652649

653650
t.Run("quota exceeded flow: conditions cascade to block Programmed/Running/Ready", func(t *testing.T) {
@@ -721,7 +718,9 @@ func TestReconcileQuota(t *testing.T) {
721718
}
722719
require.NoError(t, mgmtClient.Status().Update(context.Background(), &existingClaim))
723720

724-
// Second reconcile should see granted claim and update status.
721+
// Second reconcile should see the granted claim, update status to
722+
// QuotaGranted=True, AND remove the gate in the same pass (no third
723+
// reconcile required).
725724
_, err = r.Reconcile(context.Background(), mcreconcile.Request{Request: reconcile.Request{NamespacedName: types.NamespacedName{Namespace: namespace, Name: instanceName}}, ClusterName: clusterName})
726725
require.NoError(t, err)
727726

@@ -731,18 +730,13 @@ func TestReconcileQuota(t *testing.T) {
731730
require.NotNil(t, quotaCond)
732731
assert.Equal(t, metav1.ConditionTrue, quotaCond.Status)
733732

734-
// Third reconcile removes the gate (status is already true, no more status write needed).
735-
_, err = r.Reconcile(context.Background(), mcreconcile.Request{Request: reconcile.Request{NamespacedName: types.NamespacedName{Namespace: namespace, Name: instanceName}}, ClusterName: clusterName})
736-
require.NoError(t, err)
737-
738-
require.NoError(t, projectClient.Get(context.Background(), types.NamespacedName{Namespace: namespace, Name: instanceName}, &recovered))
739733
hasQuotaGate := false
740734
for _, g := range recovered.Spec.Controller.SchedulingGates {
741735
if g.Name == instancecontrol.QuotaSchedulingGate.String() {
742736
hasQuotaGate = true
743737
}
744738
}
745-
assert.False(t, hasQuotaGate, "QuotaSchedulingGate should have been removed after quota granted")
739+
assert.False(t, hasQuotaGate, "QuotaSchedulingGate should be removed in the same reconcile pass that sets QuotaGranted=True")
746740
})
747741

748742
t.Run("deleted before grant: finalizer deletes claim and is removed", func(t *testing.T) {
@@ -797,6 +791,194 @@ func TestReconcileQuota(t *testing.T) {
797791
})
798792
}
799793

794+
// TestQuotaGateRemovedInSingleReconcile is a regression test for the bug where
795+
// the Quota scheduling gate was never removed from an Instance after quota was
796+
// granted. The root cause was an early return in the Reconcile function: when
797+
// reconcileQuotaCondition set QuotaGranted=True (statusChanged=true), the code
798+
// wrote the status update and returned before reaching removeQuotaSchedulingGate.
799+
// Because ResourceClaims are immutable (no further transitions) and local
800+
// Instances are not watched (WithEngageWithLocalCluster(false)), no requeue ever
801+
// arrived — leaving the Quota gate stranded in spec.controller.schedulingGates
802+
// and the projected Instance stuck "Pending (SchedulingGatesPresent)".
803+
//
804+
// The fix: on the success path (quotaErr==nil), fall through to
805+
// removeQuotaSchedulingGate after persisting the status update, so gate removal
806+
// happens in the same reconcile pass as the QuotaGranted=True status write.
807+
func TestQuotaGateRemovedInSingleReconcile(t *testing.T) {
808+
const (
809+
clusterName = "test-project"
810+
namespace = "default"
811+
instanceName = "my-instance"
812+
deploymentName = "my-deployment"
813+
)
814+
815+
claimName := namespace + "--" + instanceName
816+
817+
tests := []struct {
818+
name string
819+
initialGates []computev1alpha.SchedulingGate
820+
expectGateGone bool
821+
}{
822+
{
823+
name: "Quota gate only: removed in single reconcile when claim is granted",
824+
initialGates: []computev1alpha.SchedulingGate{
825+
{Name: instancecontrol.QuotaSchedulingGate.String()},
826+
},
827+
expectGateGone: true,
828+
},
829+
{
830+
name: "Quota gate plus Network gate: Quota removed, Network preserved",
831+
initialGates: []computev1alpha.SchedulingGate{
832+
{Name: instancecontrol.NetworkSchedulingGate.String()},
833+
{Name: instancecontrol.QuotaSchedulingGate.String()},
834+
},
835+
expectGateGone: true,
836+
},
837+
{
838+
name: "No gates: no-op, reconcile completes cleanly",
839+
initialGates: []computev1alpha.SchedulingGate{},
840+
expectGateGone: false, // no gate to begin with
841+
},
842+
}
843+
844+
for _, tt := range tests {
845+
t.Run(tt.name, func(t *testing.T) {
846+
s := newTestScheme(t)
847+
848+
instance := &computev1alpha.Instance{
849+
ObjectMeta: metav1.ObjectMeta{
850+
Name: instanceName,
851+
Namespace: namespace,
852+
Generation: 1,
853+
Finalizers: []string{instanceQuotaFinalizer, instanceControllerFinalizer},
854+
OwnerReferences: []metav1.OwnerReference{
855+
{
856+
APIVersion: testComputeAPIVersion,
857+
Kind: kindWorkloadDeploymentTest,
858+
Name: deploymentName,
859+
UID: testUIDString,
860+
Controller: func() *bool { b := true; return &b }(),
861+
},
862+
},
863+
},
864+
Spec: computev1alpha.InstanceSpec{
865+
Controller: &computev1alpha.InstanceController{
866+
SchedulingGates: tt.initialGates,
867+
},
868+
Runtime: computev1alpha.InstanceRuntimeSpec{
869+
Resources: computev1alpha.InstanceRuntimeResources{InstanceType: testInstanceType},
870+
},
871+
NetworkInterfaces: []computev1alpha.InstanceNetworkInterface{},
872+
},
873+
}
874+
875+
deployment := &computev1alpha.WorkloadDeployment{
876+
ObjectMeta: metav1.ObjectMeta{Name: deploymentName, Namespace: namespace, UID: testUIDString},
877+
}
878+
879+
// ResourceClaim already in QuotaAvailable state — simulates the state
880+
// that triggered the bug: claim already granted but gate still present.
881+
claim := &quotav1alpha1.ResourceClaim{
882+
ObjectMeta: metav1.ObjectMeta{Name: claimName, Namespace: namespace},
883+
Spec: quotav1alpha1.ResourceClaimSpec{
884+
ConsumerRef: quotav1alpha1.ConsumerRef{
885+
APIGroup: miloProjectAPIGroup, Kind: miloProjectKind, Name: clusterName,
886+
},
887+
ResourceRef: quotav1alpha1.UnversionedObjectReference{
888+
APIGroup: miloProjectAPIGroup, Kind: miloProjectKind, Name: clusterName,
889+
},
890+
Requests: []quotav1alpha1.ResourceRequest{
891+
{ResourceType: quotaResourceTypeInstances, Amount: 1},
892+
},
893+
},
894+
Status: quotav1alpha1.ResourceClaimStatus{
895+
Conditions: []metav1.Condition{
896+
{
897+
Type: quotav1alpha1.ResourceClaimGranted,
898+
Status: metav1.ConditionTrue,
899+
Reason: quotav1alpha1.ResourceClaimGrantedReason,
900+
Message: "quota available",
901+
LastTransitionTime: metav1.Now(),
902+
},
903+
},
904+
},
905+
}
906+
907+
projectClient := fake.NewClientBuilder().
908+
WithScheme(s).
909+
WithObjects(instance, deployment).
910+
WithStatusSubresource(&computev1alpha.Instance{}).
911+
Build()
912+
913+
quotaClient := fake.NewClientBuilder().
914+
WithScheme(s).
915+
WithObjects(claim).
916+
WithStatusSubresource(&quotav1alpha1.ResourceClaim{}).
917+
Build()
918+
919+
mgr := &fakeMCManager{
920+
clusters: map[string]cluster.Cluster{
921+
clusterName: newFakeCluster(projectClient),
922+
},
923+
}
924+
925+
qm := quota.New(nil)
926+
qm.StoreClient(clusterName, quotaClient)
927+
928+
r := &InstanceReconciler{
929+
mgr: mgr,
930+
scheme: s,
931+
quotaClientManager: qm,
932+
edgeClusterName: testEdgeClusterName,
933+
projectIDForInstance: func(_ context.Context, cn multicluster.ClusterName, _ *computev1alpha.Instance) (string, error) {
934+
return string(cn), nil
935+
},
936+
}
937+
r.finalizers = finalizer.NewFinalizers()
938+
require.NoError(t, r.finalizers.Register(instanceControllerFinalizer, r))
939+
940+
// Exactly one reconcile — must be sufficient to both set QuotaGranted=True
941+
// and remove the Quota gate. No second reconcile should be required.
942+
_, err := r.Reconcile(context.Background(), mcreconcile.Request{
943+
Request: reconcile.Request{NamespacedName: types.NamespacedName{Namespace: namespace, Name: instanceName}},
944+
ClusterName: clusterName,
945+
})
946+
require.NoError(t, err)
947+
948+
var updated computev1alpha.Instance
949+
require.NoError(t, projectClient.Get(context.Background(),
950+
types.NamespacedName{Namespace: namespace, Name: instanceName}, &updated))
951+
952+
// QuotaGranted condition must be set to True.
953+
quotaCond := apimeta.FindStatusCondition(updated.Status.Conditions, computev1alpha.InstanceQuotaGranted)
954+
require.NotNil(t, quotaCond, "QuotaGranted condition must be present")
955+
assert.Equal(t, metav1.ConditionTrue, quotaCond.Status)
956+
assert.Equal(t, computev1alpha.InstanceQuotaGrantedReasonQuotaAvailable, quotaCond.Reason)
957+
958+
// Quota gate must be gone after the single reconcile.
959+
hasQuotaGate := false
960+
for _, g := range updated.Spec.Controller.SchedulingGates {
961+
if g.Name == instancecontrol.QuotaSchedulingGate.String() {
962+
hasQuotaGate = true
963+
}
964+
}
965+
if tt.expectGateGone {
966+
assert.False(t, hasQuotaGate,
967+
"Quota gate must be removed in the same reconcile pass as the QuotaGranted=True status write; "+
968+
"a stranded gate leaves the projected Instance stuck Pending (SchedulingGatesPresent)")
969+
}
970+
971+
// Network gate (if present) must be preserved — only the Quota gate is
972+
// cleared by InstanceReconciler; NetworkSchedulingGate is owned by
973+
// WorkloadDeploymentReconciler.
974+
for _, g := range updated.Spec.Controller.SchedulingGates {
975+
assert.NotEqual(t, instancecontrol.QuotaSchedulingGate.String(), g.Name,
976+
"Quota gate must not remain after granted claim")
977+
}
978+
})
979+
}
980+
}
981+
800982
// TestReconcileQuotaSingleMode verifies that in single-cell mode:
801983
// - the project ID is decoded from the upstream-cluster-name label on the edge
802984
// namespace (not taken from the always-"single" ClusterName)
@@ -1364,32 +1546,24 @@ func TestReconcileQuotaFailureModes(t *testing.T) {
13641546
r.finalizers = finalizer.NewFinalizers()
13651547
require.NoError(t, r.finalizers.Register(instanceControllerFinalizer, r))
13661548

1367-
// First reconcile: the live claim is True, but reconcileQuotaCondition will
1368-
// write a new QuotaGranted=True with ObservedGeneration=2. Status update
1369-
// triggers early return.
1549+
// Single reconcile: reconcileQuotaCondition writes QuotaGranted=True with
1550+
// ObservedGeneration=2 into the in-memory instance, status is persisted,
1551+
// then removeQuotaSchedulingGate reads the in-memory condition (gen=2 ==
1552+
// instance.Generation=2) and removes the gate — all in one pass.
13701553
_, err := r.Reconcile(context.Background(), reconcileReq())
13711554
require.NoError(t, err)
13721555

1373-
// The gate must NOT have been removed yet — the stale True (gen=1) should
1374-
// have been replaced with updated True (gen=2) in the first reconcile, but
1375-
// removeQuotaSchedulingGate is only called after status is written and the
1376-
// function returns early after the status update.
1377-
// Second reconcile: now status has QuotaGranted=True/gen=2 → gate is removed.
1378-
_, err = r.Reconcile(context.Background(), reconcileReq())
1379-
require.NoError(t, err)
1380-
13811556
var updated computev1alpha.Instance
13821557
require.NoError(t, projectClient.Get(context.Background(),
13831558
types.NamespacedName{Namespace: testNS, Name: testInstance}, &updated))
13841559

1385-
// After two reconciles with a live granted claim, gate should be removed.
13861560
hasGate := false
13871561
for _, g := range updated.Spec.Controller.SchedulingGates {
13881562
if g.Name == instancecontrol.QuotaSchedulingGate.String() {
13891563
hasGate = true
13901564
}
13911565
}
1392-
assert.False(t, hasGate, "gate should be removed after quota confirmed for current generation")
1566+
assert.False(t, hasGate, "gate should be removed in the same reconcile that refreshes the condition to current generation")
13931567

13941568
cond := apimeta.FindStatusCondition(updated.Status.Conditions, computev1alpha.InstanceQuotaGranted)
13951569
require.NotNil(t, cond)

0 commit comments

Comments
 (0)