Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/sdk-apply-objects.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion docs/sdk-fsm-reconciler.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions pkg/fsm/internal/reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions pkg/fsm/internal/test/core/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Comment thread
erik-ringsmuth marked this conversation as resolved.

// 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) {
Expand Down
9 changes: 9 additions & 0 deletions pkg/fsm/internal/test/core/test_fsm_reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

Expand Down