Skip to content

autoscaling: add observedGeneration, lastScaleTime and HPA-style conditions to LLMScaler - #23

Merged
aceforeverd merged 6 commits into
modelsphere:mainfrom
huskcass:llmscaler-ready
Oct 26, 2026
Merged

aceforeverd merged 6 commits into
modelsphere:mainfrom
huskcass:llmscaler-ready

Conversation

@huskcass

@huskcass huskcass commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

The console treats any LLMScaler condition with status False as failing, so every healthy scaler showed ScalingDeferred=False / NotDeferred as a warning, even though that is the normal state. There was also no way to tell a scaler with no signal from one whose target could not be scaled.

This PR moves LLMScaler status to the HPA v2 model: observedGeneration, lastScaleTime, and the AbleToScale, ScalingActive and ScalingLimited conditions, each with HPA's polarity and reasons where they map. Conditions stay the source of truth. No phase, state or summary condition is added.

Breaking change

  • ScalingDeferred is replaced by AbleToScale. The ScalingDeferred condition type and the NotDeferred reason are removed, with no alias. AbleToScale has inverted polarity: True is the normal state, including while a guard holds the write. NotDeferred becomes Synced, and DeferralTimedOut is now True.
  • Objects created before this change may keep a stale ScalingDeferred condition. Clients should prefer AbleToScale and ignore ScalingDeferred; it can be removed with kubectl patch --subresource=status.
  • ScalingActive is the HPA equivalent of a signal check. No Ready summary condition is added. Health is read from AbleToScale and ScalingActive, as for HPA.
  • DecisionAvailable is deprecated but still written. It mirrors ScalingActive exactly (same status, reason, message and set points) and will be removed in a future release. Compared with main it is now written for Prometheus scalers too (True/MetricsAvailable, False/DecisionUnavailable), is overwritten when metricProvider changes, and the Custom DecisionUnavailable message starts with custom provider returned no decision:.
  • The Deferred printer column is replaced by Active, AbleToScale and Scaling.
  • The deferral feature is new and is not used outside this repo and the console.
  • Unchanged: status.deferredSince, spec.deferTimeoutSeconds, the deferral clock, forced-write pacing, requeue timing, replica math (including the min/max-then-stabilize order), and the ScalingDeferred / DeferralTimedOut event reasons and messages.

Changes

New status fields:

Field Meaning
status.observedGeneration metadata.generation at the last status write. Every condition also carries it.
status.lastScaleTime When the controller last wrote spec.replicas on the target, by a normal write or one forced after the deferral timeout. Unchanged on any other sync.

Conditions (each matches the HPA condition of the same name):

