Skip to content

fix: default cleanupRemovedNodes to auto with node-removal grace period - #2696

Open
rugggger wants to merge 1 commit into
mainfrom
08-02-feat_default_cleanupremovednodes_to_auto_with_node-removal_grace_period
Open

fix: default cleanupRemovedNodes to auto with node-removal grace period#2696
rugggger wants to merge 1 commit into
mainfrom
08-02-feat_default_cleanupremovednodes_to_auto_with_node-removal_grace_period

Conversation

@rugggger

@rugggger rugggger commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Introduce a tri-state cleanupRemovedNodes config (false/true/auto) and make auto the shipped default.

Node-removal grace (auto)

Under auto, a backend WekaContainer whose target node disappears from the cluster is held in Stale status for a grace period before deletion, instead of being torn down immediately:

  • managed cloud (AWS/EKS, OCI/OKE): 30m grace, aligned with managedNodesPodTerminationTimeout
  • everything else: 24h grace

If the node returns within the window the grace stamp is cleared and the container recovers to Running, so a transient node loss (reboot, brief API-server blip, ASG churn) no longer forces an unnecessary deactivate/resign/rebuild.

  • true keeps the previous immediate-delete behavior
  • false disables cleanup entirely
  • Cloud-provider detection falls back to inferring from surviving cluster nodes once the removed node object is gone.

While a container sits Stale, its already-exited backend pod is reaped immediately (do-not-force-delete finalizer stripped + deleted) instead of lingering in Terminating for the whole window.

Terminal-pod reap on deletion

A new ReleaseTerminalPodOnDeletion step at the start of the deleting flow strips the do-not-force-delete finalizer from a Succeeded/Failed backend pod and removes it before the deactivate/resign steps, so it is not wedged in Terminating behind a DeactivateWekaContainer that can never complete once the node/process is gone.

Tests

  • internal/config/cleanup_removed_nodes_test.go — mode parsing / default
  • funcs_handle_node_statuses_test.go — Stale grace, cloud vs non-cloud boundary, node-return recovery, reap-during-Stale
  • funcs_common_deletion_finalizer_test.go — finalizer strip + terminal-pod reap

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

rugggger commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

How to use the Graphite Merge Queue

Add the label main-merge-queue to this PR to add it to the merge queue.

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has required the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

This stack of pull requests is managed by Graphite. Learn more about stacking.

@claude

claude Bot commented Aug 2, 2026

Copy link
Copy Markdown

Reviewing PR

  • Read the diff
  • Analyze config / tri-state parsing
  • Analyze node-status grace period logic
  • Analyze deletion flow changes
  • Post review

View job run

@rugggger
rugggger marked this pull request as ready for review August 2, 2026 11:29
@rugggger
rugggger requested a review from a team as a code owner August 2, 2026 11:29
@claude

claude Bot commented Aug 2, 2026

Copy link
Copy Markdown

Claude finished @rugggger's task in 5m 37s —— View job


PR Review

  • Read the diff
  • Analyze config / tri-state parsing
  • Analyze node-status grace period logic
  • Analyze deletion flow changes
  • Post review

The design is sound and the code is unusually well documented — the why comments on releaseTerminalPodOnDeletion and removedNodeOnSupportedCloud explain the non-obvious parts properly, and the Helm template already quotes the value (value: "{{ .Values.cleanupRemovedNodes }}"), so an unquoted YAML false still renders safely. One real bug and a few things worth reconsidering before merge. Details are in inline comments; summary below.

Blocking

1. Status update after Delete — the wait error is unreachable · funcs_handle_node_statuses.go:216-222
r.Delete bumps the object's resourceVersion (deletionTimestamp write), so the following r.Status().Update on the stale in-memory copy returns 409 Conflict (or 404 if the container had no finalizers). Every grace-elapsed deletion therefore returns a genuine error rather than the intended WaitError on line 223 — error status / warning events on a delete that actually succeeded. Clearing the stamp on an object that's going away is also a no-op. The immediate-delete path at lines 121-125 has the correct shape. Fix this →

Worth addressing

2. Invalid config fails open into the destructive mode · env.go:760-775
A typo (autoo, on, 1) logs an error and then enables cleanup. For a setting that destroys state, fail closed (off) or refuse to start. Also, unset/empty is a normal case (the chart renders value: "" for null/"") but currently produces a klog.Errorf at every start.

3. ReleaseTerminalPodOnDeletion is broader than the problem · flow_deleting_state.go:62-72
The motivating case is a pod wedged in Terminating, but the predicate fires for any terminal backend pod during deletion — including the ordinary teardown where the pod exits Succeeded. There, the pod is reaped and then ensurePodOnDeletion recreates it for deactivation: delete/recreate churn on the healthy path, and a possible recreate → Failed → reap → recreate cycle where the pod can't stay up. Gating on pod.GetDeletionTimestamp() != nil targets the actual wedge. The phase predicate also dereferences r.pod relying on predicate short-circuit order — other predicates in that file guard nil inline.

4. Cloud inference from any surviving node · funcs_handle_node_statuses.go:133-148
The homogeneity assumption breaks on mixed clusters (bare-metal backends next to a cloud node pool), where a removed non-cloud node gets the aggressive 30m window. The removed node's own ProviderID is available while the node still exists — capturing it with the NodeRemoved stamp removes the guess and avoids an unfiltered node List on every 30s requeue for up to 24h.

5. Grace periods are hardcoded · funcs_handle_node_statuses.go:22-26
No env override for a value that now ships on by default. Also worth confirming the product intent: during the Stale window the container is neither deactivated nor resigned, so on-prem the failure domain stays down and redundancy unrestored for a full day.

