General: Throttle LastActiveTime status writes on active ScaledObjects by Sarthak-Pandey · Pull Request #8260 · kedacore/keda · GitHub
Skip to content

General: Throttle LastActiveTime status writes on active ScaledObjects - #8260

Open
Sarthak-Pandey wants to merge 2 commits into
kedacore:mainfrom
Sarthak-Pandey:fix-8256-lastactivetime-throttle
Open

Sarthak-Pandey wants to merge 2 commits into
kedacore:mainfrom
Sarthak-Pandey:fix-8256-lastactivetime-throttle

Conversation

@Sarthak-Pandey

Copy link
Copy Markdown

Description

This PR fixes #8256 where LastActiveTime was unconditionally updated to time.Now() on every polling interval when a ScaledObject had active triggers and replicas > 0. Because equality.Semantic.DeepEqual detected a changed timestamp on every loop, KEDA was issuing a status PATCH to the Kubernetes API server on every pollingInterval per active workload.

Changes implemented:

  1. Rule 1 — Skip LastActiveTime when scale-to-zero/idle is impossible:

    • When minReplicaCount > 0 and idleReplicaCount == nil, the workload cannot use KEDA's scale-to-zero or idle scaling path (HPA manages replica count between min and max).
    • In this configuration, result.LastActiveTime is left unset (nil), preventing any status PATCH during steady-state active polling.
    • Legitimate updates during activation from zero/idle in scaleFromZeroOrIdle() remain untouched.
  2. Rule 2 — Throttle LastActiveTime for scale-to-zero/idle workloads:

    • When scale-to-zero or idle scaling is possible (minReplicas == 0 or idleReplicaCount != nil), LastActiveTime is required for cooldown decisions.
    • Updates are now throttled to only occur when:
      • LastActiveTime == nil (timestamp initialized for active workloads).
      • time.Since(*LastActiveTime) >= cooldownPeriod / 2 (refreshed periodically).
    • Polling frequency and status-write frequency are decoupled, reducing status writes by ~90-95% while keeping cooldown behavior sound.
  3. Helper & Unit Tests:

    • Extracted getCooldownPeriod(scaledObject) to standardize cooldown duration resolution across RequestScale() and scaleToZeroOrIdle().
    • Added unit tests in pkg/scaling/executor/scale_scaledobjects_test.go covering all conditions (Rule 1 skip, nil initialization, throttling younger than cooldownPeriod / 2, refreshing at/after cooldownPeriod / 2, and idle replica count with minReplicaCount > 0).
    • Added unit test in pkg/scaling/scale_handler_test.go verifying that handleResult() skips client.Status().Patch() when result.LastActiveTime is nil.

Checklist

  • When introducing a new scaler, I agree with the scaling governance policy
  • I have verified that my change is according to the deprecations & breaking changes policy
  • Tests have been added (if applicable)
  • Ensure make generate-scalers-schema has been run to update any outdated generated files
  • A PR is opened to update our Helm chart (repo) (if applicable, ie. when deployment manifests are modified)
  • A PR is opened to update the documentation on (repo) (if applicable)
  • Commits are signed with Developer Certificate of Origin (DCO - learn more)

Fixes #8256

Relates to #

@Sarthak-Pandey
Sarthak-Pandey requested a review from a team as a code owner October 6, 2026 20:23
@keda-automation
keda-automation requested a review from a team October 6, 2026 20:23
@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

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:

  • Update the documentation, if needed
  • Add unit & e2e tests for your changes
  • GitHub checks are passing
  • Is the DCO check failing? Here is how you can fix DCO issues

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.

@semgrep-code-kedacore

Copy link
Copy Markdown
Contributor

Semgrep found 6 context-todo findings:

🟡 Medium severity issue identified in your code:

Consider to use well-defined context

