Repository navigation
[BUG] RuleReconciler's updateRuleStatus can silently discard concurrent NodeReconciler status writes #341
Description
What happened?
updateRuleStatus (internal/controller/nodereadinessrule_controller.go:561-593) overwrites rule.Status.NodeEvaluations/AppliedNodes/FailedNodes wholesale from whatever RuleReconciler computed at the start of its node sweep. It patches with a plain client.MergeFrom (no MergeFromWithOptimisticLock), wrapped in retry.RetryOnConflict. Since the patch carries no resourceVersion precondition, the API server never returns a conflict, so the retry wrapper never actually retries.
Net effect: if NodeReconciler (a separate controller/queue) writes a per-node status update for a node while RuleReconciler's sweep is still running, updateRuleStatus's later overwrite silently erases that update. No error, no log, no retry — it just disappears.
This is the same bug class #180 fixed for node.spec.taints (see the MergeFromWithOptimisticLock comment on addTaintBySpec/removeTaintBySpec), just never applied to the rule-status patches.
Reproduction (verified against main @ 43bff46)
Drop this into internal/controller/ as a plain Go test and run go test ./internal/controller/... -run TestIssue341LostUpdateRepro -v:
package controller
import (
"context"
"testing"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/kubernetes/fake"
"k8s.io/client-go/tools/events"
fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
readinessv1alpha1 "sigs.k8s.io/node-readiness-controller/api/v1alpha1"
)
func TestIssue341LostUpdateRepro(t *testing.T) {
ctx := context.Background()
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = readinessv1alpha1.AddToScheme(scheme)
nodeA := &corev1.Node{
ObjectMeta: metav1.ObjectMeta{Name: "node-a", Labels: map[string]string{"role": "worker"}},
Status: corev1.NodeStatus{Conditions: []corev1.NodeCondition{{Type: "Ready", Status: corev1.ConditionTrue}}},
}
nodeB := &corev1.Node{
ObjectMeta: metav1.ObjectMeta{Name: "node-b", Labels: map[string]string{"role": "worker"}},
Status: corev1.NodeStatus{Conditions: []corev1.NodeCondition{{Type: "Ready", Status: corev1.ConditionTrue}}},
}
rule := &readinessv1alpha1.NodeReadinessRule{
ObjectMeta: metav1.ObjectMeta{Name: "repro-rule"},
Spec: readinessv1alpha1.NodeReadinessRuleSpec{
Conditions: []readinessv1alpha1.ConditionRequirement{{Type: "Ready", RequiredStatus: corev1.ConditionTrue}},
Taint: corev1.Taint{Key: "readiness.k8s.io/repro", Effect: corev1.TaintEffectNoSchedule},
NodeSelector: metav1.LabelSelector{MatchLabels: map[string]string{"role": "worker"}},
EnforcementMode: readinessv1alpha1.EnforcementModeContinuous,
},
}
fc := fakeclient.NewClientBuilder().WithScheme(scheme).WithObjects(nodeA, nodeB, rule).WithStatusSubresource(rule).Build()
c := &RuleReadinessController{
Client: fc, Scheme: scheme, clientset: fake.NewSimpleClientset(),
ruleCache: map[string]*readinessv1alpha1.NodeReadinessRule{rule.Name: rule}, EventRecorder: events.NewFakeRecorder(10),
}
// RuleReconciler starts a sweep from a nodeList snapshot that only knows about node-a.
if err := c.processAllNodesForRule(ctx, rule, &corev1.NodeList{Items: []corev1.Node{*nodeA}}); err != nil {
t.Fatalf("processAllNodesForRule: %v", err)
}
// While that sweep is "in flight", NodeReconciler independently reconciles node-b.
if err := c.processNodeAgainstAllRules(ctx, nodeB); err != nil {
t.Fatalf("processNodeAgainstAllRules: %v", err)
}
// RuleReconciler now finishes its (stale) sweep and patches the rule status.
if err := c.updateRuleStatus(ctx, rule); err != nil {
t.Fatalf("updateRuleStatus: %v", err)
}
final := &readinessv1alpha1.NodeReadinessRule{}
_ = fc.Get(ctx, types.NamespacedName{Name: rule.Name}, final)
for _, e := range final.Status.NodeEvaluations {
if e.NodeName == "node-b" {
return // node-b survived, bug not present
}
}
t.Fatalf("node-b's evaluation was silently discarded. Final NodeEvaluations: %+v", final.Status.NodeEvaluations)
}On main this fails with:
node-b's evaluation was silently discarded. Final NodeEvaluations: [{NodeName:node-a ...}]
node-b should be there — NodeReconciler wrote it — but RuleReconciler.updateRuleStatus's wholesale overwrite erased it, silently, with no error anywhere.
Manual reproduction on a real cluster
- Create a
NodeReadinessRulematching several nodes. - Trigger a
RuleReconcilerreconcile for the rule (e.g. touch an annotation) with enough nodes that the sweep takes a noticeable amount of time. - While that's running, flip a readiness condition on one of the rule's nodes so
NodeReconcilerreconciles it and writes its own status update for that node. - Once both finish, check
rule.status.nodeEvaluationsfor that node — the entry written in step 3 is gone, reverted to the older valueRuleReconciler's sweep had computed, with no error logged.
Impact
status.nodeEvaluations/appliedNodes/failedNodes can silently lose or revert entries whenever RuleReconciler and NodeReconciler touch the same rule concurrently — which is normal steady-state operation, not an edge case. Anything reading rule status (the reporter, dashboards, alerting) can see incorrect data with nothing in the logs to explain it.
Fix
Same fix as #180: use client.MergeFromWithOptimisticLock on the rule-status patch, and merge per-node changes by node name instead of overwriting the whole slice, so RuleReconciler's sweep only ever replaces the entries it actually recomputed.
Activity
[-]Status/annotation patches use retry.RetryOnConflict without optimistic locking, so concurrent RuleReconciler/NodeReconciler writes are silently lost[/-][+][BUG] Status/annotation patches use retry.RetryOnConflict without optimistic locking, causing silent lost updates[/+]bhuvan-somisetty commentedon Aug 3, 2026
/assign
ajaysundark commentedon Aug 3, 2026
@bhuvan-somisetty I understand the good intent behind using AI to analyze the code and raising possible issues in the working tree, but I'd recommend to evaluate the text to understand the context, implications and create the bug by yourself. Your verbose discussion with the AI is not very helpful to the maintainers to get to the core of the bug.
Please update with the specific issue this is trying to address, how it is impacting your use-case, and steps to reproduce manually, or provide a script that could help recreate the issue.
[-][BUG] Status/annotation patches use retry.RetryOnConflict without optimistic locking, causing silent lost updates[/-][+][BUG] RuleReconciler's updateRuleStatus can silently discard concurrent NodeReconciler status writes[/+]bhuvan-somisetty commentedon Aug 3, 2026
Fair point, sorry about that. Rewrote the issue - it's now just the specific bug (updateRuleStatus can silently drop a concurrent NodeReconciler status write since the merge patch has no optimistic lock), the impact, and a small test you can drop in and run to see it fail on main. Let me know if it still needs trimming.