Repository navigation
autoscaling: add observedGeneration, lastScaleTime and HPA-style conditions to LLMScaler - #23
Conversation
…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
left a comment
There was a problem hiding this comment.
No blockers.
Checked:
DecisionAvailableandScalingDeferredkeep their types, statuses, reasons, messages and set points.DeferralTimedOutandNotDeferredare untouched, andscaledMessagegives the same text as before.lastScaleTimeis set only after the targetUpdatesucceeds, on normal and forced writes. It is not set on no-op, deferral, pacing or error.observedGenerationis written on the main status write and on the invalid apiVersion, target read and target write failure paths.GenerationChangedPredicatekeeps the new error-path status writes from re-triggering reconcile.make manifests generateis clean, and theReadyprinter column is in the CRD anddist/install.yaml.
Low:
- Docs say
InvalidSpecoutranksTargetNotFound/TargetUnreadable. ForCustomwith nocustomProvider, a missing target reportsTargetNotFound, 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 onlyReadyandobservedGenerationare written.DecisionAvailableset 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 forobservedGenerationfollowing a spec change (generation > 1). - The new
Readycolumn sits beforeDeferred, sokubectl getcolumn 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
left a comment
There was a problem hiding this comment.
Re-reviewed 3b21b63 (clean commit on top of d358ead).
No blockers.
Checked:
AbleToScaleis 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,TargetUnreadableorTargetWriteFailed
- no-op: True/
Readycopies the reason wheneverAbleToScaleis False.DecisionAvailableis unchanged.- Code, docs, tests, CRD, chart CRD and
distno longer write or referenceScalingDeferred/NotDeferred. The only remaining use is theScalingDeferredevent reason, which is documented. - The
Ready,AbleToScaleandScalingJSONPaths resolve (checked with client-go jsonpath:True,True,Synced). make manifests generateleaves the tree clean, and the docs table matches the code.- Earlier Lows: precedence doc, write-failure doc and
TargetUnreadabletest are fixed. Error-path tests now checkobservedGenerationagainst generation 7. The columns are as specified.
Medium:
- An existing object keeps its old
ScalingDeferredcondition after upgrade. Nothing removes it, so a staleScalingDeferred=Truestays forever. Checked with a seeded object: after reconcile it still hasScalingDeferred=Truenext toAbleToScale=True/Synced. Suggestmeta.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
| | `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. | |
There was a problem hiding this comment.
where is the ScalingLimited ?
There was a problem hiding this comment.
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.
| | `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. | |
There was a problem hiding this comment.
Dont RolloutInProgress and ScaleDownSettling make the AbleToScale false ?
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
No blockers.
- Medium — Prometheus error bodies now land in status unbounded.
queryPrometheusdoesio.ReadAll(resp.Body)on non-200 (internal/controller/autoscaling/llmscaler_controller.go:1071-1072), and that error now flows throughnoPrometheusMetricMessage(:878) into bothDecisionAvailableandReadymessages. A proxy/HTML error page bigger than the CRD'smaxLength: 32768on 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 sameio.LimitReaderhere (or truncating innoPrometheusMetricMessage) 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: 3writesscaled 1 -> 3whileDecisionAvailable=FalseandReady=False/DecisionUnavailable. Behaviour is pre-existing and reasonable; the wording indocs/llmscaler.md:100,103,104,239could say "held, apart from min/max bounds". - Low — comment nit. The NaN comment in
computeDesiredFromMetricssays NaN results "still count as evaluated", but the loopcontinues beforeevaluated++(verified: a lone NaN givesFalse/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
left a comment
There was a problem hiding this comment.
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 loweredmaxReplicascan be held above for one stabilization window, and while it is,ScalingLimitedreadsTooManyReplicas. - Nit: the
Scalingprintcolumn (api/autoscaling/v1alpha1/llmscaler_types.go:502) anddocs/llmscaler.md:87listScaleDownStabilizedas 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
left a comment
There was a problem hiding this comment.
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
Motivation
The console treats any LLMScaler condition with status
Falseas failing, so every healthy scaler showedScalingDeferred=False/NotDeferredas 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 theAbleToScale,ScalingActiveandScalingLimitedconditions, 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
ScalingDeferredis replaced byAbleToScale. TheScalingDeferredcondition type and theNotDeferredreason are removed, with no alias.AbleToScalehas inverted polarity:Trueis the normal state, including while a guard holds the write.NotDeferredbecomesSynced, andDeferralTimedOutis nowTrue.ScalingDeferredcondition. Clients should preferAbleToScaleand ignoreScalingDeferred; it can be removed withkubectl patch --subresource=status.ScalingActiveis the HPA equivalent of a signal check. NoReadysummary condition is added. Health is read fromAbleToScaleandScalingActive, as for HPA.DecisionAvailableis deprecated but still written. It mirrorsScalingActiveexactly (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 whenmetricProviderchanges, and the CustomDecisionUnavailablemessage starts withcustom provider returned no decision:.Deferredprinter column is replaced byActive,AbleToScaleandScaling.status.deferredSince,spec.deferTimeoutSeconds, the deferral clock, forced-write pacing, requeue timing, replica math (including the min/max-then-stabilize order), and theScalingDeferred/DeferralTimedOutevent reasons and messages.Changes
New status fields:
status.observedGenerationmetadata.generationat the last status write. Every condition also carries it.status.lastScaleTimespec.replicason 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):
ScalingActiveTrueDecisionReceivedScalingActiveTrueMetricsAvailableevaluated K of M metrics; recommends N replicas).ScalingActiveFalseDecisionUnavailablespec.replicas, apart from the min/max bounds.ScalingActiveFalseInvalidSpecmetricProvider: CustomwithoutcustomProvider. Held the same way.AbleToScaleTrueRolloutInProgress/ScaleDownSettlingAbleToScaleTrueDeferralTimedOutdeferTimeoutSeconds.AbleToScaleTrueScaleDownStabilizedAbleToScaleTrueSyncedAbleToScaleFalseInvalidSpectargetRef.apiVersiondoes not parse.AbleToScaleFalseTargetNotFound/TargetUnreadableAbleToScaleFalseTargetWriteFailedspec.replicasfailed.ScalingLimitedTrueTooFewReplicas/TooManyReplicasminReplicas/ capped atmaxReplicas.ScalingLimitedTrueScaleDownLimitscaleDown.maxStepReplicascapped this sync's write (wins over a min/max clamp). NoScaleUpLimit: scale-up is never capped.ScalingLimitedFalseDesiredWithinRangeDecisionAvailableScalingActive)AbleToScaleorScalingActiveisFalse.AbleToScale=TruewithRolloutInProgress,ScaleDownSettlingorScaleDownStabilizedmeans waiting.ScalingLimitedis informational.AbleToScale=Truereasons, per sync: a guard hold orDeferralTimedOut(they describe the write), thenScaleDownStabilized, thenSynced.ScalingActivedoes not reflect target read or write failures; those are onAbleToScale, as in HPA.ScalingLimitedis only updated whenScalingActiveisTruethis sync, like HPA.apiVersionor a failed target read writes onlyAbleToScaleandstatus.observedGeneration. A failed target write also persists theScalingActive,DecisionAvailableandScalingLimitedcomputed earlier in that sync. Replica counts,deferredSinceandlastScaleTimeare untouched. Before, these paths skipped the status write. Requeue and return values are unchanged.kubectl get llmscalersprintsActive(ScalingActivestatus),AbleToScale(status) andScaling(AbleToScalereason) after the replica columns.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
testdata/controller/condition-status/: normal write, no-op, rollout deferral, Prometheus unreachable, target deleted while a deferral is running, invalidapiVersion, and a spec change (status and every condition follow the new generation).ScaleDownStabilized(including a settle guard winning), eachScalingLimitedreason,ScalingLimitedleft unchanged without a signal, and a >1 KiB Prometheus error body.AbleToScale,ScalingActive(withDecisionAvailablemirroring it) andlastScaleTime.customProvider, and switchingmetricProviderCustom -> Prometheus -> Custom.make manifests generateregenerated the CRD and deepcopy.make build-installerregenerateddist/install.yaml, and the chart CRD was synced.make testpasses.golangci-lint run ./...reports 0 issues.