Type Status Reason When
ScalingActive True DecisionReceived Custom: a decision was read this sync.
ScalingActive True MetricsAvailable Prometheus: at least one metric evaluated (evaluated K of M metrics; recommends N replicas).
ScalingActive False DecisionUnavailable No usable signal, either provider. The message names the source and error (Prometheus error bodies capped at 1 KiB). Replicas are held at spec.replicas, apart from the min/max bounds.
ScalingActive False InvalidSpec metricProvider: Custom without customProvider. Held the same way.
AbleToScale True RolloutInProgress / ScaleDownSettling A guard holds the write (also on a no-op while a deferral runs, and while forced writes are paced).
AbleToScale True DeferralTimedOut The held write was forced after deferTimeoutSeconds.
AbleToScale True ScaleDownStabilized The stabilization window kept the count above this sync's recommendation (HPA message). Not a guard.
AbleToScale True Synced None of the above: a normal write or a no-op.
AbleToScale False InvalidSpec targetRef.apiVersion does not parse.
AbleToScale False TargetNotFound / TargetUnreadable Getting the target failed.
AbleToScale False TargetWriteFailed Writing spec.replicas failed.
ScalingLimited True TooFewReplicas / TooManyReplicas The provider's count was raised to minReplicas / capped at maxReplicas.
ScalingLimited True ScaleDownLimit scaleDown.maxStepReplicas capped this sync's write (wins over a min/max clamp). No ScaleUpLimit: scale-up is never capped.
ScalingLimited False DesiredWithinRange Nothing bound the count.
DecisionAvailable (mirrors ScalingActive) Deprecated.
  • Health: a scaler is failing when AbleToScale or ScalingActive is False. AbleToScale=True with RolloutInProgress, ScaleDownSettling or ScaleDownStabilized means waiting. ScalingLimited is informational.
  • AbleToScale=True reasons, per sync: a guard hold or DeferralTimedOut (they describe the write), then ScaleDownStabilized, then Synced.
  • ScalingActive does not reflect target read or write failures; those are on AbleToScale, as in HPA.
  • ScalingLimited is only updated when ScalingActive is True this sync, like HPA.
  • Target-error paths: an invalid apiVersion or a failed target read writes only AbleToScale and status.observedGeneration. A failed target write also persists the ScalingActive, DecisionAvailable and ScalingLimited computed earlier in that sync. Replica counts, deferredSince and lastScaleTime are untouched. Before, these paths skipped the status write. Requeue and return values are unchanged.
  • kubectl get llmscalers prints Active (ScalingActive status), AbleToScale (status) and Scaling (AbleToScale reason) after the replica columns.
  • Prometheus non-200 bodies are capped at 1 KiB before they reach condition messages, as the custom provider already does.
  • docs/llmscaler.md: new status rows and a Conditions section with every type, status and reason, when the controller sets each, the HPA mapping and the health rule.

Testing

  • Envtest cases, one YAML file each, under testdata/controller/condition-status/: normal write, no-op, rollout deferral, Prometheus unreachable, target deleted while a deferral is running, invalid apiVersion, and a spec change (status and every condition follow the new generation).
  • New cases for ScaleDownStabilized (including a settle guard winning), each ScalingLimited reason, ScalingLimited left unchanged without a signal, and a >1 KiB Prometheus error body.
  • Existing deferral, timeout, pacing, metric and custom-provider YAML cases assert AbleToScale, ScalingActive (with DecisionAvailable mirroring it) and lastScaleTime.
  • Fake-client tests cover a failed target write, an unreadable target, a missing customProvider, and switching metricProvider Custom -> Prometheus -> Custom.
  • make manifests generate regenerated the CRD and deepcopy. make build-installer regenerated dist/install.yaml, and the chart CRD was synced.
  • make test passes.
  • golangci-lint run ./... reports 0 issues.

…e to LLMScaler

Clients had no single condition for scaler health. A UI that treats any
False condition as failing showed every healthy scaler as broken, since
ScalingDeferred=False/NotDeferred is the normal state.

Follow HPA v2 and KEDA:

- status.observedGeneration: metadata.generation at the last status write.
- status.lastScaleTime: when spec.replicas was last written on the target
  (normal or forced write); unchanged otherwise.
- Ready condition: True/Synced when the sync wrote, was a no-op, or a guard
  is holding the write. False with InvalidSpec, TargetNotFound,
  TargetUnreadable, TargetWriteFailed, DecisionUnavailable or
  MetricsUnavailable. Target read failures, an unparseable apiVersion and a
  failed target write now record Ready instead of skipping the status write.
- A Ready printer column before Deferred.

DecisionAvailable and ScalingDeferred, their reasons and when they are set
are unchanged. docs/llmscaler.md gains a Conditions table covering every
condition and reason.

Co-authored-by: aceforeverd <11715200+aceforeverd@users.noreply.github.com>

@huskcass huskcass left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

No blockers.

Checked:

  • DecisionAvailable and ScalingDeferred keep their types, statuses, reasons, messages and set points. DeferralTimedOut and NotDeferred are untouched, and scaledMessage gives the same text as before.
  • lastScaleTime is set only after the target Update succeeds, on normal and forced writes. It is not set on no-op, deferral, pacing or error.
  • observedGeneration is written on the main status write and on the invalid apiVersion, target read and target write failure paths.
  • GenerationChangedPredicate keeps the new error-path status writes from re-triggering reconcile.
  • make manifests generate is clean, and the Ready printer column is in the CRD and dist/install.yaml.

Low:

  • Docs say InvalidSpec outranks TargetNotFound/TargetUnreadable. For Custom with no customProvider, a missing target reports TargetNotFound, because the target read happens first (llmscaler_controller.go:162-170). This only applies to objects that predate the CEL rule. Fix the doc or the order.
  • On TargetWriteFailed, docs say only Ready and observedGeneration are written. DecisionAvailable set earlier in that sync is persisted too (llmscaler_controller.go:264). That is fine, but the doc is slightly off.
  • No test for TargetUnreadable, or for observedGeneration following a spec change (generation > 1).
  • The new Ready column sits before Deferred, so kubectl get column positions shift.
  • The helm-charts CRD copy needs a sync.

Verdict: additive and backward compatible; good to merge.

— Review Golden

AbleToScale follows the HPA convention: True is the normal state. It is
True with Synced when no guard holds, with RolloutInProgress or
ScaleDownSettling while a guard holds the write, and with
DeferralTimedOut when the timeout forces it. It is False with
InvalidSpec, TargetNotFound, TargetUnreadable or TargetWriteFailed when
the sync could not scale the target, and Ready copies that reason.

The ScalingDeferred condition and the NotDeferred reason are removed.
deferredSince, deferTimeoutSeconds, the deferral clock and the event
reasons are unchanged. The Deferred printer column becomes AbleToScale
and Scaling.

Co-authored-by: aceforeverd <11715200+aceforeverd@users.noreply.github.com>
@huskcass huskcass changed the title autoscaling: add a Ready summary, observedGeneration and lastScaleTime to LLMScaler autoscaling: add Ready, observedGeneration and lastScaleTime; replace ScalingDeferred with AbleToScale Oct 26, 2026

@huskcass huskcass left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Re-reviewed 3b21b63 (clean commit on top of d358ead).

No blockers.

Checked:

  • AbleToScale is set on every path:
    • no-op: True/Synced
    • normal write: True/Synced
    • guard hold, no-op under a running deferral, and paced forced write: True/guard reason
    • forced write after timeout: True/DeferralTimedOut
    • bad apiVersion, target read failure and target write failure: False/InvalidSpec, TargetNotFound, TargetUnreadable or TargetWriteFailed
  • Ready copies the reason whenever AbleToScale is False.
  • DecisionAvailable is unchanged.
  • Code, docs, tests, CRD, chart CRD and dist no longer write or reference ScalingDeferred/NotDeferred. The only remaining use is the ScalingDeferred event reason, which is documented.
  • The Ready, AbleToScale and Scaling JSONPaths resolve (checked with client-go jsonpath: True, True, Synced).
  • make manifests generate leaves the tree clean, and the docs table matches the code.
  • Earlier Lows: precedence doc, write-failure doc and TargetUnreadable test are fixed. Error-path tests now check observedGeneration against generation 7. The columns are as specified.

Medium:

  • An existing object keeps its old ScalingDeferred condition after upgrade. Nothing removes it, so a stale ScalingDeferred=True stays forever. Checked with a seeded object: after reconcile it still has ScalingDeferred=True next to AbleToScale=True/Synced. Suggest meta.RemoveStatusCondition(&scaler.Status.Conditions, "ScalingDeferred") before the status writes (llmscaler_controller.go:282, :631).

Low:

  • The helm-charts CRD copy needs a sync.

Verdict: rename is consistent and complete; worth clearing the stale condition, otherwise good to merge.

— Review Golden

Comment thread docs/llmscaler.md Outdated
| `AbleToScale` | `False` | `InvalidSpec` | `targetRef.apiVersion` does not parse. The sync stops. Message: `targetRef.apiVersion "<value>" does not parse: ...`. |
| `AbleToScale` | `False` | `TargetNotFound` | Getting the target returned NotFound. `deferredSince` and the replica counts stay untouched. |
| `AbleToScale` | `False` | `TargetUnreadable` | Getting the target failed for another reason. `deferredSince` and the replica counts stay untouched. |
| `AbleToScale` | `False` | `TargetWriteFailed` | Updating `spec.replicas` on the target failed. `deferredSince` and the replica counts stay untouched. |

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.

