Skip to content

fix(utils): sort nodeSelector keys in MergeNodeSelectorAndNodeAffinity to guarantee deterministic order - #6217

Open
frenemy17 wants to merge 1 commit into
fluid-cloudnative:masterfrom
frenemy17:fix/deterministic-node-affinity-merge
Open

frenemy17 wants to merge 1 commit into
fluid-cloudnative:masterfrom
frenemy17:fix/deterministic-node-affinity-merge

Conversation

@frenemy17

@frenemy17 frenemy17 commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Ⅰ. Describe what this PR does

In pkg/utils/kubeclient/pod.go, MergeNodeSelectorAndNodeAffinity converts nodeSelector map entries into corev1.NodeSelectorRequirement match expressions. Because Go map iteration order is non-deterministic, multi-label nodeSelector maps resulted in MatchExpressions slices in arbitrary order across reconciliation cycles.

When controllers construct or compare status (such as status.CacheAffinity), the fluctuating slice order caused status equality checks (reflect.DeepEqual) to detect spurious differences, triggering unnecessary API server status writes and re-reconciliation loops on every cycle.

This PR sorts the keys of nodeSelector alphabetically before building MatchExpressions, guaranteeing deterministic output order across all cycles and runtimes (CacheRuntime, Alluxio, Jindo, JuiceFS, Thin, etc.).

Ⅱ. Does this pull request fix one issue?

fixes #6216

Ⅲ. List the added test cases (unit test/integration test) if any, please explain if no tests are needed.

  • pkg/utils/kubeclient/pod_test.go:
    • Added test cases in DescribeTable for multi-label node selector (both with and without existing NodeSelectorTerms) verifying sorted MatchExpressions order.
    • Added regression tests running 100 repeated invocations on multi-label maps verifying byte-identical and order-identical output across calls for both fresh terms and appended terms.

Ⅳ. Describe how to verify it

go test -v ./pkg/utils/kubeclient -ginkgo.focus="MergeNodeSelectorAndNodeAffinity"

Ⅴ. Special notes for reviews

@fluid-e2e-bot

fluid-e2e-bot Bot commented Oct 21, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign trafalgarzzz for approval by writing /assign @trafalgarzzz in a comment. For more information see:The Kubernetes Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@fluid-e2e-bot

fluid-e2e-bot Bot commented Oct 21, 2026

Copy link
Copy Markdown

Hi @frenemy17. Thanks for your PR.

I'm waiting for a fluid-cloudnative member to verify that this patch is reasonable to test. If it is, they should reply with /ok-to-test on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work. Regular contributors should join the org to skip this step.

Once the patch is verified, the new status will be reflected by the ok-to-test label.

I understand the commands that are listed here.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes/test-infra repository.

@codecov

codecov Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 65.62%. Comparing base (86e3adf) to head (f5bb49a).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #6217   +/-   ##
=======================================
  Coverage   65.61%   65.62%           
=======================================
  Files         486      486           
  Lines       34361    34367    +6     
=======================================
+ Hits        22546    22552    +6     
  Misses      10061    10061           
  Partials     1754     1754           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@frenemy17

Copy link
Copy Markdown
Contributor Author

/assign @TrafalgarZZZ

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.

🟢 Approval recommended

The change is narrowly scoped, preserves selector semantics, and includes focused regression coverage with no unresolved findings.

No open issues 🎉

What changed in this PR

This PR fixes #6216 by making node-selector expression ordering deterministic in the shared affinity helper, preventing unnecessary status updates caused by fluctuating slice order.

Changes:

  • Sort node-selector keys before building match expressions, preserving scheduling semantics.
  • Add sorted-order coverage and a regression test checking consistency across 100 repeated calls.
File Description
pkg/​utils/​kubeclient/​pod.go Sorts selector keys to produce deterministic match expressions.
pkg/​utils/​kubeclient/​pod_test.go Tests alphabetical ordering and repeated-call consistency.

Review effort: Balanced

@cheyang cheyang 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.

