Skip to content

fix: keep OFED DaemonSet while its node pool is momentarily empty - #3362

Merged
e0ne merged 1 commit into
masterfrom
bug/5302092-ofed-stale-daemonset-grace-period
Oct 7, 2026
Merged

e0ne merged 1 commit into
masterfrom
bug/5302092-ofed-stale-daemonset-grace-period

Conversation

@rmilshtein89

@rmilshtein89 rmilshtein89 commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

One OFED DaemonSet is rendered per node pool, and pool membership is re-derived from NFD labels on every reconciliation. A pool therefore has no eligible nodes whenever its nodes are momentarily invisible to the operator — NFD labels missing right after a reboot, an untolerated taint, or an API server blip. An empty pool renders no DaemonSet, and the generic stale-object cleanup read that as "no longer wanted" and deleted a DaemonSet whose workload was still running and still needed.

An undesired DaemonSet has two causes the object alone cannot tell apart: it is genuinely unwanted, or pool discovery found no eligible nodes for it. Only the second is recoverable, so this change decides between them by asking whether the DaemonSet's own pool is still live — never by looking at node conditions or pod readiness, which say nothing about intent either.

  • Pool identity is recovered from the DaemonSet's own nodeSelector, which already pins the three NFD labels (OS name, OS version, full kernel version) that define a pool. The check needs no stored state and works for DaemonSets rendered by earlier operator versions. nodeinfo.PoolName is extracted so pool naming has a single definition.
  • An undesired DaemonSet whose pool is empty is kept and annotated with network.nvidia.com/stale-since (RFC3339 UTC). The shared ServiceAccount, RBAC objects, and init container ConfigMap are kept alongside it so the retained pods can restart. The marker lives on the object so the deferral survives operator restarts and leader-election handovers, instead of restarting the grace period — which would leave a genuinely retired pool's DaemonSet behind forever. A marker is preferred over a finalizer-guarded deletion because a deletion cannot be abandoned once deletionTimestamp is set, and recovery must be able to abandon it.
  • If the pool comes back, the annotation is removed on the next reconciliation and the deferral is canceled. If the pool is still empty after staleOFEDGracePeriod (20m), the pool is treated as retired and the DaemonSet is deleted.
  • A passing deadline produces no cluster event, so the OFED state implements a new state.RequeueProvider and reports its remaining delay up through Results.RequeueAfter. Both policy controllers schedule a RequeueAfter for it, including on an otherwise ready reconciliation — where nothing else would bring the policy back.

The generic cleanup in state_skel.go gains an opt-in retainStaleFunc veto; retained objects are reported as neither stale nor pending removal, so a state that keeps an object does not report itself notReady forever because of it. handleStaleStateObjects keeps its existing signature and behavior for every other state.

What the deferral does and does not cover

Dropping ofedDriver from a policy, or deleting the policy, still removes the DaemonSet at once whether or not the pool has nodes. The deferral only ever covers a DaemonSet the operator stopped rendering because pool discovery came up empty.

A driver version change is applied in place, because a DaemonSet is named after its pool (mofed-<os><ver>-<kernelhash>) and not after the version. While the pool has nodes it lands on the next reconciliation, as before. While the pool has none there is no rendered DaemonSet to carry the new version, and deleting the old one would take the driver away from nodes that are still running it — the failure this change exists to prevent. The upgrade therefore waits and is applied the moment the pool reappears; if the pool never does, the DaemonSet is reaped with it at the end of the grace period.

Tolerations

The driver pod now tolerates the NoSchedule halves of node.kubernetes.io/not-ready and node.kubernetes.io/unreachable. Without them a node going briefly NotReady — which the driver's own openibd restart can cause — makes itself ineligible and empties its own pool. The DaemonSet controller injects only the NoExecute halves, so a running pod survives the taint but a restarting one could not be placed again.

Node eligibility is judged against what the pod tolerates, and the two were kept as separate hand-maintained lists — one in Go for filtering, one hardcoded in the DaemonSet manifest — with nothing holding them in agreement. Both are now derived from a single definition:

  • driverPodTolerations = CR tolerations + operatorDriverPodTolerations (nvidia.com/gpu:NoSchedule and the not-ready/unreachable NoSchedule pair). This is what the pod template is rendered with; the hardcoded GPU toleration is gone from the manifest.
  • schedulableNodeTolerations = driverPodTolerations + daemonSetControllerTolerations. This is what node eligibility is judged against.

Because the filter set is derived from the pod set it is a superset by construction, so it cannot drift into claiming a node the pod could not be scheduled onto. A filter stricter than scheduling would drop nodes that could run the driver; a looser one would claim nodes the pod could never be placed on.

Validation

  • go build ./...
  • go test ./pkg/state/... ./pkg/nodeinfo/... — includes the new pkg/state/state_ofed_stale_test.go covering first observation, deferral within the grace period, cancellation when the pool returns, reaping after expiry, persistence of the marker across restarts, an unparsable annotation, a DaemonSet with no pool in its nodeSelector, immediate deletion on intentional removal, and the three driver-version-change cases (applied in place with a live pool, deferred then applied across an empty-pool window, abandoned with the pool after expiry). Also asserts the rendered DaemonSet's tolerations equal driverPodTolerations and are covered by schedulableNodeTolerations.
  • go test ./controllers/... (envtest) — the taint/toleration integration spec asserts the DaemonSet survives a pool-emptying taint with the same UID and gains the annotation, rather than being deleted.
  • gofmt -l ./controllers ./pkg — clean

Docs

docs/heterogeneous-cluster-support.md gains a "Node Pool Disappearance and Deferred Cleanup" section describing the behavior, the annotation, the grace period, and exactly which deletions are and are not deferred, plus a "Tolerations and Pool Eligibility" section describing the two derived toleration sets.

@copy-pr-bot

copy-pr-bot Bot commented Oct 4, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@greptile-apps

greptile-apps Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[High risk] Changes how the OFED driver DaemonSet lifecycle is managed.

The PR appears safe to merge based on this review; no new findings or outstanding previous findings remain.

Findings

  1. P1 Version changes retain old driver ▶

Summary

The PR defers cleanup of OFED DaemonSets when their node pools temporarily have no eligible nodes.

  • It records the deferral on each DaemonSet, schedules reconciliation at the grace-period deadline, and keeps shared resources needed by retained pods.
  • It derives rendered pod tolerations and node-eligibility tolerations from a common definition.
  • There have been no changes since the previous review.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Reconcile OFED policy] --> B{DaemonSet still desired?}
  B -- Yes --> C[Clear stale marker]
  B -- No --> D{Pool absent and policy not retargeted?}
  D -- No --> E[Ordinary stale cleanup]
  D -- Yes --> F{Grace period expired?}
  F -- No --> G[Retain DaemonSet and shared resources; schedule requeue]
  F -- Yes --> E
Loading

Reviews (8) · Last reviewed commit: "fix: keep OFED DaemonSet while its node ..." · Reviewed by Greptile

Comment thread pkg/state/state_ofed.go
Comment on lines +602 to +608
if _, live := livePools[poolName]; live {
// The pool has eligible nodes and still does not want this DaemonSet, so something
// changed on purpose.
return 0, nil
}

staleSince, err := s.markStaleSince(ctx, ds, now)

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.

P1 Version changes retain old driver When the OFED driver version changes while its node pool has no eligible nodes, no replacement DaemonSet is rendered. This branch treats the old DaemonSet as a temporary pool disappearance and retains it for up to 20 minutes. Existing pods can keep running the old driver despite the intentional policy change. The new documentation also says a version change deletes or replaces the DaemonSet immediately.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Deleting the DaemonSet on a version change with an empty pool wouldn't give those nodes the new driver; it would give them no driver at all, because no replacement can be rendered while the pool is empty.

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.

You're right — deleting the old DaemonSet in that situation would leave the affected nodes without any OFED driver, since no replacement can be rendered while the pool is empty. The version-change behavior in this PR is intentional: retain the existing DaemonSet through the grace period, apply the new image in place when the pool reappears, and reap it only if the pool remains absent.

My previous comment incorrectly treated this as an immediate replacement/deletion case; it should be withdrawn.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

Comment thread pkg/state/state_ofed.go Outdated
@rmilshtein89
rmilshtein89 force-pushed the bug/5302092-ofed-stale-daemonset-grace-period branch from 918bd57 to 3ac11cd Compare October 5, 2026 10:57
@rmilshtein89

Copy link
Copy Markdown
Contributor Author

Addressed both findings (force-pushed 3ac11cd). One was a real bug and is fixed; the other is a documentation defect rather than a behavioral one, and I've explained why below.

  1. Filtered nodes cannot schedule pods — correct, and the fix goes a step further than the report.

    The reviewer is right that addDefaultDaemonSetTolerations only ever fed the node filter, while the pod template got cr.GetTolerations() plus an nvidia.com/gpu toleration hardcoded in 0050_ofed-driver-ds.yaml. Adding the two NoSchedule tolerations to the filter alone made it claim nodes the pod could not be placed on.

    The underlying problem is that "what the pod tolerates" lived in two hand-maintained lists — one in Go, one in YAML — with nothing holding them in agreement, so this class of bug was always available. Both are now derived from one definition in state_ofed.go:

    • driverPodTolerations = CR tolerations + operatorDriverPodTolerations (nvidia.com/gpu:NoSchedule plus the not-ready/unreachable NoSchedule pair). This is what the pod template is rendered with; the hardcoded GPU toleration is gone from the manifest.
    • schedulableNodeTolerations = driverPodTolerations + daemonSetControllerTolerations. This is what node eligibility is judged against.

    Since the filter set is derived from the pod set it is a superset by construction, so it cannot drift into claiming an unschedulable node. mergeTolerations also copies instead of appending onto the CR's slice, which previously could write into the CR's backing array when it had spare capacity.

    New tests: the rendered DaemonSet's tolerations must equal driverPodTolerations, and schedulableNodeTolerations must contain all of them. The existing addDefaultDaemonSetTolerations specs now target schedulableNodeTolerations, which has the same semantics and the same 10-toleration result.

  2. Version changes retain old driver — the documentation was wrong; the behavior is intended and I'd like to keep it.

    A DaemonSet is named after its pool (mofed-<os><ver>-<kernelhash>), not after the driver version, so a version change rewrites the existing object rather than creating a replacement. With a live pool it lands on the next reconciliation exactly as before.

    With an empty pool there is no rendered DaemonSet to carry the new version, because rendering is driven by pool attributes the operator cannot see. The only alternative to retaining the old one is deleting it, which takes the driver away from nodes that are still running it and still need it — the precise failure this PR exists to prevent. So the upgrade waits, and is applied the moment the pool reappears; if it never does, the DaemonSet is reaped with the pool at the end of the grace period.

    What was genuinely wrong was the claim, in the PR body, the commit message, and docs/heterogeneous-cluster-support.md, that a version change always takes effect immediately. All three now state the real rule.

    While pinning this down I also found that the spec asserting it — "deletes a DaemonSet that is undesired while its pool still has nodes" — was passing for the wrong reason. It asserted the DaemonSet's UID changes on a version change, which it does not; the UID merely goes empty because mergeObjects copies only resourceVersion onto the update. The assertion would have held no matter what the code did. It's replaced by three specs that assert on the rendered driver image: applied in place with a live pool, deferred and then applied across an empty-pool window, and abandoned with the pool if the grace period expires.

Verification: go build ./..., go test ./pkg/state/... ./pkg/nodeinfo/..., go test ./controllers/... (envtest, 90s, full suite green), gofmt -l ./controllers ./pkg clean. golangci-lint could not run locally — the pinned v2.12.2 is built with go1.26 and panics on a dependency that requires go1.27 — so I'm relying on the CI job for lint.

