Repository navigation
fix: guard podgroup annotation patch by pod UID - #5344
Conversation
|
Welcome @YiD11! It looks like this is your first PR to volcano-sh/volcano 🎉 |
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds a UID check to prevent the podgroup controller from acting on stale queue entries when a pod with the same namespace/name has been recreated, and converts the annotation patch to a JSON Patch with a UID test for atomicity.
Changes:
- Include
podUIDinpodRequestand skip processing when the cached pod's UID differs from the queued UID. - Rewrite
updatePodAnnotationsto use a JSON Patch including atestop on/metadata/uid, replacing the prior strategic-merge patch. - Add test coverage for the recreated-pod skip and the new JSON-patch behavior; add UIDs to existing test pods.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| pkg/controllers/podgroup/pg_controller.go | UID mismatch short-circuits processNextReq. |
| pkg/controllers/podgroup/pg_controller_handler.go | Track UID in podRequest; switch to JSONPatch with UID test op. |
| pkg/controllers/podgroup/pg_controller_test.go | New tests for UID skipping and patch format; UIDs added to fixtures. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if pod.Annotations == nil { | ||
| pod.Annotations = make(map[string]string) | ||
| } | ||
| if pod.Annotations[scheduling.KubeGroupNameAnnotationKey] == "" { | ||
| patch := metadataForMergePatch{ | ||
| Metadata: annotationForMergePatch{ | ||
| Annotations: map[string]string{ | ||
| scheduling.KubeGroupNameAnnotationKey: pgName, | ||
| }, | ||
| patch = append(patch, map[string]any{ | ||
| "op": "add", | ||
| "path": "/metadata/annotations", | ||
| "value": map[string]string{ | ||
| scheduling.KubeGroupNameAnnotationKey: pgName, | ||
| }, | ||
| } | ||
|
|
||
| patchBytes, err := json.Marshal(&patch) | ||
| if err != nil { | ||
| klog.Errorf("Failed to json.Marshal pod annotation: %v", err) | ||
| return err | ||
| } | ||
|
|
||
| if _, err := pg.kubeClient.CoreV1().Pods(pod.Namespace).Patch(context.TODO(), pod.Name, types.StrategicMergePatchType, patchBytes, metav1.PatchOptions{}); err != nil { | ||
| klog.Errorf("Failed to update pod <%s/%s>: %v", pod.Namespace, pod.Name, err) | ||
| return err | ||
| } | ||
| klog.V(4).Infof("Bound Pod <%s/%s> to PodGroup <%s/%s>", pod.Namespace, pod.Name, pod.Namespace, pgName) | ||
| }) | ||
| } else if pod.Annotations[scheduling.KubeGroupNameAnnotationKey] == "" { | ||
| patch = append(patch, map[string]any{ | ||
| "op": "add", | ||
| "path": "/metadata/annotations/" + strings.ReplaceAll(scheduling.KubeGroupNameAnnotationKey, "/", "~1"), | ||
| "value": pgName, | ||
| }) |
| klog.Errorf("Failed to get pod by <%v> from cache: %v", req, err) | ||
| return true | ||
| } | ||
| if pod.UID != req.podUID { |
| for _, action := range c.kubeClient.(*kubeclient.Clientset).Actions() { | ||
| if action.Matches("patch", "pods") { | ||
| patchAction = action.(k8stesting.PatchAction) | ||
| break | ||
| } | ||
| } | ||
| if !assert.NotNil(t, patchAction) { |
There was a problem hiding this comment.
Code Review
This pull request introduces pod UID validation during pod group processing to prevent operations on recreated pods with the same namespace and name. It updates the podRequest struct to include the pod's UID, adds a UID mismatch check in the controller queue processing, and refactors updatePodAnnotations to use JSONPatch with a UID test operation. A review comment points out a potential issue where pod.Annotations is empty but not nil, which could cause a JSONPatch failure if the annotations field is absent on the API server, and suggests checking len(pod.Annotations) == 0 instead.
| if pod.Annotations == nil { | ||
| pod.Annotations = make(map[string]string) | ||
| } | ||
| if pod.Annotations[scheduling.KubeGroupNameAnnotationKey] == "" { | ||
| patch := metadataForMergePatch{ | ||
| Metadata: annotationForMergePatch{ | ||
| Annotations: map[string]string{ | ||
| scheduling.KubeGroupNameAnnotationKey: pgName, | ||
| }, | ||
| patch = append(patch, map[string]any{ | ||
| "op": "add", | ||
| "path": "/metadata/annotations", | ||
| "value": map[string]string{ | ||
| scheduling.KubeGroupNameAnnotationKey: pgName, | ||
| }, | ||
| } | ||
|
|
||
| patchBytes, err := json.Marshal(&patch) | ||
| if err != nil { | ||
| klog.Errorf("Failed to json.Marshal pod annotation: %v", err) | ||
| return err | ||
| } | ||
|
|
||
| if _, err := pg.kubeClient.CoreV1().Pods(pod.Namespace).Patch(context.TODO(), pod.Name, types.StrategicMergePatchType, patchBytes, metav1.PatchOptions{}); err != nil { | ||
| klog.Errorf("Failed to update pod <%s/%s>: %v", pod.Namespace, pod.Name, err) | ||
| return err | ||
| } | ||
| klog.V(4).Infof("Bound Pod <%s/%s> to PodGroup <%s/%s>", pod.Namespace, pod.Name, pod.Namespace, pgName) | ||
| }) | ||
| } else if pod.Annotations[scheduling.KubeGroupNameAnnotationKey] == "" { |
There was a problem hiding this comment.
If pod.Annotations is initialized but empty (i.e., len(pod.Annotations) == 0), pod.Annotations == nil evaluates to false. In this case, the controller attempts to patch the path /metadata/annotations/scheduling.k8s.io~1group-name. However, if the annotations field is absent/nil on the Kubernetes API server, this patch operation will fail with a 422 Unprocessable Entity error (specifically, add operation does not apply: doc is missing path).
Using len(pod.Annotations) == 0 instead of pod.Annotations == nil safely handles both nil and empty maps, ensuring that the entire /metadata/annotations object is created when no annotations exist.
| if pod.Annotations == nil { | |
| pod.Annotations = make(map[string]string) | |
| } | |
| if pod.Annotations[scheduling.KubeGroupNameAnnotationKey] == "" { | |
| patch := metadataForMergePatch{ | |
| Metadata: annotationForMergePatch{ | |
| Annotations: map[string]string{ | |
| scheduling.KubeGroupNameAnnotationKey: pgName, | |
| }, | |
| patch = append(patch, map[string]any{ | |
| "op": "add", | |
| "path": "/metadata/annotations", | |
| "value": map[string]string{ | |
| scheduling.KubeGroupNameAnnotationKey: pgName, | |
| }, | |
| } | |
| patchBytes, err := json.Marshal(&patch) | |
| if err != nil { | |
| klog.Errorf("Failed to json.Marshal pod annotation: %v", err) | |
| return err | |
| } | |
| if _, err := pg.kubeClient.CoreV1().Pods(pod.Namespace).Patch(context.TODO(), pod.Name, types.StrategicMergePatchType, patchBytes, metav1.PatchOptions{}); err != nil { | |
| klog.Errorf("Failed to update pod <%s/%s>: %v", pod.Namespace, pod.Name, err) | |
| return err | |
| } | |
| klog.V(4).Infof("Bound Pod <%s/%s> to PodGroup <%s/%s>", pod.Namespace, pod.Name, pod.Namespace, pgName) | |
| }) | |
| } else if pod.Annotations[scheduling.KubeGroupNameAnnotationKey] == "" { | |
| if len(pod.Annotations) == 0 { | |
| patch = append(patch, map[string]any{ | |
| "op": "add", | |
| "path": "/metadata/annotations", | |
| "value": map[string]string{ | |
| scheduling.KubeGroupNameAnnotationKey: pgName, | |
| }, | |
| }) | |
| } else if pod.Annotations[scheduling.KubeGroupNameAnnotationKey] == "" { |
|
Will take a look, thanks for your contribution. |
fa49fd2 to
8675150
Compare
8675150 to
84dd0e5
Compare
| // UID test keeps this patch from hitting a recreated pod with the same namespace/name. | ||
| patch := []map[string]any{ | ||
| { | ||
| "op": "test", |
There was a problem hiding this comment.
I haven't seen this usage very often. Can you explain why it's written this way?
There was a problem hiding this comment.
Because the patch request comes from the worker queue, it may become stale if the Pod is deleted and recreated while the request is still queued.
The purpose of using JSON Patch with a /metadata/uid test is to prevent this stale patch. If the live Pod UID is different from the UID in the patch, the API server will reject the patch.
|
I'm wondering that in #5331, "The old PodGroup is patched/reused for the new Pods", I don't know whether we will still meet it in latest codes since the podgroup name is got from pod's ownerreference, which contains new statefulset's UID, so the new pod won't be patched the old pg name: volcano/staging/src/volcano.sh/apis/pkg/apis/helpers/helpers.go Lines 192 to 208 in fdb6ae0 |
The code in this commit can reduce the probability of race conditions, but I was still able to reproduce the issue on this commit in an extreme case. I temporarily deployed an admission webhook to delay only requests for podgroup creation, then recreated the StatefulSet/Pod with the same names before releasing the old request. The pseudocode of the reproduction is: The final live Pod was owned by the new StatefulSet, but its This is the stale patch I think the |
84dd0e5 to
788b3e8
Compare
|
Hi @JesseStutler , I think I have replied your question on Jun 15, about the reproduction details and why the |
0daa8c6 to
05f5574
Compare
|
Hi @JesseStutler , I've updated the PR based on your comment. I changed the patch strategic, using strategic merge patch that carried |
OK thanks! Will take a look |
| } | ||
| } | ||
|
|
||
| func TestUpdatePodAnnotationsCarriesUID(t *testing.T) { |
There was a problem hiding this comment.
The UID guard lgtm
But Could we also add coverage through the controller’s normal processing path, rather than only testing the annotation helper directly?
TestUpdatePodAnnotationsCarriesUID calls the helper directly with a reactor that always succeeds, so it does not cover PodGroup creation, patch failure, or requeue behavior.
I’d suggest table-driven tests that enqueue requests through addPod and process them through processNextReq, covering matching UIDs, stale requests, and patch failures. We should also have a regression scenario where the old Pod’s patch is rejected, then the recreated Pod’s request is processed and bound to the PodGroup derived from its new owner UID.
Manually setting up the informer cache and stepping the worker is fine—we don’t need a fully asynchronous controller test. The important part is to exercise the actual processing path and assert the resulting Pod/PodGroup state and retry behavior, rather than only inspecting the patch payload.
05f5574 to
c038059
Compare
Add a UID check to the patch that sets the PodGroup annotation on a pod, so a stale request no longer binds a recreated pod to the old PodGroup and leaves it Pending. Signed-off-by: momo <YiD11@outlook.com>
c038059 to
3198dcf
Compare
|
Hi @JesseStutler, I've reworked the tests as you suggested. I created a new file All cases go through addPod/processNextReq. Since the fake clientset doesn't validate metadata.uid, I added a patch reactor that rejects a uid mismatch the same way the apiserver does. The table test covers a matching uid, a stale request (cache already updated, and cache still stale), and a patch failure with retry, checking the pod annotation, the podgroup owner and the requeue count. Two more tests replay the original bug: one drives the old and the new pod requests in order and checks which podgroup the new pod ends up in, the other does the same through addStatefulSet. Hope this is what you had in mind. Thanks for the review. |
|
/approve |
|
[APPROVALNOTIFIER] This PR is APPROVED This pull-request has been approved by: JesseStutler 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 |
|
/cherrypick release-1.15 |
|
@JesseStutler: once the present PR merges, I will cherry-pick it on top of release-1.15 in a new PR and assign it to you. DetailsIn response to this:
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/test-infra repository. |
|
/cherrypick release-1.14 |
|
@JesseStutler: once the present PR merges, I will cherry-pick it on top of release-1.14 in a new PR and assign it to you. DetailsIn response to this:
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/test-infra repository. |
|
/cherrypick release-1.13 |
|
@JesseStutler: once the present PR merges, I will cherry-pick it on top of release-1.13 in a new PR and assign it to you. DetailsIn response to this:
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/test-infra repository. |
|
@JesseStutler: new pull request created: #5980 DetailsIn response to this:
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/test-infra repository. |
|
@JesseStutler: new pull request created: #5981 DetailsIn response to this:
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/test-infra repository. |
|
@JesseStutler: new pull request created: #5982 DetailsIn response to this:
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/test-infra repository. |
What type of PR is this?
/kind bug
What this PR does / why we need it:
When a StatefulSet in Volcano is deleted and then recreated with the same namespace/name, the PodGroup controller may still have an old Pod request in the queue.
The
podRequestonly contains namespace and name. If the worker handles it after the Pod is recreated, the cache lookup can return the new Pod. Then the old PodGroup name may be patched into the new Pod'sscheduling.k8s.io/group-name.If that old PodGroup is garbage-collected, the new Pod keeps a annotation pointing to a missing PodGroup and can stay Pending.
This PR adds the Pod UID field in
podRequestand skips queued work when the cached Pod UID mismatches the request. And it changes the annotation update to a JSON Patch with a/metadata/uidtest, so the API server should reject the patch if the Pod was recreated after the controller read it from cache.Which issue this PR fixes:
Fixes #5331
Special notes for your reviewer:
The original issue was reproduced with StatefulSet delete/recreate loops on Volcano v1.11.0. Details are shown in the original issue page.
Does this PR introduce a user-facing change?
NONE