@Sarthak-Pandey Sarthak-Pandey changed the title fix(scaling): reduce redundant LastActiveTime status updates General: Throttle LastActiveTime status writes on active ScaledObjects Oct 6, 2026
@Sarthak-Pandey
Sarthak-Pandey force-pushed the fix-8256-lastactivetime-throttle branch from 855a858 to 79328e2 Compare October 6, 2026 20:36
result.LastActiveTime = &metav1.Time{Time: time.Now()}
if minReplicas <= 0 || scaledObject.Spec.IdleReplicaCount != nil {
cooldownPeriod := getCooldownPeriod(scaledObject)
if scaledObject.Status.LastActiveTime == nil || time.Since(scaledObject.Status.LastActiveTime.Time) >= cooldownPeriod/2 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Throttling to cooldownPeriod/2 means status.lastActiveTime can lag real activity by up to half the cooldown, and scaleToZeroOrIdle measures cooldown from that field.
Example: cooldown=300s, refreshed at t=0, active until t=149 → scaled in at t=300, i.e. 151s after the last activity.

That silently halves the guarantee cooldownPeriod gives today. Could we keep the throttle but always stamp LastActiveTime on the active→inactive transition (first inactive poll while the Active condition is still True), and have scaleToZeroOrIdle use that fresh value? That keeps the write reduction without changing cooldown semantics.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Did you see this?

default:
// triggers are active, but we didn't need to scale (replica count > 0)
result.LastActiveTime = &metav1.Time{Time: time.Now()}
if minReplicas <= 0 || scaledObject.Spec.IdleReplicaCount != nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

minReplicas <= 0 || IdleReplicaCount != nil is the cooldown-relevance rule we already have in the webhook (scaledobject_webhook.go:383-387, "CooldownPeriod is only relevant when minReplicaCount = 0 or idleReplicaCount is set").

Could we extract it as ScaledObject.IsCooldownPeriodRelevant() next to IsPollingIntervalRelevant() (#8031) and use it in both places, so they can't drift? Note it's not the same as IsPollingIntervalRelevant(): with useCachedMetrics the loop still polls, but LastActiveTime is still unused.

One edge case: if the user later sets minReplicaCount: 0 while triggers are inactive, LastActiveTime is nil/stale and scaleToZeroOrIdle scales to zero immediately, bypassing cooldown. It's also a user-visible status field people alert on. Please handle that transition (e.g. stamp it when cooldown becomes relevant) or document it.

Skip LastActiveTime updates when minReplicaCount > 0 and idleReplicaCount is nil, throttle updates to cooldownPeriod / 2 for active scale-to-zero/idle workloads to avoid unnecessary status writes on every polling interval, and stamp LastActiveTime on active-to-inactive transition so cooldown begins properly.

Centralize cooldown period relevance in ScaledObject.IsCooldownPeriodRelevant() across the executor and admission webhook.

Signed-off-by: Sarthak Pandey <sarthak@email.com>
@Sarthak-Pandey
Sarthak-Pandey force-pushed the fix-8256-lastactivetime-throttle branch from 79328e2 to 3bdd941 Compare October 26, 2026 17:30
@keda-automation
keda-automation requested a review from a team October 26, 2026 17:30
mockDeploymentGet(mockClient)
mockHealthyHPA(mockClient)

result := exec.RequestScale(context.TODO(), &so, false, false, ScaleExecutorOptions{})

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.

🟡 Medium severity issue identified in your code:
Consider to use well-defined context

To resolve this comment:

🔧 No guidance has been designated for this issue. Fix according to your organization's approved methods.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by context-todo.

You can view more details about this finding in the Semgrep AppSec Platform.

mockHealthyHPA(mockClient)

// isActive remains false
result := exec.RequestScale(context.TODO(), &so, false, false, ScaleExecutorOptions{})

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.

🟡 Medium severity issue identified in your code:
Consider to use well-defined context

To resolve this comment:

🔧 No guidance has been designated for this issue. Fix according to your organization's approved methods.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by context-todo.

You can view more details about this finding in the Semgrep AppSec Platform.

mockHealthyHPA(mockClient)

// isActive transitions from true to false
result := exec.RequestScale(context.TODO(), &so, false, false, ScaleExecutorOptions{})

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.

🟡 Medium severity issue identified in your code:
Consider to use well-defined context

To resolve this comment:

🔧 No guidance has been designated for this issue. Fix according to your organization's approved methods.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by context-todo.

You can view more details about this finding in the Semgrep AppSec Platform.

@Sarthak-Pandey

Copy link
Copy Markdown
Author

Hi @JorTurFer

Update on Issue #8256: Throttling LastActiveTime writes & Active-to-Inactive Transition

I have updated the implementation to address redundant status writes while ensuring cooldown timers behave correctly:

1. Throttled Status Writes During Active Load

  • When a ScaledObject is active and triggers remain active, updates to status.lastActiveTime are throttled to cooldownPeriod / 2.
  • When an update is not needed, result.LastActiveTime is set to nil, completely avoiding Kubernetes API / etcd status patches on every polling interval.
  • Workloads where cooldown is not relevant (minReplicaCount > 0 and idleReplicaCount == nil) skip updating LastActiveTime entirely.

2. Active-to-Inactive Transition Handling

  • When triggers transition from active -> inactive, result.LastActiveTime is immediately stamped with time.Now().
  • scaleToZeroOrIdle utilizes this fresh transition timestamp so that workloads enter the cooldown period accurately and are never scaled down prematurely due to a throttled earlier timestamp.

3. Centralized Cooldown Relevance & Webhook Warning

  • Added ScaledObject.IsCooldownPeriodRelevant() to centralize this rule in one place.
  • Updated the admission webhook to use this helper to warn users when cooldownPeriod is configured on workloads where cooldown has no effect.

4. Testing

  • Added comprehensive unit tests in scale_scaledobjects_test.go, scale_handler_test.go, and scaledobject_types_test.go covering throttle thresholds, active-to-inactive transitions, inactive-to-inactive no-op cases, and patch skipping.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1


Review info
Run configuration
  • Source mode: Auto
  • Repository: kedacore/keda
  • Branch: main
  • File: .coderabbit.yaml
  • Commit: 783c65b
  • Layers: Repository YAML
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: bdbd6b5f-f28b-4e55-b70d-d542c5bcc89e
Commits

Reviewing files that changed from the base of the PR and between 79328e2 and 3bdd941.

Files selected for processing: 5
  • apis/keda/v1alpha1/scaledobject_types.go
  • apis/keda/v1alpha1/scaledobject_types_test.go
  • apis/keda/v1alpha1/scaledobject_webhook.go
  • pkg/scaling/executor/scale_scaledobjects.go
  • pkg/scaling/executor/scale_scaledobjects_test.go
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

Comment on lines +1540 to +1541

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Major · Functional Correctness · Quick win

Keep this test out of the minimum-replica correction path.

mockDeploymentGet returns one replica, but this test sets MinReplicaCount to two. With inactive triggers and no idle count, RequestScale calls updateScaleOnScaleTarget. The test does not expect that call, so the executor test suite fails before the timestamp assertion. Set the minimum to one, or mock a target with two replicas.

This branch has not been deployed

No deployments
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.

General: lastActiveTime is rewritten on every poll, one status write per active ScaledObject per pollingInterval

2 participants