@rmilshtein89
rmilshtein89 force-pushed the bug/5302092-ofed-stale-daemonset-grace-period branch 2 times, most recently from 0f17a9d to 4f2a536 Compare October 5, 2026 12:44
@almaslennikov

Copy link
Copy Markdown
Collaborator

Hey @rmilshtein89 thanks for the PR! What will happen if we:

  1. Change the node selector for the NicNodePolicy to target a different node
  2. Create a new NicNodePolicy targeting the first node?

Will the new mofed daemonset (from the second policy) load immediately or will it be prevented from loading by antiAffinity?

@rmilshtein89

Copy link
Copy Markdown
Contributor Author

Hey @rmilshtein89 thanks for the PR! What will happen if we:

  1. Change the node selector for the NicNodePolicy to target a different node
  2. Create a new NicNodePolicy targeting the first node?

Will the new mofed daemonset (from the second policy) load immediately or will it be prevented from loading by antiAffinity?

Thanks @almaslennikov — good question, and the answer differs depending on whether the two nodes are in the same pool.

policy B's DaemonSet is created immediately, but its pod on node1 is blocked by the anti-affinity until policy A's pod there is fully gone. It's a transient block, never permanent.

The anti-affinity is cross-policy by design — it selects on nvidia.com/ofed-driver existence with topologyKey: kubernetes.io/hostname and no namespace restriction, and both policies render into the operator namespace. So it enforces "at most one driver pod per node" regardless of which policy owns it. ds-owner scopes cleanup, not scheduling. And because it's IgnoredDuringExecution, A's running pod is never evicted to make room; B just waits.

If node1 and node2 share OS + kernel version (same pool): pool name is osName+osVersion-kernel and the DaemonSet name carries no node identity, so policy A re-renders the same DaemonSet with an updated nodeSelector. planStaleRetention sees it as still desired, so nothing is retained. The DaemonSet controller then removes A's pod from node1 — note updateStrategy: OnDelete doesn't block this, since it only governs pod-template updates on nodes that should run the daemon. B's pod schedules as soon as A's finishes terminating: seconds to a couple of minutes, bounded by terminationGracePeriodSeconds (default 300) while the driver unloads its modules.

If they have different kernels or OS versions (different pools): policy A's live pool is now node2's, so its old DaemonSet isn't rendered anymore. planStaleRetention recovers that DaemonSet's pool from its own nodeSelector, finds it absent from livePools, and retains it — the retained DaemonSet keeps its old nodeSelector, so its pod keeps running on node1 and keeps blocking B for up to the full 20m staleOFEDGracePeriod. Policy B reports NotReady that whole time.

Worth being upfront that this is a limitation of the current check: it asks "does this DaemonSet's pool have eligible nodes?", which cannot distinguish "the policy was intentionally pointed away from this pool" from "this pool's nodes went momentarily invisible". Both look identical from the object. Deleting A's old DaemonSet by hand short-circuits the wait.

Comment thread pkg/state/state_ofed.go Outdated
Comment thread pkg/state/state_ofed_stale_test.go
@rmilshtein89
rmilshtein89 requested a review from rollandf October 6, 2026 14:19
One OFED DaemonSet is rendered per node pool, and pool membership is
re-derived from NFD labels on every reconciliation. A pool therefore
has no eligible nodes whenever its nodes are momentarily invisible to
the operator: NFD labels missing right after a reboot, an untolerated
taint, or an API server blip. An empty pool renders no DaemonSet, and
the generic stale-object cleanup read that as "no longer wanted" and
deleted a DaemonSet whose workload was still running and still needed.

An undesired DaemonSet has two causes that the object alone cannot
tell apart -- genuinely unwanted, or pool discovery found no eligible
nodes -- and only the second is recoverable. Decide between them by
asking whether the DaemonSet's own pool is still live, never by
looking at node conditions or pod readiness, which say nothing about
intent either. The pool is recovered from the DaemonSet's own
nodeSelector, which already pins the three NFD labels that define a
pool, so the check needs no stored state and works for DaemonSets
rendered by earlier operator versions.

