Skip to content

[BUG] RuleReconciler's updateRuleStatus can silently discard concurrent NodeReconciler status writes #341

Closed

Description

@bhuvan-somisetty

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

  1. Create a NodeReadinessRule matching several nodes.
  2. Trigger a RuleReconciler reconcile for the rule (e.g. touch an annotation) with enough nodes that the sweep takes a noticeable amount of time.
  3. While that's running, flip a readiness condition on one of the rule's nodes so NodeReconciler reconciles it and writes its own status update for that node.
  4. Once both finish, check rule.status.nodeEvaluations for that node — the entry written in step 3 is gone, reverted to the older value RuleReconciler'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

  1. changed the title [-]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[/+] on Aug 3, 2026
  2. bhuvan-somisetty commented on Aug 3, 2026

    @bhuvan-somisetty
    ContributorAuthor

    /assign

  3. ajaysundark commented on Aug 3, 2026

    @ajaysundark
    Contributor

    @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.

  4. changed the title [-][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[/+] on Aug 3, 2026
  5. bhuvan-somisetty commented on Aug 3, 2026

    @bhuvan-somisetty
    ContributorAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions