Skip to content

fix: use optimistic locking on rule status patches - #343

Merged
kubernetes-prow[bot] merged 7 commits into
kubernetes-sigs:mainfrom
bhuvan-somisetty:fix-status-patch-optimistic-locking
Sep 3, 2026
Merged

fix: use optimistic locking on rule status patches#343
kubernetes-prow[bot] merged 7 commits into
kubernetes-sigs:mainfrom
bhuvan-somisetty:fix-status-patch-optimistic-locking

Conversation

@bhuvan-somisetty

Copy link
Copy Markdown
Contributor

Description

RuleReconciler and NodeReconciler both patch NodeReadinessRule.Status concurrently, but every status/finalizer patch used a plain client.MergeFrom with no resourceVersion precondition, wrapped in retry.RetryOnConflict. A JSON merge patch only carries that precondition when MergeFromWithOptimisticLock is used, so without it the API server never returns a conflict and the retry wrapper never actually retries. This is the same bug #180 fixed for node taint patches (addTaintBySpec/removeTaintBySpec), just left open on the rule-status side.

The worst instance was updateRuleStatus: it replaced NodeEvaluations/FailedNodes wholesale from a snapshot computed at the start of a RuleReconciler sweep, so it could silently discard a concurrent NodeReconciler per-node update for a node outside that sweep's snapshot. Fixed by having processAllNodesForRule return a delta of exactly the per-node changes it made, and merging that delta by node name instead of overwriting the whole slice.

Also added the missing optimistic lock to ensureFinalizer, the finalizer removal in reconcileDelete, cleanupDeletedNodes, and markBootstrapCompleted's node annotation patch, matching the pattern already used by addTaintBySpec/removeTaintBySpec.

Related

Fixes #341

Type of Change

/kind bug

