fix(managed): requeue absent observe-only resources after the poll interval - #1088
Conversation
…terval An observe-only managed resource whose external object does not exist is a stable state, not a transient failure: nothing the reconciler does can resolve it, only the external object appearing. Returning Requeue: true routed it through the exponential failure rate limiter (1s base, 60s ceiling), so it was retried on an error-backoff schedule forever and the configured poll interval — including the per-resource poll-interval annotation — had no effect on it. Requeue after the effective poll interval instead, using the same idiom as the other steady-state paths so the jitter hook and annotation override apply. The Warning event and the ReconcileError condition are unchanged, so operators keep full visibility that the resource is missing. The genuine Observe-error branch is deliberately left rate limited. Signed-off-by: Erik Miller <erik.miller@gusto.com>
📝 WalkthroughWalkthroughThe observe-only missing-resource path now persists its error condition and schedules reconciliation after the configured poll interval. Legacy and modern tests distinguish this behavior from rate-limited retries for external observation errors. ChangesObserve-only polling retry behavior
Estimated code review effort: 2 (Simple) | ~10 minutes Suggested reviewers: Important Pre-merge checks failedPlease resolve all errors before merging. Addressing warnings is optional. ❌ Failed checks (1 error, 1 warning)
✅ Passed checks (3 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
pkg/reconciler/managed/reconciler_legacy_test.go (1)
1671-1716: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAdd coverage for jitter and per-resource poll-interval overrides.
Both observe-only missing-resource cases assert only the configured base interval. Add cases covering the interval hook/jitter and a per-resource override; otherwise a regression that drops those calculations could still pass in both suites.
pkg/reconciler/managed/reconciler_legacy_test.go#L1671-L1716: extend the legacy observe-only case or add a focused variant covering the hook and override.pkg/reconciler/managed/reconciler_modern_test.go#L1677-L1722: add the equivalent modern-suite coverage.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@pkg/reconciler/managed/reconciler_legacy_test.go` around lines 1671 - 1716, Extend the observe-only missing-resource coverage in pkg/reconciler/managed/reconciler_legacy_test.go:1671-1716 and add equivalent coverage in pkg/reconciler/managed/reconciler_modern_test.go:1677-1722. Configure each test to exercise the interval hook/jitter and a per-resource poll-interval override, then assert RequeueAfter uses the calculated overridden interval rather than only the base WithPollInterval value; preserve the existing conditioned status error assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@pkg/reconciler/managed/reconciler_legacy_test.go`:
- Around line 1671-1716: Extend the observe-only missing-resource coverage in
pkg/reconciler/managed/reconciler_legacy_test.go:1671-1716 and add equivalent
coverage in pkg/reconciler/managed/reconciler_modern_test.go:1677-1722.
Configure each test to exercise the interval hook/jitter and a per-resource
poll-interval override, then assert RequeueAfter uses the calculated overridden
interval rather than only the base WithPollInterval value; preserve the existing
conditioned status error assertions.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 6d11ba87-ffa5-42fb-a8b1-0c32b6de4e75
📒 Files selected for processing (3)
pkg/reconciler/managed/reconciler.gopkg/reconciler/managed/reconciler_legacy_test.gopkg/reconciler/managed/reconciler_modern_test.go
Description of your changes
Problem
A managed resource with
spec.managementPolicies: [Observe]whose external objectdoes not exist is re-observed on the error backoff schedule rather than on the
configured poll interval. Because the condition is a stable state — nothing the
reconciler does can resolve it; only the external object appearing can — the
resource is retried indefinitely at the rate limiter's ceiling, and the operator's
configured poll interval has no effect on it whatsoever.
For a provider whose upstream API enforces a shared per-installation rate limit,
a handful of absent observe-only resources can consume a meaningful share of the
budget and starve unrelated reconciles.
Evidence
pkg/reconciler/managed/reconciler.goreturnedRequeue: truefor this branch:Requeue: truecauses controller-runtime toAddRateLimited, andpkg/controller/options.go'sForControllerRuntime()hardcodesRateLimiter: ratelimiter.NewController()— which isSo the retry schedule is 1s, 2s, 4s, 8s, 16s, 32s, 60s, 60s, …. The queue item
is never
Forget()-ten while the condition persists, so it pins at the 60s ceilingforever.
Two consequences, both independent of how the interval is configured:
--poll-intervalnor the per-resource poll-interval annotation(
effectivePollInterval) influences this path at all. Providers commonlyconfigure 10m; the resource was polled at 60s regardless.
1s→32s burst is pure waste: no amount of prompt retrying can make an
external object exist.
Note for reviewers:
defaultPollIntervalis1 * time.Minute(
reconciler.go:52), which coincides with the limiter's 60s ceiling. With stockdefaults the steady-state cadence is therefore unchanged. The wins are (a) the
initial 1s–32s retry burst is eliminated, (b) an operator-configured poll interval
is now actually honored, and (c) the per-resource poll-interval annotation now
works on this path.
This also aligns the branch with controller-runtime's own guidance.
Result.Requeueis deprecated as of the version in
go.mod(v0.23.1), and its doc commentdescribes precisely this case:
The change
Requeue after the poll interval, using the same idiom as every other steady-state
path in this reconciler (so the jitter hook and the per-resource annotation
override both apply):
The
errors.Wrap(updateStatus(), errUpdateManagedStatus)return is retaineddeliberately: if the status update itself fails, that is a transient error and
should still back off. controller-runtime discards
Resultwhenerr != nil, sothis matches the contract used by the other
RequeueAfterpaths in this file.Why the event and condition are retained
Unchanged, byte for byte: the
reasonCannotObserveWarning event and theReconcileErrorcondition (Synced=Falsewithexternal resource does not exist). Operators must not lose visibility that an observe-only resource ismissing — this PR changes only the cadence of the retry, not its diagnostics.
Anything alerting on the condition or the event is unaffected.
Blast radius
Confined to the single
!observation.ResourceExists && policy.ShouldOnlyObserve()branch.
errExternalResourceNotExisthas no other non-test reference in the repo.within the poll interval — the same guarantee already relied upon for drift
detection on an observe-only resource that does exist.
crossplane.io/reconcile-requeststill forces an immediate reconcile for operators who want to poll now
(covered by
TestReconcileRequestAnnotation).external.Observestill returnsRequeue: trueand still backs off, which iscorrect: that is a transient failure.
any other management policy.
Tests
Extended the existing table-driven cases in both the modern and legacy suites
rather than adding parallel ones. Both cases now configure
WithPollInterval(10 * time.Minute), which makes the pair a controlledexperiment on the same input:
ObserveOnlyResourceDoesNotExist— assertsRequeueAfter: 10m(wasRequeue: true), and continues to assert theReconcileErrorcondition viathe existing
MockStatusUpdate.ExternalObserveError— still assertsRequeue: truedespite the sameconfigured poll interval, proving the fix is surgical and the error path still
rate-limits.
Verified the assertion discriminates: with the test changes applied but the
source change reverted, both
ObserveOnlyResourceDoesNotExistcases fail with-RequeueAfter: s"10m0s" / +RequeueAfter: s"0s", while bothExternalObserveErrorcases pass. With the source change, the full package andthe full repo suite pass, along with
go vetandgofmt -l.I have:
RunNix is./nix.sh flake checkto ensure this PR is ready for review.not available in my environment. I ran instead, all clean:
go build ./...,go test ./...(full repo),go test -race ./pkg/reconciler/managed/...,go vet ./pkg/reconciler/managed/...,gofmt -l(repo-wide), andgolangci-lint run ./pkg/reconciler/managed/...against the repo's.golangci.yml(0 issues). Happy to rerun under Nix if that gate catchessomething these did not.
Linked a PR or a docs tracking issue to [document this change].Thischanges retry cadence for one internal branch, with no API or option
change. Glad to file a docs tracking issue if you consider the cadence
change user-visible enough to warrant one.
AddedDeferringbackport release-x.ylabels to auto-backport this PR.the backport decision to maintainers; I can add labels if you tell me which
release branches you want this in.
Opened as a draft: happy to adjust the approach before review if you would rather
solve this differently (for example by making the rate limiter configurable
instead).
🤖