where is the ScalingLimited ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added in cf9afa7. ScalingLimited follows HPA: True/TooFewReplicas or TooManyReplicas when the count was raised to minReplicas or capped at maxReplicas, True/ScaleDownLimit when scaleDown.maxStepReplicas capped the write (it wins over a min/max clamp; there is no ScaleUpLimit since scale-up is never capped), and False/DesiredWithinRange otherwise. Like HPA it is only updated on a sync with a usable signal, and it does not affect Ready.

Comment thread docs/llmscaler.md Outdated
| `DecisionAvailable` | `True` | `DecisionReceived` | Custom provider: a decision was read this sync. Message: `decision server recommends N replicas`. |
| `DecisionAvailable` | `False` | `DecisionUnavailable` | Custom provider: the request failed or the reply could not be used (see [Custom metric provider](#custom-metric-provider)). The error is the message. |
| `AbleToScale` | `True` | `Synced` | No guard holds. A normal write (`scaled X -> Y`), or a no-op with no deferral running (`replica count is already at the step target`). `deferredSince` is cleared. |
| `AbleToScale` | `True` | `RolloutInProgress` | A guard holds the write and this one matched first. A `Deployment` is mid-rollout: `observedGeneration < generation`, `updatedReplicas < spec.replicas`, or `replicas > updatedReplicas`. Covers the deferring case, a no-op while a deferral is already running, and forced-write pacing. |

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.

Dont RolloutInProgress and ScaleDownSettling make the AbleToScale false ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

No. Like HPA, holds keep AbleToScale=True: the target can be scaled and the write is being held on purpose. RolloutInProgress and ScaleDownSettling stay as True reasons, and cf9afa7 adds True/ScaleDownStabilized for the stabilization window. A guard hold or DeferralTimedOut wins over ScaleDownStabilized, which wins over Synced. False is kept for real failures: InvalidSpec, TargetNotFound, TargetUnreadable, TargetWriteFailed.

DecisionAvailable now reports whether the sync has a usable scaling
signal for both providers, like HPA ScalingActive. Prometheus sets it
True with MetricsAvailable when at least one metric evaluates and
False with DecisionUnavailable otherwise; Custom keeps DecisionReceived
and DecisionUnavailable, and a missing customProvider is InvalidSpec.
The messages name the source and the error. MetricsUnavailable is
removed. Every recommendation writes the condition, so switching
metricProvider no longer leaves a stale one.

Ready now reads the conditions in order: AbleToScale False, then
DecisionAvailable False, then Synced.

Add tests for an unreadable target, a provider switch and a spec change
bumping observedGeneration, and document which conditions each
target-error path writes.

Co-authored-by: aceforeverd <11715200+aceforeverd@users.noreply.github.com>

@huskcass huskcass left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

No blockers.

  • Medium — Prometheus error bodies now land in status unbounded. queryPrometheus does io.ReadAll(resp.Body) on non-200 (internal/controller/autoscaling/llmscaler_controller.go:1071-1072), and that error now flows through noPrometheusMetricMessage (:878) into both DecisionAvailable and Ready messages. A proxy/HTML error page bigger than the CRD's maxLength: 32768 on condition messages would make every status update fail validation until Prometheus recovers. The custom provider already caps at 1 KiB (customprovider.go:114-115); the same io.LimitReader here (or truncating in noPrometheusMetricMessage) would close it.
  • Low — "replicas are held" isn't quite true with no signal. With every metric failing, desired falls back to the current spec but is still clamped to min/max (llmscaler_controller.go:724) and run through scale-down stabilization, so e.g. spec 1 / minReplicas: 3 writes scaled 1 -> 3 while DecisionAvailable=False and Ready=False/DecisionUnavailable. Behaviour is pre-existing and reasonable; the wording in docs/llmscaler.md:100,103,104,239 could say "held, apart from min/max bounds".
  • Low — comment nit. The NaN comment in computeDesiredFromMetrics says NaN results "still count as evaluated", but the loop continues before evaluated++ (verified: a lone NaN gives False/DecisionUnavailable … query returned NaN).

Verified: query error, empty result, NaN and partial (evaluated 1 of 2 metrics) paths; Custom unset/error/success; provider switch overwrites DecisionAvailable both ways; no MetricsUnavailable references left; Ready precedence matches the docs; earlier Lows all addressed. make test passes and make manifests generate leaves the tree clean.

Verdict: good to merge once the Prometheus error body is capped.

— Review Golden

Follow HPA for two more status signals. AbleToScale stays True with
ScaleDownStabilized when the scale-down stabilization window keeps the
count above this sync's recommendation; guard holds and
DeferralTimedOut still win, and Synced is the fallback.

ScalingLimited reports TooFewReplicas, TooManyReplicas, ScaleDownLimit
(maxStepReplicas capped the write) or DesiredWithinRange. Like HPA it is
only updated on a sync with a usable signal, and it never affects Ready.

Cap Prometheus non-200 bodies at 1 KiB before they reach condition
messages, and correct the docs and comments on the no-signal fallback
and on skipped non-finite samples.

Co-authored-by: aceforeverd <11715200+aceforeverd@users.noreply.github.com>
Use HPA's conditions alone instead of a summary. ScalingActive carries
the usable-signal semantics DecisionAvailable had: True with
DecisionReceived or MetricsAvailable, False with DecisionUnavailable or
InvalidSpec. Target read and write failures stay on AbleToScale.

DecisionAvailable is still written from the same place with the same
values and is marked deprecated. The Ready condition, which was never
released, is removed, and its printer column becomes Active.

Rename the ready-status test cases to condition-status.

Co-authored-by: aceforeverd <11715200+aceforeverd@users.noreply.github.com>
@huskcass huskcass changed the title autoscaling: add Ready, observedGeneration and lastScaleTime; replace ScalingDeferred with AbleToScale autoscaling: add observedGeneration, lastScaleTime and HPA-style conditions to LLMScaler Oct 26, 2026

@huskcass huskcass left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

No blockers.

  • Low (known) — min/max still runs before stabilization (internal/controller/autoscaling/llmscaler_controller.go, recommendReplicas). This is unchanged and pending a decision. A lowered maxReplicas can be held above for one stabilization window, and while it is, ScalingLimited reads TooManyReplicas.
  • Nit: the Scaling printcolumn (api/autoscaling/v1alpha1/llmscaler_types.go:502) and docs/llmscaler.md:87 list ScaleDownStabilized as a "hold reason", but the condition docs call it "not a guard". "a hold or stabilization reason" would match.

Verified: DecisionAvailable mirrors ScalingActive (status, reason, message, observedGeneration) on every path through setDecisionCondition. That covers Prometheus ok, query error, empty result and unparseable target, and Custom unset/error/success. On first-sync target errors both are left untouched. Target read/write failures only touch AbleToScale. The Active column JSONPath is correct. Docs, API comments and CRD agree. No Ready condition remains, and main never had one, so upgraded objects have no stale Ready to clean up. make test passes and make manifests generate leaves the tree clean.

Verdict: good to merge.

— Review Golden

The Scaling printer column and its docs listed ScaleDownStabilized as a
hold reason, but it is not a guard. List the guard holds separately and
describe ScaleDownStabilized as the stabilization window keeping the
count, matching the condition docs.

Co-authored-by: aceforeverd <11715200+aceforeverd@users.noreply.github.com>

@huskcass huskcass left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

No blockers.

Docs-only follow-up: the Scaling printcolumn description and docs/llmscaler.md now list ScaleDownStabilized separately from the guard holds, consistent with the condition docs; CRD, dist and chart match. Good to merge.

— Review Golden

@aceforeverd
aceforeverd merged commit cb51ff6 into modelsphere:main Oct 26, 2026
5 checks passed
@huskcass
huskcass deleted the llmscaler-ready branch October 26, 2026 09:05
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.

2 participants