Skip to content

fix: Stop counting routine waits as reconcile errors - #623

Merged
scotwells merged 3 commits into
mainfrom
claude/project-thread-spic9w
Nov 6, 2026
Merged

scotwells merged 3 commits into
mainfrom
claude/project-thread-spic9w

Conversation

@scotwells

@scotwells scotwells commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Scot Schuchert-Wells · project thread

Several compute controllers report reconcile errors for outcomes that a retry resolves, which inflates the reconcile error rate and puts healthy controllers on the error backoff. The outcomes are a write that lost to a newer version of the object, a wait for a projection or write-back to finish deleting, and a finalizer release on an object that is already gone. This change retries conflicts after a second and polls deletion waits every five seconds without reporting either as a failure, and treats an already-deleted object as done. A real error combined with one of these outcomes is still reported, and every absorbed retry is counted, so genuine failures still surface and stuck waits stay visible.

Details

  • A shared helper applies to the workload, workload deployment (control plane and cell), federator, instance (cell), instance projector and internal DNS publisher reconcilers. It splits aggregated and joined errors at the first combining error on the wrap chain and absorbs them only when every member is routine. A mix of conflicts and removal waits polls on the removal interval.
  • compute_reconcile_routine_retries_total{controller,reason} counts each absorbed retry, so a wait that never ends or a conflict loop shows as a steady rate. An alert on wait age is left as a follow-up.
  • The federator's existing deletion-pending sentinel and 5s poll become the shared ones. The projector's projection wait, its DNS lifetime replacement wait and the cell instance's write-back wait now use them instead of plain errors.
  • The finalizer-release update ignores NotFound, which a stale cache produces after the object has been released.

Test plan

  • New unit tests cover the helper (conflicts, removal waits, mixed and nested aggregates, NotFound passthrough) and the federator's finalizer release racing a stale copy or a deleted object
  • Existing projector tests updated to expect a timed requeue instead of an error
  • Full unit and envtest suite passes locally, and golangci-lint reports no issues
  • Once deployed, the reconcile error rate for these controllers falls while the routine retry counter picks up the same outcomes

🤖 Generated with Claude Code

@scotwells
scotwells requested a review from a team as a code owner November 5, 2026 22:32
@cla-assistant

cla-assistant Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@cla-assistant

cla-assistant Bot commented Nov 5, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@scotwells
scotwells requested a review from kevwilliams November 5, 2026 22:32
@scotwells scotwells self-assigned this Nov 5, 2026
ecv
ecv previously approved these changes Nov 5, 2026

@ecv ecv left a comment

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.

Approve.

settleRoutineErrors only absorbs an error when every member of an aggregate or join is a conflict or the removal-pending sentinel, and a real error alongside either still surfaces; the tests cover mixed aggregate and join cases.

  • Classification is sound for the shapes callers produce today: a wrapped real error, join(conflict, real), agg[conflict, real] and join(pending, real) all surface. Two holes: everyError uses errors.As(err, &agg), which finds the first Aggregate anywhere in the tree, so errors.Join(kerrors.NewAggregate([conflict]), real) or fmt.Errorf("%w; %w", agg, real) classifies as routine and drops real; type-switch on the root's Errors() / Unwrap() []error instead. And a real error stringified with %v next to a %w sentinel is lost by construction. No current controller nests these, so neither is live.
  • A mix of one conflict and one removal-pending in a single aggregate is reported as an error (neither predicate holds for all members). Harmless, but it is the common shape if a Finalize collects both; consider a combined predicate.
  • client.IgnoreNotFound on the post-finalization Update is safe: Updated only means the in-memory finalizer was dropped, and NotFound means the object is already gone, so no cleanup on another object is skipped (hub and downstream cleanup run inside Finalize before this write).
  • A wait that never finishes now polls every 5s forever with no error signal: the pending branch logs at V(1) only, and the backoff that used to grow to minutes is gone. A stuck hub copy or instance projection (including the DNS projection replacement, which previously backed off) shows nothing on the dashboards. Same for a 1s conflict loop. Add a counter or an info log with the elapsed wait, or return the error after a bounded wait.
  • In InternalDNSPublisherReconciler.Reconcile the deferred settle reads ctx after the timeout cancel has fired; harmless for logging, but pass the original context to be safe.

