Skip to content

Commit 14e29d7

Browse files
sourcehawkclaude
andauthored
fix(component)!: stop FlushStatus from writing stale status back on a conflict (#241)
On a 409 Conflict, FlushStatus wrote the staged owner again with the resourceVersion of the server. The staged owner came from the read at the top of the reconcile, so the retry wrote old values over the status fields of other writers. This defeated optimistic locking. FlushStatus now makes one Status().Update. On any error, a conflict included, it writes nothing and returns the error, wrapped with %w so that apierrors.IsConflict still reports true. The controller requeues, and the next reconcile reads the current owner. A stale write never lands, so a status field or condition of another writer is never rolled back. BREAKING CHANGE: FlushStatus no longer takes the comps argument. The signature is FlushStatus(ctx, rec). Remove the third argument at each call site. A conflict is now returned as a reconcile error instead of being retried. ReconcileContext.APIReader stays for the resource reads of BlockOnForeignController and DeleteOnlyIfOwned. Fixes #238 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7073a2a commit 14e29d7

21 files changed

Lines changed: 441 additions & 1021 deletions

File tree

‎docs/component.md‎

Lines changed: 47 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -656,7 +656,8 @@ Four properties of the aggregate condition are easy to miss:
656656
- A component disabled by a feature gate reports `True`/`Disabled` and therefore counts as ready, for the same reason.
657657
- `Aggregate` looks only at the components that you pass in. If the controller no longer builds a component, the
658658
condition of that component stays on the owner. `Aggregate` ignores that stale condition, but users still see it,
659-
because [`FlushStatus`](#persisting-status-with-flushstatus) merges conditions by type and never prunes them.
659+
because [`FlushStatus`](#persisting-status-with-flushstatus) writes the conditions on the owner as they are and never
660+
prunes them.
660661
- The returned `Condition` is a plain struct. A caller that wants different prose can change the message before it
661662
stages the condition with `meta.SetStatusCondition`.
662663

@@ -700,9 +701,8 @@ func (r *WebAppReconciler) Reconcile(ctx context.Context, req reconcile.Request)
700701
}
701702
702703
recCtx := component.ReconcileContext{ /* ... */ Owner: owner}
703-
var comps []*component.Component
704704
defer func() {
705-
if flushErr := component.FlushStatus(ctx, recCtx, comps); flushErr != nil && err == nil {
705+
if flushErr := component.FlushStatus(ctx, recCtx); flushErr != nil && err == nil {
706706
err = flushErr
707707
}
708708
}()
@@ -711,7 +711,6 @@ func (r *WebAppReconciler) Reconcile(ctx context.Context, req reconcile.Request)
711711
if err != nil {
712712
return reconcile.Result{}, err
713713
}
714-
comps = append(comps, comp)
715714
716715
if err := comp.Reconcile(ctx, recCtx); err != nil {
717716
return reconcile.Result{}, err
@@ -788,7 +787,7 @@ recCtx := component.ReconcileContext{
788787
Scheme: r.Scheme, // *runtime.Scheme
789788
EventRecorder: r.EventRecorder, // events.EventRecorder, from manager.GetEventRecorder(name)
790789
Metrics: r.Metrics, // component.MetricsRecorder, optional; see Metrics
791-
APIReader: r.APIReader, // client.Reader, from manager.GetAPIReader(); direct reads on a status conflict
790+
APIReader: r.APIReader, // client.Reader, from manager.GetAPIReader(); direct reads of live objects
792791
Owner: owner, // the CRD that owns this component
793792
}
794793
@@ -825,10 +824,8 @@ func (r *WebAppReconciler) Reconcile(ctx context.Context, req reconcile.Request)
825824
APIReader: r.APIReader,
826825
Owner: owner,
827826
}
828-
// Declared before the deferred flush so the closure sees every component built below.
829-
var comps []*component.Component
830827
defer func() {
831-
if flushErr := component.FlushStatus(ctx, recCtx, comps); flushErr != nil && err == nil {
828+
if flushErr := component.FlushStatus(ctx, recCtx); flushErr != nil && err == nil {
832829
err = flushErr
833830
}
834831
}()
@@ -837,80 +834,61 @@ func (r *WebAppReconciler) Reconcile(ctx context.Context, req reconcile.Request)
837834
if err != nil {
838835
return reconcile.Result{}, err
839836
}
840-
comps = append(comps, comp)
841837
return reconcile.Result{}, comp.Reconcile(ctx, recCtx)
842838
}
843839
```
844840

845-
`FlushStatus` performs one `Status().Update` call, wrapped in `retry.RetryOnConflict`. That call writes the **whole
846-
status subresource**, not only the conditions. Every status field the controller staged in memory is sent, and every
847-
field it did not stage is sent exactly as it stands on the in-memory object. Fetch the owner fresh at the top of each
848-
reconcile, so the fields nobody staged still hold current server state. An owner carried over from an earlier pass
849-
writes stale values back over newer ones.
841+
`FlushStatus(ctx context.Context, rec ReconcileContext) error` performs one `Status().Update` call. That call writes the
842+
**whole status subresource**, not only the conditions. Every status field on the in-memory owner is sent, whether the
843+
controller staged it or not.
850844

851-
On a 409 Conflict, for example when another writer updated the owner between the controller's `Get` and this call,
852-
`FlushStatus` keeps the staged owner as the object it writes. It fetches the server's copy into a **separate** object,
853-
takes that copy's `resourceVersion` so the retry targets the live object, restores from it only the conditions whose
854-
type this flush does not own, and retries. The staged owner is never replaced.
845+
The API server accepts the write only if the `resourceVersion` of the owner still matches the stored object. The
846+
`resourceVersion` that the `Get` at the top of the reconcile read is therefore the guard for the write. Fetch the owner
847+
fresh at the top of each reconcile. An owner carried over from an earlier pass holds an old `resourceVersion`, so its
848+
flush can only conflict.
855849

856-
That fetch goes through `ReconcileContext.APIReader` when it is set, and through `Client` otherwise. Set it from the
857-
manager's `GetAPIReader()`. The manager's default `Client` serves reads from the informer cache, which can lag the very
858-
write that caused the conflict and hand back the same stale `resourceVersion`, so a retry through the cache may conflict
859-
again until the cache catches up. A direct read sees the server as it is.
850+
When another writer updates the owner between the `Get` and the flush, the API server rejects the write with a 409
851+
Conflict. `FlushStatus` does not retry. Nothing is written, and it returns the error, for which `apierrors.IsConflict`
852+
(`k8s.io/apimachinery/pkg/api/errors`) reports `true`. Return the error from `Reconcile`, as the deferred call above
853+
does. controller-runtime then requeues the owner with rate limiting, and the next reconcile reads the current owner and
854+
computes its status again.
860855

861-
Two things follow from that. Non-condition status fields staged during the reconcile survive a conflict, because the
862-
object holding them is the object that gets written. And a condition type this flush does not own keeps the server's
863-
value rather than the copy the controller happens to be holding, so a concurrent update by another writer is not rolled
864-
back.
856+
Because a stale write never lands, `FlushStatus` never rolls back a status field or a condition that another writer set.
857+
Two controllers can share the status of one owner, each one writing its own conditions and fields with `FlushStatus`,
858+
and neither one has to declare which fields it owns.
865859

866-
Which types count as owned comes from the components passed to `FlushStatus`:
867-
868-
```go
869-
component.FlushStatus(ctx, recCtx, []*component.Component{backendComp, frontendComp})
870-
```
871-
872-
Their condition types are the owned set, so it cannot drift from what the components actually write.
873-
874-
Note that this is a slice, while [`Aggregate`](#aggregating-components-into-one-owner-condition) takes the same
875-
components variadically. The difference is deliberate, for the reason below.
876-
877-
The parameter is required rather than variadic on purpose. A variadic would let every existing call keep compiling while
878-
silently retaining the old, wider ownership, which would make a correctness fix opt-in and invisible. Requiring the
879-
argument forces each call site to be looked at exactly once.
880-
881-
**You own exactly what you pass, and nothing else.** A controller that manages no components passes `nil`, and `nil` and
882-
an empty slice behave identically: neither owns any condition type. Ownership is by condition type, not by who staged
883-
the condition. A condition the controller stages by hand is owned only if its type belongs to a passed component;
884-
otherwise, as with an owner-level aggregate or the only condition a
885-
[validation-only CRD](#one-write-path-for-the-owners-status) reports, it is unowned. On a conflict an unowned condition
886-
follows the server, and the next reconcile stages it again.
887-
888-
!!! note "An owner-level aggregate is not owned by any component"
889-
890-
A condition the controller stages itself, such as a `Ready` produced by
891-
[`component.Aggregate`](#aggregating-components-into-one-owner-condition), belongs to no component, so a conflict
892-
reverts it to the value the server already holds and the next reconcile stages it again. Passing the components
893-
covers their own conditions; the aggregate is recomputed from them each pass anyway.
894-
895-
For unowned types the server is the source of truth, absence included. An unowned condition the server no longer
896-
carries is dropped from the staged owner rather than written back, so a condition another writer removed is not
897-
resurrected. A condition the server has never held, such as the first write of the aggregate, is dropped for the
898-
same reason and staged again on the next reconcile.
899-
900-
"The next reconcile" depends on one happening. With controller-runtime's defaults, every update to the owner
901-
triggers a reconcile, status-only ones included, so the successful write itself re-queues the owner and the aggregate
902-
is restaged promptly. A controller that filters status-only updates out of its watch, for example with a
903-
generation-based predicate, does not get that re-queue and the aggregate lags until the next reconcile from another
904-
source.
905-
906-
After a successful update, `FlushStatus` records metrics for every condition on the owner. If `Metrics` is `nil`,
907-
recording is skipped. See [Metrics](#metrics).
860+
After a successful update, `FlushStatus` records metrics for every condition on the owner. A failed update records none.
861+
If `Metrics` is `nil`, recording is skipped. See [Metrics](#metrics).
908862

909863
This split is what lets a controller with several components stage several conditions during one reconcile and persist
910864
them in a single write. Persisting after each component would race the components' writes and produce 409 conflicts. See
911865
[Keep Controllers Thin](guidelines.md#keep-controllers-thin) and
912866
[One Component Per Logical Condition](guidelines.md#one-component-per-logical-condition).
913867

868+
### Requeue on a conflict without a reconcile error
869+
870+
A returned conflict is logged as a reconciler error. A conflict is normal when other writers share the owner, so a
871+
controller can requeue it quietly instead. In the deferred call, check for a conflict and return a short `RequeueAfter`
872+
with a `nil` error:
873+
874+
```go
875+
defer func() {
876+
flushErr := component.FlushStatus(ctx, recCtx)
877+
switch {
878+
case flushErr == nil:
879+
case err != nil:
880+
// Reconcile already returns an error, and that error requeues the owner.
881+
case apierrors.IsConflict(flushErr):
882+
res, err = reconcile.Result{RequeueAfter: time.Second}, nil
883+
default:
884+
err = flushErr
885+
}
886+
}()
887+
```
888+
889+
The deferred call sets the named results, so declare them as `(res reconcile.Result, err error)`. Use `RequeueAfter`,
890+
not `Requeue`, which is deprecated as of controller-runtime v0.25.
891+
914892
### One write path for the owner's status
915893

916894
A CR's status is written once per reconcile, through `FlushStatus`. This rule also holds for a controller that builds no
@@ -934,12 +912,10 @@ func (r *PolicyReconciler) Reconcile(ctx context.Context, req reconcile.Request)
934912
}
935913
meta.SetStatusCondition(policy.GetStatusConditions(), cond)
936914
937-
// nil: this controller owns no component conditions. On a conflict the staged
938-
// condition follows the server and is written again on the next reconcile.
939915
return reconcile.Result{}, component.FlushStatus(ctx, component.ReconcileContext{
940916
Client: r.Client,
941917
Owner: policy,
942-
}, nil)
918+
})
943919
}
944920
```
945921

‎docs/getting-started.md‎

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -266,22 +266,22 @@ self-induced update conflicts.
266266

267267
`ReconcileContext` has six fields:
268268

269-
| Field | Type | Notes |
270-
| --------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
271-
| `Client` | `client.Client` | The controller-runtime client. |
272-
| `Scheme` | `*runtime.Scheme` | The operator scheme. |
273-
| `EventRecorder` | `events.EventRecorder` | For Kubernetes events. `GetEventRecorder(name)` on the manager, v0.23 and later. |
274-
| `Metrics` | `component.MetricsRecorder` | Optional. `metrics.NewRecorder(...)` records condition and resource metrics; pass `nil` to skip both. See [Metrics](component.md#metrics). |
275-
| `APIReader` | `client.Reader` | Optional, recommended. `GetAPIReader()` on the manager; `FlushStatus` reads through it on a 409 so the retry sees the live owner, not the informer cache. |
276-
| `Owner` | `component.OperatorCRD` | The owner object you fetched. Your CRD satisfies this via `GetStatusConditions`. |
269+
| Field | Type | Notes |
270+
| --------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
271+
| `Client` | `client.Client` | The controller-runtime client. |
272+
| `Scheme` | `*runtime.Scheme` | The operator scheme. |
273+
| `EventRecorder` | `events.EventRecorder` | For Kubernetes events. `GetEventRecorder(name)` on the manager, v0.23 and later. |
274+
| `Metrics` | `component.MetricsRecorder` | Optional. `metrics.NewRecorder(...)` records condition and resource metrics; pass `nil` to skip both. See [Metrics](component.md#metrics). |
275+
| `APIReader` | `client.Reader` | Optional, recommended. `GetAPIReader()` on the manager; the component reads live objects through it instead of the informer cache. |
276+
| `Owner` | `component.OperatorCRD` | The owner object you fetched. Your CRD satisfies this via `GetStatusConditions`. |
277277

278278
!!! note
279279

280-
`FlushStatus` performs the status write itself: it calls `Client.Status().Update` on the owner (retrying on
281-
conflict). Do not also call `Status().Update` for the conditions the framework manages, or you will double-write.
282-
Because the call is deferred, the conditions are flushed even if `Reconcile` returns an error. Pass the components
283-
you built: on a conflict their condition types stay on the staged owner, while every other condition is refreshed
284-
from the server.
280+
`FlushStatus` performs the status write itself: it calls `Client.Status().Update` on the owner once. Do not also
281+
call `Status().Update` for the conditions the framework manages, or you will double-write. Because the call is
282+
deferred, the conditions are flushed even if `Reconcile` returns an error. If another writer updated the owner after
283+
you fetched it, the write fails with a conflict and nothing is written. The error requeues the owner, and the next
284+
reconcile starts from the current object.
285285

286286
```go
287287
package app
@@ -300,7 +300,7 @@ type Controller struct {
300300
Scheme *runtime.Scheme
301301
EventRecorder events.EventRecorder
302302
Metrics component.MetricsRecorder
303-
APIReader client.Reader // from mgr.GetAPIReader(); direct reads on a status conflict
303+
APIReader client.Reader // from mgr.GetAPIReader(); direct reads of live objects
304304

305305
NewDeploymentResource func(*ExampleApp) (component.Resource, error)
306306
NewConfigMapResource func(*ExampleApp) (component.Resource, error)
@@ -315,10 +315,8 @@ func (r *Controller) reconcile(ctx context.Context, owner *ExampleApp) (err erro
315315
APIReader: r.APIReader,
316316
Owner: owner,
317317
}
318-
// Declared before the deferred flush so the closure sees the component built below.
319-
var comps []*component.Component
320318
defer func() {
321-
if flushErr := component.FlushStatus(ctx, recCtx, comps); flushErr != nil && err == nil {
319+
if flushErr := component.FlushStatus(ctx, recCtx); flushErr != nil && err == nil {
322320
err = flushErr
323321
}
324322
}()
@@ -343,8 +341,6 @@ func (r *Controller) reconcile(ctx context.Context, owner *ExampleApp) (err erro
343341
if err != nil {
344342
return err
345343
}
346-
comps = append(comps, comp)
347-
348344
return comp.Reconcile(ctx, recCtx)
349345
}
350346
```

0 commit comments

Comments
 (0)