Skip to content

[Train / Autoscaler] Fix 60s worker group startup stall when the elastic reservation changes after a resize decision - #66601

Closed
pseudo-rnd-thoughts wants to merge 3 commits into
ray-project:masterfrom
pseudo-rnd-thoughts:train/fix-elastic-e2e
Closed

pseudo-rnd-thoughts wants to merge 3 commits into
ray-project:masterfrom
pseudo-rnd-thoughts:train/fix-elastic-e2e

Conversation

@pseudo-rnd-thoughts

@pseudo-rnd-thoughts pseudo-rnd-thoughts commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Description

test_elastic_e2e started 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:

  1. The reservation grows past the decision. The elastic policy requests max_workers bundles, so its reservation keeps growing as nodes join. _try_get_selectors required len(label_selectors) == num_workers exactly. A ResizeDecision(14) followed by more nodes above 14 joining therefore never counted as ready. It polled for 60s, raised WorkerGroupStartupTimeoutError and burned a retry. In the failing CI run, this cost 60 of the 180 seconds:

    Deciding to resize the worker group from 8 -> 14 workers.
    The worker group startup timed out after 60.0 seconds waiting for 14 workers.
    Detected ready resources for 32 workers ... Started training worker group of size 32
    

    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.

  2. The reservation shrinks below the decision. make_decision_for_non_running_worker_group sized the group from the cached reservation (up to 1s old), but pinning uses recompute=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.

…r than elastic request

Signed-off-by: Mark Towers <mark@anyscale.com>
@pseudo-rnd-thoughts pseudo-rnd-thoughts added train Ray Train Related Issue core-autoscaler autoscaler related issues labels Sep 30, 2026
Signed-off-by: Mark Towers <mark@anyscale.com>
@pseudo-rnd-thoughts
pseudo-rnd-thoughts marked this pull request as ready for review September 30, 2026 16:27
@pseudo-rnd-thoughts
pseudo-rnd-thoughts requested review from a team as code owners September 30, 2026 16:27

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread python/ray/train/v2/_internal/execution/scaling_policy/elastic.py

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit f8408ce. Configure here.

@iamjustinhsu iamjustinhsu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks!

@pseudo-rnd-thoughts pseudo-rnd-thoughts added the go add ONLY when ready to merge, run all tests label Sep 30, 2026
Signed-off-by: Mark Towers <mark@anyscale.com>

@justinvyu justinvyu left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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: ElasticScalingPolicy reads 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 mismatched len(label_selectors).
  • Controller: _start_worker_group uses decision.label_selectors when 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_workers is ready (not necessarily true in the cluster upscaling case) and return a ResizeDecision. Rely on a 60 second timeout when starting the worker group before retrying.
    • Elastic: Return NoopDecision until the coordinator returns enough reserved resources for min_workers before returning a ResizeDecision.

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

@pseudo-rnd-thoughts

Copy link
Copy Markdown
Member Author

Closing in favor of #66646

matthewdeng pushed a commit that referenced this pull request Oct 9, 2026
…#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core-autoscaler autoscaler related issues go add ONLY when ready to merge, run all tests train Ray Train Related Issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants