Skip to content

perf(cache): derive worker node affinity during status construction to avoid duplicate read - #6200

Open
frenemy17 wants to merge 1 commit into
fluid-cloudnative:masterfrom
frenemy17:feat/cache-runtime-worker-nodeaffinity
Open

frenemy17 wants to merge 1 commit into
fluid-cloudnative:masterfrom
frenemy17:feat/cache-runtime-worker-nodeaffinity

Conversation

@frenemy17

@frenemy17 frenemy17 commented Oct 4, 2026 •

Copy link
Copy Markdown

Ⅰ. Describe what this PR does

Previously, each CacheRuntime status update cycle read the worker AdvancedStatefulSet twice:

  1. In manager.ConstructComponentStatus(...) to build worker replica status.
  2. Immediately after in manager.GetNodeAffinity(...) to compute worker node affinity.

This PR extends ComponentManager with ConstructComponentStatusAndAffinity(...) so that worker node affinity is derived directly from the single workload read that status construction already performs.

This addresses #5879 by cutting the lookup count in half (from 2 down to 1 Get per status cycle) with:

  • Zero staleness: Any out-of-band updates to the worker StatefulSet's nodeSelector are immediately reflected in status.CacheAffinity on the very next reconcile cycle (self-healing, without stale affinity reaching the nodeaffinitywithcache webhook).
  • No engine-level caching: Eliminates cache invalidation complexity, copy-discipline hazards, or pointer-identity issues.

Ⅱ. Does this pull request fix one issue?

fixes #5879

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

  • pkg/ddc/cache/component/component_test.go:
    • Added test verifying ConstructComponentStatusAndAffinity returns both status and affinity for AdvancedStatefulSetManager.
    • Added test verifying ConstructComponentStatusAndAffinity returns both status and affinity for DaemonSetManager.
  • pkg/ddc/cache/engine/status_test.go:
    • Added test verifying worker workload is read exactly once per status cycle (1 Get per cycle).
    • Added test pinning zero staleness: out-of-band updates to the worker StatefulSet's nodeSelector immediately update status.CacheAffinity on the subsequent cycle.
    • Added test verifying error propagation when the worker component is not found.

Ⅳ. Describe how to verify it

go test -v ./pkg/ddc/cache/...

Ⅴ. Special notes for reviews

None

@fluid-e2e-bot

fluid-e2e-bot Bot commented Oct 4, 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.

@frenemy17

Copy link
Copy Markdown
Author

/assign @yangyuliufeng

@codecov

codecov Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 65.52%. Comparing base (d8b37f2) to head (106b165).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #6200      +/-   ##
==========================================
+ Coverage   65.47%   65.52%   +0.04%     
==========================================
  Files         486      486              
  Lines       34307    34313       +6     
==========================================
+ Hits        22463    22482      +19     
+ Misses      10085    10076       -9     
+ Partials     1759     1755       -4     

☔ 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
frenemy17 force-pushed the feat/cache-runtime-worker-nodeaffinity branch from 49c54fc to 106b165 Compare October 6, 2026 05:07
@fluid-e2e-bot

fluid-e2e-bot Bot commented Oct 6, 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 ask for approval from yangyuliufeng by writing /assign @yangyuliufeng 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

@cheyang
cheyang requested a balanced review from Copilot and removed request for xliuqq October 8, 2026 00:02

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.

🟡 Changes recommended

Missing cache invalidation can leave application scheduling constraints stale after worker scheduling changes, including across controller restarts.

1 open finding
What changed in this PR

Reduces redundant worker workload reads during CacheRuntime status reconciliation, addressing #5879.

Changes:

  • Caches worker node affinity on the engine and reuses persisted status.
  • Adds tests for cache reuse, selector merging, and fetch failures.
File Description
pkg/​ddc/​cache/​engine/​status.go Reuses cached affinity during status updates.
pkg/​ddc/​cache/​engine/​status_test.go Tests affinity caching and API read counts.
pkg/​ddc/​cache/​engine/​engine.go Adds the worker affinity cache field.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment thread pkg/ddc/cache/engine/status.go Outdated
Comment on lines +75 to +78
if e.cacheAffinity != nil {
status.CacheAffinity = e.cacheAffinity.DeepCopy()
} else if status.CacheAffinity != nil {
e.cacheAffinity = status.CacheAffinity.DeepCopy()

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

The premise checks out mechanically — on base, every status-update cycle fetches the worker AdvancedStatefulSet twice (once in ConstructComponentStatus, once for node affinity), and this PR eliminates the second read via an engine-level cache plus a persisted-status fallback; the new happy-path tests are solid. One correction on framing: this controller's client is informer-cached, so the removed fetch never actually reached the API server (0 GETs/cycle measured against envtest on both base and head) — the saving is one informer lookup plus a deepcopy per cycle, not API-server load relief. The main concern is the tradeoff that saving buys: the cached affinity is never invalidated, so an out-of-band change to the worker StatefulSet's nodeSelector — the only way worker placement moves today, since fluid never updates the STS template in place — leaves status.CacheAffinity stale forever, even across controller restarts, and that field feeds the nodeaffinitywithcache webhook, which injects preferred and required node terms into app pods; on base the next cycle self-healed it. Both sides of this debate reproduced that regression independently, and agree on the cheapest fix: derive the affinity from the Get the status path already performs every cycle, which keeps the performance win with zero staleness. Smaller items: deep-copy the status write on the cache-miss branch, and add a test pinning the staleness semantic one way or the other. Copilot (advisory) flags the same issue with the same repro and remedy.

Comment thread pkg/ddc/cache/engine/status.go Outdated
if err != nil {
return false, err
// Worker Affinity: read from cache or runtime status to avoid calling GetNodeAffinity on every status update cycle
if e.cacheAffinity != nil {

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.

Once e.cacheAffinity (or the persisted status.CacheAffinity) is set, the worker AdvancedStatefulSet's nodeSelector/node-affinity is never read again — not on later cycles, not after a controller restart (the persisted-status branch re-seeds the engine cache with no fetch), not ever; the only escape is deleting the CacheRuntime CR. The worker STS pod template can change while the runtime lives, and fluid's own control plane neither propagates nor notices it (reconcileStatefulSet is create-only; SyncComponentSpec patches only replicas/image/resources), so an out-of-band STS edit or delete+recreate — the standard way to move cache workers today — is exactly the change this cache freezes out. This is not a cosmetic status field: status.CacheAffinity is consumed by the nodeaffinitywithcache webhook (pkg/webhook/plugins/nodeaffinitywithcache/node_affinity_with_cache.go:159,194), which injects it as preferred AND required node-selector terms on app pods, and pkg/ddc/thin/referencedataset/sync.go:235 propagates it into ThinRuntime status. A stale value pins newly scheduled app pods to the old node set and can make them unschedulable on the required tier. On base, the every-cycle refetch self-healed this within one sync cycle; this PR silently removes that self-healing. Confirmed by two independent verification harnesses (reviewer branches verify/cache-affinity-claude and verify/cache-worker-affinity-cache-codex on the cheyang/fluid fork): after the cache warms and the worker STS's nodeSelector changes out-of-band, status.CacheAffinity stays at the old value on this head and refreshes on base — red on head, green on base, and green on head under a temporary always-fetch control patch; the staleness also survives a simulated controller restart. Suggested fix: cheapest and fully correct — derive the affinity from the STS object ConstructComponentStatus already fetches every cycle (zero extra reads, never stale); or invalidate on template/spec change or TTL; or, if out-of-band STS changes are declared unsupported, document create-time-only semantics as an explicit decision rather than a side effect of a perf PR. Rated major rather than blocker because the trigger requires an out-of-band STS template change, but it is a verified regression of self-healing in a scheduling-visible field.

Comment thread pkg/ddc/cache/engine/engine.go Outdated
// always use getRuntimeInfo() method instead of use this directly.
runtimeInfo base.RuntimeInfoInterface

// cacheAffinity caches the worker node affinity to avoid calling kubeclient.GetStatefulSet on every status update cycle

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.

The PR (and issue #5879) frames this as removing unnecessary API-server load, but the cache controller's client is informer-cached for everything except Secrets (cmd/cache/app/cache.go wires NewFluidControllerClient → NewCacheClientBypassSecrets; controller-runtime routes reads through the manager's informer cache, and the AdvancedStatefulSet informer is warm regardless). Measured against a real API server (envtest, counting transport): 0 API-server GETs of the worker ASTS per status cycle on both base and this PR with the cached client; 2 per cycle on base and 1 after the first cycle on this PR with a direct (uncached) client. The real saving is one informer lookup + ASTS deepcopy per status cycle — worth having, but CPU-level. This recalibrates the F1 tradeoff (a CPU micro-optimization bought with permanent staleness of a scheduling-relevant field), so the PR/commit message should describe the benefit accurately.

Comment thread pkg/ddc/cache/engine/status.go Outdated
if err != nil {
return false, err
// Worker Affinity: read from cache or runtime status to avoid calling GetNodeAffinity on every status update cycle
if e.cacheAffinity != nil {

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.

Is an out-of-band edit or delete+recreate of the worker AdvancedStatefulSet (to change nodeSelector/affinity) a supported way to move cache workers? Fluid's reconciler never updates the STS pod template after creation (create-only reconcileStatefulSet; SyncComponentSpec touches only replicas/image/resources), so it is de facto the only way today — yet neither this PR nor issue #5879 states a position. The answer decides the F1 disposition: if supported, the cache needs invalidation (or the piggyback design); if declared unsupported, the staleness needs only explicit documentation (status.CacheAffinity is create-time-only, delete the CacheRuntime to refresh). Since the field feeds the nodeaffinitywithcache scheduling webhook, the call should be the maintainer's, made explicitly rather than implied by a perf PR.

Comment thread pkg/ddc/cache/engine/status_test.go Outdated
})
})

Describe("Worker node affinity caching", func() {

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.

The four new specs cover the happy caching paths well (first-cycle fetch + Get counts, init from persisted status, NodeSelector merge across cycles, GetNodeAffinity error propagation). Missing: (a) a spec where the worker STS pod template changes after the cache is warm — the exact scenario where the F1 staleness lives, and the regression guard if F1 is fixed — asserting either that the value is intentionally kept (locking in the tradeoff) or that it refreshes (if invalidation is added); (b) a spec asserting CacheAffinity is still persisted after a conflict retry (the existing conflict test, which predates this PR, covers worker phase only; a supplementary harness spec shows the current behavior is correct, so this is a coverage gap, not a bug). Drop-in starting points already exist: TestVerifyStaleCacheAffinityAfterWorkloadChange on branch verify/cache-worker-affinity-cache-codex and the VA-F1/VA-F1b/VA-C1 specs on branch verify/cache-affinity-claude.

Comment thread pkg/ddc/cache/engine/status.go Outdated
return false, err
}
e.cacheAffinity = affinity
status.CacheAffinity = affinity

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.

On the cache-miss branch, e.cacheAffinity = affinity; status.CacheAffinity = affinity leaves the engine cache and the status object handed to the API server as the same object, while the other two branches DeepCopy(). No misbehavior today (nothing mutates either reference in place after the write), but any future in-place mutation of the written status would silently corrupt the engine cache. Verified by two independent pointer-identity canaries. Fix: status.CacheAffinity = affinity.DeepCopy() for copy-discipline consistency across all three branches.

Comment thread pkg/ddc/cache/engine/status.go Outdated
status.CacheAffinity = e.cacheAffinity.DeepCopy()
} else if status.CacheAffinity != nil {
e.cacheAffinity = status.CacheAffinity.DeepCopy()
} else {

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.

ConstructComponentStatus and GetNodeAffinity each Get the same worker AdvancedStatefulSet in the same status cycle (call-count evidence: base performs 2 worker-STS Gets per status cycle, one of which is the status-construction Get that already carries the object the affinity is derived from). Returning the merged node affinity out of that one read — or extending the ComponentManager interface with a combined status+affinity method, which already groups both operations — would meet issue #5879's goal with zero staleness risk and no engine-level cache to invalidate, letting this cache block be dropped entirely. This is also the cheapest correct fix for the major finding above, and makes it moot at the cost of a small interface change.

…o avoid duplicate read (fluid-cloudnative#5879)

Signed-off-by: Siddhanth Sadashiv Raikar <raikarsiddhanth@gmail.com>
@frenemy17
frenemy17 force-pushed the feat/cache-runtime-worker-nodeaffinity branch from 106b165 to 1d66cff Compare October 8, 2026 10:07
@frenemy17 frenemy17 changed the title perf(cache): avoid fetching statefulset for node affinity on every status update cycle perf(cache): derive worker node affinity during status construction to avoid duplicate read Oct 8, 2026
@sonarqubecloud

sonarqubecloud Bot commented Oct 8, 2026

Copy link
Copy Markdown

@frenemy17

Copy link
Copy Markdown
Author

Makes total sense about the informer cache and the staleness risk with out-of-band updates, good call.

Refactored it to drop the engine-level cache entirely. Instead, we now just pull affinity directly from the workload object during status construction via ConstructComponentStatusAndAffinity.

Quick rundown of what’s in this push:

  • Removed cacheAffinity on CacheEngine completely, so zero chance of staleness if nodeSelector shifts out-of-band.
  • Extended ComponentManager so both StatefulSet and DaemonSet managers return affinity alongside status from that single fetch.
  • Added tests in status_test.go checking that we only fetch once per cycle and that nodeSelector updates immediately reflect on the next run.
  • Added manager unit tests in component_test.go.

Let me know what you think..

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.

[CacheRuntime] get node affinity for worker do not call kubeclient.GetStatefulSet in every status update cycle

4 participants