fix(kube): reconcile delete targets after watcher sync#32262
Open
thc1006 wants to merge 1 commit into
Open
Conversation
thc1006
force-pushed
the
fix/waitfordelete-flake
branch
from
June 24, 2026 07:37
cccc747 to
223d114
Compare
thc1006
marked this pull request as draft
July 11, 2026 22:31
This comment has been minimized.
This comment has been minimized.
thc1006
force-pushed
the
fix/waitfordelete-flake
branch
from
July 12, 2026 00:45
223d114 to
97b88fd
Compare
This comment has been minimized.
This comment has been minimized.
thc1006
force-pushed
the
fix/waitfordelete-flake
branch
from
July 12, 2026 02:26
97b88fd to
a4611ee
Compare
This comment has been minimized.
This comment has been minimized.
WaitForDelete's delete-path observer treated a target's transient Unknown status as already deleted: it skipped Unknown resources, so during informer sync -- when every target is briefly Unknown -- the set aggregated to NotFound and cancelled the watch before any deletion was confirmed (the helm#32261 flake). Simply not skipping Unknown would instead hang an already-gone resource until the timeout, because the kstatus watcher only reports NotFound from an observed delete event and never emits one for an object absent from its initial LIST (the helm#32214 regression). Start the watcher first and, once it has synced, confirm any still-Unknown target with a metadata.name filtered LIST rather than trusting the Unknown heuristic. A filtered LIST is authorized as the list verb the watcher already requires, so this adds no get permission, unlike a point GET. Each target is confirmed by its own checker goroutine (bounded concurrency), so a slow or stuck LIST for one target does not block another and one target's server Retry-After never defers another's retry; retriable errors use an explicit allowlist and a per-target capped backoff, while permanent and discovery errors fail closed. Retries are bounded only by the wait context, so no arbitrary per-request timeout is imposed on a slow aggregated API. A context error takes precedence over the watch-ended-before-confirmation error, and a deletion already fully confirmed is reported as success even if the deadline fires. Signed-off-by: thc1006 <[email protected]>
thc1006
force-pushed
the
fix/waitfordelete-flake
branch
from
July 12, 2026 03:21
a4611ee to
ef48f27
Compare
This comment has been minimized.
This comment has been minimized.
thc1006
marked this pull request as ready for review
July 12, 2026 11:11
Contributor
Author
|
Marking this ready for formal review. Since the previous Draft update, I addressed the remaining failure-isolation cases:
Both cases now have deterministic regression tests:
The latest The remaining questions are architectural and policy decisions for review rather than known correctness gaps:
One operational detail for reviewers: the implementation creates one checker goroutine per still- |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
WaitForDeletecould either return success before a resource was actually deleted(the
TestStatusWaitForDeleteflake, #32261) or, with the naive fix, hang until--timeouton an already-gone resource (#32214). Both come from the delete pathtrusting a resource's transient
Unknownstatus. This reworks the path to start thewatcher first and, once it has synced, confirm any still-
Unknowntarget with ametadata.name-filtered LIST — an authoritative check instead of a heuristic.Background — what actually goes wrong on
mainThe delete-path observer skips
Unknownresources:During informer sync every target is briefly
Unknown, so they are all skipped, theaggregated status of the (now empty) set is
NotFound, and the watch is cancelled —WaitForDeletereturns success before any deletion is confirmed. That is the #32261flake.
The obvious fix — stop skipping
Unknown— breaks the other direction: the kstatuswatcher only reports
NotFoundfrom an observed delete event, and never emits onefor an object that was already absent from its initial LIST. Such a target stays
Unknownforever and the wait hangs to the timeout (the #32214 regression, which iswhy the earlier attempt was reverted).
So
Unknownon the delete path is ambiguous: it means "already gone" or "stillbeing deleted", and the watcher alone cannot disambiguate the already-gone case.
Change
waitForDeletestarts theStatusWatcherfirst, then drives a smalldeleteReconciler. When the watcher emits itsSyncevent, any target stillUnknownis confirmed with ametadata.name-filtered LIST against the samedynamic client and REST mapper the watch uses. An empty result means the object is
gone; the target is marked confirmed-gone, separately from the collector, and the
wait can complete. The
Unknown-as-deleted heuristic is removed entirely.listverb the watcheralready requires — unlike a point GET, which needs the separate
getverb.Identities scoped to
list/watchbut notget(common for controllers) keepworking.
Unknowntarget is confirmed by its own checkergoroutine, under a bounded concurrency limit, retrying on its own backoff. A slow or
stuck LIST for one target does not block another target's check, and one target's
server
Retry-Afternever defers another target's retry. When the watcher gives atarget a definitive status, that target's in-flight check is cancelled. Retries are
bounded only by the overall wait context — no arbitrary per-request timeout is
imposed, so a slow aggregated API is not prematurely failed.
(throttling, API/server timeouts, service-unavailable, and recognized transport
failures — connection refused/reset, lost HTTP/2 connection, probable EOF, net-level
timeout). The per-target backoff honors a server-requested
Retry-After, capped so asingle large hint cannot consume the wait budget.
the watch-ended-before-confirmation error and is returned discoverable via
errors.Is(diagnostics for still-present resources may accompany it, but thewatch-ended sentinel never does). A deletion that was fully confirmed is reported as
success even if the deadline fires as the last confirmation lands.
The observer performs no API I/O; every cluster read happens in the reconciler, off
the event path.
Tests
pkg/kube/statuswait_test.goadds deterministic coverage:StatusWatchers for the full path: a target absent from the initial LIST isconfirmed gone by the reconcile (not left hanging); a transient error is retried; an
outage longer than the retry window still resolves; the watch closing with an
unconfirmed target returns the sentinel and not a context error; context cancel
during a blocking LIST returns
context.Canceledalone; a parent deadline propagatesas
DeadlineExceeded; one always-failing target does not starve a second; a targetwhose LIST is stuck in flight does not block a second target from being confirmed
concurrently; and one target's long
Retry-Afterdoes not delay another's retry.deleteReconcilerstate-machine unit tests for the invariants that a fakecannot pin down deterministically: a still-
Unknowntarget never signals completion(anti-flake); a live-confirmed-gone target is not resurrected by a stale late
Current; a fully confirmed deletion reports success even under an expired context.Retry-Afterextraction,and the per-target retry wait (including the cap), plus the
resourceGoneLISTclassification and its defensive field-selector check.
go test -race -count=100 -run 'TestStatusWaitForDelete|TestDeleteReconciler' ./pkg/kube/is stable.Manual verification (local, not CI)
The dynamic fake ignores
FieldSelector, so the LIST path was also verified on areal API server (a disposable kind cluster), not in CI. A matrix confirmed
gone-detection for namespaced built-ins, a cluster-scoped resource, a CRD, an absent
object, and a same-named decoy — and, crucially, that an identity with
list/watchbut not
get(kubectl auth can-ireports list=yes, watch=yes, get=no) completesWaitForDelete, where a point GET would beForbidden. These runs are local evidence;no kind/integration job is wired into CI in this PR.
Review requested
Two points I'd like maintainer input on before this goes further:
watcher syncs. The alternative is to have the upstream watcher
(
fluxcd/cli-utils) emit an initialNotFoundfor targets absent from its firstLIST, fixing this class of races at the source and letting Helm drop the reconcile
entirely. The Helm-side fallback is self-contained and needs no upstream change, but
I'd welcome a call on whether this belongs here or should be driven upstream.
InternalError,NoMatchfrom a removed CRD, malformed-request errors) deliberately fail the wait closed
rather than retry. This is the conservative choice; if maintainers would prefer to
also retry transient 500s, that's a one-line allowlist change — I left it out on
purpose and would rather it be an explicit decision.
Issue
Fixes #32261