Skip to content

fix: avoid stale resourceVersion errors in ServiceL2Status and ServiceBGPStatus reconcile - #3079

Merged
oribon merged 2 commits into
metallb:mainfrom
somaz94:fix/l2status-reconcile-loop
Aug 5, 2026
Merged

oribon merged 2 commits into
metallb:mainfrom
somaz94:fix/l2status-reconcile-loop

Conversation

@somaz94

@somaz94 somaz94 commented Jun 24, 2026 •

Copy link
Copy Markdown
Contributor

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 List as the write target for controllerutil.CreateOrPatch:

state = &item                         // layer2_status_controller.go
state = &serviceBGPStatuses.Items[0]  // bgp_status_controller.go

That object carries the resourceVersion it had when it was listed. If the status is deleted between the List and the CreateOrPatch — for example by another speaker that briefly became leader and deletes other nodes' statuses — CreateOrPatch takes the Get → NotFound → Create path with that stale resourceVersion still set, and the API server rejects the create:

resourceVersion should not be set on objects to be created

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, no resourceVersion). The create path then runs with nothing stale set and succeeds on the first pass. CreateOrPatch re-populates the object via Get before patching, so the non-racy path is unchanged. The observed status is captured separately as observedStatus because the steady-state no-op short-circuit previously read it from state.Status; without that, the DeepEqual comparison 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 between List and CreateOrPatch.

Verified locally (envtest, k8s 1.34.1):

  • go test -short ./internal/k8s/controllers/... — 10/10 specs pass, 0 skipped.
  • Reverting only the two controller .go files to main and re-running makes both new specs fail with exactly resourceVersion should not be set on objects to be created, so the tests do pin this bug rather than passing vacuously.
  • gofmt clean, go vet clean.
  • golangci-lint run ./internal/k8s/controllers/... — 0 issues. Note: run with 2.12.2; the repo pins 2.11.4 in tasks.py and I could not run the pinned container locally, so CI is the authority on that one.

Rebased onto current main.

Release note:

