SIGN IN SIGN UP

KEP-849: Per-role HPA support for DisaggregatedSet (#910)

* KEP-849: Per-role HPA support for DisaggregatedSet

Adds a KEP proposing per-role autoscaling for DisaggregatedSet through a
new lightweight DisaggregatedSetRoleScaler CRD that exposes the /scale
subresource. HPA, KEDA, or any /scale-aware controller can then drive
replicas for a single role (e.g., prefill or decode) independently.

Motivation: pointing an HPA directly at the underlying LeaderWorkerSet
breaks because LWS names include a revision hash (e.g.,
myds-6ad7c921-prefill), so the HPA orphans on every rolling update.

Design:
- A DisaggregatedSetRoleScaler targets exactly one (DisaggregatedSet,
  role) pair via targetRef and exposes /scale.
- DisaggregatedRoleSpec gains a Scaling sub-struct with Mode:
  Static (default, today's behavior) or External.
- When a role is External, the DS controller reads the desired replica
  count from the scaler and writes back status.replicas and
  status.selector so the HPA feedback loop closes across rollouts.
- Fully opt-in and per-role: DisaggregatedSets without any scaler
  behave exactly as they do today.

Rolling-update interaction is specified: the scaler drives only the
new-revision target, old revisions keep their existing snapshot-and-drain
behavior, and a monotonicity guard prevents an HPA scale-down from
reversing a rollout in progress.

Refs: #849

* KEP-849: Scope alpha to single-slice; document slices interaction

KEP-846 (DisaggregatedSet Slices) was merged after this KEP was drafted.
It adds spec.slices to DisaggregatedSet, replicating the whole role
topology into N independent copies with slice-aware LWS naming and
per-slice rollouts.

This raises unresolved design questions for scaler scope, selector
composition, and rolling-update interaction when slices > 1. Rather
than prejudge them, alpha scopes explicitly to spec.slices == 1:

- Add a "Interaction with DisaggregatedSet Slices" section that
  summarizes KEP-846's impact and enumerates the three candidate
  scaler shapes (aggregate / per-slice CR / per-role applied
  per-slice). Proposed direction for slices > 1 (deferred to a
  follow-up KEP): per-slice scaler CR.
- Add a non-goal for multi-slice support in alpha.
- Webhook rejects External-mode roles when the referenced DS has
  slices > 1, and rejects raising slices above 1 while any role is
  External.
- Update graduation criteria to call out the single-slice scope for
  alpha and the follow-up KEP for beta.

* KEP-849: Redesign around auto-created scalers (PR review feedback)

The DisaggregatedSet controller now creates the DisaggregatedSetRoleScaler
for each role that sets scaling.mode: External, rather than requiring the
user to author one. This matches the DisaggregatedSet→LeaderWorkerSet
ownership pattern (parent controller creates and manages the child) and
eliminates the "user forgot to create the scaler" foot-gun.

API changes:
- Dropped spec.targetRef on the scaler; instances now live at the
  deterministic name "<disaggregatedset>-<role>" and carry a
  Controller=true, BlockOwnerDeletion=true owner reference back to the
  DisaggregatedSet. The target association is derivable from the name +
  ownerRef + controller-applied labels.
- Kept RoleScaling with only Mode (Static | External). No bootstrap
  seed field; autoscalers seed themselves.
- The DS webhook rejects a create/update where an External role would
  produce a scaler name > 253 characters (fast admission-time error
  rather than a runtime naming fallback).

Bootstrap:
- Autoscaler is responsible for the first write to spec.replicas. HPA
  enforces minReplicas unconditionally when the target is below the
  floor; KEDA does the same via minReplicaCount; custom Mimir-based
  autoscalers (e.g. mistral) bootstrap themselves from their own
  min-replicas config. The narrow case of a per-pod-metric-only
  autoscaler with no min-replicas floor is documented as an operator
  limitation (workaround: one-time kubectl scale).

Safe Static→External transitions:
- Flipping a running role from Static to External never silently drains
  the LWS. If the LWS already exists at N replicas, the controller
  holds it at N (planner clamps targetNew to currentNew; simple
  reconcile returns without scaling). Only freshly created External
  roles start at 0. WaitingForScaler is set as an informational status
  condition alongside; it does not gate any behavior.
- The pre-existing all-or-nothing CEL rule on non-External roles
  further protects most Static→External and External→Static
  transitions by rejecting updates that would leave some non-External
  roles with replicas set and others unset.

Controller wiring:
- Uses Owns(&Scaler{}) instead of a cross-CRD Watches + targetRef
  mapper. No cross-CRD dependency on autoscaling/v2. Owns() requires
  Controller=true on the ownerRef to filter events.

Validation:
- Minimal webhook surface (spec.replicas >= 0, name-length check).
  No uniqueness check needed; the deterministic name + controller
  ownerRef adoption check handles conflict.

Motivation and prose:
- Reworked motivation to clarify that KEP-846 slices (whole-topology
  replication) does not give per-role autoscaling — the two features
  serve different purposes.
- Added a 4th motivation reason: DisaggregatedSet already owns per-role
  replica counts, so autoscaling belongs at the DS layer.
- Dropped the Implementation History section.
- Replaced the misleading Deployment→ReplicaSet analogy in the Summary
  with the DisaggregatedSet→LeaderWorkerSet analogy (a ReplicaSet is
  not a /scale target; a Scaler is, so the roles are inverted).
  Alternative 4 keeps the Deployment→ReplicaSet ownership precedent
  qualified with the flip.
- Clarified that Scaling == nil and Scaling.Mode == Static are
  behaviorally identical but mechanically distinct (existing DS
  objects round-trip unchanged).

New rejected alternatives:
- Alternative 4: user-authored scaler CR (was the v1 shape). Rejected
  because ownership is unconventional (controller can't take
  Controller=true) and users typo/omit the targetRef.
- Alternative 5: initialReplicas seed field on the role. Rejected
  because every real-world autoscaler bootstraps itself, and the
  field duplicates minReplicas/minReplicaCount that users already set
  on their HPA/KEDA object.

Refs: kubernetes-sigs/lws#849, kubernetes-sigs/lws#910 review
Signed-off-by: Mathis Felardos <mathis@mistral.ai>

* KEP-849: Post-autocreate refinements

Design and prose refinements applied on top of the autocreate
redesign.

Design changes:
- Aggregate scaler /scale semantics. status.replicas now sums pods
  across all revisions (new + draining old), and status.selector is
  a stable revision-agnostic label selector
  (disaggregatedset.x-k8s.io/name=<ds>,disaggregatedset.x-k8s.io/role=<role>).
  The prior per-revision selector caused HPA to over-scale during
  rollouts because it saw only the new-revision subset carrying its
  share of traffic at inflated per-pod utilization. Aggregate keeps
  HPA's math self-consistent.
- Drop the per-role CEL rule forbidding spec.replicas > 0 on
  External roles. LeaderWorkerSetSpec.Replicas has
  +kubebuilder:default=1 which is inherited through the inlined
  LeaderWorkerSetTemplateSpec; API-server defaulting runs before
  CEL, so the rule would reject every External role written the
  natural way. Replaced with a webhook warning that only fires for
  spec.replicas > 1 (values 0 and 1 are indistinguishable from the
  default).
- No-shrink guard now tracked per role — renamed from
  "monotonicity guard" for readability.

Prose changes:
- Trim Rolling Update Interaction to three paragraphs (drop the
  worked HPA-math example, the counter-scenario paragraph, the
  trivial stability-check bullet, and the mixed-metrics caveat
  paragraph which duplicated a Drawback).
- Note HPA/drain-schedule coupling as beta future work with two
  concrete follow-ups (accelerate drain on scale-down, accelerate
  new-revision growth on a sudden scale-up spike).
- Rework Slices Interaction to lay out the three shapes (aggregate,
  per-slice CR, per-role applied per-slice) without pre-committing
  a direction; aggregate looks strongest for hardware-partitioned
  deployments but the choice is deferred to a follow-up KEP.
- Rework Alternative 2 to acknowledge that KEP-846 slices makes
  /scale on the DS mechanically valid (mapping to spec.slices) but
  address a different problem (slice-count vs per-role scaling).
- Drop Validation and Edge Cases sections. Content was largely
  duplicating struct-level markers, Risks and Mitigations, or
  Rolling Update Interaction. Preserve the one unique edge case
  (user hand-creates a scaler at the controller-managed name) as a
  new Risk entry.
- Drop the initialReplicas-seed alternative (Alternative 5); the
  arguments live in Risks and Mitigations already.
- Trim Alternative 4 (user-authored scaler CR) from 4-bullet
  argument to 2 short paragraphs.
- Drop the printer-column mitigation from Drawback #2 (with 2–10
  roles per DS, no single static column can cleanly display
  per-role state, so the mitigation was oversold).
- Various clarity fixes: describe Scaling == nil vs Static
  distinction, spell out the "N × R rollouts" bullet, distinguish
  fresh External roles from Static→External flips in the
  hold-in-place risk, and re-add Implementation History.

Signed-off-by: Mathis Felardos <mathis@mistral.ai>

* KEP-849: Update LWS naming examples for slices-aware format

KEP-846 (slices) merged and the generated LWS name is now
<ds>-<slice>-<revision>-<role> (e.g. myds-0-6ad7c921-prefill),
where the slice segment sits before the revision because slices
are the durable identity and revisions are ephemeral.

Update the two motivation examples and the Alternative 1
rejection text to reflect the current naming. The examples use
slice 0 concretely since alpha scopes to spec.slices == 1.

Signed-off-by: Mathis Felardos <mathis@mistral.ai>

---------

Signed-off-by: Mathis Felardos <mathis@mistral.ai>
M
Mathis Felardos committed
7072b297f13dab0ca0e92dc45b2076ea63a35e01
Parent: 17a50ca
Committed by GitHub <noreply@github.com> on 7/9/2026, 1:28:33 PM