Verified from the ground up: the premise behind this fix is real. On the merge-base, a multi-label nodeSelector makes MergeNodeSelectorAndNodeAffinity emit MatchExpressions in Go's randomized map order, so the reflect.DeepEqual status gate in pkg/ddc/*/status.go sees a phantom diff every cycle — we reproduced it independently with two harnesses (6 distinct key orderings per 1000 calls on base; 172/200 flapped comparison cycles and 26/30 real status writes per 30 reconcile cycles against an envtest API server). On this head both harnesses converge: sorted, byte-stable output on both generation paths, and exactly one status write before convergence. The sort is the right minimal fix — expression order carries no scheduling meaning inside a NodeSelectorTerm — the PR ships its own regression tests, caller packages show no new failures versus base, and CI is fully green at the reviewed head.

Two immaterial nits and one small coverage suggestion, none blocking: the PR body names a nonexistent status.WorkerNodeAffinity field (only status.CacheAffinity exists); the new tests pin the fresh-terms path but not the append-to-existing-affinity branch; and the same unsorted map-range pattern survives pre-existing in the cache-affinity webhook and two PV-creation sites — worth a tracking issue rather than changes here.

Next round

[MUST] (none — merge is not gated on any finding)
[SHOULD] Add one multi-label + existing-NodeSelectorTerms table entry to pin the append path (pod_test.go, mirroring the 'exist node affinity' entry)
[SHOULD] Correct status.WorkerNodeAffinity → status.CacheAffinity in the PR body
[SHOULD] File a tracking issue for the pre-existing unsorted map-range sites (node_affinity_with_cache.go:138,174; require_node_with_fuse.go:93; dataset/volume/create.go:119; efc/create_volume.go:150) and consider a shared sorted-keys helper
[Filed] none

Reviewer disagreement: none — every finding from both reviews was confirmed in cross-critique, and Reviewer C (Copilot) reviewed this head without raising any findings.

Non-blocking observations (4) — not part of this round's ask; fix now, follow up, or file an issue as you see fit
  • pkg/webhook/plugins/nodeaffinitywithcache/node_affinity_with_cache.go:174 [minor] — pre-existing, not introduced by this PR — Pre-existing: cache-affinity webhook composes injected terms by iterating maps, so multi-dataset pods can get NodeSelectorTerms in varying order across admissions
    Same class of nondeterminism as issue #6216, one layer down in the consumer of status.CacheAffinity: getTieredLocalityNodeSelectorTerms iterates for name, runtimeInfo := range runtimeInfos (line 174, required path) and getTieredLocalityPreferredSchedulingTerms iterates range preferredRuntimes (line 138, preferred path), so for pods bound to multiple datasets the order of injected NodeSelectorTerms varies between admissions even after this PR. NodeSelectorTerms are OR-ed and the inner MatchExpressions now come from the sorted status.CacheAffinity, so scheduling semantics are unchanged and no reconcile loop exists — but the emitted pod spec term order is nondeterministic. Present on the merge-base (pkg/webhook/ untouched by this PR); not introduced by this PR. Codex confirmed in cross-critique and independently corroborated the pre-existing attribution (empty base..head diff on pkg/webhook/). Tracking-issue material, not a hold on this PR.
  • pkg/utils/dataset/volume/create.go:119 [nit] — pre-existing, not introduced by this PR — Pre-existing: same unsorted map-range pattern survives in PV node affinity creation (producer side)
    pkg/utils/dataset/volume/create.go:119 and pkg/ddc/efc/create_volume.go:150 build pv.Spec.NodeAffinity.Required.NodeSelectorTerms[0].MatchExpressions by ranging over runtime.GetFuseNodeSelector() without sorting — the exact pattern this PR fixes in MergeNodeSelectorAndNodeAffinity. Benign today: those PVs are create-only (guarded by IsPersistentVolumeExistWithContext, no compare-then-update path in either file — verified by both reviewers) and scheduler semantics are order-insensitive. Not this PR's scope; a shared sorted-keys helper would prevent further copies. Claude's cross-critique addendum: at least two more pre-existing sites exist (pkg/webhook/plugins/requirenodewithfuse/require_node_with_fuse.go:93 and the nodeaffinitywithcache webhook above), so this is really the fourth and fifth copy of the pattern — strengthening the shared-helper recommendation without changing the verdict.
  • pkg/utils/kubeclient/pod_test.go:416 [minor] — PR's own suite lacks a multi-label + pre-existing-affinity (append path) case
    The two new tests cover only the podAffinity==nil generation path (table entry at line 416 and the 100-invocation determinism It at line 441); the only pre-existing entry reaching the append branch ("exist node affinity") uses a single-label map, structurally incapable of detecting an ordering regression. Determinism is equally load-bearing on the append branch (pod.go:148) where sorted expressions are appended to NodeSelectorTerms already present in the worker spec's Affinity — the realistic case when the Runtime CR specifies affinity that propagates into the worker StatefulSet template, and exactly what all 8 callers pass (workers.Spec.Template.Spec.Affinity). A future regression limited to that branch (e.g. a per-branch restructure of the key collection) would not be caught by this suite; the shared sort before the branch means a wholesale sort removal is still caught. Confirmed by Codex in cross-critique with independent corroboration (TestVerifyMergeIntoExistingTermsDeterministic fails on base, passes on head, unpinned by the PR's suite). Behavior itself is verified correct on head by both harnesses. Suggested fix: one more Entry with a multi-label nodeSelector plus a podAffinity carrying an existing NodeSelectorTerm, mirroring the existing 'exist node affinity' entry.
  • [nit] — nit: PR body mentions nonexistent status.WorkerNodeAffinity
    The PR description says the fluctuation affected 'status.CacheAffinity or status.WorkerNodeAffinity', but no field named WorkerNodeAffinity exists in api/ or pkg/ (grep finds zero hits); the local Go variables named workerNodeAffinity in pkg/ddc/{alluxio,jindo,jindocache,jindofsx,juicefs}/status.go are assigned into runtimeToUpdate.Status.CacheAffinity, and the #6200 CacheEngine path likewise writes status.CacheAffinity (pkg/ddc/cache/engine/status.go:73) — almost certainly where the name came from. Cosmetic doc inaccuracy only; the linked issue #6216 correctly names CacheAffinity, so the auto-close target is unaffected. Confirmed by Claude in cross-critique (missed in Claude's own round).
