Skip to content

Commit 15d0b7d

Browse files
committed
Retry project Delete attempts with backoff
1 parent b0852ea commit 15d0b7d

File tree

2 files changed

+227
-21
lines changed

2 files changed

+227
-21
lines changed

pkg/project/apiserver/registry/project/proxy/proxy.go

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ package proxy
33
import (
44
"context"
55
"fmt"
6+
"time"
67

78
kerrors "k8s.io/apimachinery/pkg/api/errors"
89
metainternal "k8s.io/apimachinery/pkg/apis/meta/internalversion"
910
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1011
"k8s.io/apimachinery/pkg/runtime"
12+
"k8s.io/apimachinery/pkg/util/wait"
1113
"k8s.io/apimachinery/pkg/watch"
1214
apirequest "k8s.io/apiserver/pkg/endpoints/request"
1315
"k8s.io/apiserver/pkg/registry/rest"
@@ -208,32 +210,66 @@ func (s *REST) Update(ctx context.Context, name string, objInfo rest.UpdatedObje
208210

209211
var _ = rest.GracefulDeleter(&REST{})
210212

213+
// maxRetriesOnConflict is the maximum retry count for Delete calls which
214+
// result in resource conflicts.
215+
const maxRetriesOnConflict = 10
216+
217+
// maxDuration set max duration of delete retries. Deleting a project affects apiserver latency,
218+
// so this should be kept as small as possible
219+
const maxDuration = time.Second
220+
211221
// Delete deletes a Project specified by its name
212222
func (s *REST) Delete(ctx context.Context, name string, objectFunc rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
213223
var opts metav1.DeleteOptions
214224
if options != nil {
215225
opts = *options
216226
}
217-
if objectFunc != nil {
218-
obj, err := s.Get(ctx, name, &metav1.GetOptions{})
219-
if err != nil {
220-
return nil, false, err
221-
}
222-
projectObj, ok := obj.(*projectapi.Project)
223-
if !ok || projectObj == nil {
224-
return nil, false, fmt.Errorf("not a project: %#v", obj)
227+
var lastErr error
228+
err := wait.ExponentialBackoffWithContext(ctx, wait.Backoff{Steps: maxRetriesOnConflict, Duration: maxDuration}, func(ctx context.Context) (bool, error) {
229+
var err error
230+
if objectFunc != nil {
231+
var obj runtime.Object
232+
obj, err = s.Get(ctx, name, &metav1.GetOptions{})
233+
if err != nil {
234+
lastErr = fmt.Errorf("unable to get project: %w", err)
235+
return false, nil
236+
}
237+
projectObj, ok := obj.(*projectapi.Project)
238+
if !ok || projectObj == nil {
239+
lastErr = fmt.Errorf("not a project: %#v", obj)
240+
return false, nil
241+
}
242+
243+
// Make sure the object hasn't changed between Get and Delete - pass UID and RV to delete options
244+
// unless Precondition is already set
245+
if opts.Preconditions == nil {
246+
opts.Preconditions = &metav1.Preconditions{}
247+
}
248+
if opts.Preconditions.UID == nil {
249+
opts.Preconditions.UID = &projectObj.UID
250+
}
251+
if opts.Preconditions.ResourceVersion == nil {
252+
opts.Preconditions.ResourceVersion = &projectObj.ResourceVersion
253+
}
254+
255+
if err := objectFunc(ctx, obj); err != nil {
256+
lastErr = fmt.Errorf("validation func failed: %w", err)
257+
return false, nil
258+
}
225259
}
226-
227-
// Make sure the object hasn't changed between Get and Delete - pass UID and RV to delete options
228-
if opts.Preconditions == nil {
229-
opts.Preconditions = &metav1.Preconditions{}
230-
}
231-
opts.Preconditions.UID = &projectObj.UID
232-
opts.Preconditions.ResourceVersion = &projectObj.ResourceVersion
233-
234-
if err := objectFunc(ctx, obj); err != nil {
235-
return nil, false, err
260+
err = s.client.Delete(ctx, name, opts)
261+
switch {
262+
case err == nil:
263+
return true, nil
264+
case kerrors.IsConflict(err):
265+
lastErr = err
266+
return false, nil
267+
default:
268+
return false, err
236269
}
270+
})
271+
if err != nil && wait.Interrupted(err) {
272+
return &metav1.Status{Status: metav1.StatusFailure}, false, lastErr
237273
}
238-
return &metav1.Status{Status: metav1.StatusSuccess}, false, s.client.Delete(ctx, name, opts)
274+
return &metav1.Status{Status: metav1.StatusSuccess}, false, nil
239275
}

pkg/project/apiserver/registry/project/proxy/proxy_test.go

Lines changed: 172 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,22 @@ package proxy
22

33
import (
44
"context"
5+
"errors"
6+
"fmt"
57
"strings"
68
"testing"
79

810
corev1 "k8s.io/api/core/v1"
9-
"k8s.io/apimachinery/pkg/api/errors"
11+
kerrors "k8s.io/apimachinery/pkg/api/errors"
1012
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1113
"k8s.io/apimachinery/pkg/labels"
1214
"k8s.io/apimachinery/pkg/runtime"
15+
ktypes "k8s.io/apimachinery/pkg/types"
1316
"k8s.io/apiserver/pkg/authentication/user"
1417
apirequest "k8s.io/apiserver/pkg/endpoints/request"
1518
"k8s.io/apiserver/pkg/registry/rest"
1619
"k8s.io/client-go/kubernetes/fake"
20+
ktesting "k8s.io/client-go/testing"
1721

1822
oapi "github.com/openshift/openshift-apiserver/pkg/api"
1923
projectapi "github.com/openshift/openshift-apiserver/pkg/project/apis/project"
@@ -81,7 +85,7 @@ func TestCreateInvalidProject(t *testing.T) {
8185
Annotations: map[string]string{oapi.OpenShiftDisplayName: "h\t\ni"},
8286
},
8387
}, rest.ValidateAllObjectFunc, &metav1.CreateOptions{})
84-
if !errors.IsInvalid(err) {
88+
if !kerrors.IsInvalid(err) {
8589
t.Errorf("Expected 'invalid' error, got %v", err)
8690
}
8791
}
@@ -182,3 +186,169 @@ func TestDeleteProjectValidation(t *testing.T) {
182186
t.Errorf("Expected validation function to be called")
183187
}
184188
}
189+
190+
func TestDeleteProjectValidationRetries(t *testing.T) {
191+
mockClient := &fake.Clientset{}
192+
storage := REST{
193+
client: mockClient.CoreV1().Namespaces(),
194+
}
195+
maxRetries := 3
196+
validationRetries := 0
197+
validationFunc := func(ctx context.Context, obj runtime.Object) error {
198+
if validationRetries < maxRetries {
199+
validationRetries++
200+
return fmt.Errorf("not there yet")
201+
}
202+
return nil
203+
}
204+
obj, _, err := storage.Delete(apirequest.NewContext(), "foo", validationFunc, &metav1.DeleteOptions{})
205+
if obj == nil {
206+
t.Error("Unexpected nil obj")
207+
}
208+
if err != nil {
209+
t.Errorf("Unexpected non-nil error: %#v", err)
210+
}
211+
status, ok := obj.(*metav1.Status)
212+
if !ok {
213+
t.Errorf("Expected status type, got: %#v", obj)
214+
}
215+
if status.Status != metav1.StatusSuccess {
216+
t.Errorf("Expected status=success, got: %#v", status)
217+
}
218+
if len(mockClient.Actions()) != maxRetries+2 {
219+
t.Errorf("Expected client action for get, got %v", mockClient.Actions())
220+
}
221+
for i := range len(mockClient.Actions()) - 1 {
222+
if !mockClient.Actions()[i].Matches("get", "namespaces") {
223+
t.Errorf("Expected call #%d to get-namespace, got %#v", i, mockClient.Actions()[i])
224+
}
225+
}
226+
if !mockClient.Actions()[len(mockClient.Actions())-1].Matches("delete", "namespaces") {
227+
t.Errorf("Expected call #%d to delete-namespace, got %#v", len(mockClient.Actions())-1, mockClient.Actions()[len(mockClient.Actions())-1])
228+
}
229+
if validationRetries != maxRetries {
230+
t.Errorf("Expected validation function to be retried %d times, got %d", maxRetries, validationRetries)
231+
}
232+
}
233+
234+
func TestDeleteProjectValidationError(t *testing.T) {
235+
mockClient := &fake.Clientset{}
236+
storage := REST{
237+
client: mockClient.CoreV1().Namespaces(),
238+
}
239+
validationError := fmt.Errorf("faulty function")
240+
241+
validationFunc := func(ctx context.Context, obj runtime.Object) error {
242+
return validationError
243+
}
244+
obj, _, err := storage.Delete(apirequest.NewContext(), "foo", validationFunc, &metav1.DeleteOptions{})
245+
if obj == nil {
246+
t.Error("Unexpected nil obj")
247+
}
248+
status, ok := obj.(*metav1.Status)
249+
if !ok {
250+
t.Errorf("Expected status type, got: %#v", obj)
251+
}
252+
if status.Status != metav1.StatusFailure {
253+
t.Errorf("Expected status=failure, got: %#v", status)
254+
}
255+
if err == nil {
256+
t.Errorf("Expected error but got nil")
257+
}
258+
if errors.Unwrap(err) != validationError {
259+
t.Errorf("Unexpected error: %#v", errors.Unwrap(err))
260+
}
261+
}
262+
263+
func TestDeleteProjectValidationPreconditionUID(t *testing.T) {
264+
uid := ktypes.UID("first-uid")
265+
resourceVersion := "10"
266+
meta := metav1.ObjectMeta{Name: "foo", UID: uid, ResourceVersion: resourceVersion}
267+
namespaceList := corev1.NamespaceList{
268+
Items: []corev1.Namespace{
269+
{
270+
ObjectMeta: meta,
271+
},
272+
},
273+
}
274+
275+
tests := []struct {
276+
testName string
277+
uid, resourceVersion string
278+
}{
279+
{
280+
testName: "all unset",
281+
},
282+
{
283+
testName: "uid set",
284+
uid: "first-uid",
285+
},
286+
{
287+
testName: "resourceVersion set",
288+
resourceVersion: "10",
289+
},
290+
{
291+
testName: "both set",
292+
uid: "first-uid",
293+
resourceVersion: "10",
294+
},
295+
}
296+
for _, test := range tests {
297+
t.Run(test.testName, func(t *testing.T) {
298+
mockClient := fake.NewSimpleClientset(&namespaceList)
299+
storage := REST{
300+
client: mockClient.CoreV1().Namespaces(),
301+
}
302+
validationFunc := func(ctx context.Context, obj runtime.Object) error {
303+
return nil
304+
}
305+
306+
expectedUID := uid
307+
expectedRV := resourceVersion
308+
deleteOpts := &metav1.DeleteOptions{}
309+
if len(test.uid) > 0 {
310+
expectedUID = ktypes.UID(test.uid)
311+
if deleteOpts.Preconditions == nil {
312+
deleteOpts.Preconditions = &metav1.Preconditions{}
313+
}
314+
deleteOpts.Preconditions.UID = &expectedUID
315+
}
316+
if len(test.resourceVersion) > 0 {
317+
expectedRV = test.resourceVersion
318+
if deleteOpts.Preconditions == nil {
319+
deleteOpts.Preconditions = &metav1.Preconditions{}
320+
}
321+
deleteOpts.Preconditions.ResourceVersion = &expectedRV
322+
}
323+
324+
storage.Delete(apirequest.NewContext(), "foo", validationFunc, deleteOpts)
325+
if len(mockClient.Actions()) != 2 {
326+
t.Errorf("Expected client action for get and delete, got %v", mockClient.Actions())
327+
}
328+
if !mockClient.Actions()[0].Matches("get", "namespaces") {
329+
t.Errorf("Expected call to get-namespace, got %#v", mockClient.Actions()[0])
330+
}
331+
lastAction := mockClient.Actions()[1]
332+
if !lastAction.Matches("delete", "namespaces") {
333+
t.Errorf("Expected call to delete-namespace, got %#v", mockClient.Actions()[1])
334+
}
335+
deleteAction := lastAction.(ktesting.DeleteActionImpl)
336+
preconditions := deleteAction.DeleteOptions.Preconditions
337+
if preconditions == nil {
338+
t.Fatalf("Expected DeleteOptions precondition to be non-nil")
339+
}
340+
if preconditions.UID == nil {
341+
t.Fatalf("Expected DeleteOptions precondition UID to be non-nil")
342+
}
343+
if *preconditions.UID != expectedUID {
344+
t.Errorf("Expected DeleteOptions precondition UID to %#v, got %#v", expectedUID, *preconditions.UID)
345+
}
346+
if preconditions.ResourceVersion == nil {
347+
t.Fatalf("Expected DeleteOptions precondition ResourceVersion to be non-nil")
348+
}
349+
if *preconditions.ResourceVersion != expectedRV {
350+
t.Errorf("Expected DeleteOptions precondition ResourceVersion to %#v, got %#v", expectedRV, *preconditions.ResourceVersion)
351+
}
352+
})
353+
}
354+
}

0 commit comments

Comments
 (0)