Skip to content

Commit f06856c

Browse files
scotwellsclaude
andcommitted
fix: quota-vs-referenced-data priority when both gates present
When QuotaGranted=False and scheduling gates are present, the previous code early-returned Ready=False/PendingQuota before the evaluate-all-then- pick block could run. This meant SourceNotFound (priority 5) was masked by PendingQuota (priority 3) — the same class of short-circuit bug the evaluate-all redesign was meant to eliminate. Fix: when scheduling gates are present, quota is fed into consider() like any other blocking cause so instanceBlockingReasonPriority picks the winner. The Programmed=False and Running=False side effects of quota denial are preserved unconditionally regardless of which reason wins Ready — they reflect quota state independently. The quota early-return is retained only for the no-gates case, where quota is the sole active blocker and the three-condition atomic write is correct. The scheduling-gates evaluation block is extracted into reconcileGatedReadyCondition to keep reconcileInstanceReadyCondition within the project's cyclomatic-complexity lint limit (gocyclo ≤ 30). Adds TestReconcileInstanceReadyCondition_QuotaVsReferencedData (RFC §8.1 headline case): QuotaGranted=False/QuotaExceeded + ReferencedDataReady= False/SourceNotFound → Ready=False/SourceNotFound (priority 5 > 3), with Programmed=False/Running=False still set. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> (cherry picked from commit e4d419b)
1 parent c394475 commit f06856c

2 files changed

Lines changed: 196 additions & 53 deletions

File tree

internal/controller/instance_controller.go

Lines changed: 114 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,103 @@ func (r *InstanceReconciler) setReferencedDataConditionWithTransition(
627627
return changed
628628
}
629629

