Skip to content

feat(rbgsa-controller): rollout-aware scale-down and spec.replicas drift detection#270

Open
sebest wants to merge 7 commits into
sgl-project:mainfrom
sebest:worktree-feat+rbgsa-rollout-aware-scaling
Open

feat(rbgsa-controller): rollout-aware scale-down and spec.replicas drift detection#270
sebest wants to merge 7 commits into
sgl-project:mainfrom
sebest:worktree-feat+rbgsa-rollout-aware-scaling

Conversation

@sebest

@sebest sebest commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

This PR adds two related RBGSA-controller behaviors. They are bundled because the second reuses the watch infrastructure introduced for the first.

Summary

1. Rollout-aware scale-down gating (original scope)

  • Gate scale-down at the RBGSA controller level for partition-based workloads (StatefulSet, LWS, RoleInstanceSet) while a rolling update is in progress.
  • StatefulSet deletes highest-ordinal pods on scale-down, which are the already-updated pods — this destroys rollout progress.
  • Scale-up and Deployment-backed roles are unaffected.
  • Opt-in via scalingAdapter.scaleDownPolicy: DeferDuringRollout (default is Unrestricted for backward compatibility).

2. Wake on per-role spec.replicas drift (new — second commit)

  • Extend the RBG-watch predicate (renamed RBGRoleStatusPredicateRBGRoleSpecOrStatusPredicate) to also fire when any role's spec.replicas changes between old and new.
  • Without this, the RBGSA controller's writeback path only wakes on status changes, so an external mutator that stomps spec.roles[].replicas (e.g. helm upgrade whose template stops emitting the field, kubectl edit from a manifest that omits replicas, non-SSA scripts) waits for the next status-change event before the apiserver-stored spec is corrected.
  • Pairs with the in-memory override added in fix(rbg-controller): override role.Replicas from bound RBGSA #306: that override prevents the workload from being sized to the stale value on the next reconcile, and this predicate change closes the latency gap by waking the writeback immediately on the stomp instead of when status next changes.
  • New rolesReplicasDiffer helper matches roles by name; added, removed, and renamed roles all count as a difference. Other spec fields (image, env, command) deliberately do not fire the predicate — the adapter reconcile is irrelevant to them.

Changes

Rollout-aware scale-down:

  • Add Conditions field and ScaleDownDeferred condition to RBGSA status
  • Add ScaleDownPolicy field to ScalingAdapter spec (DeferDuringRollout / Unrestricted)
  • Detect rollout via RoleStatus.UpdatedReplicas < Replicas (workload-agnostic)
  • Gate only partition-based workloads using existing IsStatefulRole() helper
  • Add Watches() on RBG for event-driven rollout completion detection (resolves existing TODO)
  • Emit Warning event on first deferral only (rate-limited, not on every requeue)
  • Use SSA for all status writes (consistent field manager ownership)
  • DeepCopy after Get to prevent informer cache corruption
  • Expand doc/features/autoscaler.md with full scaling adapter documentation
  • Update API reference and variables docs

Spec drift detection:

  • Rename RBGRoleStatusPredicateRBGRoleSpecOrStatusPredicate; restructure UpdateFunc to OR-in a per-role spec.replicas check
  • Add rolesReplicasDiffer helper (matches by role name; nil/non-nil pointer transitions and added/removed/renamed roles all count as differences)

Design proposal

Closes #269

Companion PR

Test plan

  • All RBGSA controller unit tests pass, including:
    • TestScaleDownDeferredDuringRollout, TestScaleDownDeferredConditionLifecycle, TestFindScalingAdaptersByRBG (rollout gating)
    • TestRBGRoleSpecOrStatusPredicate (renamed; extended with 6 new sub-tests covering spec.replicas nil↔set, equal-but-other-fields-differ, added/removed/renamed roles)
    • TestRBGStatusChangedPredicate (envtest-side, also updated to use the renamed predicate)
  • Full controller test suite passes (go test ./internal/controller/workloads/...)
  • make generate && make manifests — codegen clean
  • make lint — passes (cyclomatic complexity under 30)
  • Manual: create RBG with STS role + RBGSA + HPA, trigger rollout, observe scale-down deferred
  • Manual: simulate helm upgrade that drops spec.roles[].replicas, observe RBGSA writeback fires immediately (paired with fix(rbg-controller): override role.Replicas from bound RBGSA #306)

@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@sebest sebest closed this Apr 12, 2026
@sebest sebest deleted the worktree-feat+rbgsa-rollout-aware-scaling branch April 12, 2026 06:35
@sebest sebest restored the worktree-feat+rbgsa-rollout-aware-scaling branch April 12, 2026 06:37
@sebest sebest reopened this Apr 12, 2026
@sebest sebest force-pushed the worktree-feat+rbgsa-rollout-aware-scaling branch 2 times, most recently from 160a7f2 to 66ab30f Compare April 12, 2026 06:42
@sebest sebest marked this pull request as ready for review April 12, 2026 07:50
@Syspretor

Copy link
Copy Markdown
Collaborator

@sebest I think the motivation behind this change makes a lot of sense. Please give us a little time to think through the API and workflow design, and I’ll follow up with feedback afterward.

@sebest sebest force-pushed the worktree-feat+rbgsa-rollout-aware-scaling branch from ee6febd to 820664c Compare April 13, 2026 05:51
@Syspretor Syspretor self-requested a review April 13, 2026 11:52
@Syspretor

Copy link
Copy Markdown
Collaborator

@sebest We reviewed this PR in our community weekly meeting, and overall everyone really appreciated both the motivation and the design.

The only feedback we had was around the naming/semantics of DeferDuringRollout, which feels a bit ambiguous to us. Would it make sense to consider a clearer name, something along the lines of DeferUntilRolloutCompleted, to make the intended behavior more explicit?

@Syspretor

Copy link
Copy Markdown
Collaborator

@sebest the e2e failure is not caused by this PR. It was due to an issue on the main branch, which I’ve already fixed. Could you please rebase onto the latest main branch and push again to rerun the e2e tests? Thanks!

@Syspretor

Copy link
Copy Markdown
Collaborator

@sebest btw, We’d also be glad to have you join the community discussions. If you’re interested, please feel free to reach out to me at tongyuguo@outlook.com.

sebest added 6 commits April 21, 2026 22:39
StatefulSet/LWS delete pods from the highest ordinals on scale-down,
which are the already-updated pods (ordinal >= partition). This destroys
rollout progress and forces the rollout to restart from zero.

Gate scale-down at the RBGSA controller level for partition-based
workloads (StatefulSet, LWS, RoleInstanceSet) while a rolling update
is in progress. Scale-up and Deployment-backed roles are unaffected.

Changes:
- Add Conditions field and ScaleDownDeferred condition to RBGSA status
- Add rollout detection via RoleStatus.UpdatedReplicas < Replicas
- Add Watches() on RBG for event-driven rollout completion detection
- Emit Warning event on first deferral only (rate-limited)
- Use SSA for all status writes (consistent field manager ownership)
- DeepCopy after Get to prevent informer cache corruption

Closes: sgl-project#269
Add ScaleDownPolicy field to ScalingAdapter spec with two values:
- DeferDuringRollout (default): defers scale-down during partition-based
  rollout to prevent rollout progress destruction
- Unrestricted: allows scale-down at any time (opt-out)

The default is DeferDuringRollout when the field is unset, making the
gating behavior the safe default without requiring explicit opt-in.
Change default ScaleDownPolicy from DeferDuringRollout to Unrestricted
for backward compatibility — existing deployments are unaffected unless
users explicitly opt in with scaleDownPolicy: DeferDuringRollout.

Documentation:
- Expand doc/features/autoscaler.md with full scaling adapter guide,
  ScaleDownPolicy explanation, and the partition ordinal problem
- Add scaleDownPolicy field to doc/reference/api.md
- Add ScaleDownDeferred condition to doc/reference/variables.md
- Add autoscaling example link to doc/TOC.md
Extract deferScaleDownIfNeeded() and scaleRole() from the Reconcile
method to reduce cyclomatic complexity from 31 to under 30.

Also fix gofmt import ordering.
The kubebuilder binary is not used by `make test` (which runs plain
`go test`). The v2.3.1 download fails on arm64 runners since that
release predates arm64 support, causing spurious CI failures.

Envtest tests use `setup-envtest` (installed via Makefile) instead.
Add 6 envtest tests covering the full controller interaction:

StatefulSet with DeferDuringRollout policy:
- Scale-down deferred during rollout (condition set, replicas unchanged)
- Scale-down proceeds after rollout completes (via RBG status watch)
- Scale-up during rollout proceeds normally

StatefulSet with Unrestricted policy:
- Scale-down during rollout proceeds (no gating)

Deployment with DeferDuringRollout policy:
- Scale-down during rollout proceeds (Deployments are not partition-based)

Existing basic_test.go test continues to pass.

In envtest, STS status is manually patched to simulate rollout state
since no real StatefulSet controller runs. For the "rollout completes"
test, RBG status is patched directly because the RBG controller cannot
maintain accurate RoleStatuses without a real STS controller to track
ObservedGeneration.
@sebest sebest force-pushed the worktree-feat+rbgsa-rollout-aware-scaling branch from 820664c to 9e754d4 Compare April 22, 2026 05:39
Extend RBGRoleSpecOrStatusPredicate (renamed from RBGRoleStatusPredicate)
to also fire when any role's spec.replicas value changes between the old
and new RBG. Without this, the RBGSA controller's writeback path only
wakes on status changes, so an external mutator that stomps
spec.roles[].replicas (e.g. helm upgrade dropping the field, kubectl
edit, non-SSA scripts) waits for the next status-change event before
the apiserver-stored spec is corrected.

Pairs with the in-memory override added in the RBG controller (PR sgl-project#306):
the override prevents the workload from being sized to the stale value
on the next reconcile, and this predicate change closes the latency
gap by waking the writeback immediately on the stomp instead of when
status next changes.

The new rolesReplicasDiffer helper matches roles by name; added,
removed, and renamed roles all count as a difference. We deliberately
do not fire on every spec change — other fields (image, env, command)
churn frequently and the adapter reconcile is irrelevant to them.
@sebest sebest changed the title feat(rbgsa-controller): gate scale-down during partition-based rollout feat(rbgsa-controller): rollout-aware scale-down and spec.replicas drift detection Apr 26, 2026
}

logger := log.FromContext(ctx)
msg := fmt.Sprintf(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Structured logger (klog/logr) doesn't interpret %s format verbs — this ends up logging the literal %s in the message string. Should be key-value style:

logger.Error(err, "Failed to update status", "rbgScalingAdapterName", rbgScalingAdapter.Name)

@cheyang cheyang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review: rollout-aware scale-down & spec.replicas drift detection

Status: This PR currently has merge conflicts — needs rebase onto latest main (as previously requested).

Architecture & Design

The approach is well-considered:

  • Gating at the RBGSA controller level rather than the workload controller is the right call — it keeps the scaling adapter self-contained and avoids entangling rollout logic with the core RBG reconciler.
  • The distinction between partition-based workloads (STS, LWS, RoleInstanceSet) and Deployments is correctly scoped via IsStatefulRole().
  • Default of Unrestricted preserves backward compatibility without surprise behavioral changes for existing users.
  • The rolesReplicasDiffer predicate for spec.replicas drift detection is narrowly scoped (only fires on replica count changes, not other spec mutations), which avoids excessive reconcile churn.

Correctness

  • deferScaleDownIfNeeded correctly short-circuits for scale-up (desiredReplicas >= currentReplicas).
  • Rollout detection via UpdatedReplicas < Replicas from RBG status is reasonable — it reflects the workload controller's view of partition progress.
  • Event emission only on first deferral (checking condition presence before emitting) avoids log/event noise on every 30s requeue.
  • The DeepCopy after Get prevents informer cache mutation — important since conditions are modified in-place.
  • SSA for status writes is consistent with the existing pattern in this controller.

Minor Issue

One structured-logger misuse (see inline comment): logger.Error(err, "Failed to update status for %s", ...)%s isn't interpolated by logr.

Tests

Coverage is thorough:

  • 13 table-driven unit test cases covering all permutations of workload type, policy, rollout state, and scaling direction
  • Condition lifecycle tests (set/clear/idempotent re-set)
  • 6 envtest integration tests covering the full controller interaction including scale subresource writes and RBG status watch
  • Predicate tests for the new rolesReplicasDiffer logic

Summary

The design and implementation are solid. The blocking issue is the merge conflict. Once rebased and CI passes, this looks ready to merge pending the one-line logger fix.

@cheyang

cheyang commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Nicely designed feature with strong test coverage (810 lines of unit tests + 350 lines of envtest). The rollout-gating logic, condition lifecycle, and SSA status writes all look correct. The DeepCopy-after-Get fix for informer cache safety is a good catch.

I left one concrete comment on a malformed logger.Error call in the scale-down error path, and a minor test coverage suggestion.

The PR currently has merge conflicts with main -- a rebase is needed before this can be merged.

rbgScalingAdapterApplyConfig := ToRoleBasedGroupScalingAdapterApplyConfiguration(rbgScalingAdapter).
WithStatus(
ToRoleBasedGroupScalingAdapterStatusApplyConfiguration(rbgScalingAdapter.Status, true).WithReplicas(*desiredReplicas),
ToRoleBasedGroupScalingAdapterStatusApplyConfiguration(rbgScalingAdapter.Status, true).WithReplicas(desiredReplicas),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This logger.Error call uses %s in the message string and passes rbgScalingAdapter.Name as a lone positional arg:

logger.Error(err, "Failed to update status for %s", rbgScalingAdapter.Name)

In logr, Error(err, msg, keysAndValues...) does not interpolate format verbs -- %s appears literally in the output, and the adapter name becomes an orphaned key (odd-numbered arg) that triggers a MISSING value warning in the structured log.

Consider:

logger.Error(err, "Failed to update status", "rbgScalingAdapterName", rbgScalingAdapter.Name)

{Name: "worker", Replicas: 10, ReadyReplicas: 7, UpdatedReplicas: 3},
},
expectDeferred: true,
expectRequeueAfter: 30 * time.Second,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The condition lifecycle tests cover deferral, rollout completion, and HPA adjusting upward, but there is no test for the scenario where the HPA adjusts desired replicas to exactly match current replicas while a ScaleDownDeferred condition is active. In that case clearScaleDownDeferredCondition on the "nothing to do" path should remove the stale condition. A small table-driven case would close this gap.

This is a minor gap -- the path is exercised implicitly, but an explicit test documents the expected behavior.

@cheyang

cheyang commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Hi @sebest — this PR now conflicts with main. Please rebase onto the latest main and force-push so we can validate the changes with green CI. The previously noted logger.Error format-string fix is still outstanding as well. Thanks!

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.

[Feature] Rollout-aware scaling for RBGSA on partition-based workloads

3 participants