Follow-ups:

  • Expose a reconcile_waits_total{reason} counter and an alert on a wait older than some threshold, so routine waits stay out of the error rate without becoming invisible.

Copy link
Copy Markdown
Contributor Author

Addressed the review in a9eb498 and e7e276b:

  • Combined errors are now split at the first aggregate or join on the wrap chain, rather than at the first aggregate found anywhere in the tree. Both of your examples, a joined nested aggregate beside a real error and %w; %w, now report the real error, and tests cover them. A real error stringified with %v is still lost, as you said, and nothing produces that today.
  • An error that mixes conflicts and removal waits is now routine and polls on the 5s removal interval.
  • Added compute_reconcile_routine_retries_total{controller,reason}, which counts every retry this absorbs, so a stuck wait or a conflict loop shows as a steady rate per controller. I left the wait-age alert as a follow-up.
  • The DNS publisher's deferred settle now captures the context before the timeout wraps it.

Generated by Claude Code

@scotwells
scotwells requested a review from ecv November 5, 2026 23:42

@ecv ecv left a comment

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.

Approve.

Both gaps from my last review are closed at e7e276b: routine retries are now counted, and a real error beside a nested aggregate is reported.

  • everyError now walks the single wrap chain and splits at the first Aggregate or multi-%w/Join it meets, recursing into each member, so errors.Join(agg[conflict], real) and fmt.Errorf("%w; %w", agg[pending], real) both surface real; both cases are in the table test. A chain with no combining error falls back to matching the original error, as before.
  • reconcile_routine_retries_total{controller,reason} is incremented on every absorbed retry, so a wait or conflict loop that never ends shows on dashboards without entering the error rate.
  • A conflict mixed with a removal wait now polls on the 5 s removal interval rather than erroring, and the internal DNS publisher's deferred settle captures the context before the timeout wraps it.

Follow-ups:

  • Add an alert on a sustained reconcile_routine_retries_total rate per controller (for example, a removal wait retrying for over 30 minutes) so a stuck wait pages, not only charts.

Several controllers return errors for outcomes that resolve on retry: a
write that lost to a newer version, a wait on an object's deletion, and a
finalizer release on an object that is already gone. Each one counted as a
reconcile failure and drew the error backoff.

Key changes:
- add a shared outcome helper that turns write conflicts into a 1s retry and
  removal waits into a 5s poll, and reports any real error combined with them
- apply it to the workload, workload deployment, federator, instance,
  instance projector and internal DNS publisher reconcilers
- report the projector's projection and DNS lifetime waits and the cell
  instance's write-back wait as removal pending
- ignore NotFound when releasing a finalizer on an object already deleted
Review on the first pass noted that a wait which never ends polled
silently, and that a real error nested beside an aggregate could be
classified as routine.

Key changes:
- count retried reconciles by controller and reason, so a stuck wait or
  conflict loop shows on dashboards without entering the error rate
- split combined errors at the first aggregate or join on the wrap chain,
  so a real error beside a nested aggregate is reported
- poll on the removal interval when conflicts and removal waits mix
- capture the internal DNS publisher's context before its timeout wraps it
Satisfy goconst by naming the controller and reason labels the routine retry
counter and its logs share.
@scotwells
scotwells force-pushed the claude/project-thread-spic9w branch from e7e276b to 92a14d7 Compare November 6, 2026 19:32
@scotwells
scotwells requested a review from ecv November 6, 2026 19:32
@scotwells
scotwells merged commit 2a4e9e2 into main Nov 6, 2026
11 checks passed
@scotwells
scotwells deleted the claude/project-thread-spic9w branch November 6, 2026 19:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants