From 9fe1bbba89e5b2db3f8702545c4bb2359fcade24 Mon Sep 17 00:00:00 2001 From: Sri Ramanujam Date: Wed, 27 May 2026 16:48:44 -0400 Subject: [PATCH] return requeue result when conflict is detected --- docs/sdk-apply-objects.md | 4 +- docs/sdk-fsm-reconciler.md | 2 +- pkg/fsm/internal/reconciler.go | 9 ++++ pkg/fsm/internal/test/core/controller_test.go | 47 +++++++++++++++++++ .../internal/test/core/test_fsm_reconciler.go | 9 ++++ 5 files changed, 69 insertions(+), 2 deletions(-) diff --git a/docs/sdk-apply-objects.md b/docs/sdk-apply-objects.md index 239e9d6..cafadb3 100644 --- a/docs/sdk-apply-objects.md +++ b/docs/sdk-apply-objects.md @@ -172,7 +172,9 @@ which consists of: Usage of the resource lock (i.e. sending the update request with `metadata.resourceVersion` populated) ensures that the Kubernetes server will reject the update if it's operating on a version of the object that is out of date. -This guarantees that your controller will not overwrite data managed by other controllers. +This guarantees that your controller will not overwrite data managed by other controllers. The SDK transparently +handles these rejections by requeueing the reconciliation with backoff, which gives your controller the opportunity +to read the latest version of the object and retry the update with non-stale data. To use the resource lock, do the following: diff --git a/docs/sdk-fsm-reconciler.md b/docs/sdk-fsm-reconciler.md index 0e3d30e..229d050 100644 --- a/docs/sdk-fsm-reconciler.md +++ b/docs/sdk-fsm-reconciler.md @@ -78,7 +78,7 @@ Broadly speaking, there are three types of results: terminal state, simply complete 2. **requeue**—instructs the reconciler to trigger again after a user-specified amount of time. This is used in cases where - a controller is waiting for an external condition to be fulfilled. + a controller is waiting for an external condition to be fulfilled. The SDK will also requeue when applying outputs returns a retryable Conflict error. 3. **error**—the reconciler logs an error message and will retrigger, the delay of which is subject to exponential backoff diff --git a/pkg/fsm/internal/reconciler.go b/pkg/fsm/internal/reconciler.go index 9d65027..a2a486a 100644 --- a/pkg/fsm/internal/reconciler.go +++ b/pkg/fsm/internal/reconciler.go @@ -290,6 +290,15 @@ func (r *fsmReconciler[T, Obj]) reconcile( } if err := r.applyOutputs(ctx, log, obj, out); err != nil { + // if the error is a conflict, requeue with backoff instead of erroring + if k8serrors.IsConflict(err) { + condition.Status = corev1.ConditionFalse + condition.Reason = "ApplyOutputsConflict" + condition.Message = fmt.Sprintf("Conflict when applying outputs: %v", err) + conditions.SetConditions(condition) + return obj, conditions, types.RequeueResultWithBackoff(fmt.Sprintf("conflict when applying outputs: %v", err)) + } + // Mark the state's condition as failed since outputs couldn't be applied if !condition.IsEmpty() { condition.Status = corev1.ConditionFalse diff --git a/pkg/fsm/internal/test/core/controller_test.go b/pkg/fsm/internal/test/core/controller_test.go index 67140af..adefc07 100644 --- a/pkg/fsm/internal/test/core/controller_test.go +++ b/pkg/fsm/internal/test/core/controller_test.go @@ -475,6 +475,53 @@ var _ = Describe("Controller", Ordered, func() { }).Should(Succeed()) }) + It("should requeue with backoff on conflict when applying outputs with optimistic lock", func() { + // Pre-create the conflict target ConfigMap + conflictTarget := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "conflict-target", + Namespace: "default", + }, + Data: map[string]string{"original": "data"}, + } + Expect(c.Create(ctx, conflictTarget)).To(Succeed()) + + // Trigger the conflict path via annotation + claim := newTestClaim() + _, err := controllerutil.CreateOrPatch(ctx, c, claim, func() error { + if claim.Annotations == nil { + claim.Annotations = map[string]string{} + } + claim.Annotations["result-type"] = "conflict-on-apply" + return nil + }) + Expect(err).ToNot(HaveOccurred()) + + // Verify that conflict was detected, Reason is set, and Ready = false + Eventually(func(g Gomega) { + actualClaim := &testv1alpha1.TestClaim{} + g.Expect(c.Get(ctx, client.ObjectKeyFromObject(claim), actualClaim)).To(Succeed()) + actual := actualClaim.GetCondition("custom-status-condition") + g.Expect(actual.Status).To(Equal(corev1.ConditionFalse)) + g.Expect(string(actual.Reason)).To(Equal("ApplyOutputsConflict")) + g.Expect(status.ResourceReady(actualClaim)).To(BeFalse()) + }).Should(Succeed()) + + // Remove annotation to allow recovery on next reconcile + _, err = controllerutil.CreateOrPatch(ctx, c, claim, func() error { + delete(claim.Annotations, "result-type") + return nil + }) + Expect(err).ToNot(HaveOccurred()) + + // object goes ready after conflict is resolved + Eventually(func(g Gomega) { + actualClaim := &testv1alpha1.TestClaim{} + g.Expect(c.Get(ctx, client.ObjectKeyFromObject(claim), actualClaim)).To(Succeed()) + g.Expect(status.ResourceReady(actualClaim)).To(BeTrue()) + }).Should(Succeed()) + }) + It("should delete managed resources", func() { // wait for state to be processed Eventually(func(g Gomega) { diff --git a/pkg/fsm/internal/test/core/test_fsm_reconciler.go b/pkg/fsm/internal/test/core/test_fsm_reconciler.go index cdd91df..42cc42e 100644 --- a/pkg/fsm/internal/test/core/test_fsm_reconciler.go +++ b/pkg/fsm/internal/test/core/test_fsm_reconciler.go @@ -268,6 +268,15 @@ func (r *reconciler) testResultTypes() *state { return r.noopState(), fsmtypes.DoneAndRequeueAfterCompletionWithBackoff("Done and requeue after completion with backoff") case "requeue-after-completion": return r.noopState(), fsmtypes.DoneAndRequeueAfterCompletion("Done and requeue after completion", 30*time.Second) + case "conflict-on-apply": + cm := &corev1.ConfigMap{} + if err := r.c.Get(ctx, client.ObjectKey{Name: "conflict-target", Namespace: "default"}, cm); err != nil { + return nil, fsmtypes.ErrorResult(fmt.Errorf("getting conflict target: %w", err)) + } + cm.Data = map[string]string{"modified-by": "fsm-transition"} + cm.SetResourceVersion("1") // deliberately stale to trigger conflict + out.Apply(cm, io.WithOptimisticLock()) + return nil, fsmtypes.DoneResult() } }