Skip to content

Commit db3d37a

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 3701723 commit db3d37a

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
@@ -623,6 +623,103 @@ func (r *InstanceReconciler) setReferencedDataConditionWithTransition(
623623
return changed
624624
}
625625

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

14351532
quotaGrantedCondition := apimeta.FindStatusCondition(instance.Status.Conditions, computev1alpha.InstanceQuotaGranted)
1436-
if quotaGrantedCondition != nil && quotaGrantedCondition.Status == metav1.ConditionFalse {
1533+
quotaDenied := quotaGrantedCondition != nil && quotaGrantedCondition.Status == metav1.ConditionFalse
1534+
1535+
// When scheduling gates are present, all blocking causes — including quota —
1536+
// are evaluated together so the priority function picks the most actionable
1537+
// one to surface on Ready. Quota side effects (Programmed=False, Running=False)
1538+
// are preserved unconditionally when quota is denied, regardless of which
1539+
// reason wins Ready.
1540+
//
1541+
// When no gates are present and quota is denied, fall through to the early
1542+
// return below which sets Ready, Programmed, and Running atomically.
1543+
hasSchedulingGates := instance.Spec.Controller != nil && len(instance.Spec.Controller.SchedulingGates) > 0
1544+
1545+
if quotaDenied && !hasSchedulingGates {
1546+
// No gates: quota is the only active blocking cause. Set all three
1547+
// conditions atomically and return — same behavior as before.
14371548
msg := quotaGrantedCondition.Message
14381549
changed = apimeta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{
14391550
Type: computev1alpha.InstanceProgrammed,
@@ -1472,58 +1583,8 @@ func (r *InstanceReconciler) reconcileInstanceReadyCondition(
14721583
readyCondition = readyCondition.DeepCopy()
14731584
}
14741585

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

15291590
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.
@@ -2439,6 +2442,85 @@ func TestReconcileInstanceReadyCondition_EvaluateAllThenPick(t *testing.T) {
24392442
})
24402443
}
24412444

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

0 commit comments

Comments
 (0)