Repository navigation
fix(component)!: stop FlushStatus from writing stale status back on a conflict - #241
Conversation
… conflict 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>
There was a problem hiding this comment.
🟢 Approval recommended
The implementation, caller migrations, regression coverage, and documentation consistently support the intended conflict behavior, with no blocking issues identified.
No open issues 🎉
What changed in this PR
Fixes #238 by removing FlushStatus conflict retries, preventing stale status from overwriting another controller’s updates.
Changes:
- Removes the
compsargument and returns wrapped errors after one status update. - Adds regression coverage for conflicts, shared status, and metrics.
- Migrates callers and documents conflict-driven requeueing.
| File | Description |
|---|---|
| plugin/skills/structuring-operators/SKILL.md | Updates controller and aggregate guidance. |
| plugin/skills/structuring-operators/references/guidelines.md | Syncs revised reconciliation guidance. |
| plugin/skills/building-components/SKILL.md | Explains single-write conflict handling. |
| plugin/skills/building-components/references/component.md | Syncs updated status documentation. |
| plugin/agents/reviewer.md | Updates conflict-handling review checks. |
| pkg/component/zz_mock_crd_test.go | Clarifies the test status field’s purpose. |
| pkg/component/flush_status_test.go | Adds conflict and shared-status regression tests. |
| pkg/component/conditions_test.go | Removes obsolete retry tests; relocates flush tests. |
| pkg/component/component.go | Removes retries and simplifies FlushStatus. |
| pkg/component/component_test.go | Migrates test calls. |
| pkg/component/aggregate.go | Corrects status-persistence GoDoc. |
| examples/mutations-and-gating/app/controller.go | Migrates flush calls and APIReader comments. |
| examples/grace-inconsistency/app/controller.go | Migrates flush calls and APIReader comments. |
| examples/extraction-and-guards/app/controller.go | Migrates flush calls and APIReader comments. |
| examples/custom-resource/app/controller.go | Migrates flush calls and APIReader comments. |
| examples/component-prerequisites/app/controller.go | Removes obsolete component ownership tracking. |
| e2e/framework/reconciler.go | Migrates the namespaced test reconciler. |
| e2e/framework/cluster_reconciler.go | Migrates the cluster-scoped test reconciler. |
| docs/guidelines.md | Updates conflict and condition-retirement guidance. |
| docs/getting-started.md | Updates introductory API usage. |
| docs/component.md | Documents single-write behavior and optional quiet requeueing. |
Review effort: Balanced
|
Review round 1: the Copilot review of aa3fae8 has no posted comments and no suppressed comments, so there is nothing to change. The review body states Comment check against
The old text of these comments said that FlushStatus reads through APIReader on a conflict. That is false now, so it was removed. The other removed comments explained the deleted conflict retry, ownership code and tests, and they went with that code. CI is green, e2e included. |
Description
Fixes #238
On a 409 Conflict,
FlushStatuswrote the stale owner again with a freshresourceVersion, so it rolled back status fields that another controller had written after the read. This PR removes the retry:FlushStatusmakes oneStatus().Update, and on a conflict it writes nothing and returns the error. Callers now see a conflict as a reconcile error and a requeue, andFlushStatusloses itscompsargument, which is a breaking change.Changes
FlushStatus(ctx, rec, comps)is nowFlushStatus(ctx, rec). To migrate, remove the third argument at each call site. Nothing else changes for a caller.FlushStatusmakes one status update. It returns any error wrapped with%w, soapierrors.IsConflictstill reportstruefor a conflict. Condition metrics are recorded only after a successful write, as before.APIReader.ReconcileContext.APIReaderdoc now names what still uses it: the live reads forBlockOnForeignControllerandDeleteOnlyIfOwned.FlushStatusGoDoc,docs/component.md,docs/getting-started.md,docs/guidelines.md, the plugin skills, the reviewer agent, the examples and the e2e reconcilers are updated. Text about owned conditions, unowned conditions that revert on a conflict, and the two-step removal of a retired component's condition is removed.docs/component.mdadds a short optional pattern: in the deferred call, turn a conflict intoRequeueAfterwith anilerror, so it is not logged as a reconciler error. It is documentation only, with no new framework API.Challenges
Why removing the retry fixes #238 for every controller, with no list of owned fields: the stale write was the retry itself. The first
Status().Updatecarries theresourceVersionfrom the read at the top of the reconcile, so the API server rejects it when anyone else wrote in between. Without a retry, nothing stale lands. The next reconcile starts from the current object, so it writes back only what it computes itself, plus the other writers' values exactly as the server holds them. The first acceptance criterion of #238 ("declare owned fields, or write only owned paths") is not needed, because nothing stale is written. The retry came in as a convenience, so that an external write would not fail the reconcile. The ownership machinery existed only to make that retry safe, so it goes with it.Related
Testing
pkg/component/flush_status_test.gorun the FlushStatus conflict retry writes back stale non-condition status fields of other writers #238 scenario against a real API server. The test reads the owner. A second writer then setsstatus.observedGenerationand aReadycondition. A flush of the stale owner returns an error for whichapierrors.IsConflictis true. The server still holds the second writer's values, the staleAcceptedcondition did not land, and no condition metrics were recorded. A second spec then fetches fresh, stagesAcceptedagain and flushes. The server holds the controller'sAcceptedand the other writer'sReadyandobservedGeneration.make allpasses, and so doesmake run-examples. The e2e reconcilers already fetch the owner on every reconcile, and no e2e spec checks for reconcile errors. A conflict now causes one more requeue, which theEventuallyassertions absorb. I did not run e2e locally, because CI runs it.🤖 Generated with Claude Code