6. Default flip needs a release note. Deployments without CLEANUP_REMOVED_NODES set (non-Helm installs, or charts pinned to older values) go from never deleting backends on node removal to deleting after 30m/24h. Neither the chart docs nor .ainav were updated for the tri-state (CLAUDE.md asks for .ainav updates when behavior changes).

Tests

Solid coverage of the state matrix (mode × node present/absent × cloud/non-cloud, plus the 40m boundary case). Two gaps:

  • All grace-path assertions are Expect(err).To(HaveOccurred()), which can't tell a WaitError from a real failure — exactly what hides finding Operator sdk #1.
  • funcs_common_deletion_finalizer_test.go "step predicates" re-implements the predicate closure locally rather than exercising DeletingStateFlow's step, so a divergence in the real flow wouldn't be caught.

I did not run make test — the sandbox here blocks go build/module access, so all findings are from static reading; CI should confirm.
· branch

@graphite-app

graphite-app Bot commented Aug 2, 2026

Copy link
Copy Markdown

Graphite Automations

"Add anton/matt/sergey/kristina as reviwers on operator PRs" took an action on this PR • (08/02/26)

3 reviewers were added to this PR based on Anton Bykov's automation.

Comment on lines +216 to +223
if err := r.Delete(ctx, r.container); err != nil {
return err
}
delete(r.container.Status.Timestamps, nodeRemovedKey)
if err := r.Status().Update(ctx, r.container); err != nil {
return err
}
return lifecycle.NewWaitError(errors.New("backend node removed past grace period, deleting container"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Status update after Delete will fail — the WaitError on line 223 is effectively unreachable.

r.Delete makes the API server write the object (adds deletionTimestamp, since WekaContainer carries a finalizer), which bumps resourceVersion. The in-memory r.container still holds the pre-delete resourceVersion, so r.Status().Update here returns a 409 Conflict (or 404 NotFound if the object had no finalizers and was reaped outright). The step therefore returns a real error instead of the intended wait error, every time grace elapses — surfacing an error status / warning event on a deletion that actually succeeded.

Also, clearing nodeRemovedKey on an object that is already being deleted has no purpose — the object is going away. The immediate-delete path at lines 121-125 gets this right (delete, then return the wait error, no status write).

Suggested change
if err := r.Delete(ctx, r.container); err != nil {
return err
}
delete(r.container.Status.Timestamps, nodeRemovedKey)
if err := r.Status().Update(ctx, r.container); err != nil {
return err
}
return lifecycle.NewWaitError(errors.New("backend node removed past grace period, deleting container"))
if err := r.Delete(ctx, r.container); err != nil {
return err
}
return lifecycle.NewWaitError(errors.New("backend node removed past grace period, deleting container"))

Note the test at funcs_handle_node_statuses_test.go:151 doesn't catch this because it only asserts Expect(err).To(HaveOccurred()) — a conflict error and a wait error are indistinguishable to that assertion.

Comment thread internal/config/env.go
Comment on lines +760 to +775
func getCleanupRemovedNodesMode() CleanupRemovedNodesMode {
val := env.GetString("CLEANUP_REMOVED_NODES", string(CleanupRemovedNodesAuto))
val = strings.ToLower(strings.TrimSpace(val))

switch CleanupRemovedNodesMode(val) {
case CleanupRemovedNodesOff:
return CleanupRemovedNodesOff
case CleanupRemovedNodesOn:
return CleanupRemovedNodesOn
case CleanupRemovedNodesAuto:
return CleanupRemovedNodesAuto
default:
klog.Errorf("invalid CLEANUP_REMOVED_NODES value %q, defaulting to %q", val, CleanupRemovedNodesAuto)
return CleanupRemovedNodesAuto
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Invalid config fails open into the destructive mode. A typo (autoo, on, 1, yes) logs an error and then falls back to auto, i.e. the operator will start deleting backend containers after the grace period. For a setting that destroys state, failing closed (off) — or refusing to start — is the safer contract; auto should only be reached when the value is genuinely absent.

Two separate cases are also conflated here: unset/empty (expected — the chart renders value: "" if a user sets cleanupRemovedNodes: "" or null) and garbage. The empty case logging klog.Errorf("invalid CLEANUP_REMOVED_NODES value \"\"") at every operator start is noise. Suggest short-circuiting val == "" to the default before the switch, and reserving the error log (fail-closed) for actual garbage.

Comment on lines +62 to +72
// gone). This runs before the deactivate/resign steps precisely so it is not blocked by them.
&lifecycle.SimpleStep{
Name: "ReleaseTerminalPodOnDeletion",
Run: r.releaseTerminalPodOnDeletion,
Predicates: lifecycle.Predicates{
lifecycle.IsNotFunc(r.PodNotSet),
r.container.IsBackend,
func() bool {
return r.pod.Status.Phase == v1.PodSucceeded || r.pod.Status.Phase == v1.PodFailed
},
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things on this step:

  1. The predicate is broader than the problem it solves. The motivating case is a terminal pod wedged in Terminating behind the deactivate steps. As written it fires for any terminal backend pod during deletion, including the ordinary teardown where the pod exits Succeeded after weka local stop. In that path the pod is deleted here, then ensurePodOnDeletion (line ~76, predicate PodNotSet && NodeIsSet && ShouldDeactivate && !Deactivated) recreates it for deactivation — delete/recreate churn on the healthy path, and a potential recreate → Failed → reap → recreate cycle when the pod cannot stay up. Narrowing to r.pod.GetDeletionTimestamp() != nil (or additionally gating on node gone / NotReady) targets the wedged case precisely.

  2. The third predicate dereferences r.pod and relies on the engine short-circuiting IsNotFunc(r.PodNotSet) first. Other predicates in this file guard inline (r.wekaClient != nil && ...), which suggests not depending on evaluation order:

Suggested change
// gone). This runs before the deactivate/resign steps precisely so it is not blocked by them.
&lifecycle.SimpleStep{
Name: "ReleaseTerminalPodOnDeletion",
Run: r.releaseTerminalPodOnDeletion,
Predicates: lifecycle.Predicates{
lifecycle.IsNotFunc(r.PodNotSet),
r.container.IsBackend,
func() bool {
return r.pod.Status.Phase == v1.PodSucceeded || r.pod.Status.Phase == v1.PodFailed
},
},
&lifecycle.SimpleStep{
Name: "ReleaseTerminalPodOnDeletion",
Run: r.releaseTerminalPodOnDeletion,
Predicates: lifecycle.Predicates{
lifecycle.IsNotFunc(r.PodNotSet),
r.container.IsBackend,
func() bool {
return r.pod != nil && (r.pod.Status.Phase == v1.PodSucceeded || r.pod.Status.Phase == v1.PodFailed)
},
},
},

Comment on lines +133 to +148
func (r *containerReconcilerLoop) removedNodeOnSupportedCloud(ctx context.Context, node *v1.Node) (bool, error) {
if node != nil {
return discovery.IsSupportedCloudProvider(node.Spec.ProviderID), nil
}

nodes, err := r.KubeService.GetNodes(ctx, nil)
if err != nil {
return false, fmt.Errorf("failed to list nodes to determine cloud provider: %w", err)
}
for i := range nodes {
if discovery.IsSupportedCloudProvider(nodes[i].Spec.ProviderID) {
return true, nil
}
}
return false, nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two concerns with inferring the provider from surviving nodes:

  • Correctness of the homogeneity assumption: any single node in the cluster with an aws/oci ProviderID makes every removed node "managed cloud" and shortens its grace from 24h to 30m. Mixed clusters (bare-metal Weka backends alongside a cloud node pool, or self-managed instances in EKS registered without a cloud provider) get the wrong — and more aggressive — window. The removed node's own ProviderID is available while the node exists; capturing it once (annotation or status field, alongside the NodeRemoved stamp) would remove the guess entirely and make the decision stable across the whole window.
  • Cost: this runs on the fallback path on every reconcile — the grace loop requeues every 30s, for up to 24h, per Stale container — and each call does an unfiltered List of all nodes. Cached or not, computing this once when the stamp is written and reusing it avoids the repetition.

Comment on lines +129 to +135
err := r.deleteIfNoNode(context.Background())

Expect(err).To(HaveOccurred())
Expect(containerExists(r)).To(BeTrue())
Expect(container.Status.Status).To(Equal(weka.Stale))
Expect(container.Status.Timestamps).To(HaveKey(nodeRemovedKey))
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every path here asserts only Expect(err).To(HaveOccurred()), which cannot distinguish the intended lifecycle.WaitError from a genuine failure — that's precisely what hides the post-Delete status-update conflict flagged on funcs_handle_node_statuses.go:216-222. Worth asserting the error is a wait error (and ideally its requeue duration), so a real error can't silently pass as "expected".

Comment on lines 22 to +26

const (
nodeRemovedKey = "NodeRemoved"
nodeRemovalGracePeriod = 24 * time.Hour // non-cloud: node may return slowly
nodeRemovalGracePeriodManagedCloud = 30 * time.Minute // managed cloud (aws/oci); aligns w/ managedNodesPodTerminationTimeout

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two notes on these constants:

  • Not configurable. Given the two-orders-of-magnitude spread (30m vs 24h) and that this is the new default behavior, an env override (e.g. NODE_REMOVAL_GRACE_PERIOD) would make field triage much easier than shipping a new operator image to change it.
  • 24h keeps the cluster degraded for a day. During the Stale window the container is neither deactivated nor resigned, so its drives stay in the cluster and the failure domain stays down. That's the deliberate trade-off for "the node might come back", but on-prem 24h is a long time to defer redundancy restoration — is that the intended product decision, or would a few hours (with true available for operators who want immediate cleanup) be a better default?

Also: nodeRemovedKey is a bare string, while the other timestamp keys in this package come from typed API constants (weka.TimestampStopAttempt). Since it's persisted into Status.Timestamps on real objects, promoting it to the API constants alongside the others would keep it discoverable.

@rugggger
rugggger force-pushed the 08-02-feat_default_cleanupremovednodes_to_auto_with_node-removal_grace_period branch from 141ba68 to 4b23882 Compare August 2, 2026 13:01
@claude

claude Bot commented Aug 2, 2026

Copy link
Copy Markdown

Claude finished @rugggger's task in 3m 33s —— View job


Re-review (revision 2)

  • Diff the new revision against my previous review
  • Check whether prior findings were addressed
  • Review newly introduced code
  • Post review

The branch was amended since my 11:34 review. The only code change in this revision is the fix to finding #1, and it's correct:

if err := r.Delete(ctx, r.container); err != nil {
    return err
}
return lifecycle.NewWaitError(errors.New("backend node removed past grace period, deleting container"))

funcs_handle_node_statuses.go:216-219 — the post-Delete stamp-clear + Status().Update are gone, so the grace-elapsed path now returns the intended WaitError instead of a 409 Conflict. This matches the immediate-delete path at lines 121-125. ✅

Still open from the previous round

# Finding Location Status
2 Invalid config fails open into the destructive mode; empty value logs klog.Errorf at every start internal/config/env.go:760-775 unchanged
3 ReleaseTerminalPodOnDeletion predicate is broader than the wedge it targets; dereferences r.pod relying on predicate short-circuit order flow_deleting_state.go:62-72 unchanged
4 Cloud inference from any surviving node (homogeneity assumption + unfiltered List every 30s for up to 24h) funcs_handle_node_statuses.go:133-148 unchanged
5 Grace periods hardcoded, no env override; nodeRemovedKey is a bare string vs. the typed API timestamp constants funcs_handle_node_statuses.go:22-26 unchanged
6 Default flip needs a release note; chart docs / .ainav not updated for the tri-state unchanged

On #2 specifically, worth confirming: k8s.io/utils/env.GetString returns the set value when the var exists, so CLEANUP_REMOVED_NODES="" (what the chart renders for null/"") reaches the default: branch — the new test "empty defaults to auto" passes through the error path, which is the behavior I'd flag rather than lock in.

Tests were not updated for the assertion gap: every grace-path case still asserts only Expect(err).To(HaveOccurred()) (funcs_handle_node_statuses_test.go:129-350). That assertion is what hid finding #1 in the first place — a WaitError and a genuine failure are indistinguishable to it. Now that the fix is in, asserting lifecycle.IsWaitError(err) (and ideally the requeue duration) would keep it from regressing.

New in this pass

Reap gate keys on phase, not on deletionTimestamp · funcs_handle_node_statuses.go:176 (inline comment posted)
The pod this block exists to unstick is the one carrying deletionTimestamp + WekaFinalizer. PodGC on a deleted Node patches status.phase = Failed before deleting on recent Kubernetes, so the common case is covered — but a pod force-deleted before the status patch lands (or by admin/ASG tooling) sits in Terminating with phase Running and is missed, lingering with the finalizer for the whole Stale window. deletePod already strips the finalizer before its deletionTimestamp short-circuit, so adding || r.pod.GetDeletionTimestamp() != nil to the gate is free.

Verdict

The blocking bug is resolved. Nothing else new is blocking — #2 (fail-open on a destructive default) is the one I'd still want addressed before this ships as the default, since it turns a config typo into eventual backend deletion.

I did not run make test — this sandbox blocks go build and module-cache access, so all findings are from static reading; CI should confirm.
· branch

// The backend pod on a removed node has no live process and holds no drives; reap it immediately
// (strip the do-not-force-delete finalizer + delete) so it is not stuck in Terminating for the whole
// Stale window. The container itself stays Stale — only the dead pod object is removed.
if r.pod != nil && (r.pod.Status.Phase == v1.PodSucceeded || r.pod.Status.Phase == v1.PodFailed) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reap gate is phase-based (Succeeded/Failed), but the pod this is meant to unstick is defined by its deletionTimestamp, not its phase.

When a Node object is deleted, PodGC force-deletes the pods bound to it. On recent Kubernetes it patches status.phase = Failed first (so this predicate matches), but the pod that is actually wedged is any pod carrying deletionTimestamp + WekaFinalizer — and a pod whose kubelet vanished before the status patch lands, or one force-deleted by an admin/ASG tooling, can sit in Terminating while still reporting phase Running. Those linger with the finalizer for the entire Stale window (up to 24h) — exactly the state this block exists to prevent.

deletePod already handles the already-Terminating case correctly (strips the finalizer before the deletionTimestamp short-circuit), so widening the gate costs nothing:

Suggested change
if r.pod != nil && (r.pod.Status.Phase == v1.PodSucceeded || r.pod.Status.Phase == v1.PodFailed) {
if r.pod != nil && (r.pod.Status.Phase == v1.PodSucceeded || r.pod.Status.Phase == v1.PodFailed || r.pod.GetDeletionTimestamp() != nil) {

(Same observation applies to the ReleaseTerminalPodOnDeletion predicate in flow_deleting_state.go — there deletionTimestamp != nil is arguably the only condition you want, per my earlier comment.)

@rugggger
rugggger force-pushed the 08-02-feat_default_cleanupremovednodes_to_auto_with_node-removal_grace_period branch from 4b23882 to 0acded3 Compare August 2, 2026 13:18
@claude

claude Bot commented Aug 2, 2026

Copy link
Copy Markdown

Claude finished @rugggger's task in 3m 37s —— View job


Re-review (revision 3)

  • Diff the new revision against my previous review
  • Check whether prior findings were addressed
  • Review newly introduced code
  • Post review

Three of the open findings were addressed in this revision, and the fixes are the right ones.

Resolved

#2 — config now fails closed · internal/config/env.go:760-775

case CleanupRemovedNodesAuto, "": // "" == set-but-empty, treat as default
	return CleanupRemovedNodesAuto
default:
	klog.Warningf("invalid CLEANUP_REMOVED_NODES value %q, disabling removed-node cleanup; ...")
	return CleanupRemovedNodesOff

Set-but-empty (what the chart renders for null/"") is now an explicit case rather than falling through the error path, and a typo disables cleanup instead of enabling it. cleanup_removed_nodes_test.go:21 locks the fail-closed behavior in. Note this diverges from getBoolEnvOrDefault, which os.Exit(1)s on a bad value — deliberate divergence is fine, but if you'd rather be consistent, exiting is also a safe direction here.

#3ReleaseTerminalPodOnDeletion predicate narrowed · flow_deleting_state.go:71-74

return r.pod.GetDeletionTimestamp() != nil &&
	(r.pod.Status.Phase == v1.PodSucceeded || r.pod.Status.Phase == v1.PodFailed)

This targets the actual wedge (pod held in Terminating by WekaFinalizer) and no longer fires on the ordinary teardown where the pod simply exits Succeeded — so the reap→ensurePodOnDeletion recreate churn on the healthy path is gone. The step-predicate tests at funcs_common_deletion_finalizer_test.go:166-207 were updated in sync, including the "does not fire for a terminal backend pod that merely exited" case.

#6 — docs / release notevalues.yaml documents the tri-state and quotes the default, helm-install.md carries an explicit Note that the default changed from never-delete to delete-after-grace, and .ainav/config/index.md has the entry. Chart back-compat checks out: manager.yaml:178 renders value: "{{ .Values.cleanupRemovedNodes }}", so an existing unquoted cleanupRemovedNodes: false still resolves to Off.

New in this pass

Doc drift on releaseTerminalPodOnDeletion · funcs_common_deletion.go:404-405 (inline comment posted) — the doc block still says the trigger is "has already reached a terminal phase (Succeeded/Failed)", but the caller now also requires deletionTimestamp != nil. Read in isolation the comment describes exactly the behavior the new gate excludes.

Still open

# Finding Location Note
4 Cloud inference from any surviving node — homogeneity assumption, plus an unfiltered List on every 30s requeue for up to 24h funcs_handle_node_statuses.go:133-148 unchanged
5 Grace periods hardcoded (30m/24h), no env override; nodeRemovedKey a bare string vs. the typed API timestamp constants funcs_handle_node_statuses.go:23-27 unchanged
Reap gate keys on phase only, not deletionTimestamp funcs_handle_node_statuses.go:176 unchanged — see below
Predicate dereferences r.pod relying on IsNotFunc(r.PodNotSet) short-circuit order flow_deleting_state.go:72 unchanged, cosmetic
Grace-path tests assert only Expect(err).To(HaveOccurred()) funcs_handle_node_statuses_test.go:131,145,159,185,211 unchanged

On the reap gate: now that the deleting flow requires deletionTimestamp, a pod that is Terminating but still reporting phase Running (kubelet gone before PodGC's status patch, or an admin/tooling force-delete) is missed by both gates — funcs_handle_node_statuses.go:176 skips it during the Stale window, and flow_deleting_state.go:71-74 skips it during teardown. It then sits with WekaFinalizer for the whole window. On current Kubernetes PodGC patches phase = Failed first, so this is the uncommon path, not the common one — but deletePod already strips the finalizer before its deletionTimestamp short-circuit (funcs_common_deletion.go:372-391), so adding || r.pod.GetDeletionTimestamp() != nil to line 176 costs nothing.

On the test assertions: HaveOccurred() is what hid the original blocking bug (a 409 Conflict and a WaitError are indistinguishable to it). Now that both the delete path and the grace path return WaitErrors deliberately, lifecycle.IsWaitError(err) — plus the requeue duration on the within-grace case — would keep that from regressing.

Verdict

Nothing blocking. The remaining items are hardening (#4, #5, the phase-vs-deletionTimestamp gate) and test-assertion strength; the fail-open config issue that concerned me about shipping this as the default is fixed.

I did not run make test — this sandbox blocks go build and module-cache access, so all findings are from static reading; CI should confirm.
· branch

}

// releaseTerminalPodOnDeletion strips the do-not-force-delete finalizer from, and reaps, a backend pod
// that has already reached a terminal phase (Succeeded/Failed) while its container is being deleted.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doc drift after the predicate narrowing: the caller now also requires deletionTimestamp != nil (flow_deleting_state.go:72), but this doc block still describes the trigger as "has already reached a terminal phase (Succeeded/Failed) while its container is being deleted". Anyone reading this function in isolation would conclude any terminal pod gets reaped here, which is exactly the behavior the new gate deliberately excludes.

Suggested change
// that has already reached a terminal phase (Succeeded/Failed) while its container is being deleted.
// that has already reached a terminal phase (Succeeded/Failed) and is already being force-removed
// (deletionTimestamp set) while its container is being deleted. A pod that merely exited mid-teardown
// without a deletionTimestamp is left to the normal flow — see the predicate in flow_deleting_state.go.

@rugggger
rugggger force-pushed the 08-02-feat_default_cleanupremovednodes_to_auto_with_node-removal_grace_period branch from 0acded3 to c28cdb7 Compare August 17, 2026 07:03
@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown

Claude finished @rugggger's task in 1m 59s —— View job


Re-review (revision 4) — no code change since my last pass

  • Diff the new revision against my previous review
  • Check whether prior findings were addressed
  • Review newly introduced code
  • Post review

This push is a rebase onto current main, not a content change. Every construct I cited in the revision-3 review is byte-identical at the identical line number:

Citation from rev 3 Now
funcs_handle_node_statuses.go:24-25 grace constants unchanged, same lines
funcs_handle_node_statuses.go:176 phase-only reap gate unchanged, same line
flow_deleting_state.go:73 narrowed predicate unchanged, same line
funcs_common_deletion.go:405 doc drift unchanged, same line
funcs_handle_node_statuses_test.go:131,145,159,185,211 HaveOccurred() unchanged, same lines

The only shift is in internal/config/env.go (the new parser now lands at 782-796 instead of 760-775), which is just main growing above it. So no re-review of the logic is warranted — my revision-3 verdict stands as-is: nothing blocking.

Rebase-safety checks I did run

  • No stale bool call sites. Config.CleanupRemovedNodes changed type from bool to CleanupRemovedNodesMode; main did not add any new reader between 08-02 and 08-16. The only two consumers are still funcs_common_deletion.go:598 (.CleansOnNodeRemoval()) and funcs_handle_node_statuses.go:99.
  • Referenced helpers still exist with the same signatures: discovery.IsSupportedCloudProvider (internal/services/discovery/discovery.go:52) and KubeService.GetNodes(ctx, map[string]string) (internal/services/kubernetes/kubernetes.go:183).
  • Chart wiring intact: manager.yaml:180 still renders value: "{{ .Values.cleanupRemovedNodes }}", values.yaml:178 is cleanupRemovedNodes: "auto" — an existing unquoted false still resolves to Off.
  • The one main commit that touched the same files (e40124aa, the NFS sentinel pointer fix) is substantively unrelated; no semantic conflict with this diff.

Still open (carried over, all non-blocking)

# Finding Location
4 Cloud inference from any surviving node — homogeneity assumption + unfiltered node List on every 30s requeue for up to 24h funcs_handle_node_statuses.go:133-148
5 Grace periods hardcoded (30m/24h), no env override; nodeRemovedKey a bare string vs. the typed API timestamp constants funcs_handle_node_statuses.go:24-26
Reap gate keys on phase only — a pod Terminating while still reporting phase Running is missed by both gates and keeps WekaFinalizer for the whole window funcs_handle_node_statuses.go:176
Doc block still describes the pre-narrowing trigger (no deletionTimestamp requirement) funcs_common_deletion.go:405
Grace-path tests assert only HaveOccurred(), which cannot distinguish a deliberate WaitError from a real failure — that assertion is what hid the original blocking bug funcs_handle_node_statuses_test.go:131,145,159,185,211
Predicate dereferences r.pod relying on IsNotFunc(r.PodNotSet) short-circuit order flow_deleting_state.go:72 (cosmetic)

I did not run make test/go build — this sandbox denies the build command and module-cache access, so all conclusions above come from static reading of the tree; CI should confirm.
· branch

@rugggger
rugggger force-pushed the 08-02-feat_default_cleanupremovednodes_to_auto_with_node-removal_grace_period branch from c28cdb7 to 7aab4a2 Compare August 26, 2026 12:41
@claude

claude Bot commented Aug 26, 2026

Copy link
Copy Markdown

Claude finished @rugggger's task in 1m 36s —— View job


Re-review (revision 5) — rebase only, no content change

  • Diff the new revision against my previous review
  • Verify no rebase interaction with main's new deleting-flow steps
  • Confirm prior findings' status

This push is another rebase onto current main (AuthorDate: Aug 2, CommitDate: Aug 26) with an identical tree. Every construct I cited in revision 3 / revision 4 is byte-identical; only line numbers moved where main grew above them:

Construct rev 4 now
grace constants funcs_handle_node_statuses.go:24-26 same
phase-only reap gate funcs_handle_node_statuses.go:176 same
grace-elapsed delete + WaitError :216-219 same
narrowed ReleaseTerminalPodOnDeletion predicate flow_deleting_state.go:73 flow_deleting_state.go:86-87
doc drift on releaseTerminalPodOnDeletion funcs_common_deletion.go:405 funcs_common_deletion.go:417
getCleanupRemovedNodesMode fail-closed parser env.go:782-796 env.go:802-816

So the logic needs no re-review — my revision-3 verdict stands: nothing blocking.

Rebase-safety checks I did run

main gained new steps inside DeletingStateFlow since the last pass (GetWekaClient, FetchTargetCluster, ManageCsiNodeRetainLabel — from the csi-node retain-claim work), which is the one place a rebase could plausibly change this PR's behavior. It doesn't:

  • All three new steps sit behind WekaContainerManagesCsi, which is r.container.IsClientContainer() && config.Config.Csi.Enabled (csi_steps.go:19). ReleaseTerminalPodOnDeletion is gated on r.container.IsBackend, so the two sets are disjoint — a backend container skips the new steps entirely and still reaches the reap before DeactivateWekaContainer/resign, which is the ordering the PR depends on.
  • ManageCsiNodeRetainLabel carries ContinueOnError: true, so even for a client it cannot wedge the flow.
  • Config wiring intact: CleanupRemovedNodes has exactly two readers — funcs_common_deletion.go:619 (.CleansOnNodeRemoval()) and funcs_handle_node_statuses.go:99. main added no new reader, so no stale bool call site from the type change.
  • discovery.IsSupportedCloudProvider and KubeService.GetNodes(ctx, map[string]string) still exist with the same signatures.

Still open (carried over, all non-blocking)

# Finding Location
4 Cloud inference from any surviving node — homogeneity assumption breaks on mixed clusters; plus an unfiltered node List on every 30s requeue for up to 24h funcs_handle_node_statuses.go:133-148
5 Grace periods hardcoded (30m/24h), no env override; nodeRemovedKey a bare string vs. the typed API timestamp constants funcs_handle_node_statuses.go:24-26
Reap gate keys on phase only — a pod Terminating while still reporting phase Running is missed by both gates and keeps WekaFinalizer for the whole window funcs_handle_node_statuses.go:176
Doc block still describes the pre-narrowing trigger (no deletionTimestamp requirement) funcs_common_deletion.go:417
Grace-path tests assert only HaveOccurred(), which cannot distinguish a deliberate WaitError from a real failure — that assertion is what hid the original blocking bug funcs_handle_node_statuses_test.go:129,143,157,183,195
Predicate dereferences r.pod relying on IsNotFunc(r.PodNotSet) short-circuit order flow_deleting_state.go:85 (cosmetic)

The commit message now carries an explicit BREAKING CHANGE: note for the default flip, which closes the release-note half of finding #6 properly.

I did not run make test/go build — this sandbox denies the build command and module-cache access, so all conclusions above come from static reading of the tree; CI should confirm.
· branch

@rugggger rugggger changed the title feat: default cleanupRemovedNodes to auto with node-removal grace period fix: default cleanupRemovedNodes to auto with node-removal grace period Aug 26, 2026
@rugggger
rugggger force-pushed the 08-02-feat_default_cleanupremovednodes_to_auto_with_node-removal_grace_period branch from 7aab4a2 to 7a245a9 Compare August 26, 2026 14:10
@claude

claude Bot commented Aug 26, 2026

Copy link
Copy Markdown

Re-review (revision 6)

  • Diff the new revision against my previous review
  • Confirm whether any content changed
  • Post verdict

View job run

@rugggger
rugggger force-pushed the 08-02-feat_default_cleanupremovednodes_to_auto_with_node-removal_grace_period branch from 7a245a9 to ef8695e Compare August 26, 2026 14:12
@claude

claude Bot commented Aug 26, 2026

Copy link
Copy Markdown

Claude finished @rugggger's task in 1m 29s —— View job


Re-review (revision 6) — rebase only, no content change

  • Diff this revision against my last review
  • Check for rebase interaction with the new main commit
  • Confirm prior findings' status

This push is another rebase, this time onto a main that now contains 118bfdf8 ("support not allocating loop devices for weka"). AuthorDate: Aug 2, CommitDate: Aug 26 17:11 +0300; the tree is unchanged from revision 5 — every construct I cited is byte-identical at the same line numbers:

Construct Line
grace constants funcs_handle_node_statuses.go:24-26
phase-only reap gate funcs_handle_node_statuses.go:176
narrowed ReleaseTerminalPodOnDeletion predicate flow_deleting_state.go:86
doc drift on releaseTerminalPodOnDeletion funcs_common_deletion.go:416
fail-closed getCleanupRemovedNodesMode env.go:802

So the logic needs no re-review — my revision-3 verdict stands: nothing blocking.

Rebase-safety checks I did run

  • No file overlap between 118bfdf8 and this PR. That commit touches weka_runtime.py, resources/pod.go, container_factory.go, client_reconciler_loop.go, the api dumps, and the pkg/weka-k8s-api submodule pointer — none of the files this PR changes.
  • Config wiring intact: Config.CleanupRemovedNodes still has exactly two readers — funcs_common_deletion.go:619 (.CleansOnNodeRemoval()) and funcs_handle_node_statuses.go:99. No new reader arrived that would still expect the old bool type.
  • One thing I could not verify: 118bfdf8 bumps the pkg/weka-k8s-api submodule pointer, and the submodule isn't checked out in this sandbox, so I can't confirm weka.Stale / Status.Timestamps / WekaContainerModeDrive are unchanged there. The bump adds a loop-devices field, so a removal is unlikely — CI compilation is the check.

Still open (carried over, all non-blocking)

# Finding Location
4 Cloud inference from any surviving node — homogeneity assumption breaks on mixed clusters; plus an unfiltered node List on every 30s requeue for up to 24h funcs_handle_node_statuses.go:133-148
5 Grace periods hardcoded (30m/24h), no env override; nodeRemovedKey a bare string vs. the typed API timestamp constants funcs_handle_node_statuses.go:24-26
Reap gate keys on phase only — a pod Terminating while still reporting phase Running is missed by both gates and keeps WekaFinalizer for the whole window funcs_handle_node_statuses.go:176
Doc block still describes the pre-narrowing trigger (no deletionTimestamp requirement) funcs_common_deletion.go:416
Grace-path tests assert only HaveOccurred(), which cannot distinguish a deliberate WaitError from a real failure — that assertion is what hid the original blocking bug funcs_handle_node_statuses_test.go:129,143,157,183,195
Predicate dereferences r.pod relying on IsNotFunc(r.PodNotSet) short-circuit order flow_deleting_state.go:85 (cosmetic)

I did not run make test/go build — the pkg/weka-k8s-api submodule is not checked out here and module-cache access is blocked, so all conclusions come from static reading of the tree; CI should confirm.
· branch

@rugggger
rugggger force-pushed the 08-02-feat_default_cleanupremovednodes_to_auto_with_node-removal_grace_period branch from ef8695e to d91dba2 Compare September 7, 2026 10:14
@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown

Claude finished @rugggger's task in 1m 32s —— View job


Re-review (revision 7) — rebase only, no content change

  • Diff this revision against my last review
  • Check for rebase interaction with the new main commits
  • Confirm prior findings' status

This push is another rebase, now onto main tip 8a3a433a. AuthorDate: Aug 2 14:26, CommitDate: Sep 7 08:29. The PR's diff is content-identical to revision 6 — every construct I cited is byte-identical at the same line:

Construct Line
grace constants funcs_handle_node_statuses.go:25-26
phase-only reap gate funcs_handle_node_statuses.go:176
narrowed ReleaseTerminalPodOnDeletion predicate flow_deleting_state.go:86-87
doc drift on releaseTerminalPodOnDeletion funcs_common_deletion.go:416
fail-closed getCleanupRemovedNodesMode env.go:808

So no re-review of the logic is warranted — my revision-3 verdict stands: nothing blocking.

Rebase-safety checks I did run

Four main commits landed since my last pass; two of them touch files this PR also touches, so the merge result was worth checking:

  • f53778d2 (pod-metrics toggle) also edits internal/config/env.go — both sets of additions survived. Config.MetricsScraping… from that commit and this PR's CleanupRemovedNodesMode / getCleanupRemovedNodesMode() are both present; the parser now sits at env.go:808-823, wired at env.go:621. No lost hunk.
  • Three commits edit charts/weka-operator/values.yaml (f53778d2 podMetrics, 08be82b6 skipDefaultFilesystemCreation, 8a3a433a) — all still present (values.yaml:304, :732); this PR's only remaining hunk there is the cleanupRemovedNodes block at :174-183. Chart wiring intact: manager.yaml:180 still renders value: "{{ .Values.cleanupRemovedNodes }}", so an existing unquoted cleanupRemovedNodes: false still resolves to Off.
  • No stale bool call site. Config.CleanupRemovedNodes still has exactly two readers — funcs_common_deletion.go:619 (.CleansOnNodeRemoval()) and funcs_handle_node_statuses.go:99. The new main commits added no reader that would still expect the old bool.
  • f53778d2 also restructures reconciler_loop.go / metrics_steps.go, but only adds fields/steps around the metrics path; the new tests construct containerReconcilerLoop by field name, so added fields don't break them. f6d58f84 touches funcs_pod_ensure.go, unrelated to the deleting flow.

Still open (carried over, all non-blocking)

# Finding Location
4 Cloud inference from any surviving node — homogeneity assumption breaks on mixed clusters; plus an unfiltered node List on every 30s requeue for up to 24h funcs_handle_node_statuses.go:133-148
5 Grace periods hardcoded (30m/24h), no env override; nodeRemovedKey a bare string vs. the typed API timestamp constants funcs_handle_node_statuses.go:24-26
Reap gate keys on phase only — a pod Terminating while still reporting phase Running is missed by both gates and keeps WekaFinalizer for the whole window funcs_handle_node_statuses.go:176
Doc block still describes the pre-narrowing trigger (no deletionTimestamp requirement) funcs_common_deletion.go:416
Grace-path tests assert only HaveOccurred(), which cannot distinguish a deliberate WaitError from a real failure — that assertion is what hid the original blocking bug funcs_handle_node_statuses_test.go:131,145,159,185,211
Predicate dereferences r.pod relying on IsNotFunc(r.PodNotSet) short-circuit order flow_deleting_state.go:85 (cosmetic)

I did not run make test/go build — module-cache access is blocked in this sandbox, so all conclusions come from static reading of the tree; CI should confirm the rebase compiles.
· branch

Introduce a tri-state cleanupRemovedNodes config (false/true/auto) and make
auto the shipped default. Under auto, a backend WekaContainer whose target
node disappears from the cluster is held in Stale status for a grace period
before deletion, instead of being torn down immediately:

- managed cloud (AWS/EKS, OCI/OKE): 30m grace, aligned with
  managedNodesPodTerminationTimeout
- everything else: 24h grace

If the node returns within the window the grace stamp is cleared and the
container recovers to Running, so a transient node loss (reboot, brief
API-server blip, ASG churn) no longer forces an unnecessary
deactivate/resign/rebuild. "true" keeps the previous immediate-delete
behavior; "false" disables cleanup entirely. Cloud-provider detection falls
back to inferring from surviving cluster nodes once the removed node object
is gone.

While a container sits Stale, its already-exited backend pod is reaped
immediately (do-not-force-delete finalizer stripped + deleted) instead of
lingering in Terminating for the whole window.

Also reap a terminal backend pod at the start of the deleting flow: a new
ReleaseTerminalPodOnDeletion step strips the do-not-force-delete finalizer
from a Succeeded/Failed backend pod and removes it before the
deactivate/resign steps, so it is not wedged in Terminating behind a
DeactivateWekaContainer that can never complete once the node/process is gone.

Note: CLEANUP_REMOVED_NODES now defaults to "auto" instead of the
previous effective "false". Deployments that do not set cleanupRemovedNodes
explicitly will now delete a backend WekaContainer after its node has been gone
for the grace period (30m on managed cloud, 24h otherwise), whereas previously
such containers were never deleted on node removal. Set cleanupRemovedNodes:
"false" to retain the old behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rugggger
rugggger force-pushed the 08-02-feat_default_cleanupremovednodes_to_auto_with_node-removal_grace_period branch from d91dba2 to 3d831de Compare September 7, 2026 11:09
@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown

Claude finished @rugggger's task in 50s —— View job


Re-review (revision 8) — no change since my last pass

  • Diff this revision against my last review
  • Confirm the base and the diff content

This push is a no-op re-push: the branch base is still origin/main tip 8a3a433a — the same base as revision 7 — and the diff is content-identical (same 11 files, same 754/-18). Only the commit timestamp moved (AuthorDate: Aug 2 14:26, CommitDate: Sep 7 14:08 +0300, previously 08:29).

Spot-checked every construct I've been tracking; all byte-identical at the same lines:

Construct Line
grace constants funcs_handle_node_statuses.go:24-26
phase-only reap gate funcs_handle_node_statuses.go:176
grace-elapsed Delete + WaitError funcs_handle_node_statuses.go:216-219
narrowed ReleaseTerminalPodOnDeletion predicate flow_deleting_state.go:86-87
fail-closed getCleanupRemovedNodesMode env.go:808-823

Per the exit-early instruction, no re-review of the logic — my revision-3 verdict stands: nothing blocking.

Still open (carried over, all non-blocking)

# Finding Location
4 Cloud inference from any surviving node — homogeneity assumption breaks on mixed clusters; plus an unfiltered node List on every 30s requeue for up to 24h funcs_handle_node_statuses.go:133-148
5 Grace periods hardcoded (30m/24h), no env override; nodeRemovedKey a bare string vs. the typed API timestamp constants funcs_handle_node_statuses.go:24-26
Reap gate keys on phase only — a pod Terminating while still reporting phase Running is missed by both gates and keeps WekaFinalizer for the whole window funcs_handle_node_statuses.go:176
Grace-path tests assert only HaveOccurred(), which cannot distinguish a deliberate WaitError from a real failure funcs_handle_node_statuses_test.go:131,145,159,185,211
Predicate dereferences r.pod relying on IsNotFunc(r.PodNotSet) short-circuit order flow_deleting_state.go:85 (cosmetic)

The doc-drift item on funcs_common_deletion.go is resolved — the comment block at flow_deleting_state.go:72-78 now spells out the deletionTimestamp gate and why it's there.

I did not run make test/go build — module-cache access is blocked in this sandbox, so all conclusions come from static reading of the tree; CI should confirm.
· branch

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.

1 participant