Fixed the ServiceL2Status and ServiceBGPStatus controllers returning "resourceVersion should not be set on objects to be created" instead of re-creating the status when it was deleted while a reconcile was in flight.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same bug exists for bgp_status_controller.go, might be worth fixing as well

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@somaz94 somaz94 changed the title fix: avoid stale resourceVersion churn in ServiceL2Status reconcile (#3063) fix: avoid stale resourceVersion churn in ServiceL2Status and ServiceBGPStatus reconcile (#3063) Jun 25, 2026
@somaz94
somaz94 force-pushed the fix/l2status-reconcile-loop branch from 3b7e3a9 to e999bad Compare July 2, 2026 02:31
@somaz94

somaz94 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto latest main to pick up the go 1.25.11 / x/net v0.55.0 bump (5be638f0) — that clears the static-security-analysis govulncheck failures, which were stale-toolchain findings unrelated to this PR's changes. The bgp_status_controller.go case @yahlifried mentioned is already handled in 3b7e3a9f with a deterministic reproducer test. PTAL — thanks!

@somaz94
somaz94 force-pushed the fix/l2status-reconcile-loop branch from e999bad to f2c04fd Compare July 24, 2026 06:58
@somaz94

somaz94 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

@yahlifried @oribon — could I ask for a re-run of the two failing e2e legs (e2e (ipv6, frr, manifests) and e2e (ipv6, frr-k8s, manifests)) on run 30073923459? I believe both are flakes rather than regressions from this PR:

  • The failing specs do not touch what this PR changes. grep -niE "ServiceBGPStatus|ServiceL2Status" over unnummbered.go, service_selector.go and validate.go returns zero hits, while every spec that does exercise those status CRs (l2tests/l2.go, l2tests/interface_selector.go, bgptests/bgp.go) passed.
  • The twin legs of the same modes passed in the same run: e2e (ipv6, frr, helm) and e2e (ipv6, frr-k8s, helm) are both green.
  • Across this branch's three CI runs a different spec failed each time, and one run had e2e fully green — not the shape of a deterministic regression.
  • Both failures are timeouts (timed out after 30s waiting for a route; timed out after 240s with 1 of 3 nodes not converging into VRF red), i.e. convergence timing.

Separately, is static-security-analysis a required check here? It looks repo-wide rather than PR-specific — main has been red on it consistently, the workflow installs the scanner unpinned (go install golang.org/x/vuln/cmd/govulncheck@latest), and the reported vulns are all in indirect deps this PR does not touch. This same branch was green on that leg on 2026-07-05 with essentially the same diff.

Happy to rebase if you would like, but since the one commit this is behind (5fb56ec8) only addresses 1 of the 3 reported vulns, I do not think it would clear the scan on its own. Let me know if you would prefer a different approach.

@somaz94
somaz94 force-pushed the fix/l2status-reconcile-loop branch from f2c04fd to 0d3d52c Compare July 30, 2026 02:59

@oribon oribon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment on lines +135 to +138
// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure I understand this part. can you elaborate why the previous version looped forever?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@somaz94

somaz94 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

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 failed

In the leader branch the reconciler picked its CreateOrPatch target straight out of the List result:

for _, item := range serviceL2statuses.Items {
    if item.Labels[LabelAnnounceNode] == r.NodeName && state == nil {
        state = &item   // carries Name, Namespace, UID *and* ResourceVersion

controllerutil.CreateOrPatch begins with a Get on that object, and both outcomes are broken here:

  • Get returns NotFound — another speaker briefly became leader and deleted our status between the List and this call. CreateOrPatch then takes the create path and calls Create(state). But state is still the listed object, so metadata.resourceVersion is populated and the API server rejects it with resourceVersion should not be set on objects to be created. A failed Get does not clear the struct, so nothing along that path ever strips the field.
  • Get succeeds from a lagging informer cache while the object is already gone in etcd — it takes the patch path, and the patch fails with servicel2statuses.metallb.io "l2-<rand>" not found.

Those are exactly the two errors #3063 reports alternating: one race, observed from either side of the cache lag.

Why it doesn't stop

This is the part worth being precise about — it is a livelock rather than a literal for {}, which I think is where the "infinite loop" wording is causing confusion.

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 resourceVersion and fails at exactly the same line.

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 it

The 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: Get returns NotFound, the create path runs with no resourceVersion set, Create succeeds, and the reconcile returns nil. The status exists again, and the following reconcile short-circuits on DeepEqual and writes nothing. The race isn't removed — it's made recoverable in a single pass instead of unrecoverable.

CreateOrPatch re-populates the object via Get before patching, so the non-racy path is unaffected. The only thing dropped is item.Status, which the no-op short-circuit needs — that's why the change keeps it separately as observedStatus instead of reading state.Status. Without that, the comparison would always be false and we'd trade one source of churn for another.

What the test pins down

layer2_status_controller_test.go encodes precisely this sequence: a status is created (so it carries a real resourceVersion), the client is armed to delete it immediately after the List — standing in for the speaker that briefly became leader — and the reconcile must return without error and converge to exactly one owned status for the node. Against the pre-fix code that first Reconcile returns the resourceVersion should not be set on objects to be created error. bgp_status_controller_test.go does the same for the BGP side, which is the case @yahlifried asked about earlier.

Happy to fold any of this into the commit message or an in-tree comment if you'd like it recorded there.

@oribon

oribon commented Aug 3, 2026

Copy link
Copy Markdown
Member

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).
To be clear, I'm fine with the code changes and the scenarios you're describing are good to fix, but I'm not sure I understand the root cause.

@somaz94

somaz94 commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor Author

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 Labels[announceNode] == r.NodeName, deletes anything else, and when nothing was adopted creates a replacement under a fresh GenerateName. So on any label/leader mismatch the object is destroyed and recreated with a new name rather than relabelled. The non-leader path deletes too, and since the controller watches ServiceL2Status, each delete and each create feeds another reconcile.

That reframes this PR: the stale resourceVersion isn't what generates the churn, it's what makes the churn unrecoverable. state carries the resourceVersion of the object the reconcile just observed; if that object is already gone when CreateOrPatch does its Get, the create path runs with a stale resourceVersion and is rejected outright, so that pass can never re-create the object and the cycle repeats instead of settling. That's the alternation in the issue (resourceVersion should not be set... <-> l2-<rand> not found).

So I'd say there are two things here, and this PR only fixes the second:

  1. delete-and-recreate-under-a-new-name on leader/label mismatch (introduced by c501ad7)
  2. stale resourceVersion turning that into a hard, non-recovering failure (this PR)

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.

@oribon

oribon commented Aug 3, 2026

Copy link
Copy Markdown
Member

it's what makes the churn unrecoverable

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.
let's reframe the PR around being what it is (not mentioning infinite loops or whatever), and clean the code to only be around it (and no need for the abundance of comments inside). I'm not entirely sure it would fix what's mentioned in the issue (because the rca isn't convincing), but it's at least a bug deserving a fix (handling stale rvs).

@somaz94
somaz94 force-pushed the fix/l2status-reconcile-loop branch from 0d3d52c to 94a39e8 Compare August 4, 2026 01:35
@somaz94 somaz94 changed the title fix: avoid stale resourceVersion churn in ServiceL2Status and ServiceBGPStatus reconcile (#3063) fix: avoid stale resourceVersion errors in ServiceL2Status and ServiceBGPStatus reconcile Aug 4, 2026
@somaz94

somaz94 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Agreed, and thanks for pushing back on it — reframed the PR to be only about the stale resourceVersion handling.

  • Dropped the root-cause narrative and the loop/flood framing from the title, description and release note. It now describes what you agreed it is: each such event produces several unnecessary errors instead of recovering in one pass.
  • Changed Fixed #3063 to Related to #3063, so it no longer auto-closes an issue this may not fully explain.
  • Trimmed the code to just the fix. The loop restructure in layer2_status_controller.go is reverted, so the only change there is the one branch that builds the write target. Source diff is down to +15/-2 and +14/-2.
  • Cut the comment blocks down to three lines each, and removed the same narrative from the test comments.

The tests are unchanged in substance. Reverting only the two controller .go files still makes both specs fail with exactly resourceVersion should not be set on objects to be created, so they pin the behaviour rather than passing vacuously.

Also rebased onto current main.

@oribon

oribon commented Aug 5, 2026

Copy link
Copy Markdown
Member

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 // avoid carrying a stale resourceVersion into CreateOrPatch. The test comments (struct docs, inline explanations) should be similarly trimmed, the code is self-explanatory enough - let the test name and PR description carry the context.
Also, drop the "churn" and "looping forever" language throughout, this PR is about avoiding stale resourceVersions on concurrent deletes, not about fixing a reconcile loop.

somaz94 added 2 commits August 5, 2026 18:12
Signed-off-by: somaz <genius5711@gmail.com>
Signed-off-by: somaz <genius5711@gmail.com>
@somaz94
somaz94 force-pushed the fix/l2status-reconcile-loop branch from 94a39e8 to 90d9c97 Compare August 5, 2026 09:18
@somaz94

somaz94 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

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, gofmt/go vet clean, golangci-lint 0 issues.

@oribon oribon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks!

@oribon
oribon enabled auto-merge August 5, 2026 09:33
@oribon
oribon added this pull request to the merge queue Aug 5, 2026
Merged via the queue into metallb:main with commit 246685b Aug 5, 2026
30 of 31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants