fix: avoid stale resourceVersion errors in ServiceL2Status and ServiceBGPStatus reconcile - #3079
Conversation
There was a problem hiding this comment.
Code Review
This pull request addresses a reconcile-loop regression (#3063) in the Layer2StatusReconciler. It modifies the controller to reference existing ServiceL2Status objects by identity (name and namespace) rather than reusing the listed object directly, which prevents CreateOrPatch from failing with a stale resourceVersion error when an object is concurrently deleted. Additionally, a regression test has been added to verify this behavior. There are no review comments, so I have no feedback to provide.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
There was a problem hiding this comment.
same bug exists for bgp_status_controller.go, might be worth fixing as well
There was a problem hiding this comment.
Good catch — bgp_status_controller.go had the exact same pattern: state = &serviceBGPStatuses.Items[0] carried the listed object's resourceVersion into the CreateOrPatch create path. Fixed it the same way in 3b7e3a9 — reference the existing status by identity only (Name+Namespace) and capture the observed status separately for the no-op short-circuit. Added a deterministic reproducer (bgp_status_controller_test.go) mirroring the L2 one; it fails with the exact resourceVersion should not be set on objects to be created error before the fix and passes after. Thanks for flagging it.
3b7e3a9 to
e999bad
Compare
|
Rebased onto latest |
e999bad to
f2c04fd
Compare
|
@yahlifried @oribon — could I ask for a re-run of the two failing e2e legs (
Separately, is Happy to rebase if you would like, but since the one commit this is behind ( |
f2c04fd to
0d3d52c
Compare
oribon
left a comment
There was a problem hiding this comment.
the code change (and test) looks good to me for avoiding resourceVersion should not be set on objects to be created scenarios, but I don't understand in particular why this solves the issue (or why the described scenario causes an "infinite loop"). can you please explain?
| // Before the fix, the reconciler selected the listed object (carrying its | ||
| // resourceVersion) as the CreateOrPatch target; once it was gone, CreateOrPatch | ||
| // took the create path with a stale resourceVersion and failed with | ||
| // "resourceVersion should not be set on objects to be created", looping forever. |
There was a problem hiding this comment.
not sure I understand this part. can you elaborate why the previous version looped forever?
There was a problem hiding this comment.
Fair — "looping forever" is loose wording in that comment, and I've explained the mechanism in full in a top-level comment. The short version for this line:
The failing reconcile is side-effect-free. It creates nothing, patches nothing, and leaves both the cluster and the informer cache exactly as they were. controller-runtime requeues on the returned error, the reconciler lists again, selects the same object with the same dead resourceVersion, and fails at the same line with identical inputs. Nothing in the cycle transitions state, so nothing can end it — it only escapes if the cache catches up, and under multi-speaker leader churn that window keeps reopening.
So it is a livelock, not a literal infinite loop. If it reads better, I'm happy to reword this comment to:
// Before the fix, the reconciler selected the listed object (carrying its
// resourceVersion) as the CreateOrPatch target; once it was gone, CreateOrPatch
// took the create path with a stale resourceVersion and failed with
// "resourceVersion should not be set on objects to be created". The attempt had
// no side effects, so every requeue retried it with identical inputs and the
// reconcile never converged.|
Thanks for taking a look, @oribon — happy to explain. Short version: the race was always there. What the old code did was make it unrecoverable, so every retry reproduced the identical failure and no progress was ever made. Why the old code failedIn the leader branch the reconciler picked its for _, item := range serviceL2statuses.Items {
if item.Labels[LabelAnnounceNode] == r.NodeName && state == nil {
state = &item // carries Name, Namespace, UID *and* ResourceVersion
Those are exactly the two errors #3063 reports alternating: one race, observed from either side of the cache lag. Why it doesn't stopThis is the part worth being precise about — it is a livelock rather than a literal The reconcile returns an error, so controller-runtime requeues it. On the requeue the reconciler lists again and — as long as the informer cache still serves the deleted status — selects the same object with the same dead The decisive property is that the failing attempt is completely side-effect-free: nothing is created, nothing is patched, and neither the cluster nor the cached object changes. So the next attempt runs with byte-identical inputs and produces a byte-identical failure. There is no state transition anywhere in the cycle that could let it exit — it can only escape if the cache happens to catch up. Under multi-speaker leader churn the window keeps reopening, so in practice it doesn't: hence the sustained ~5–6 errors/sec per service in the reporter's 3-node repro, and 859 errors in ~7 minutes in the downstream report (spatiumnorth/spatiumddi#284). That is also why it is HA-specific — with a single speaker there is no concurrent deleter, the window never opens, and the same code path is fine. Why the change fixes itThe fix stops reusing the listed object as the write target and builds one carrying identity only: state = &v1beta1.ServiceL2Status{
ObjectMeta: metav1.ObjectMeta{Name: item.Name, Namespace: item.Namespace},
}The same race now resolves on the first pass:
What the test pins down
Happy to fold any of this into the commit message or an in-tree comment if you'd like it recorded there. |
|
I understand why the 2 scenarios you described are solved by the fix, what I don't understand is why we would get so many logs like mentioned in the issue (and repeatedly), other than "churn" and "cache lag" (which sounds a bit odd, because the issue mentions that this is easily reproducible). |
|
Fair question, and I think my earlier answer was wrong to lean on "churn". You're right that a race doesn't square with mzac reporting it as 100% reproducible from startup. I dug into the v0.15.3 -> v0.16.0 boundary. The only commit touching this file in that range is c501ad7 ("L2Status controller: refactor to leader node manages resources"), and it changed the object's lifecycle in a way I think explains the volume better than a race does: v0.15.3 adopted whatever status existed for the service, with no label check: if len(serviceL2statuses.Items) > 0 {
state = &serviceL2statuses.Items[0]
}so one object per service was reused and patched in place across leader changes. Stable identity. v0.16.0 only adopts when That reframes this PR: the stale resourceVersion isn't what generates the churn, it's what makes the churn unrecoverable. So I'd say there are two things here, and this PR only fixes the second:
I haven't been able to prove from the code alone that (1) never converges with a single stable leader, so I don't want to overstate it. mzac's "starts the instant the speakers come up" suggests the leader view is still settling at that point. mzac offered to run a #3079 build against the 3-node repro, which would tell us whether the errors stop entirely or just stop being fatal. That seems like the cheapest way to settle it, and it also tells you whether (1) needs its own fix. Happy to narrow this PR to (2) and open a separate issue for (1) if you'd prefer them split. |
I'm not sure that's correct, but I do agree it turns each churn event into several unnecessary errors instead of recovering in one pass, and that's what I think this PR should be about. |
0d3d52c to
94a39e8
Compare
|
Agreed, and thanks for pushing back on it — reframed the PR to be only about the stale resourceVersion handling.
The tests are unchanged in substance. Reverting only the two controller Also rebased onto current main. |
|
The code changes look good. The comments are way too verbose though, both in the controllers and the tests. The production code comments (8-10 lines each) should be a single line like |
Signed-off-by: somaz <genius5711@gmail.com>
Signed-off-by: somaz <genius5711@gmail.com>
94a39e8 to
90d9c97
Compare
|
Trimmed as suggested. The production comments are now the one-liner you proposed, and the test comments are down to the two non-obvious ones (the missing field index, and why a second reconcile is needed) — the rest now rides on the spec name and the PR description. Also dropped the "churn"/"loop" wording everywhere, including the two commit messages and the test identifiers, so nothing in the code claims this is a reconcile-loop fix. No logic changed — the diff against the previous push is comments and naming only. 10/10 specs still pass on envtest 1.34.1, |
Is this a BUG FIX or a FEATURE ?:
/kind bug
What this PR does / why we need it:
Both status controllers reuse the object returned by the
Listas the write target forcontrollerutil.CreateOrPatch:That object carries the
resourceVersionit had when it was listed. If the status is deleted between theListand theCreateOrPatch— for example by another speaker that briefly became leader and deletes other nodes' statuses —CreateOrPatchtakes theGet → NotFound → Createpath with that staleresourceVersionstill set, and the API server rejects the create:So the reconcile returns an error instead of re-creating the status. Each such event produces several unnecessary errors before the state settles, rather than recovering in a single pass.
Fix: build the write target from identity only (
Name+Namespace, noresourceVersion). The create path then runs with nothing stale set and succeeds on the first pass.CreateOrPatchre-populates the object viaGetbefore patching, so the non-racy path is unchanged. The observed status is captured separately asobservedStatusbecause the steady-state no-op short-circuit previously read it fromstate.Status; without that, theDeepEqualcomparison would never match and every reconcile would write.The leader-manages behavior (deleting other nodes' / redundant statuses) is untouched.
Special notes for your reviewer:
Scope: this is only about handling stale resourceVersions. It deliberately does not claim to explain or fix everything reported in #3063 — that discussion is unresolved and this PR no longer asserts a root cause for it. Related to #3063.
Added a deterministic regression test per controller. Each wraps the client to (1) emulate the manager field index the reconciler lists by, which envtest's direct client does not provide, and (2) delete the owned status right after the
List, reproducing the window betweenListandCreateOrPatch.Verified locally (envtest, k8s 1.34.1):
go test -short ./internal/k8s/controllers/...— 10/10 specs pass, 0 skipped..gofiles tomainand re-running makes both new specs fail with exactlyresourceVersion should not be set on objects to be created, so the tests do pin this bug rather than passing vacuously.gofmtclean,go vetclean.golangci-lint run ./internal/k8s/controllers/...— 0 issues. Note: run with 2.12.2; the repo pins 2.11.4 intasks.pyand I could not run the pinned container locally, so CI is the authority on that one.Rebased onto current
main.Release note: