Repository navigation
[Train / Autoscaler] Fix 60s worker group startup stall when the elastic reservation changes after a resize decision - #66601
Conversation
…r than elastic request Signed-off-by: Mark Towers <mark@anyscale.com>
Signed-off-by: Mark Towers <mark@anyscale.com>
There was a problem hiding this comment.
Code Review
This pull request refactors the autoscaling coordinator's reservation logic by introducing a helper function _sum_bundles and updating _compute_reservations to be more flexible. It also improves the elastic scaling policy by ensuring worker counts are verified against a fresh view of reserved resources to avoid stale data issues. I have identified a potential AttributeError in the new check that should be addressed.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Reviewed by Cursor Bugbot for commit f8408ce. Configure here.
Signed-off-by: Mark Towers <mark@anyscale.com>
There was a problem hiding this comment.
Thanks for tracking this down. Both stalls come from the same thing: the ResizeDecision.num_workers and the label selector pins are computed from different reservation snapshots. The size comes from the cached view in the policy, and the pins come from a recompute=True poll in _try_get_selectors. Any change between the two becomes a 60s timeout.
The approach in this PR is to let the pinning step tolerate a mismatch, but that adds some complexity around using a larger reservation for a smaller number of desired workers. Could we instead just take one snapshot and derive the label selector pins along with the ResizeDecision.num_workers value, so that by construction there can't be a mismatch?
Proposal for this PR: compute elastic's pins in the scaling policy and put them on the decision
@dataclass
class ResizeDecision(ScalingDecision):
num_workers: int
resources_per_worker: Dict[str, float]
# Per-worker node pins taken from the same reservation snapshot that chose
# `num_workers`. None = not pinned by the policy (TPU, zero-resource
# workers, or a policy that doesn't compute pins yet -- see below).
label_selectors: Optional[List[LabelSelector]] = None- Elastic:
ElasticScalingPolicyreads the reservation once (recompute=True), counts workers from it, and builds selectors from that same snapshot with_reserved_resources_to_bundle_label_selectors. The "grew" and "shrank" cases can't happen anymore to lead to mismatchedlen(label_selectors). - Controller:
_start_worker_groupusesdecision.label_selectorswhen they're there. It keeps the existing poll as a fallback for the fixed policy. This fallback can be removed after the followup to unify the FixedScalingPolicy below:
if can_pin_to_reservation:
if decision.label_selectors is not None:
label_selector = decision.label_selectors
else:
# Fixed policy (for now): poll until the reservation covers num_workers.
label_selector = self._scaling_policy.get_reserved_bundle_label_selectors(
num_workers
)
if label_selector is None:
raise WorkerGroupStartupTimeoutError(num_workers=num_workers)Benefit of this is that we don't need logic reconciling M reserved worker resources with N target workers.
Follow-up: Unify the FixedScalingPolicy to also reserve resources from the coordinator and package label selector pins in the ResizeDecision
- Today, elastic and fixed scaling policies differ in the way they reserve resources:
- Fixed: immediately assume that
num_workersis ready (not necessarily true in the cluster upscaling case) and return aResizeDecision. Rely on a 60 second timeout when starting the worker group before retrying. - Elastic: Return
NoopDecisionuntil the coordinator returns enough reserved resources formin_workersbefore returning aResizeDecision.
- Fixed: immediately assume that
This can also be an opportunity to unify these two codepaths paths to both request resources from the coordinator up front (and determine the label selectors) and wait for all resources to be ready before attempting to launch the worker group with ResizeDecision.
I attached a full sketch PR as a reference, but I think it can be broken down into 2 PRs with the split described above. #66646
|
Closing in favor of #66646 |
…#66646) ## Description `test_elastic_e2e` has been timing out on CI since #66002, which pins Train V2 workers to AutoscalingCoordinator reservations. The elastic worker group start waits the full 60s (`RAY_TRAIN_WORKER_GROUP_START_TIMEOUT_S`) whenever the reservation changes between the scaling decision and pinning. The decision is sized from the cached reservation, while the pins come from a separate `recompute=True` poll in `_try_get_selectors`, so: 1. **The reservation grows past the decision.** Elastic requests `max_workers` bundles, so after `ResizeDecision(14)` the reservation can reach 32 slots. `len(label_selectors) != num_workers` then never passes. 2. **The reservation shrinks below the decision.** After a node dies, the cached view still counts it, and pinning waits for workers that don't exist. This PR takes the decision and the pins from the **same snapshot**, so they can't disagree: - **Pins on `ResizeDecision`.** `ResizeDecision.label_selectors` is built by the scaling policy from the reservation that chose `num_workers`, using the same `_floor_slots` math. The STRICT_PACK/STRICT_SPREAD layout check from #66002 is kept. The controller just applies the pins and no longer polls the coordinator. - **Elastic** decides and pins from a fresh (`recompute=True`) view. In the non-running path it reads the fresh view only once the cached one looks ready, so waiting doesn't trigger a full coordinator tick every loop. - **Fixed** now waits for its reservation in `make_decision_for_non_running_worker_group` (returning `NoopDecision` until every worker is covered), the same way elastic waits for `min_workers`. This removes `get_reserved_bundle_label_selectors`, its 60s poll loop, and the controller's `WorkerGroupStartupTimeoutError` raise. Both policies log a rate-limited "Waiting for reserved resources" message while they wait. Before, elastic logged nothing when nothing was reserved. - **Stop the placement group wait early when a pinned node dies.** The coordinator drops nodes that `ray.nodes()` reports dead, but the GCS can still report a just-removed node as alive for a short time. A pin made in that window can never be placed, so `WorkerGroup` now polls `pg_handle.wait()` in 1s intervals and stops as soon as a pinned node is no longer alive. It raises the same retryable `WorkerGroupStartupTimeoutError` instead of stalling for 60s. `test_elastic_e2e` hits this window on purpose by removing every node and restarting right away. Locally this went from a 60s stall to giving up after about 1s. Skipping pins (TPU, zero-resource workers, user `label_selector`) moves into `ScalingPolicy._should_pin_to_reservation`. The controller still lets a callback-provided `label_selector` win, because the policy can't see those. Behavior change: a fixed run waiting for capacity no longer fails and retries every 60s through the failure policy. It waits in the controller loop like elastic. With the default `controller_failure_limit=-1`, the only visible difference is the log messages. ## Related issues Alternative to #66601, which fixes the same `test_elastic_e2e` flake differently: it picks a subset of a larger reservation with `_compute_reservations` and adds a relative `_FIT_EPSILON` to the coordinator's shared `_bundle_can_fit_on_node`. This PR avoids the Train → Ray Data `_compute_reservations` dependency and leaves the coordinator's fit check, which every requester shares, unchanged. It also avoids an extra restart when the reservation grows, because it starts at the size the reservation supports now. ## Testing All passing locally (macOS, `ray_dev_py311`): ``` pytest python/ray/train/v2/tests/test_autoscaling_coordinator_client.py \ python/ray/train/v2/tests/test_elastic_scaling_policy.py # 37 passed pytest python/ray/train/v2/tests/test_controller.py \ python/ray/train/v2/tests/test_controller_callback_behaviour.py \ python/ray/train/v2/tests/test_failure_policy.py \ python/ray/train/tests/test_autoscaling_coordinator_client.py # 65 passed pytest python/ray/train/v2/tests/test_scheduling.py \ python/ray/train/v2/tests/test_elastic_e2e.py \ python/ray/train/v2/tests/test_state.py # 34 passed pytest python/ray/train/v2/tests/test_worker_group.py \ python/ray/train/v2/tests/test_elastic_e2e.py # 43 passed pre-commit run --files <changed files> # clean ``` In the last `test_elastic_e2e` run, the controller log shows the "decide 4 right after every node was removed" restart stopping after about 1s ("Giving up on the placement group early…") instead of the 60s stall seen before the early-stop change. Known gap: when it stops early, the retried `WorkerGroupStartupTimeoutError` still says "timed out after 60.0 seconds". The info log just before it gives the real reason. AI assistance (Claude Code) was used to write this change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Justin Yu <justinvyu@anyscale.com> Signed-off-by: Mark Towers <mark@anyscale.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Mark Towers <mark@anyscale.com> Co-authored-by: Mark Towers <mark.m.towers@gmail.com>

Description
test_elastic_e2estarted timing out on CI about 45% of the time after #66002, which pins Train V2 workers to autoscaling coordinator reservations. The root cause is that elastic worker-group starts stall for the full 60s (RAY_TRAIN_WORKER_GROUP_START_TIMEOUT_S) whenever the reservation changes between the resize decision and pinning. This was due to the scaling policy required that exacting the resize labels be available (if len(label_selectors) != num_workers:) and if too many or too few then the whole thing would stall until the startup would timeout causing a new resize attempt that would pass.More detail on the two cases:
The reservation grows past the decision. The elastic policy requests
max_workersbundles, so its reservation keeps growing as nodes join._try_get_selectorsrequiredlen(label_selectors) == num_workersexactly. AResizeDecision(14)followed by more nodes above 14 joining therefore never counted as ready. It polled for 60s, raisedWorkerGroupStartupTimeoutErrorand burned a retry. In the failing CI run, this cost 60 of the 180 seconds:Cursor Bugbot flagged this on [train] Pin Train V2 workers to autoscaling coordinator reservations … #66002 ("Extra reserved slots block pinning"), but the comment was never addressed.
The reservation shrinks below the decision.
make_decision_for_non_running_worker_groupsized the group from the cached reservation (up to 1s old), but pinning usesrecompute=True. After a node dies, the decision still counts the dead node's slots, so pinning waits 60s for workers that don't exist. The rewritten e2e test below exposed this issue: after removing one node, the controller decided on 12 workers when only 8 remained.