-
Notifications
You must be signed in to change notification settings - Fork 665
[Feat] Consume StormService spec.mode in the controller update path and PodAutoscaler #2617
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
c320d81
7e15a22
d419b4b
cd9add8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,8 +39,34 @@ import ( | |
| "github.com/vllm-project/aibrix/pkg/controller/constants" | ||
| ) | ||
|
|
||
| // AutoscalingStormServiceModeAnnotationKey is the PodAutoscaler annotation that | ||
| // historically selected how role-level scaling applies to a StormService target: | ||
| // "replica" scales StormService.spec.replicas, anything else scales the targeted | ||
| // role's replicas. | ||
| // | ||
| // Deprecated: declare StormService.spec.mode instead. The annotation is only | ||
| // honored as a compatibility fallback when the target StormService does not | ||
| // declare spec.mode, and may be removed once spec.mode is broadly adopted. | ||
| const AutoscalingStormServiceModeAnnotationKey = "autoscaling.aibrix.ai/storm-service-mode" | ||
|
|
||
| // stormServiceScalingMode resolves the deployment mode used to route role-level | ||
| // scaling for a StormService target. A declared StormService.spec.mode is the | ||
| // source of truth. When spec.mode is unset, the deprecated | ||
| // autoscaling.aibrix.ai/storm-service-mode annotation on the PodAutoscaler is | ||
| // honored as a compatibility fallback ("replica" selects replica mode), and any | ||
| // other value keeps the legacy pooled default. spec.replicas is intentionally | ||
| // not used for inference here: replicas == 1 cannot distinguish a pooled | ||
| // StormService from a scaled-down replica-mode one. | ||
| func stormServiceScalingMode(pa *autoscalingv1alpha1.PodAutoscaler, ss *orchestrationv1alpha1.StormService) orchestrationv1alpha1.StormServiceMode { | ||
| if ss.Spec.Mode != "" { | ||
| return ss.Spec.Mode | ||
| } | ||
| if pa.Annotations[AutoscalingStormServiceModeAnnotationKey] == "replica" { | ||
| return orchestrationv1alpha1.StormServiceReplicaMode | ||
| } | ||
| return orchestrationv1alpha1.StormServicePooledMode | ||
| } | ||
|
|
||
| // WorkloadScale provides scaling operations for different workload types. | ||
| // It provides the mechanism to get/set replica counts on workload resources, | ||
| // while AutoScaler provides the intelligence to compute desired replica counts. | ||
|
|
@@ -152,9 +178,11 @@ func (s *workloadScale) getCurrentReplicasForRole(ctx context.Context, pa *autos | |
| return 0, err | ||
| } | ||
|
|
||
| // replica mode, return the replicas directly | ||
| // we can not easily use `*ss.Spec.Replicas > 1` as condition since 1 could be pool or replica both case under autoscaling scenarios | ||
| if pa.Annotations[AutoscalingStormServiceModeAnnotationKey] == "replica" { | ||
| // Replica mode scales the whole StormService, so report spec.replicas directly. | ||
| if stormServiceScalingMode(pa, ss) == orchestrationv1alpha1.StormServiceReplicaMode { | ||
| if ss.Spec.Replicas == nil { | ||
| return 0, nil | ||
| } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you please double-check this nil branch? Since
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You are right, 0 was the wrong resolution. Nothing defaults the field before the autoscaler reads it: the CRD schema has no default for spec.replicas (config/crd/orchestration/orchestration.aibrix.ai_stormservices.yaml:61-63) and the mutating webhook only injects the runtime sidecar. Fixed in cd9add8: the branch now returns ss.Spec.ResolvedReplicas() (pkg/controller/podautoscaler/workload_scale.go:185), a helper next to ResolvedMode() that resolves nil to the documented default of 1 and preserves explicit 0 (api/orchestration/v1alpha1/stormservice_types.go:103-114). Covered by TestGetCurrentReplicasReplicaModeNilReplicas. The stormservice reconcile path now goes through the same helper, details in the sync.go thread. |
||
| return *ss.Spec.Replicas, nil | ||
| } | ||
|
|
||
|
|
||
|
|
@@ -246,9 +274,9 @@ func (s *workloadScale) setDesiredReplicasForRole(ctx context.Context, pa *autos | |
| return err | ||
| } | ||
| upd := cur.DeepCopy() | ||
| // TODO: tricky part. it's hard to know replica=1 is pooling or replica mode, we can use autoscaling to limit it. | ||
| // we can extract the method and fallback to ss object annotation to check as well. | ||
| if pa.Annotations[AutoscalingStormServiceModeAnnotationKey] == "replica" { | ||
| // Replica mode scales the whole StormService through spec.replicas; pooled mode | ||
| // scales the targeted role. See stormServiceScalingMode for the resolution order. | ||
| if stormServiceScalingMode(pa, cur) == orchestrationv1alpha1.StormServiceReplicaMode { | ||
| upd.Spec.Replicas = ptr.To(replicas) | ||
| return s.client.Patch(ctx, upd, client.MergeFrom(cur)) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -272,17 +272,16 @@ func (r *StormServiceReconciler) rollout(ctx context.Context, stormService, curr | |
| if len(updated) == int(expectReplica) { | ||
| return nil | ||
| } | ||
| switch stormService.Spec.UpdateStrategy.Type { | ||
| case "": | ||
| // By default use RollingUpdate strategy | ||
| fallthrough | ||
| case orchestrationv1alpha1.RollingUpdateStormServiceStrategyType: | ||
| return r.rollingUpdate(allRoleSets, stormService, current, currentCR, updateCR) | ||
| case orchestrationv1alpha1.InPlaceUpdateStormServiceStrategyType: | ||
| // The update path follows the declared spec.mode when it is set and falls back to | ||
| // the legacy updateStrategy.type selection otherwise, see EffectiveUpdateStrategyType. | ||
| strategyType, err := EffectiveUpdateStrategyType(stormService) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. While reviewing the update path changes, we noticed a critical issue regarding how In stormService.Status.UpdatedReplicas == *stormService.Spec.Replicas
stormService.Status.Replicas == *stormService.Spec.ReplicasThis will cause a critical nil pointer dereference panic and crash the controller whenever a Additionally, in Please ensure that
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you please double-check the
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Verified: nothing defaults spec.replicas before reconcile. The CRD schema carries no default (config/crd/orchestration/orchestration.aibrix.ai_stormservices.yaml:61-63) and the mutating webhook only does annotation gated sidecar injection (pkg/webhook/stormservice_webhook.go:50-70), so nil reaches the controllers. The split you describe was real and actually worse than expectReplica == 0. scaling() calls MinAvailable and MaxSurge on every reconcile, and both dereferenced the pointer unconditionally (old pkg/controller/stormservice/utils.go:63,79,88), so a StormService created without replicas panicked the controller on its first reconcile. That predates this PR (the sites blame to e3bb459), but both of your comments trace to the same root cause, so I fixed it here. cd9add8 adds StormServiceSpec.ResolvedReplicas() (api/orchestration/v1alpha1/stormservice_types.go:103-114), which resolves nil to the documented default of 1 and preserves explicit 0, and routes scaling(), rollout(), the rolling update budgets and updateStatus() through it (sync.go:148,264,315,385, utils.go:63,80,89). The raw derefs at the old sync.go:399-400 are gone. TestScalingNilReplicasResolvesToDefault segfaults on the previous code and now asserts exactly one RoleSet is created for an omitted replicas. |
||
| if err != nil { | ||
| return err | ||
| } | ||
| if strategyType == orchestrationv1alpha1.InPlaceUpdateStormServiceStrategyType { | ||
| return r.inPlaceUpdate(allRoleSets, stormService, current, currentCR, updateCR) | ||
| default: | ||
| return fmt.Errorf("unexpected stormService strategy type: %s", stormService.Spec.UpdateStrategy.Type) | ||
| } | ||
| return r.rollingUpdate(allRoleSets, stormService, current, currentCR, updateCR) | ||
| } | ||
|
|
||
| // rollingUpdate: rolling update logic for replica mode | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since
spec.replicasdefaults to1when omitted (as documented instormservice_types.go), returning0here whenss.Spec.Replicasisnilcan lead to incorrect autoscaling calculations (e.g., division by zero or incorrect scaling ratios). It should return1instead to reflect the default replica count.