Verified review delta
  • F2-webhook-term-order-nondeterministic: open (preexisting)
  • pv-nodeaffinity-map-order-siblings: open (preexisting)
  • F1-coverage-append-path: open (initial-pr)
  • pr-body-workernodeaffinity-field: open (initial-pr)

Code: clear. CI: success; attribution: baseline. Required CI checks still apply.

…y to guarantee deterministic order (fluid-cloudnative#6216)

- In MergeNodeSelectorAndNodeAffinity, sort the keys of nodeSelector before building
  expressions to prevent Go map iteration randomization from producing matchExpressions
  in non-deterministic order.
- This eliminates spurious status updates and controller re-reconciles for multi-label workers.
- Add multi-label test entry and determinism regression tests over 100 repeated invocations in pod_test.go.

Fixes fluid-cloudnative#6216

Signed-off-by: Siddhanth Sadashiv Raikar <raikarsiddhanth@gmail.com>
@frenemy17
frenemy17 force-pushed the fix/deterministic-node-affinity-merge branch from 4fd9701 to f5bb49a Compare October 24, 2026 19:10
@sonarqubecloud

Copy link
Copy Markdown

@frenemy17

Copy link
Copy Markdown
Contributor Author

Thank you for the review! All review findings and recommendations have been addressed:

  • [SHOULD] Multi-label + existing NodeSelectorTerms test: Added Entry("multi-label node selector appended to existing node affinity terms sorted deterministically", ...) in pkg/utils/kubeclient/pod_test.go and a 100-iteration determinism test asserting byte-identical and order-identical MatchExpressions output when appending to existing affinity terms.
  • [SHOULD] Correct status.WorkerNodeAffinity in PR body: Corrected PR body to reference status.CacheAffinity.
  • [SHOULD] Tracking issue for pre-existing unsorted map-range sites: Opened [FEATURE] Unify and sort map iteration in NodeSelector and affinity generation sites across webhooks and PV volume creation #6220 to track introducing a shared sorted helper across nodeaffinitywithcache, requirenodewithfuse, dataset/volume/create.go, and efc/create_volume.go.

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.

🟢 Approval recommended

The localized sorting change preserves scheduling semantics and includes focused regression coverage for both merge paths.

No open issues 🎉

Review effort: Balanced

@cheyang cheyang 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.

Round 2 on the force-pushed head f5bb49a3 (previous head 4fd9701c): the delta is test-only — +84 lines in pkg/utils/kubeclient/pod_test.go, with pod.go byte-identical — and it closes every actionable item from the last review. The base moved 86e3adf3 → e5251b39, but that is a workflows-only dependency bump (no changes under pkg/, api/, or cmd/; the PR was not rebased), so the effective merge-base and every reviewed contract are unchanged.

What changed for the better, with evidence:

  • Append-path coverage added (the round-1 SHOULD): a multi-label + existing-NodeSelectorTerms table entry and a 100-invocation determinism spec now pin the append branch that all 8 callers actually exercise. The tests are real, not vacuous — grafted onto the unfixed base they fail (both new specs red; 4/10 focused), on head they pass 10/10. Two independent graft differentials agree, and the new test code was checked against the implementation (the DeepCopy at pod.go:101 makes the 101-call pointer reuse safe).
  • PR body corrected: the nonexistent status.WorkerNodeAffinity mention is gone — the body now names status.CacheAffinity only.
  • Pre-existing sites tracked: issue #6220 (opened by the author, verified open) covers the remaining unsorted map-range sites in the cache-affinity webhook and PV creation, so they stay out of this PR's scope by design rather than by omission.

Because the base moved, the premise was re-verified rather than carried: the bug still reproduces on the new base (169/200 simulated reconcile cycles flap; 20/30 cycles issue real status writes against an envtest API server), and the head is clean (0/200 flap; exactly one status write before convergence). No new findings in the delta, no caller changes, and all required CI is green at the frozen head (only the merge tide itself pending).

Next round

[MUST] (none — merge is not gated on any finding)
[SHOULD] (none outstanding — all round-1 SHOULD items are resolved)
[Filed] #6220 (pre-existing unsorted map-range sites: node_affinity_with_cache.go:138,174; require_node_with_fuse.go:93; dataset/volume/create.go:119; efc/create_volume.go:150 — filed by the author)

Reviewer disagreement: none — both reviews converged on the same four ledger entries with the same dispositions, and every cross-critique claim was independently reproduced. Reviewer C (Copilot) reviewed this head and raised no findings.

Non-blocking observations (2) — not part of this round's ask; fix now, follow up, or file an issue as you see fit
  • pkg/webhook/plugins/nodeaffinitywithcache/node_affinity_with_cache.go:174 [minor] — pre-existing, not introduced by this PR — Pre-existing: cache-affinity webhook composes injected terms by iterating maps, so multi-dataset pods can get NodeSelectorTerms in varying order across admissions
    Pre-existing (introducedByPR: false; git diff base..head -- pkg/webhook/ is empty on both bases, and the base advance touched no Go code): getTieredLocalityNodeSelectorTerms iterates for name, runtimeInfo := range runtimeInfos (line 174, required path) and getTieredLocalityPreferredSchedulingTerms iterates range preferredRuntimes (line 138, preferred path), so for pods bound to multiple datasets the order of injected NodeSelectorTerms varies between admissions even after this PR. NodeSelectorTerms are OR-ed and the inner MatchExpressions now come from sorted status.CacheAffinity, so scheduling semantics are unchanged and no reconcile loop exists — but the emitted pod spec term order is nondeterministic. Confirmed by both reviewers in round 1 and unchanged this round. Deferred, not a hold on this PR.
  • pkg/utils/dataset/volume/create.go:119 [nit] — pre-existing, not introduced by this PR — Pre-existing: same unsorted map-range pattern survives in PV node affinity creation (producer side)
    Pre-existing: pkg/utils/dataset/volume/create.go:119, pkg/ddc/efc/create_volume.go:150 and pkg/webhook/plugins/requirenodewithfuse/require_node_with_fuse.go:93 build MatchExpressions by ranging over runtime.GetFuseNodeSelector() without sorting — the exact pattern this PR fixes in MergeNodeSelectorAndNodeAffinity. Benign today: create-only paths (create.go guarded by IsPersistentVolumeExistWithContext; efc/create_volume.go uses plain Client.Create), no compare-then-update path in either file (verified by both reviewers), scheduler semantics order-insensitive. Not this PR's scope. Deferred, not a hold.
Verified review delta
  • F2-webhook-term-order-nondeterministic: deferred (preexisting)
  • pv-nodeaffinity-map-order-siblings: deferred (preexisting)
  • F1-coverage-append-path: fixed (initial-pr)
  • pr-body-workernodeaffinity-field: fixed (initial-pr)

Code: clear. CI: success; attribution: baseline. Required CI checks still apply.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] MergeNodeSelectorAndNodeAffinity emits MatchExpressions in nondeterministic map order

4 participants