Skip to content

fix(kube): reconcile delete targets after watcher sync#32262

Open
thc1006 wants to merge 1 commit into
helm:mainfrom
thc1006:fix/waitfordelete-flake
Open

fix(kube): reconcile delete targets after watcher sync#32262
thc1006 wants to merge 1 commit into
helm:mainfrom
thc1006:fix/waitfordelete-flake

Conversation

@thc1006

@thc1006 thc1006 commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

TL;DR

WaitForDelete could either return success before a resource was actually deleted
(the TestStatusWaitForDelete flake, #32261) or, with the naive fix, hang until
--timeout on an already-gone resource (#32214). Both come from the delete path
trusting a resource's transient Unknown status. This reworks the path to start the
watcher first and, once it has synced, confirm any still-Unknown target with a
metadata.name-filtered LIST — an authoritative check instead of a heuristic.

Background — what actually goes wrong on main

The delete-path observer skips Unknown resources:

if rs.Status == status.UnknownStatus && desired == status.NotFoundStatus {
    continue
}

During informer sync every target is briefly Unknown, so they are all skipped, the
aggregated status of the (now empty) set is NotFound, and the watch is cancelled —
WaitForDelete returns success before any deletion is confirmed. That is the #32261
flake.

The obvious fix — stop skipping Unknown — breaks the other direction: the kstatus
watcher only reports NotFound from an observed delete event, and never emits one
for an object that was already absent from its initial LIST. Such a target stays
Unknown forever and the wait hangs to the timeout (the #32214 regression, which is
why the earlier attempt was reverted).

So Unknown on the delete path is ambiguous: it means "already gone" or "still
being deleted", and the watcher alone cannot disambiguate the already-gone case.

Change

  • waitForDelete starts the StatusWatcher first, then drives a small
    deleteReconciler. When the watcher emits its Sync event, any target still
    Unknown is confirmed with a metadata.name-filtered LIST against the same
    dynamic 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.
  • No new RBAC. A filtered LIST is authorized as the list verb the watcher
    already requires — unlike a point GET, which needs the separate get verb.
    Identities scoped to list/watch but not get (common for controllers) keep
    working.
  • Failure isolation. Each still-Unknown target is confirmed by its own checker
    goroutine, 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-After never defers another target's retry. When the watcher gives a
    target 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.
  • Backpressure-aware retry. Retriable errors use an explicit allowlist
    (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 a
    single large hint cannot consume the wait budget.
  • Faithful error reporting. A context error (cancel/deadline) takes precedence over
    the watch-ended-before-confirmation error and is returned discoverable via
    errors.Is (diagnostics for still-present resources may accompany it, but the
    watch-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.go adds deterministic coverage:

  • fake StatusWatchers for the full path: a target absent from the initial LIST is
    confirmed 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.Canceled alone; a parent deadline propagates
    as DeadlineExceeded; one always-failing target does not starve a second; a target
    whose LIST is stuck in flight does not block a second target from being confirmed
    concurrently; and one target's long Retry-After does not delay another's retry.
  • direct deleteReconciler state-machine unit tests for the invariants that a fake
    cannot pin down deterministically: a still-Unknown target 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.
  • table-driven tests for the retriable-error allowlist, the Retry-After extraction,
    and the per-target retry wait (including the cap), plus the resourceGone LIST
    classification 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 a
real 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/watch
but not get (kubectl auth can-i reports list=yes, watch=yes, get=no) completes
WaitForDelete, where a point GET would be Forbidden. 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:

  1. Ownership. This confirms deletion Helm-side, via a filtered LIST after the
    watcher syncs. The alternative is to have the upstream watcher
    (fluxcd/cli-utils) emit an initial NotFound for targets absent from its first
    LIST, 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.
  2. Fail-closed policy. Non-allowlisted errors (a 500 InternalError, NoMatch
    from 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

@pull-request-size pull-request-size Bot added the size/M Denotes a PR that changes 30-99 lines, ignoring generated files. label Jun 24, 2026
@thc1006
thc1006 force-pushed the fix/waitfordelete-flake branch from cccc747 to 223d114 Compare June 24, 2026 07:37
@pull-request-size pull-request-size Bot added size/L Denotes a PR that changes 100-499 lines, ignoring generated files. and removed size/M Denotes a PR that changes 30-99 lines, ignoring generated files. labels Jun 24, 2026
@thc1006
thc1006 marked this pull request as draft July 11, 2026 22:31
@thc1006

This comment has been minimized.

@thc1006
thc1006 force-pushed the fix/waitfordelete-flake branch from 223d114 to 97b88fd Compare July 12, 2026 00:45
@pull-request-size pull-request-size Bot added size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. and removed size/L Denotes a PR that changes 100-499 lines, ignoring generated files. labels Jul 12, 2026
@thc1006 thc1006 changed the title fix(kube): check resource existence before waiting for deletion WIP: fix(kube): reconcile delete targets after watcher sync Jul 12, 2026
@thc1006

This comment has been minimized.

@thc1006
thc1006 force-pushed the fix/waitfordelete-flake branch from 97b88fd to a4611ee Compare July 12, 2026 02:26
@thc1006

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
thc1006 force-pushed the fix/waitfordelete-flake branch from a4611ee to ef48f27 Compare July 12, 2026 03:21
@thc1006

This comment has been minimized.

@thc1006
thc1006 marked this pull request as ready for review July 12, 2026 11:11
Copilot AI review requested due to automatic review settings July 12, 2026 11:11
@thc1006 thc1006 changed the title WIP: fix(kube): reconcile delete targets after watcher sync fix(kube): reconcile delete targets after watcher sync Jul 12, 2026

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@thc1006

thc1006 commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

Marking this ready for formal review.

Since the previous Draft update, I addressed the remaining failure-isolation cases:

  • each still-Unknown target now has an independent checker context and retry schedule;
  • LIST concurrency is capped at eight;
  • a watcher status cancels that target's in-flight check;
  • one blocked LIST no longer serializes the remaining targets;
  • one target's Retry-After no longer delays another target's retry.

Both cases now have deterministic regression tests:

  • an in-flight blocked LIST while another absent target must still be confirmed;
  • a long Retry-After on one target while another retries independently.

The latest build-test, golangci-lint, and CodeQL runs are green.

The remaining questions are architectural and policy decisions for review rather than known correctness gaps:

  1. whether this reconciliation belongs in Helm or should move upstream into fluxcd/cli-utils;
  2. whether additional errors such as HTTP 500 should be retried instead of failing closed;
  3. whether the locally verified kind/RBAC matrix should be incorporated into an automated integration or acceptance test.

One operational detail for reviewers: the implementation creates one checker goroutine per still-Unknown target, while limiting concurrent LIST requests to eight. A single stuck request therefore does not serialize all targets, but if every concurrency slot is occupied by blocked requests, later checks wait for a slot or the overall context deadline.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: TestStatusWaitForDelete flakes due to WaitForDelete informer initialization race

2 participants