fix: prevent stale scale-loop result from clobbering fresh unpause - #7877
fix: prevent stale scale-loop result from clobbering fresh unpause#7877Goutham-Annem wants to merge 1 commit into
Conversation
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
|
Thank you for your contribution! 🙏 Please understand that we will do our best to review your PR and give you feedback as soon as possible, but please bear with us if it takes a little longer as expected. While you are waiting, make sure to:
Once the initial tests are successful, a KEDA member will ensure that the e2e tests are run. Once the e2e tests have been successfully completed, the PR may be merged at a later date. Please be patient. Learn more about our contribution guide. |
|
Nice fix! Re-validating the Paused condition against the freshly fetched object before applying is the right approach. The test with stale vs fresh ScaledObject state is clear and covers the exact race condition. The CHANGELOG entry is well-written too. |
There was a problem hiding this comment.
Pull request overview
This PR fixes a race where a background scale-loop iteration can re-apply a stale Paused=true condition onto a ScaledObject that has already been unpaused, by re-validating Paused=true results against the freshly fetched object before merging conditions.
Changes:
- Add a guard in
scaleHandler.handleResultto skip applyingPaused=truefrom a stale scale-loop result when the current object no longer needs to be paused by annotation. - Add a regression unit test covering the stale-paused vs fresh-unpause scenario.
- Add a changelog entry documenting the user-visible fix for #6421.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| pkg/scaling/scale_handler.go | Re-validates Paused=true against the current object before merging conditions to prevent stale clobbering. |
| pkg/scaling/scale_handler_test.go | Adds a regression test for the stale paused result overriding a fresh unpause. |
| CHANGELOG.md | Documents the fix under Unreleased → Fixes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Since #6421 is fairly old, a lot has changed in the pause handling in the meantime (the reconciler now has a dedicated So, could you help me to check if the original scenario from #6421 (creating a ScaledObject that already carries the |
fadab06 to
900443a
Compare
vidigoat
left a comment
There was a problem hiding this comment.
Subtle race, and the re-validation approach reads well — recomputing NeedToBePausedByAnnotation() against the freshly-fetched object before letting a stale Paused=true through is a clean way to keep the scale loop from clobbering a just-persisted unpause.
One scoping question: the guard type-asserts current to *ScaledObject, so it's skipped for ScaledJob. Is the same stale-paused-clobbers-unpause race reachable for ScaledJobs, or is the paused-by-annotation path effectively ScaledObject-only here? Just want to be sure #6421 doesn't need a parallel guard on the ScaledJob path. Otherwise this looks solid.
|
Thanks for the question, @rickbrouwer. I traced the current The path that causes it (current
So yes, the original #6421 symptom is still reproducible on On the |
cff7e91 to
70fe88c
Compare
|
Applied your suggestion — changed |
70fe88c to
2791a6d
Compare
|
Addressed the test weakness: added a |
The scale loop's RequestScale computes its result.Conditions (including Paused) against whatever ScalableObject it was handed, which can be a cached read that hasn't yet observed a recent annotation change. Since handleResult re-fetches the object fresh before patching, a stale Paused=true decision from that earlier computation could overwrite a Paused=false condition the reconciler had already persisted moments earlier, causing Paused to flip back to true after a ScaledObject is unpaused. Re-validate the Paused condition against the freshly fetched object before applying it in handleResult, so a stale decision can no longer clobber a more recent unpause. Fixes kedacore#6421 Signed-off-by: Goutham Annem <gouthemannem@gmail.com>
2791a6d to
6d7fcab
Compare
|
Thanks for the follow-up reviews, @rickbrouwer and @vidigoat. I've addressed all the open points: @rickbrouwer's suggestions:
@vidigoat's question about ScaledJob: switch typedCurrent := current.(type) {
case *kedav1alpha1.ScaledObject:
needsPause = typedCurrent.NeedToBePausedByAnnotation()
case *kedav1alpha1.ScaledJob:
needsPause = typedCurrent.NeedToBePausedByAnnotation()
}
if !needsPause { continue }All existing tests still pass. Happy to add a ScaledJob-specific test case if that would be helpful. |
|
Hi @rickbrouwer — just a gentle ping. I've addressed all the points from your review: |
|
As you can see, I had already assigned this PR to me to review. Pinging twice in 6 hours is really not necessary. |
|
Hi @rickbrouwer — all three review comments have been addressed in the latest commits:
Would appreciate a re-review when you have a moment. Thank you! |
|
Did you even read what I said? |
|
Sorry for the spam, my bad. |
Fix the
Pausedcondition onScaledObjectflipping back totrueshortly after being unpaused, caused by a race between the controller reconciler and the background scale loop.Checklist
Root cause
Two independent writers can both set the
Pausedcondition on aScaledObject:ScaledObjectReconciler.reconcileScaledObject— watch-event-driven, setsPaused=falseas soon as it observes the pause annotation removed.checkScalers→RequestScale→handlePaused) — runs on its own polling interval and re-derivesPausedfrom whateverScaledObjectit read viah.client.Get, which can be a cache read that hasn't yet observed the annotation removal.handleResultalready does the right thing for everything else — it re-fetches the object fresh inside aRetryOnConflictloop and merges each condition byTyperather than replacing the whole array, so concurrent unrelated changes are preserved. But it had no way to know that aPaused=trueentry inresult.Conditionswas computed against stale data, so it would still apply it on top of the fresh read, clobbering aPaused=falsethe reconciler had just persisted.Fix
In
handleResult, when the result carriesPaused=true, re-validate it against the freshly fetched object's currentNeedToBePausedByAnnotation()before applying it. If the annotation is no longer present on the fresh object, the stale decision is skipped and the existing (correct) condition is left untouched.Paused=falseentries, and all other condition types, are unaffected and continue to merge exactly as before.This was previously discussed in #7563 (closed from inactivity, not rejected) — @rickbrouwer's suggestion there to make it "structurally impossible for the scale loop to overwrite Paused" is exactly what this re-validation achieves, scoped to the one place the race actually occurs.
Testing
TestHandleResult_DoesNotClobberFreshUnpauseWithStalePausedResult, modeled on the existingTestHandleResult_DeltaDoesNotOverwriteConcurrentChangespattern: it feedshandleResulta stale paused object (mirroring what the executor would have seen) alongside aresult.ConditionssayingPaused=true, while the mockedGetreturns a freshly-unpaused object. Asserts the persisted/patched state remainsPaused=false../pkg/scaling/...,./apis/keda/v1alpha1/..., and./controllers/keda/...suites pass (including the envtest-backed integration suite).golangci-lint run ./...: 0 issues.Fixes #6421