When the pool is empty the DaemonSet is kept and stamped with
network.nvidia.com/stale-since, and the shared ServiceAccount, RBAC
and init container ConfigMap are kept alongside it so the retained
pods can restart. The marker lives on the object so the deferral
survives operator restarts and leader-election handovers instead of
restarting the grace period, which would leave a genuinely retired
pool's DaemonSet behind forever. A marker is preferred over a
finalizer-guarded deletion because a deletion cannot be abandoned
once deletionTimestamp is set, and recovery must be able to abandon
it. If the pool comes back the marker is dropped; if the pool is
still absent after staleOFEDGracePeriod it is treated as retired and
the DaemonSet is deleted.

Dropping ofedDriver or deleting the policy still removes the
DaemonSet at once, pool or no pool: the deferral only ever covers a
DaemonSet the operator stopped rendering because discovery came up
empty. A driver version change is applied in place, since a
DaemonSet is named after its pool and not after the version. It
lands on the next reconciliation while the pool has nodes; while the
pool has none there is no rendered DaemonSet to carry the new
version, and deleting the old one would take the driver away from
nodes that are still running it, so the upgrade waits for the pool
and is applied the moment it reappears.

Retargeting a NicNodePolicy is intent too and is likewise exempt,
because deferring it would hold the old driver pod on a node for the
whole grace period, and the driver pods are mutually anti-affine per
node, so a policy that had taken that node over could not start there
until the deferral expired. A rendered nodeSelector is the policy's
own nodeSelector laid over the pool labels, so ask whether a
DaemonSet's nodeSelector still covers everything its policy selects
for; if it does not, it was built for different nodes and goes to the
stale cleanup at once, abandoning a deferral already counting down.
The pool labels are not reserved to the manifest -- targeting one OS
build is an ordinary thing for a policy to write -- and entries a
policy sets count as its own, which is why the question is about
covering rather than about subtracting a list of label keys from the
rendered selector. Covering is one-directional on purpose: a policy
that drops one of several selectors has widened, every node it
matched before it matches still, so the pool check alone decides.
Node labels changing is not intent either, since a label disappearing
is indistinguishable from the transient label loss the deferral
absorbs, whereas a policy edit is unambiguous. A NicClusterPolicy has
no nodeSelector at all, so its DaemonSets never look retargeted.

A passing deadline produces no cluster event, so the OFED state
reports its remaining delay up through the state manager and both
policy controllers schedule a RequeueAfter for it, including on an
otherwise ready reconciliation where nothing else would bring the
policy back.

Finally, make the driver pod tolerate the NoSchedule halves of the
not-ready and unreachable taints. Without them a node going briefly
NotReady -- which the driver's own openibd restart can cause --
makes itself ineligible and empties its own pool. The DaemonSet
controller injects only the NoExecute halves, so a running pod
survives the taint but a restarting one could not be placed again.

Node eligibility is judged against what the driver pod tolerates,
and the two were kept as separate lists -- one in Go for filtering,
one hardcoded in the DaemonSet manifest -- with nothing holding them
in agreement. Derive both from a single definition instead:
driverPodTolerations is what the pod template is rendered with, and
schedulableNodeTolerations is that plus what the DaemonSet
controller injects. A filter stricter than scheduling drops nodes
that could have run the driver; a looser one claims nodes the pod
could never be placed on.

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Signed-off-by: Ruven Milshtein <rmilshtein@nvidia.com>
@rmilshtein89
rmilshtein89 force-pushed the bug/5302092-ofed-stale-daemonset-grace-period branch from 72a13c8 to 20d620a Compare October 7, 2026 07:39

@almaslennikov almaslennikov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@rmilshtein89 Thank you for working on this! LGTM

@e0ne e0ne left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

@e0ne
e0ne merged commit 13d2916 into master Oct 7, 2026
42 of 45 checks passed
@e0ne
e0ne deleted the bug/5302092-ofed-stale-daemonset-grace-period branch October 7, 2026 12:58
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.

4 participants