fix: use optimistic locking on rule status patches - #343
fix: use optimistic locking on rule status patches#343bhuvan-somisetty wants to merge 3 commits into
Conversation
✅ Deploy Preview for node-readiness-controller canceled.
|
|
[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?
There was a problem hiding this comment.
Good push — checked, and it's still needed, but not for the reason I originally reached for. The annotation merge itself is fine (map, merges cleanly). What the lock actually guards is the hasTaintBySpec check right before the patch: without the resourceVersion precondition, a taint added between that check and the Patch call goes undetected, and we'd mark bootstrap complete on a node that still carries the taint. There's already a regression test for exactly this ("should not mark bootstrap completed when the rule taints concurrently"), and it predates this PR — this lock isn't new, this PR only touched variable naming here. Added a comment inline explaining this so it's not re-litigated later.
|
|
||
| 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.
There was a problem hiding this comment.
Yes, applies to finalizers too, same root cause. Confirmed and covered: ensureFinalizer's add and reconcileDelete's finalizer removal both now go through client.MergeFromWithOptimisticLock (same pattern as addTaintBySpec/removeTaintBySpec). All four call sites (rule status, finalizer add, finalizer remove, cleanupDeletedNodes) share patchRuleStatusWithOptimisticLock or the equivalent inline pattern, so nothing is left on the plain-MergeFrom path.
|
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.
There was a problem hiding this comment.
Fair, trimmed both comments (nodeStatusDelta and applyNodeStatusDelta) to describe what they do rather than the #341 story. Thanks for the go.dev link, wasn't following that convention closely enough here.
|
|
||
| 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.
There was a problem hiding this comment.
Valid concern, and you're right that we weren't watching for it. Added node_readiness_status_patch_conflicts_total (labels: resource, operation), incremented on every 409 from an optimistic-locked patch — rule status, rule finalizer add/remove, node taint add/remove, and the node bootstrap annotation patch. Should give a direct signal instead of inferring it from reconcile latency. Happy to dig into the scale-test numbers with @vitorfloriano once this metric is out there.
|
|
||
| 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.
|
xref - #320 (comment) @rawadhossain can we run an experiment with this patch and #320 API conflicts metric? That could be the baseline for #345, on how NRE helps with status handling. cc @Karthik-K-N |
Tighten the nodeStatusDelta/applyNodeStatusDelta doc comments per review feedback to be self-describing instead of narrating the issue they fix. Add node_readiness_status_patch_conflicts_total, incremented whenever an optimistic-locked patch (rule status, rule finalizer add/remove, node taint add/remove, node bootstrap annotation) hits a 409, so the 409 rate this optimistic locking can introduce is observable instead of only showing up indirectly as reconcile latency. Document why markBootstrapCompleted still needs its optimistic lock even though the patch itself is annotation-only and merges cleanly: the lock guards the hasTaintBySpec check preceding it, not the annotation merge, which the "should not mark bootstrap completed when the rule taints concurrently" regression test already covers.
|
Thanks for the thorough review. Pushed a follow-up commit addressing the open threads: trimmed the nodeStatusDelta/applyNodeStatusDelta doc comments to be self-describing, added node_readiness_status_patch_conflicts_total to make the 409 rate from optimistic locking observable (rule status, rule finalizer add/remove, node taint add/remove, bootstrap annotation), confirmed and documented why markBootstrapCompleted still needs its lock (guards the hasTaintBySpec check, not the annotation merge - existing regression test covers it), and confirmed finalizer add/remove both go through the same optimistic-lock pattern as rule status now. Left the nodeStatusDelta merge-by-name approach itself as-is since you want to look deeper there. |
| // StatusPatchConflicts tracks optimistic-lock conflicts on status/annotation patches. | ||
| // A rising rate here means concurrent writers are contending for the same object; it may | ||
| // show up as increased reconcile latency before it shows up as visible errors. | ||
| StatusPatchConflicts = prometheus.NewCounterVec( |
There was a problem hiding this comment.
@bhuvan-somisetty Could we leave the new metrics from this PR scope to #320 and #288 to handle this holistically?
Deferred to kubernetes-sigs#320/kubernetes-sigs#288 per review feedback so conflict metrics get handled holistically instead of piecemeal here.
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?