fix: use optimistic locking on rule status patches - #343
Conversation
✅ Deploy Preview for node-readiness-controller ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: bhuvan-somisetty The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
|
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 |
|
|
||
| node.Annotations[annotationKey] = bootstrapAnnotationValue(ruleName) | ||
| if err := r.Patch(ctx, node, patch); err != nil { | ||
| if err := r.Patch(ctx, node, client.MergeFromWithOptions(stored, client.MergeFromWithOptimisticLock{})); err != nil { |
There was a problem hiding this comment.
It seems like annotations can merge cleanly (because it is a map?) so optimistic locking here is actually a bad idea, as can compete with Kubelet patches as well..
There was a problem hiding this comment.
Recently, we had to add optimistic lock for annotation patch to fix a race condition with remove-taint. This PR had previously suggested that approach, but we initially thought it wasn't necessary. Thank you for pointing this out.
There was a problem hiding this comment.
Given it's already handled does this change here for node annotations still necessary?
|
|
||
| stored := latest.DeepCopy() | ||
| controllerutil.RemoveFinalizer(latest, finalizerName) | ||
| return r.Patch(ctx, latest, client.MergeFromWithOptions(stored, client.MergeFromWithOptimisticLock{})) |
There was a problem hiding this comment.
Does this issue apply for finalizers as well? Or did you verify only for rule.status. confirm all the patches are required.
|
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
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. Since a JSON merge patch never carries that precondition unless MergeFromWithOptimisticLock is used, the API server never returns a conflict and the retry wrapper never actually retries. Worse, updateRuleStatus 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. Fix this 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. Signed-off-by: bhuvan-somisetty <somisettybhuvan5@gmail.com>
fd82d60 to
b6eab97
Compare
|
Rebased onto latest main branch and resolved all merge conflicts. All tests are passing cleanly. |
ajaysundark
left a comment
There was a problem hiding this comment.
Thanks for the PR. I left some comments. Will have a second deeper look later.
| // applyNodeStatusDelta merges delta into rule's NodeEvaluations/FailedNodes, replacing only the | ||
| // entries for nodes present in delta and leaving every other node's entry untouched. | ||
| // | ||
| // This is the crux of fixing the lost-update bug described in #341: a naive full-slice | ||
| // replacement of NodeEvaluations/FailedNodes (computed from a nodeList snapshot taken at the | ||
| // start of a RuleReconciler sweep) would silently discard any per-node status update written | ||
| // concurrently by NodeReconciler for a node this particular sweep didn't touch. Merging by node | ||
| // name instead means each writer only ever overwrites the entries it just recomputed. |
There was a problem hiding this comment.
Can we keep these comments concise? A function doc comment could self describe what functionality it is exposing. Context specific to this issue doesn't belong there, unless it carries long-term maintenance information.
https://go.dev/doc/comment#func is a good ref for comments.
|
|
||
| patch := client.MergeFrom(latestRule.DeepCopy()) | ||
|
|
||
| err := r.patchRuleStatusWithOptimisticLock(ctx, rule.Name, func(latestRule *readinessv1alpha1.NodeReadinessRule) bool { |
There was a problem hiding this comment.
I expect this will spike 409s at API for our scale test.
cc @vitorfloriano and I dont think we monitor API conflicts..it may reflect in reconcile latency though, not sure.
|
|
||
| log.Info("Processing all nodes for rule", "rule", rule.Name, "totalNodes", len(nodeList.Items)) | ||
|
|
||
| delta := nodeStatusDelta{ |
There was a problem hiding this comment.
I like this idea in theory, but want to look deeper into the details and risks of it. Will spend more time on this and add more comments.
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?