Testing

  • go build ./...
  • go vet ./...
  • go test ./internal/controller/... (63/63 specs pass; the only failure locally is envtest's Windows-only teardown limitation, unrelated to this change)
  • Added two regression tests: one proving a NodeReconciler-written evaluation for a node outside the RuleReconciler sweep survives updateRuleStatus, and one proving updateRuleStatus actually retries (and doesn't lose data) on a genuine conflict.

Checklist

  • make test passes
  • make lint passes

Does this PR introduce a user-facing change?

Fixed a bug where concurrent status writes from RuleReconciler and NodeReconciler to the same NodeReadinessRule could silently overwrite each other, since the retry-on-conflict wrapper around these patches never actually detected a conflict.

@kubernetes-prow kubernetes-prow Bot added do-not-merge/invalid-commit-message Indicates that a PR should not merge because it has an invalid commit message. kind/bug Categorizes issue or PR as related to a bug. labels Aug 3, 2026
@netlify

netlify Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploy Preview for node-readiness-controller canceled.

Name Link
🔨 Latest commit 2c09114
🔍 Latest deploy log https://app.netlify.com/projects/node-readiness-controller/deploys/6a97c78189d5020008a02d4c

@kubernetes-prow kubernetes-prow Bot added needs-ok-to-test Indicates a PR that requires an org member to verify it is safe to test. cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. labels Aug 3, 2026
@kubernetes-prow

Copy link
Copy Markdown

Hi @bhuvan-somisetty. Thanks for your PR.

I'm waiting for a kubernetes-sigs member to verify that this patch is reasonable to test. If it is, they should reply with /ok-to-test on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work.

Regular contributors should join the org to skip this step.

Once the patch is verified, the new status will be reflected by the ok-to-test label.

I understand the commands that are listed here.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository.

@kubernetes-prow kubernetes-prow Bot added the size/L Denotes a PR that changes 100-499 lines, ignoring generated files. label Aug 3, 2026
@bhuvan-somisetty
bhuvan-somisetty force-pushed the fix-status-patch-optimistic-locking branch from fc312d0 to 68f2b6d Compare August 3, 2026 10:17
@kubernetes-prow kubernetes-prow Bot removed the do-not-merge/invalid-commit-message Indicates that a PR should not merge because it has an invalid commit message. label Aug 3, 2026
@bhuvan-somisetty

Copy link
Copy Markdown
Contributor Author

@ajaysundark fixed the commit message (had a couple of bare #NNN references prow flagged as invalid). Should be clear now, ready whenever you get a chance to take a look.

@kubernetes-prow kubernetes-prow Bot added the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Aug 4, 2026
@bhuvan-somisetty
bhuvan-somisetty force-pushed the fix-status-patch-optimistic-locking branch from 68f2b6d to cb75223 Compare August 5, 2026 06:26
@kubernetes-prow kubernetes-prow Bot added needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. and removed needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. labels Aug 5, 2026
@ajaysundark

Copy link
Copy Markdown
Contributor

/ok-to-test

@kubernetes-prow kubernetes-prow Bot added ok-to-test Indicates a non-member PR verified by an org member that is safe to test. and removed needs-ok-to-test Indicates a PR that requires an org member to verify it is safe to test. labels Aug 7, 2026

@ajaysundark ajaysundark left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR. Did you confirm if all the patches are required, please write e2e tests if possible.

Comment thread internal/controller/node_controller.go
Comment thread internal/controller/nodereadinessrule_controller.go
@bhuvan-somisetty

Copy link
Copy Markdown
Contributor Author

Good catch, both of you. Reverted the node annotation patch back to a plain client.MergeFrom — you're right that it merges cleanly as a map, so the optimistic lock there was just going to fight with kubelet's own patches for no reason.

Kept the lock on the two finalizer patches (add + remove) though, since finalizers is a []string and a JSON merge patch replaces list fields wholesale rather than merging them — without the resourceVersion precondition a concurrent write there could get silently dropped. Left a comment at each site explaining why the two cases are handled differently so it's not ambiguous next time.

Also added two unit tests simulating concurrent RuleReconciler/NodeReconciler status writes to make sure the merge-by-node-name logic actually survives a real conflict and retry, not just the happy path.

@bhuvan-somisetty
bhuvan-somisetty force-pushed the fix-status-patch-optimistic-locking branch from dea3fbc to dec6907 Compare August 10, 2026 11:45
@kubernetes-prow kubernetes-prow Bot removed the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Aug 10, 2026
@DsThakurRawat DsThakurRawat mentioned this pull request Aug 10, 2026
2 tasks
@kubernetes-prow kubernetes-prow Bot added the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Aug 14, 2026
@bhuvan-somisetty
bhuvan-somisetty force-pushed the fix-status-patch-optimistic-locking branch from dec6907 to fd82d60 Compare August 14, 2026 16:43
@kubernetes-prow kubernetes-prow Bot added needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. and removed needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. labels Aug 14, 2026
Deferred to kubernetes-sigs#320/kubernetes-sigs#288 per review feedback so conflict metrics get handled holistically instead of piecemeal here.

@ajaysundark ajaysundark left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left some comments, could you ptal?
Thanks for iterating on this.

Comment thread internal/controller/node_controller.go Outdated
Comment on lines +438 to +443
// The optimistic lock here isn't guarding the annotation merge itself (that's map-valued
// and merges cleanly against concurrent writers, e.g. Kubelet). It guards the
// hasTaintBySpec check above: without it, a taint added between that check and the Patch
// below would go undetected, and we'd mark bootstrap complete on a node that still carries
// the taint. See the "should not mark bootstrap completed when the rule taints concurrently"
// test for the regression this prevents.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// The optimistic lock here isn't guarding the annotation merge itself (that's map-valued
// and merges cleanly against concurrent writers, e.g. Kubelet). It guards the
// hasTaintBySpec check above: without it, a taint added between that check and the Patch
// below would go undetected, and we'd mark bootstrap complete on a node that still carries
// the taint. See the "should not mark bootstrap completed when the rule taints concurrently"
// test for the regression this prevents.
// The optimistic lock here protects from a race-condition adding a taint between hasTaintBySpec
// check and mark completed annotation patch from concurrent reconciliations.

Comment thread internal/controller/helper.go
Comment on lines +286 to +289
// processAllNodesForRule processes all nodes when a rule changes. It mutates rule.Status in place
// (as before) and additionally returns a nodeStatusDelta describing exactly which nodes' status
// this sweep changed, so updateRuleStatus can merge those changes into the latest stored status
// instead of replacing NodeEvaluations/FailedNodes wholesale.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// processAllNodesForRule processes all nodes when a rule changes. It mutates rule.Status in place
// (as before) and additionally returns a nodeStatusDelta describing exactly which nodes' status
// this sweep changed, so updateRuleStatus can merge those changes into the latest stored status
// instead of replacing NodeEvaluations/FailedNodes wholesale.
// processAllNodesForRule processes all nodes when a rule changes. It mutates rule.Status in place
// and additionally returns a nodeStatusDelta describing exactly which nodes' status are changed.
// so updateRuleStatus can merge those changes into the latest stored status
// instead of replacing NodeEvaluations/FailedNodes wholesale.

Comment on lines +606 to +615
// patchRuleStatusWithOptimisticLock fetches the latest NodeReadinessRule, lets mutate apply status
// changes to it, and patches the result back with an optimistic-locked JSON merge patch. mutate
// should return false if it made no changes, to skip an unnecessary Patch call.
//
// We use client.MergeFromWithOptimisticLock here for the same reason addTaintBySpec/
// removeTaintBySpec do (see node_controller.go): a JSON merge patch replaces slice fields
// (NodeEvaluations, AppliedNodes, FailedNodes) wholesale rather than merging them, so without a
// resourceVersion precondition retry.RetryOnConflict can never observe a genuine conflict and a
// concurrent status write from the other reconciler (RuleReconciler and NodeReconciler both patch
// NodeReadinessRule.Status independently) can be silently overwritten.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// patchRuleStatusWithOptimisticLock fetches the latest NodeReadinessRule, lets mutate apply status
// changes to it, and patches the result back with an optimistic-locked JSON merge patch. mutate
// should return false if it made no changes, to skip an unnecessary Patch call.
//
// We use client.MergeFromWithOptimisticLock here for the same reason addTaintBySpec/
// removeTaintBySpec do (see node_controller.go): a JSON merge patch replaces slice fields
// (NodeEvaluations, AppliedNodes, FailedNodes) wholesale rather than merging them, so without a
// resourceVersion precondition retry.RetryOnConflict can never observe a genuine conflict and a
// concurrent status write from the other reconciler (RuleReconciler and NodeReconciler both patch
// NodeReadinessRule.Status independently) can be silently overwritten.
// patchRuleStatusWithOptimisticLock fetches the latest NodeReadinessRule, and apply mutate status
// changes to it. It then patches the result to API with an optimistic-locked JSON merge patch. mutate
// should return false if it made no changes, to skip an unnecessary Patch call.
//
// We use client.MergeFromWithOptimisticLock here for a JSON merge patch replaces slice fields
// (NodeEvaluations, AppliedNodes, FailedNodes) wholesale rather than merging them, so without a
// resourceVersion precondition retry.RetryOnConflict can never observe a genuine conflict and a
// concurrent status write from the other reconciler (RuleReconciler and NodeReconciler both patch
// NodeReadinessRule.Status independently) can be silently overwritten.

// it is merged into the latest stored status by node name (see applyNodeStatusDelta) rather than
// replacing those fields wholesale, so a concurrent per-node update from NodeReconciler
// (processNodeAgainstAllRules) for a node outside this sweep isn't silently discarded.
func (r *RuleReadinessController) updateRuleStatus(ctx context.Context, rule *readinessv1alpha1.NodeReadinessRule, delta nodeStatusDelta) error {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should updateRuleStatus skip patch when delta is empty?

@ajaysundark

Copy link
Copy Markdown
Contributor

/ok-to-test

@bhuvan-somisetty

Copy link
Copy Markdown
Contributor Author

Updated comments and docstrings per suggestions, added unit tests for applyNodeStatusDelta, and updated updateRuleStatus to return early when there are no status changes. Thanks for the review!

@kubernetes-prow kubernetes-prow Bot added size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. and removed size/L Denotes a PR that changes 100-499 lines, ignoring generated files. labels Aug 24, 2026
@DsThakurRawat

Copy link
Copy Markdown
Contributor

took this for a run locally since it has been open a while and main has moved under it.

the headline, because it is the thing that goes stale: i merged current upstream/main
(6723e00) into your branch head (89bc5b4) and it is clean. zero conflicts across the
three files that both touched, go build ./... and go vet ./... clean, and the full
suite green on the merged tree (internal/controller 16.3s, plus reporter, metrics and
webhook packages). so the rebase is not going to be the hard part.

on the premise, i wanted to see the two behaviours rather than trust the docs, so i put a
throwaway envtest package on the branch and made two writers race a real API server:

plain client.MergeFrom from a stale copy
  writer A held rv=199, object had moved to rv=203
  patch err  = <nil>
  isConflict = false
  -> patch SUCCEEDED from a stale copy

client.MergeFromWithOptimisticLock from a stale copy
  writer A held rv=207, object had moved to rv=208
  patch err  = Operation cannot be fulfilled on nodes "lockproof-opt":
               the object has been modified; please apply your changes to the latest
               version and try again
  isConflict = true

so the retry.RetryOnConflict wrappers really were unreachable, exactly as your
description says.

the slice half is the part i found more interesting, because on a map field the unlocked
patch is harmless. i seeded a node with taint a, let writer B append taint b, then had
a stale writer A write back [a, c]:

writer A snapshot taints: [a]
after writer B          : [a b]
after writer A          : [a c]
-> writer B's taint "b" was silently discarded. no error, no conflict.

which is why the optimistic lock on its own would not have been enough and the per node
delta is doing real work.

three things i checked because they are where this kind of change usually goes wrong, and
all three hold:

  • applyNodeStatusDelta runs inside the patchRuleStatusWithOptimisticLock closure, so it
    re-applies against the freshly fetched object on every retry attempt rather than against
    the snapshot. that is the bit that would quietly undo the fix if it were outside.
  • the nodeStatusDelta docstring says AppliedNodes, ObservedGeneration and
    DryRunResults have a single writer and are safe to overwrite. grepping for writes to
    those three, they only ever appear in nodereadinessrule_controller.go, never in
    node_controller.go, so excluding them is right.
  • switching from wholesale replace to merge normally means entries for deleted nodes stop
    being pruned. cleanupDeletedNodes still re-filters against the fresh object inside its
    own closure, so that path is unaffected.

one thing i went looking for and did not find, worth recording so nobody else spends the
time: #381 landed after your merge base and adds clearNodeFailure, which mutates the
stale in memory rule rather than the fresh one. that looked like it would reintroduce
the same lost update through a new door. it does not. on the merged tree the node path
rebuilds FailedNodes inside the locked closure keeping other nodes' entries and
re-appending only this node's, so the clear is expressed per node and lands correctly. it
is correct but indirect, since clearNodeFailure's only observable job is to make that
append loop find nothing.

which leads to the one suggestion i have, and it only becomes visible once both changes
sit on the same branch. after the merge there are two per node merge implementations: your
applyNodeStatusDelta in helper.go, and the inline evaluation and FailedNodes rebuild
in processNodeAgainstAllRules. they do the same thing with different code. folding the
node path onto applyNodeStatusDelta with a single node delta would leave one merge rule
to reason about instead of two, and would mean the sort order is applied consistently on
both paths. entirely your call whether that belongs here or in a follow up, since this PR
is already doing enough.

happy to share the envtest package if it is useful as a regression test, though it is
proving a client-go property rather than anything about this repo, so it probably does not
belong in the tree.

@ajaysundark ajaysundark left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some comments, ptal!

This is close, thanks for iterating on this PR.

Comment on lines +629 to +631
if !mutate(latestRule) {
return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually can we handle skip rule-patch here, instead of checking whether each of the field changed within the mutate function, so all write calls get this check?

Suggested change
if !mutate(latestRule) {
return nil
}
mutate(latestRule)
if apiequality.Semantic.DeepEqual(stored.Status, latestRule.Status) {
return nil
}

func (r *RuleReadinessController) patchRuleStatusWithOptimisticLock(
ctx context.Context,
ruleName string,
mutate func(latest *readinessv1alpha1.NodeReadinessRule) (changed bool),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: if you no longer need the return value of mutate, this can be void. This could simplify the mutate functions as they only need to transform, dont have to check each of the fields and return false/true.

Comment on lines +651 to +658
appliedNodesChanged := !slices.Equal(latestRule.Status.AppliedNodes, rule.Status.AppliedNodes)
observedGenChanged := latestRule.Status.ObservedGeneration != rule.Status.ObservedGeneration
dryRunChanged := !reflect.DeepEqual(latestRule.Status.DryRunResults, rule.Status.DryRunResults)
hasDeltaChanges := len(delta.evaluations) > 0 || len(delta.failures) > 0

patch := client.MergeFrom(latestRule.DeepCopy())
if !hasDeltaChanges && !appliedNodesChanged && !observedGenChanged && !dryRunChanged {
return false
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can be reverted to previous commit state, by checking the semantic comparison in the patch helper instead.

Comment thread internal/controller/node_controller.go
@ajaysundark

Copy link
Copy Markdown
Contributor

@bhuvan-somisetty Could you also check the failing lint?

@ajaysundark

Copy link
Copy Markdown
Contributor

folding the node path onto applyNodeStatusDelta with a single node delta would leave one merge rule
the sort order is applied consistently on both paths

@DsThakurRawat Thanks for taking a review pass on this. I saw the sort-order. But dont follow your single node-delta suggestion. Could you clarify?

happy to share the envtest package if it is useful as a regression test

are you suggesting an e2e test for concurrent rule update scenario? Feel free send a PR if you want us to take a look.

@ajaysundark

Copy link
Copy Markdown
Contributor

/ok-to-test

@bhuvan-somisetty
bhuvan-somisetty force-pushed the fix-status-patch-optimistic-locking branch from 89bc5b4 to eb5a35e Compare August 30, 2026 10:38
@bhuvan-somisetty

Copy link
Copy Markdown
Contributor Author

Addressed all suggestions: updated patchRuleStatusWithOptimisticLock to check apiequality.Semantic.DeepEqual to skip unchanged status patches across all callers, simplified the mutate functions to void return, and added node name sorting in NodeReconciler. Thanks!

@rawadhossain

Copy link
Copy Markdown
Member

/retest

Signed-off-by: bhuvan-somisetty <somisettybhuvan5@gmail.com>
@bhuvan-somisetty

Copy link
Copy Markdown
Contributor Author

/retest

@DsThakurRawat

Copy link
Copy Markdown
Contributor

taking both questions in order.

  1. the single node-delta suggestion, and why it is not just tidying.

applyNodeStatusDelta already takes a map keyed by node name, so one node is just a
one-entry map. the suggestion is that the inline block in processNodeAgainstAllRules
builds a nodeStatusDelta for this one node and calls it, rather than open coding
find-or-append a second time.

what makes it worth doing is that the two implementations do not agree today. the inline
block starts from a zero value:

currEval := readinessv1alpha1.NodeEvaluation{}
for _, eval := range rule.Status.NodeEvaluations {
    if eval.NodeName == node.Name { currEval = eval; break }
}

then writes currEval into latestRule whether or not the loop found anything.
evaluateRuleForNode returns early on any taint add or remove failure, before
updateNodeEvaluationStatus runs, so in that case the cached rule holds no evaluation for
the node and the zero value is what gets persisted.

on your head eb5a35e merged with current main 4c6b9d2: seeded status with a healthy
evaluation for probe-node and one for other-node, made the node patch fail, called
processNodeAgainstAllRules.

BEFORE: probe-node has a real evaluation, other-node has one too
AFTER : NodeEvaluations count = 2
  [0] nodeName=""           taintStatus=""        conditionResults=0
  [1] nodeName="other-node" taintStatus="Present" conditionResults=0

probe-node's evaluation is gone, replaced by an entry with an empty nodeName.
other-node survives, so merge-by-name is doing its job. it is only the node being
reconciled that gets clobbered, and with the sort you just added the empty name sorts to
index 0.

  1. the delta path does not have this, which is the actual argument.

same starting status, same failure expressed the way processAllNodesForRule expresses it,
a failure entry and no evaluation entry:

AFTER delta path: NodeEvaluations count = 2
  [0] nodeName="other-node" taintStatus="Present" conditionResults=0
  [1] nodeName="probe-node" taintStatus="Absent"  conditionResults=1
FailedNodes count = 1
  [0] nodeName="probe-node" reason="EvaluationError"

the evaluation survives and the failure is recorded next to it. applyNodeStatusDelta is
guarded on len(delta.evaluations) > 0, and processAllNodesForRule only puts an entry in
the map when it found one, so "nothing evaluated this sweep" means "leave what is there".
folding the node path onto it fixes the above as a side effect. the full suite is green on
that merged tree, internal/controller included, so nothing currently catches it.

  1. on the envtest package, no, i was not proposing an e2e test.

what i had proves a client-go property, merge patch versus optimistic lock. that is true of
any controller and does not belong in this repo, which is why i hedged it rather than
offering it outright.

  1. there is a real gap next to it though.

every concurrency test in internal/controller today is either the controller-runtime fake
client with an interceptor scripting a single conflict, or single threaded. none run two
writers concurrently against a real API server. the lost update this PR fixes is a genuine
race and a scripted conflict does not reproduce it. the test worth having is two goroutines
on envtest driving a rule-level and a node-level status write at the same rule, asserting
neither side's entry is lost, plus the empty nodeName case above pinned as a regression.

happy to send that as a PR, either onto this branch or as a follow up once this merges.
tell me which shape you want and i will put it up.

  1. unrelated, the lint @bhuvan-somisetty is chasing.

make lint reproduces here as a single gofmt issue at
internal/controller/helper_unit_test.go:238: the map value comments in the new
applyNodeStatusDelta test need realigning, and there is a trailing blank line at end of
file. gofmt -w internal/controller/helper_unit_test.go clears it.

Signed-off-by: bhuvan-somisetty <somisettybhuvan5@gmail.com>
@bhuvan-somisetty

Copy link
Copy Markdown
Contributor Author

Good call on using applyNodeStatusDelta in processNodeAgainstAllRules! Refactored processNodeAgainstAllRules to construct a nodeStatusDelta for the target node and pass it to applyNodeStatusDelta, avoiding duplicate logic and preventing zero-value evaluation entries when evaluation fails. Thanks!

Comment thread internal/controller/node_controller.go Outdated
…ler status delta

Signed-off-by: bhuvan-somisetty <somisettybhuvan5@gmail.com>
@bhuvan-somisetty

Copy link
Copy Markdown
Contributor Author

Updated NodeReconciler (processNodeAgainstAllRules) to handle evaluation success and failure distinctly when building nodeStatusDelta:

  • On evaluation success: delta.failures[node.Name] = nil to clear any stale failure from latestRule.Status.FailedNodes, and delta.evaluations[node.Name] is set to the fresh evaluation.
  • On evaluation failure: delta.failures[node.Name] is set to the recorded failure, and delta.evaluations[node.Name] is omitted so stale evaluations aren't persisted.

Also added unit tests for these cases in helper_unit_test.go. Thanks for catching this!

@ajaysundark ajaysundark left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/lgtm

@kubernetes-prow kubernetes-prow Bot added the lgtm "Looks good to me", indicates that a PR is ready to be merged. label Sep 3, 2026
@kubernetes-prow

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: ajaysundark, bhuvan-somisetty

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@kubernetes-prow kubernetes-prow Bot added the approved Indicates a PR has been approved by an approver from all required OWNERS files. label Sep 3, 2026
@kubernetes-prow
kubernetes-prow Bot merged commit 80f59f3 into kubernetes-sigs:main Sep 3, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Indicates a PR has been approved by an approver from all required OWNERS files. cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. kind/bug Categorizes issue or PR as related to a bug. lgtm "Looks good to me", indicates that a PR is ready to be merged. ok-to-test Indicates a non-member PR verified by an org member that is safe to test. size/XL Denotes a PR that changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

4 participants