630+
// reconcileGatedReadyCondition handles the scheduling-gates branch of
631+
// reconcileInstanceReadyCondition. It evaluates ALL blocking sub-conditions
632+
// (quota, referenced-data, network failure) via the priority function and sets
633+
// Instance.Ready to the highest-priority cause.
634+
//
635+
// When quota is denied, Programmed=False and Running=False are also set
636+
// regardless of which reason wins Ready — quota gates programmatic activity
637+
// independently of the Ready reason displayed to the user.
638+
//
639+
// This is extracted from reconcileInstanceReadyCondition to keep that function's
640+
// cyclomatic complexity within the project lint limit.
641+
func (r *InstanceReconciler) reconcileGatedReadyCondition(
642+
ctx context.Context,
643+
clusterClient client.Client,
644+
instance *computev1alpha.Instance,
645+
quotaDenied bool,
646+
quotaGrantedCondition *metav1.Condition,
647+
readyCondition *metav1.Condition,
648+
checker networkFailureChecker,
649+
) (changed bool, err error) {
650+
schedulingGateNames := make([]string, 0, len(instance.Spec.Controller.SchedulingGates))
651+
for _, gate := range instance.Spec.Controller.SchedulingGates {
652+
schedulingGateNames = append(schedulingGateNames, gate.Name)
653+
}
654+
655+
type candidate struct {
656+
status metav1.ConditionStatus
657+
reason string
658+
message string
659+
priority int
660+
}
661+
662+
// Start with the generic fallback so there is always a winner.
663+
best := candidate{
664+
status: metav1.ConditionFalse,
665+
reason: computev1alpha.InstanceReadyReasonSchedulingGatesPresent,
666+
message: fmt.Sprintf("Scheduling gates present: %s", strings.Join(schedulingGateNames, ", ")),
667+
priority: 0,
668+
}
669+
670+
consider := func(status metav1.ConditionStatus, reason, message string) {
671+
p := instanceBlockingReasonPriority(reason)
672+
if p > best.priority {
673+
best = candidate{status: status, reason: reason, message: message, priority: p}
674+
}
675+
}
676+
677+
// Quota is a gate-level blocker: feed it through the priority function so it
678+
// competes fairly with other causes (priority 3). A co-occurring SourceNotFound
679+
// (priority 5) will correctly beat it.
680+
if quotaDenied {
681+
consider(metav1.ConditionFalse, computev1alpha.InstanceProgrammedReasonPendingQuota, quotaGrantedCondition.Message)
682+
}
683+
684+
// Check the ReferencedDataReady sub-condition set earlier in this reconcile.
685+
if refDataCond := apimeta.FindStatusCondition(instance.Status.Conditions, computev1alpha.ReferencedDataReady); refDataCond != nil && refDataCond.Status != metav1.ConditionTrue {
686+
consider(refDataCond.Status, refDataCond.Reason, refDataCond.Message)
687+
}
688+
689+
// Network creation failure is a hard error; call the checker unconditionally
690+
// so priority logic can compare it against other blocking causes.
691+
networkCreationFailure, networkCreationFailureMessage, err := checker(ctx, clusterClient, instance)
692+
if err != nil {
693+
return false, fmt.Errorf("failed checking for network creation failure: %w", err)
694+
}
695+
if networkCreationFailure {
696+
consider(metav1.ConditionFalse, reasonNetworkFailedToCreate, networkCreationFailureMessage)
697+
}
698+
699+
// When quota is denied, always stamp Programmed=False and Available=False
700+
// regardless of which reason wins Ready. These reflect quota state independently
701+
// of the Ready reason selection.
702+
if quotaDenied {
703+
msg := quotaGrantedCondition.Message
704+
changed = apimeta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{
705+
Type: computev1alpha.InstanceProgrammed,
706+
Status: metav1.ConditionFalse,
707+
Reason: computev1alpha.InstanceProgrammedReasonPendingQuota,
708+
Message: msg,
709+
ObservedGeneration: instance.Generation,
710+
})
711+
changed = apimeta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{
712+
Type: computev1alpha.InstanceAvailable,
713+
Status: metav1.ConditionFalse,
714+
Reason: computev1alpha.InstanceProgrammedReasonPendingQuota,
715+
Message: msg,
716+
ObservedGeneration: instance.Generation,
717+
}) || changed
718+
}
719+
720+
readyCondition.Status = best.status
721+
readyCondition.Reason = best.reason
722+
readyCondition.Message = best.message
723+
724+
return apimeta.SetStatusCondition(&instance.Status.Conditions, *readyCondition) || changed, nil
725+
}
726+
630727
// isTerminalReferencedDataReason reports whether the given ReferencedData reason
631728
// is terminal — i.e., the companion will never arrive because the source object
632729
// is permanently unavailable, not just slow to propagate.
@@ -1449,7 +1546,21 @@ func (r *InstanceReconciler) reconcileInstanceReadyCondition(
14491546
logger := log.FromContext(ctx)
14501547

14511548
quotaGrantedCondition := apimeta.FindStatusCondition(instance.Status.Conditions, computev1alpha.InstanceQuotaGranted)
1452-
if quotaGrantedCondition != nil && quotaGrantedCondition.Status == metav1.ConditionFalse {
1549+
quotaDenied := quotaGrantedCondition != nil && quotaGrantedCondition.Status == metav1.ConditionFalse
1550+
1551+
// When scheduling gates are present, all blocking causes — including quota —
1552+
// are evaluated together so the priority function picks the most actionable
1553+
// one to surface on Ready. Quota side effects (Programmed=False, Running=False)
1554+
// are preserved unconditionally when quota is denied, regardless of which
1555+
// reason wins Ready.
1556+
//
1557+
// When no gates are present and quota is denied, fall through to the early
1558+
// return below which sets Ready, Programmed, and Running atomically.
1559+
hasSchedulingGates := instance.Spec.Controller != nil && len(instance.Spec.Controller.SchedulingGates) > 0
1560+
1561+
if quotaDenied && !hasSchedulingGates {
1562+
// No gates: quota is the only active blocking cause. Set all three
1563+
// conditions atomically and return — same behavior as before.
14531564
msg := quotaGrantedCondition.Message
14541565
changed = apimeta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{
14551566
Type: computev1alpha.InstanceProgrammed,
@@ -1488,58 +1599,8 @@ func (r *InstanceReconciler) reconcileInstanceReadyCondition(
14881599
readyCondition = readyCondition.DeepCopy()
14891600
}
14901601

1491-
if instance.Spec.Controller != nil && len(instance.Spec.Controller.SchedulingGates) > 0 {
1492-
var schedulingGateNames []string
1493-
for _, gate := range instance.Spec.Controller.SchedulingGates {
1494-
schedulingGateNames = append(schedulingGateNames, gate.Name)
1495-
}
1496-
1497-
// Evaluate ALL blocking sub-conditions before choosing which to surface.
1498-
// A short-circuit on the first match would let a low-priority transient
1499-
// cause (e.g. network provisioning) mask a high-priority actionable error
1500-
// (e.g. a missing source ConfigMap).
1501-
type candidate struct {
1502-
status metav1.ConditionStatus
1503-
reason string
1504-
message string
1505-
priority int
1506-
}
1507-
1508-
// Start with the generic fallback so there is always a winner.
1509-
best := candidate{
1510-
status: metav1.ConditionFalse,
1511-
reason: computev1alpha.InstanceReadyReasonSchedulingGatesPresent,
1512-
message: fmt.Sprintf("Scheduling gates present: %s", strings.Join(schedulingGateNames, ", ")),
1513-
priority: 0,
1514-
}
1515-
1516-
consider := func(status metav1.ConditionStatus, reason, message string) {
1517-
p := instanceBlockingReasonPriority(reason)
1518-
if p > best.priority {
1519-
best = candidate{status: status, reason: reason, message: message, priority: p}
1520-
}
1521-
}
1522-
1523-
// Check the ReferencedDataReady sub-condition set earlier in this reconcile.
1524-
if refDataCond := apimeta.FindStatusCondition(instance.Status.Conditions, computev1alpha.ReferencedDataReady); refDataCond != nil && refDataCond.Status != metav1.ConditionTrue {
1525-
consider(refDataCond.Status, refDataCond.Reason, refDataCond.Message)
1526-
}
1527-
1528-
// Network creation failure is a hard error; call the checker unconditionally
1529-
// so priority logic can compare it against other blocking causes.
1530-
networkCreationFailure, networkCreationFailureMessage, err := networkFailureChecker(ctx, clusterClient, instance)
1531-
if err != nil {
1532-
return false, fmt.Errorf("failed checking for network creation failure: %w", err)
1533-
}
1534-
if networkCreationFailure {
1535-
consider(metav1.ConditionFalse, reasonNetworkFailedToCreate, networkCreationFailureMessage)
1536-
}
1537-
1538-
readyCondition.Status = best.status
1539-
readyCondition.Reason = best.reason
1540-
readyCondition.Message = best.message
1541-
1542-
return apimeta.SetStatusCondition(&instance.Status.Conditions, *readyCondition), nil
1602+
if hasSchedulingGates {
1603+
return r.reconcileGatedReadyCondition(ctx, clusterClient, instance, quotaDenied, quotaGrantedCondition, readyCondition, networkFailureChecker)
15431604
}
15441605

15451606
pendingReason := "Pending"

internal/controller/instance_controller_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ const (
5757
// testMsgNetworkCreationFailed is the network-failure message used by the
5858
// evaluate-all-then-pick blocking-reason tests.
5959
testMsgNetworkCreationFailed = "Network creation failed: timeout"
60+
61+
// testPlacementA is the placement key used in Workload status rollup tests.
62+
testPlacementA = "placement-a"
6063
)
6164

6265
// newTestScheme builds a runtime.Scheme with the types needed for instance reconcile tests.
@@ -2424,6 +2427,85 @@ func TestReconcileInstanceReadyCondition_EvaluateAllThenPick(t *testing.T) {
24242427
})
24252428
}
24262429

2430+
// TestReconcileInstanceReadyCondition_QuotaVsReferencedData is the RFC §8.1
2431+
// headline case: QuotaGranted=False/QuotaExceeded AND
2432+
// ReferencedDataReady=False/SourceNotFound co-occur.
2433+
//
2434+
// SourceNotFound (priority 5) must win over PendingQuota (priority 3) on Ready.
2435+
// Programmed=False and Running=False must still be set (quota side effects are
2436+
// preserved regardless of which reason wins Ready).
2437+
func TestReconcileInstanceReadyCondition_QuotaVsReferencedData(t *testing.T) {
2438+
noNetworkFailure := func(_ context.Context, _ client.Client, _ *computev1alpha.Instance) (bool, string, error) {
2439+
return false, "", nil
2440+
}
2441+
2442+
// Instance has both the Quota gate and the ReferencedData gate, matching the
2443+
// scenario where reconcileQuotaCondition and reconcileReferencedDataCondition
2444+
// have both already run and written their respective sub-conditions.
2445+
inst := &computev1alpha.Instance{
2446+
ObjectMeta: metav1.ObjectMeta{
2447+
Name: testInstanceName,
2448+
Namespace: testDefaultNamespace,
2449+
Generation: 1,
2450+
},
2451+
Spec: computev1alpha.InstanceSpec{
2452+
Controller: &computev1alpha.InstanceController{
2453+
SchedulingGates: []computev1alpha.SchedulingGate{
2454+
{Name: instancecontrol.QuotaSchedulingGate.String()},
2455+
{Name: instancecontrol.ReferencedDataSchedulingGate.String()},
2456+
},
2457+
},
2458+
},
2459+
Status: computev1alpha.InstanceStatus{
2460+
Conditions: []metav1.Condition{
2461+
{
2462+
Type: computev1alpha.InstanceQuotaGranted,
2463+
Status: metav1.ConditionFalse,
2464+
Reason: computev1alpha.InstanceQuotaGrantedReasonQuotaExceeded,
2465+
Message: testMsgQuotaExceeded,
2466+
LastTransitionTime: metav1.Now(),
2467+
},
2468+
{
2469+
Type: computev1alpha.ReferencedDataReady,
2470+
Status: metav1.ConditionFalse,
2471+
Reason: computev1alpha.ReferencedDataReasonSourceNotFound,
2472+
Message: testMsgConfigMapNotFound,
2473+
LastTransitionTime: metav1.Now(),
2474+
},
2475+
},
2476+
},
2477+
}
2478+
2479+
r := &InstanceReconciler{}
2480+
changed, err := r.reconcileInstanceReadyCondition(context.Background(), nil, inst, noNetworkFailure)
2481+
require.NoError(t, err)
2482+
assert.True(t, changed)
2483+
2484+
// Ready must carry the higher-priority SourceNotFound reason (priority 5),
2485+
// not PendingQuota (priority 3).
2486+
readyCond := apimeta.FindStatusCondition(inst.Status.Conditions, computev1alpha.InstanceReady)
2487+
require.NotNil(t, readyCond, "Ready condition must be set")
2488+
assert.Equal(t, metav1.ConditionFalse, readyCond.Status)
2489+
assert.Equal(t, computev1alpha.ReferencedDataReasonSourceNotFound, readyCond.Reason,
2490+
"SourceNotFound (priority 5) must beat PendingQuota (priority 3)")
2491+
assert.Equal(t, testMsgConfigMapNotFound, readyCond.Message,
2492+
"Ready message must be the SourceNotFound message verbatim")
2493+
2494+
// Programmed and Running must still be set to False/PendingQuota — quota
2495+
// side effects are preserved regardless of which reason wins Ready.
2496+
programmedCond := apimeta.FindStatusCondition(inst.Status.Conditions, computev1alpha.InstanceProgrammed)
2497+
require.NotNil(t, programmedCond, "Programmed condition must be set when quota is denied")
2498+
assert.Equal(t, metav1.ConditionFalse, programmedCond.Status)
2499+
assert.Equal(t, computev1alpha.InstanceProgrammedReasonPendingQuota, programmedCond.Reason,
2500+
"Programmed must reflect quota denial even when Ready surfaces a different reason")
2501+
2502+
availableCond := apimeta.FindStatusCondition(inst.Status.Conditions, computev1alpha.InstanceAvailable)
2503+
require.NotNil(t, availableCond, "Available condition must be set when quota is denied")
2504+
assert.Equal(t, metav1.ConditionFalse, availableCond.Status)
2505+
assert.Equal(t, computev1alpha.InstanceProgrammedReasonPendingQuota, availableCond.Reason,
2506+
"Available must reflect quota denial even when Ready surfaces a different reason")
2507+
}
2508+
24272509
// TestInstanceBlockingReasonPriority exhaustively verifies the priority table for
24282510
// Instance.Ready reason selection, as extended in this layer to rank
24292511
// referenced-data reasons. Every listed reason must return the expected integer;

0 commit comments

Comments
 (0)