collapse patch conflict retry onto GuaranteedUpdate

builds on #62868 1. When the incoming patch specified a resourceVersion that failed as a precondition, the patch handler would retry uselessly 5 times. This PR collapses onto GuaranteedUpdate, which immediately stops retrying in that case. 2. When the incoming patch did not specify a resourceVersion, and persisting to etcd contended with other etcd updates, the retry would try to detect patch conflicts with deltas from the first 'current object' retrieved from etcd and fail with a conflict error in that case. Given that the user did not provide any information about the starting version they expected their patch to apply to, this does not make sense, and results in arbitrary conflict errors, depending on when the patch was submitted relative to other changes made to the resource. This PR changes the patch application to be performed on the object retrieved from etcd identically on every attempt. fixes #58017 SMP is no longer computed for CRD objects fixes #42644 No special state is retained on the first attempt, so the patch handler correctly handles the cached storage optimistically trying with a cached object first
parent 62514022
...@@ -582,17 +582,6 @@ func TestPatch(t *testing.T) { ...@@ -582,17 +582,6 @@ func TestPatch(t *testing.T) {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
// this call waits for the resourceVersion to be reached in the cache before returning.
// We need to do this because the patch gets its initial object from the storage, and the cache serves that.
// If it is out of date, then our initial patch is applied to an old resource version, which conflicts
// and then the updated object shows a conflicting diff, which permanently fails the patch.
// This gives expected stability in the patch without retrying on an known number of conflicts below in the test.
// See https://issue.k8s.io/42644
_, err = noxuNamespacedResourceClient.Get("foo", metav1.GetOptions{ResourceVersion: createdNoxuInstance.GetResourceVersion()})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// a patch with no change // a patch with no change
createdNoxuInstance, err = noxuNamespacedResourceClient.Patch("foo", types.MergePatchType, patch) createdNoxuInstance, err = noxuNamespacedResourceClient.Patch("foo", types.MergePatchType, patch)
if err != nil { if err != nil {
......
...@@ -90,9 +90,6 @@ func (scope *RequestScope) AllowsStreamSchema(s string) bool { ...@@ -90,9 +90,6 @@ func (scope *RequestScope) AllowsStreamSchema(s string) bool {
return s == "watch" return s == "watch"
} }
// MaxRetryWhenPatchConflicts is the maximum number of conflicts retry during a patch operation before returning failure
const MaxRetryWhenPatchConflicts = 5
// ConnectResource returns a function that handles a connect request on a rest.Storage object. // ConnectResource returns a function that handles a connect request on a rest.Storage object.
func ConnectResource(connecter rest.Connecter, scope RequestScope, admit admission.Interface, restPath string, isSubresource bool) http.HandlerFunc { func ConnectResource(connecter rest.Connecter, scope RequestScope, admit admission.Interface, restPath string, isSubresource bool) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) { return func(w http.ResponseWriter, req *http.Request) {
......
...@@ -173,32 +173,46 @@ func (p *testPatcher) New() runtime.Object { ...@@ -173,32 +173,46 @@ func (p *testPatcher) New() runtime.Object {
} }
func (p *testPatcher) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc) (runtime.Object, bool, error) { func (p *testPatcher) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc) (runtime.Object, bool, error) {
currentPod := p.startingPod // Simulate GuaranteedUpdate behavior (retries internally on etcd changes if the incoming resource doesn't pin resourceVersion)
if p.numUpdates > 0 { for {
currentPod = p.updatePod currentPod := p.startingPod
} if p.numUpdates > 0 {
p.numUpdates++ currentPod = p.updatePod
}
p.numUpdates++
obj, err := objInfo.UpdatedObject(ctx, currentPod) // Remember the current resource version
if err != nil { currentResourceVersion := currentPod.ResourceVersion
return nil, false, err
}
inPod := obj.(*example.Pod)
if inPod.ResourceVersion != p.updatePod.ResourceVersion {
return nil, false, apierrors.NewConflict(example.Resource("pods"), inPod.Name, fmt.Errorf("existing %v, new %v", p.updatePod.ResourceVersion, inPod.ResourceVersion))
}
if currentPod == nil { obj, err := objInfo.UpdatedObject(ctx, currentPod)
if err := createValidation(currentPod); err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
} else { inPod := obj.(*example.Pod)
if err := updateValidation(currentPod, inPod); err != nil { if inPod.ResourceVersion == "" || inPod.ResourceVersion == "0" {
return nil, false, err inPod.ResourceVersion = p.updatePod.ResourceVersion
}
if inPod.ResourceVersion != p.updatePod.ResourceVersion {
// If the patch didn't have an opinion on the resource version, retry like GuaranteedUpdate does
if inPod.ResourceVersion == currentResourceVersion {
continue
}
// If the patch changed the resource version and it mismatches, conflict
return nil, false, apierrors.NewConflict(example.Resource("pods"), inPod.Name, fmt.Errorf("existing %v, new %v", p.updatePod.ResourceVersion, inPod.ResourceVersion))
}
if currentPod == nil {
if err := createValidation(currentPod); err != nil {
return nil, false, err
}
} else {
if err := updateValidation(currentPod, inPod); err != nil {
return nil, false, err
}
} }
}
return inPod, false, nil return inPod, false, nil
}
} }
func (p *testPatcher) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { func (p *testPatcher) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
...@@ -549,8 +563,7 @@ func TestPatchResourceWithConflict(t *testing.T) { ...@@ -549,8 +563,7 @@ func TestPatchResourceWithConflict(t *testing.T) {
startingPod: &example.Pod{}, startingPod: &example.Pod{},
changedPod: &example.Pod{}, changedPod: &example.Pod{},
updatePod: &example.Pod{}, updatePod: &example.Pod{},
expectedPod: &example.Pod{},
expectedError: `Operation cannot be fulfilled on pods.example.apiserver.k8s.io "foo": existing 2, new 1`,
} }
// See issue #63104 for discussion of how much sense this makes. // See issue #63104 for discussion of how much sense this makes.
...@@ -576,6 +589,13 @@ func TestPatchResourceWithConflict(t *testing.T) { ...@@ -576,6 +589,13 @@ func TestPatchResourceWithConflict(t *testing.T) {
tc.updatePod.APIVersion = examplev1.SchemeGroupVersion.String() tc.updatePod.APIVersion = examplev1.SchemeGroupVersion.String()
tc.updatePod.Spec.NodeName = "anywhere" tc.updatePod.Spec.NodeName = "anywhere"
tc.expectedPod.Name = name
tc.expectedPod.Namespace = namespace
tc.expectedPod.UID = uid
tc.expectedPod.ResourceVersion = "2"
tc.expectedPod.APIVersion = examplev1.SchemeGroupVersion.String()
tc.expectedPod.Spec.NodeName = "there"
tc.Run(t) tc.Run(t)
} }
......
...@@ -43,7 +43,6 @@ go_test( ...@@ -43,7 +43,6 @@ go_test(
"//vendor/k8s.io/apimachinery/pkg/runtime:go_default_library", "//vendor/k8s.io/apimachinery/pkg/runtime:go_default_library",
"//vendor/k8s.io/apimachinery/pkg/runtime/schema:go_default_library", "//vendor/k8s.io/apimachinery/pkg/runtime/schema:go_default_library",
"//vendor/k8s.io/apimachinery/pkg/types:go_default_library", "//vendor/k8s.io/apimachinery/pkg/types:go_default_library",
"//vendor/k8s.io/apiserver/pkg/endpoints/handlers:go_default_library",
"//vendor/k8s.io/apiserver/pkg/features:go_default_library", "//vendor/k8s.io/apiserver/pkg/features:go_default_library",
"//vendor/k8s.io/apiserver/pkg/util/feature:go_default_library", "//vendor/k8s.io/apiserver/pkg/util/feature:go_default_library",
"//vendor/k8s.io/client-go/kubernetes:go_default_library", "//vendor/k8s.io/client-go/kubernetes:go_default_library",
......
...@@ -29,11 +29,10 @@ import ( ...@@ -29,11 +29,10 @@ import (
"k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/types"
"k8s.io/apiserver/pkg/endpoints/handlers"
"k8s.io/kubernetes/test/integration/framework" "k8s.io/kubernetes/test/integration/framework"
) )
// Tests that the apiserver retries non-overlapping conflicts on patches // Tests that the apiserver retries patches
func TestPatchConflicts(t *testing.T) { func TestPatchConflicts(t *testing.T) {
s, clientSet, closeFn := setup(t) s, clientSet, closeFn := setup(t)
defer closeFn() defer closeFn()
...@@ -41,7 +40,7 @@ func TestPatchConflicts(t *testing.T) { ...@@ -41,7 +40,7 @@ func TestPatchConflicts(t *testing.T) {
ns := framework.CreateTestingNamespace("status-code", s, t) ns := framework.CreateTestingNamespace("status-code", s, t)
defer framework.DeleteTestingNamespace(ns, s, t) defer framework.DeleteTestingNamespace(ns, s, t)
numOfConcurrentPatches := 2 * handlers.MaxRetryWhenPatchConflicts numOfConcurrentPatches := 100
UIDs := make([]types.UID, numOfConcurrentPatches) UIDs := make([]types.UID, numOfConcurrentPatches)
ownerRefs := []metav1.OwnerReference{} ownerRefs := []metav1.OwnerReference{}
...@@ -69,10 +68,8 @@ func TestPatchConflicts(t *testing.T) { ...@@ -69,10 +68,8 @@ func TestPatchConflicts(t *testing.T) {
successes := int32(0) successes := int32(0)
// Run a lot of simultaneous patch operations to exercise internal API server retry of patch application. // Run a lot of simultaneous patch operations to exercise internal API server retry of application of patches that do not specify resourceVersion.
// Internally, a patch API call retries up to MaxRetryWhenPatchConflicts times if the resource version of the object has changed. // They should all succeed.
// If the resource version of the object changed between attempts, that means another one of our patch requests succeeded.
// That means if we run 2*MaxRetryWhenPatchConflicts patch attempts, we should see at least MaxRetryWhenPatchConflicts succeed.
wg := sync.WaitGroup{} wg := sync.WaitGroup{}
for i := 0; i < numOfConcurrentPatches; i++ { for i := 0; i < numOfConcurrentPatches; i++ {
wg.Add(1) wg.Add(1)
...@@ -123,8 +120,8 @@ func TestPatchConflicts(t *testing.T) { ...@@ -123,8 +120,8 @@ func TestPatchConflicts(t *testing.T) {
} }
wg.Wait() wg.Wait()
if successes < handlers.MaxRetryWhenPatchConflicts { if successes < int32(numOfConcurrentPatches) {
t.Errorf("Expected at least %d successful patches for %s, got %d", handlers.MaxRetryWhenPatchConflicts, "secrets", successes) t.Errorf("Expected at least %d successful patches for %s, got %d", numOfConcurrentPatches, "secrets", successes)
} else { } else {
t.Logf("Got %d successful patches for %s", successes, "secrets") t.Logf("Got %d successful patches for %s", successes, "secrets")
} }
......
...@@ -22,7 +22,6 @@ go_test( ...@@ -22,7 +22,6 @@ go_test(
"//test/utils/image:go_default_library", "//test/utils/image:go_default_library",
"//vendor/k8s.io/api/core/v1:go_default_library", "//vendor/k8s.io/api/core/v1:go_default_library",
"//vendor/k8s.io/apimachinery/pkg/api/errors:go_default_library", "//vendor/k8s.io/apimachinery/pkg/api/errors:go_default_library",
"//vendor/k8s.io/apimachinery/pkg/api/meta:go_default_library",
"//vendor/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library", "//vendor/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library",
"//vendor/k8s.io/apimachinery/pkg/apis/meta/v1/unstructured:go_default_library", "//vendor/k8s.io/apimachinery/pkg/apis/meta/v1/unstructured:go_default_library",
"//vendor/k8s.io/apimachinery/pkg/fields:go_default_library", "//vendor/k8s.io/apimachinery/pkg/fields:go_default_library",
......
...@@ -27,7 +27,6 @@ import ( ...@@ -27,7 +27,6 @@ import (
"k8s.io/api/core/v1" "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/labels"
...@@ -286,23 +285,6 @@ func TestPatch(t *testing.T) { ...@@ -286,23 +285,6 @@ func TestPatch(t *testing.T) {
t.Logf("%v", string(jsonObj)) t.Logf("%v", string(jsonObj))
} }
obj, err := result.Get()
if err != nil {
t.Fatal(err)
}
metadata, err := meta.Accessor(obj)
if err != nil {
t.Fatal(err)
}
// this call waits for the resourceVersion to be reached in the cache before returning. We need to do this because
// the patch gets its initial object from the storage, and the cache serves that. If it is out of date,
// then our initial patch is applied to an old resource version, which conflicts and then the updated object shows
// a conflicting diff, which permanently fails the patch. This gives expected stability in the patch without
// retrying on an known number of conflicts below in the test.
if _, err := c.Core().Pods(ns.Name).Get(name, metav1.GetOptions{ResourceVersion: metadata.GetResourceVersion()}); err != nil {
t.Fatal(err)
}
return nil return nil
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment