Skip to content

Do not report host deletion as completed on an unknown API error - #2057

Merged
sunsingerus merged 1 commit into
Altinity:0.27.4from
somanchi004-code:fix/delete-host-error-classification
Aug 31, 2026
Merged

sunsingerus merged 1 commit into
Altinity:0.27.4from
somanchi004-code:fix/delete-host-error-classification

Conversation

@somanchi004-code

Copy link
Copy Markdown

deleteHost() fetches the host's StatefulSet and treats any error from that Get as
"already deleted":

if host.Runtime.CurStatefulSet, err = w.c.kube.STS().Get(ctx, host); err != nil {
    ... EventReasonDeleteCompleted ...
        Info("Delete host: %s/%s - completed StatefulSet not found - already deleted? err: %v", ...)
    return nil
}

Only NotFound means the host is actually gone. A Forbidden, a timeout, a 429 or a
reset connection all take the same branch, and the early return skips the two cleanup
steps below it:

  • deleteTables() — which the comment just above it notes is needed so ZooKeeper stops
    tracking the host's tables
  • Controller.deleteHost() — which deletes the host's PVCs

The PVCs matter most because they have no owner reference. creator/pvc.go comments
it out in two places to stay compatible with the PV retain policy, so Kubernetes GC
will not reclaim them and this call is the only thing that ever does.

This PR only changes the classification: NotFound keeps today's behaviour, anything
else emits DeleteFailed and returns the error instead of reporting success.
apiErrors.IsNotFound is already used this way 8 times in this package — worker-pdb.go:41
is the same get / classify / act shape.

Verified on a cluster

Single-node k3s v1.36.2+k3s1, operator 0.27.2, PVCs on Ceph RBD. To make the failure
deterministic I removed get on statefulsets from the operator's ClusterRole, so the
call returns Forbidden.

Before this change, deleting the CHI removed the CR and the StatefulSet but left
the PVC and PV bound, with no CR remaining to reconcile them away:

21:39:40.121144  worker-deleter.go:545  Delete host: c1/0-0 - completed StatefulSet not found -
                 already deleted? err: statefulsets.apps "chi-repro-c1-0-0" is forbidden: ...
21:39:40.889626  worker-deleter.go:335  Delete CHI completed
$ kubectl -n chi-test get chi,sts
No resources found in chi-test namespace.
$ kubectl -n chi-test get pvc
data-volume-chi-repro-c1-0-0-0   Bound   pvc-2b49bdbf-...   1Gi   RWO   rook-ceph-block

A control run with unmodified RBAC removed CHI, StatefulSet, pod, PVC and PV cleanly,
so the normal path works — the leak is specific to the misclassified error.

After this change, the same delete reports the failure instead of claiming success:

Error  DeleteFailed  Delete host: c1/0-0 - unable to get StatefulSet, host deletion
                     not performed. err: statefulsets.apps "chi-repro-c1-0-0" is
                     forbidden: ...

I also re-created the CHI under the patched build first and it reconciled to
Completed normally, so the NotFound path is unaffected.

Scope

This is deliberately narrow, and on its own it does not stop the leak. In the run
above the PVC was still orphaned and the CHI was still deleted, because the error this
patch now returns is discarded by the callers at worker-deleter.go:634 and :747 and
the finalizer is removed regardless. Fixing that means deciding a retry policy, which is
not mine to decide, so I have raised it separately in #2056 rather than widening this
patch. What this change buys by itself is that the operator stops reporting a deletion
it did not perform.

I also considered simply proceeding with cleanup after a failed Get, but
PVCDeleter.HostCanDeletePVC walks volume mounts via api.CurStatefulSet and defaults
to PVCReclaimPolicyDelete when it finds none — so that path would delete PVCs whose
reclaim policy could not be confirmed. Returning the error seemed clearly safer.

Notes

No behaviour change when the StatefulSet is genuinely absent, which is the common case
this branch was written for.

AI disclosure: I used Claude Code while investigating this. The reproduction above was
run by me on my own cluster, and I have described separately in #2056 which parts I
observed and which I only traced in the source.

deleteHost() fetches the host StatefulSet and treats any error from that
Get as "StatefulSet not found - already deleted", emitting a
DeleteCompleted event and returning nil. Only NotFound actually means the
host is gone; a Forbidden, a timeout or any other transient API error
takes the same branch.

That early return skips both deleteTables() - which the surrounding
comment notes is required so ZooKeeper stops tracking the host's tables -
and Controller.deleteHost(), which deletes the host's PVCs. Those PVCs
carry no owner reference (see model/common/creator/pvc.go, where it is
commented out to stay compatible with the PV retain policy), so the
operator's own call is the only thing that ever reclaims them.

Classify the error instead: keep the existing behaviour for NotFound, and
on any other error emit DeleteFailed and return it rather than claiming
the host was deleted. apiErrors.IsNotFound is already used this way
elsewhere in the package, for example in worker-pdb.go.

Signed-off-by: Somanchi Poorna Sobhita <somanchi004@gmail.com>
@alex-zaitsev
alex-zaitsev changed the base branch from master to 0.27.3 August 9, 2026 08:57
@sunsingerus sunsingerus added the planned for review This feature is planned for review label Aug 17, 2026
@sunsingerus
sunsingerus changed the base branch from 0.27.3 to 0.27.4 August 17, 2026 14:28
@sunsingerus

Copy link
Copy Markdown
Collaborator

Reviewed this in depth. The diagnosis is right, the alternative you rejected is genuinely the wrong one, and the scoping is honest — I verified all four of your load-bearing claims against the code and they hold. One concrete change requested before merge.

Verified

  • IsNotFound is safe here. I traced the full chain — kube.STS().Get → commonKube.GetWithRetry (common/kube/retry.go:123-146) → client-go. No fmt.Errorf/%w wrapping anywhere, so the raw *StatusError reaches your check and the common case is byte-for-byte unchanged. Worth adding to the PR description, since a wrapper here would have silently broken the NotFound path.
  • PVCs really are unowned — model/common/creator/pvc.go:50,73, both commented out under the PV-retain-policy rationale. No PersistentVolumeClaimRetentionPolicy anywhere either, so STS deletion doesn't reclaim them.
  • Your rejected alternative would be worse than the leak. HostCanDeletePVC (model/common/volume/deleter.go:41) seeds policy := PVCReclaimPolicyDelete and only overrides inside WalkVolumeMounts(api.CurStatefulSet, …), which returns immediately when !HasCurStatefulSet() (type_host.go:467-470). A failed Get leaves that nil — so "proceed anyway" deletes Retain PVCs. Data loss, not just a leak. Good call.
  • 8 apiErrors.IsNotFound uses in this package, and worker-pdb.go:41 is indeed the canonical shape.

One thing that strengthens your case and isn't in the description: IsTransientAPIError deliberately excludes IsForbidden (retry.go:66), and the Get is already wrapped in GetWithRetry. So transients are absorbed before line 541 — the residual class reaching your new branch is terminal, which is exactly the class where "already deleted" was a lie.

Requested change — WithError introduces an uncancellable ~50s stall

WithError → writeStatus → _writeErrors returns true → StatusUpdate(context.Background(), …) (common/announcer/announcer.go:274) → statusUpdateRetry (chi/kube/cr.go:126-146): maxRetryAttempts = 50 with time.Sleep(1 * time.Second) between attempts.

Two things make this bite:

  1. The ctx is context.Background(), so the IsContextDone guards at cr.go:116 and :150 can never fire. Operator shutdown cannot interrupt it.
  2. Default config is status.fields.action: false but error: true (config/config.yaml). So the old WithAction path wrote no status at all — _writeActions short-circuits — while the new WithError path does. This stall is introduced by the change, not pre-existing.

Failure scenario: the apiserver is degraded, so the STS Get fails and the status write fails for the same reason. Each failing host now burns ~49s inside deleteCRProtocol's wg.Wait() (worker-deleter.go:321), where it previously returned instantly. That is precisely the failure mode this PR targets, so the change makes deletion slower and less responsive in the one scenario it exists for.

Suggested fix: keep WithEvent(...) and the Error(...) log line, drop WithError(...). The Event is the part that survives anyway — the CR is deleted seconds later by uninstallFinalizer, so the status write is lost regardless.

Nits

  • worker-deleter.go:553 — "Report the failure and let the caller decide." No caller decides: the sole caller is _ = w.deleteHost(ctx, chi, h) at :640, inside a goroutine, and deleteCHI uninstalls the finalizer unconditionally. Suggest "report the failure; deletion of this host is skipped", plus a // TODO(#2056) at the discard site so the dead return value is self-documenting.
  • Worth noting in the comment that the Get is already retried, so a reader knows the residual class is terminal rather than transient.
  • Minor: host.Runtime.CurStatefulSet is assigned from the failed Get. client-go returns a non-nil empty struct on error, so it is left non-nil-but-empty. Harmless today since both branches return immediately, but sts, err := ...; if err != nil { … }; host.Runtime.CurStatefulSet = sts avoids the trap.

Test

There is no coverage of worker-deleter.go at all today, and this one is cheap to pin: Controller.kube is the interfaces.IKube interface, IKubeSTS.Get is on it, and the error branch returns before deleteTables/c.deleteHost, so nothing else needs faking. common/statefulset/statefulset-reconciler_test.go:48 has the fakeSTS pattern to copy, and announcer.NewAnnouncer(nil, nil) is inert because the write paths are gated on capable().

A table test over NotFound / Forbidden / InternalError / TooManyRequests / opaque, asserting deleteHost returns nil for the first and non-nil for the rest, fails on the current code and passes after. Making the fake's Delete panic also pins the real safety property: no k8s mutation when the Get could not be classified.

Given this is data-loss-adjacent, I'd like the test before merge.

Verdict

The shape is right and the deferral to #2056 is legitimate scoping, not scope-dodging — the finalizer question is a real policy decision that deserves its own review. Fix the WithError stall and add the unit test and I'm happy to see this land.

@sunsingerus
sunsingerus merged commit 21e0db8 into Altinity:0.27.4 Aug 31, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

planned for review This feature is planned for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants