fix: use optimistic locking on rule status patches - #343
Conversation
✅ Deploy Preview for node-readiness-controller canceled.
|
|
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 Regular contributors should join the org to skip this step. Once the patch is verified, the new status will be reflected by the I understand the commands that are listed here. DetailsInstructions 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. |
fc312d0 to
68f2b6d
Compare
|
@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. |
68f2b6d to
cb75223
Compare
|
/ok-to-test |
|
Good catch, both of you. Reverted the node annotation patch back to a plain Kept the lock on the two finalizer patches (add + remove) though, since 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. |
dea3fbc to
dec6907
Compare
dec6907 to
fd82d60
Compare
Deferred to kubernetes-sigs#320/kubernetes-sigs#288 per review feedback so conflict metrics get handled holistically instead of piecemeal here.
ajaysundark
left a comment
There was a problem hiding this comment.
I left some comments, could you ptal?
Thanks for iterating on this.
| // 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. |
There was a problem hiding this comment.
| // 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. |
| // 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. |
There was a problem hiding this comment.
| // 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. |
| // 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. |
There was a problem hiding this comment.
| // 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 { |
There was a problem hiding this comment.
should updateRuleStatus skip patch when delta is empty?
|
/ok-to-test |
|
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! |
|
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 on the premise, i wanted to see the two behaviours rather than trust the docs, so i put a so the the slice half is the part i found more interesting, because on a map field the unlocked which is why the optimistic lock on its own would not have been enough and the per node three things i checked because they are where this kind of change usually goes wrong, and
one thing i went looking for and did not find, worth recording so nobody else spends the which leads to the one suggestion i have, and it only becomes visible once both changes happy to share the envtest package if it is useful as a regression test, though it is |
ajaysundark
left a comment
There was a problem hiding this comment.
Left some comments, ptal!
This is close, thanks for iterating on this PR.
| if !mutate(latestRule) { | ||
| return nil | ||
| } |
There was a problem hiding this comment.
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?
| 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), |
There was a problem hiding this comment.
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.
| 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 | ||
| } |
There was a problem hiding this comment.
this can be reverted to previous commit state, by checking the semantic comparison in the patch helper instead.
|
@bhuvan-somisetty Could you also check the failing lint? |
@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?
are you suggesting an e2e test for concurrent rule update scenario? Feel free send a PR if you want us to take a look. |
|
/ok-to-test |
…ntic status comparison
89bc5b4 to
eb5a35e
Compare
|
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! |
|
/retest |
Signed-off-by: bhuvan-somisetty <somisettybhuvan5@gmail.com>
|
/retest |
|
taking both questions in order.
what makes it worth doing is that the two implementations do not agree today. the inline currEval := readinessv1alpha1.NodeEvaluation{}
for _, eval := range rule.Status.NodeEvaluations {
if eval.NodeName == node.Name { currEval = eval; break }
}then writes on your head
same starting status, same failure expressed the way the evaluation survives and the failure is recorded next to it.
what i had proves a client-go property, merge patch versus optimistic lock. that is true of
every concurrency test in happy to send that as a PR, either onto this branch or as a follow up once this merges.
|
Signed-off-by: bhuvan-somisetty <somisettybhuvan5@gmail.com>
|
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! |
…ler status delta Signed-off-by: bhuvan-somisetty <somisettybhuvan5@gmail.com>
|
Updated NodeReconciler (processNodeAgainstAllRules) to handle evaluation success and failure distinctly when building nodeStatusDelta:
Also added unit tests for these cases in helper_unit_test.go. Thanks for catching this! |
|
[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 DetailsNeeds approval from an approver in each of these files:
Approvers can indicate their approval by writing |
Description
RuleReconcilerandNodeReconcilerboth patchNodeReadinessRule.Statusconcurrently, but every status/finalizer patch used a plainclient.MergeFromwith no resourceVersion precondition, wrapped inretry.RetryOnConflict. A JSON merge patch only carries that precondition whenMergeFromWithOptimisticLockis used, so without it the API server never returns a conflict and the retry wrapper never actually retries. This is the same bug#180fixed for node taint patches (addTaintBySpec/removeTaintBySpec), just left open on the rule-status side.The worst instance was
updateRuleStatus: it replacedNodeEvaluations/FailedNodeswholesale from a snapshot computed at the start of aRuleReconcilersweep, so it could silently discard a concurrentNodeReconcilerper-node update for a node outside that sweep's snapshot. Fixed by havingprocessAllNodesForRulereturn 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 inreconcileDelete,cleanupDeletedNodes, andmarkBootstrapCompleted's node annotation patch, matching the pattern already used byaddTaintBySpec/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)NodeReconciler-written evaluation for a node outside theRuleReconcilersweep survivesupdateRuleStatus, and one provingupdateRuleStatusactually retries (and doesn't lose data) on a genuine conflict.Checklist
make testpassesmake lintpassesDoes this PR introduce a user-facing change?