import collections import os import time from typing import Dict, List, Optional from unittest.mock import MagicMock, patch import pytest import ray from ray._private.state import state as ray_state from ray.exceptions import RayActorError, RayTaskError from ray.runtime_env import RuntimeEnv from ray.train.v2._internal.callbacks import backend_setup from ray.train.v2._internal.callbacks.backend_setup import BackendSetupCallback from ray.train.v2._internal.constants import ( ENV_VARS_TO_PROPAGATE, WORKER_GROUP_START_TIMEOUT_S_ENV_VAR, WORKER_HEALTH_CHECK_TIMEOUT_S_ENV_VAR, ) from ray.train.v2._internal.exceptions import ( InsufficientClusterResourcesError, WorkerGroupStartupFailedError, WorkerGroupStartupTimeoutError, WorkerHealthCheckFailedError, WorkerHealthCheckTimeoutError, ) from ray.train.v2._internal.execution.callback import ( ReplicaGroupCallback, WorkerGroupCallback, ) from ray.train.v2._internal.execution.context import ( DistributedContext, get_train_context, ) from ray.train.v2._internal.execution.worker_group import ( ActorMetadata, RayTrainWorker, Worker, WorkerGroup, WorkerGroupContext, WorkerGroupState, ) from ray.train.v2._internal.util import ObjectRefWrapper from ray.train.v2.api.config import RunConfig, ScalingConfig from ray.train.v2.tests.util import DummyObjectRefWrapper, create_dummy_run_context from ray.util.state import list_actors pytestmark = pytest.mark.usefixtures("mock_runtime_context") @pytest.fixture(autouse=True, scope="module") def ray_start_4_cpus(): ray.init(num_cpus=4) yield ray.shutdown() def _default_inactive_worker_group(**kwargs): default_config = { "train_run_context": create_dummy_run_context(), "worker_group_context": _default_worker_group_context(), } default_config.update(kwargs) return WorkerGroup(**default_config) def _default_worker_group_context(**kwargs): default_config = { "run_attempt_id": "test_run_attempt_id", "train_fn_ref": DummyObjectRefWrapper(lambda: None), "num_workers": 4, "resources_per_worker": {"CPU": 1}, } default_config.update(kwargs) return WorkerGroupContext(**default_config) def test_worker_group_create(): """Test WorkerGroup.create() factory method.""" worker_group = WorkerGroup.create( train_run_context=create_dummy_run_context(), worker_group_context=_default_worker_group_context(), ) assert len(worker_group) == 4 assert worker_group.has_started() with pytest.raises(ValueError, match="Worker group is active"): worker_group._start() worker_group.shutdown() with pytest.raises(ValueError, match="Worker group is not active"): worker_group.get_workers() def test_replace_replica_group(): """Test that replace_replica_group correctly replaces a failing replica group.""" wg = _default_inactive_worker_group() wg._start() # Remember old state. old_workers = wg.get_workers() old_state = wg.get_worker_group_state() old_replica_groups = wg.get_replica_groups() old_rg0_workers = old_replica_groups[0].get_workers() old_rg1_workers = old_replica_groups[1].get_workers() # Replace replica group 0 and get new state. wg.replace_replica_group(0) new_workers = wg.get_workers() new_state = wg.get_worker_group_state() new_replica_groups = wg.get_replica_groups() # Assert most of WorkerGroupState is preserved. assert len(new_workers) == len(old_workers) assert new_state.start_time == old_state.start_time assert new_state.placement_group_handle is old_state.placement_group_handle assert new_state.sync_actor is old_state.sync_actor # Assert replica group 0 workers are replaced but with same distributed contexts. new_rg0_workers = new_replica_groups[0].get_workers() for old_w, new_w in zip(old_rg0_workers, new_rg0_workers): assert new_w is not old_w new_rg1_workers = new_replica_groups[1].get_workers() for old_w, new_w in zip(old_rg1_workers, new_rg1_workers): assert new_w is old_w for old_w, new_w in zip(old_rg0_workers, new_rg0_workers): assert ( new_w.distributed_context.world_rank == old_w.distributed_context.world_rank ) # Assert other state is as expected. for w in new_rg0_workers: assert ( wg._worker_rank_to_replica_group_rank[w.distributed_context.world_rank] == 0 ) for old_w in old_rg0_workers: assert ( old_w.distributed_context.world_rank not in wg._world_rank_to_ongoing_poll ) wg.shutdown() def test_replace_replica_group_succeed_on_retry(): """Test that replace_replica_group raises WorkerGroupStartupFailedError when a replacement worker fails to initialize.""" class FailingWorker(RayTrainWorker): def __init__(self): raise RuntimeError("Replacement worker failed to start.") wg = _default_inactive_worker_group() wg._start() # Swap the worker class so replacement workers will fail. wg._worker_cls = FailingWorker with pytest.raises(WorkerGroupStartupFailedError): wg.replace_replica_group(0) # Swap worker class so second attempt succeeds. wg._worker_cls = RayTrainWorker wg.replace_replica_group(0) wg.shutdown() @pytest.mark.parametrize( "runtime_env", [{"env_vars": {"DUMMY_VAR": "abcd"}}, RuntimeEnv(env_vars={"DUMMY_VAR": "abcd"})], ) def test_worker_group_create_with_runtime_env(runtime_env): """Test WorkerGroup.create() factory method with a custom runtime environment.""" run_config = RunConfig(worker_runtime_env=runtime_env) train_run_context = create_dummy_run_context(run_config=run_config) worker_group_context = _default_worker_group_context() worker_group = WorkerGroup.create( train_run_context=train_run_context, worker_group_context=worker_group_context, ) env_vars = worker_group.execute(lambda: os.environ.get("DUMMY_VAR")) assert env_vars == ["abcd"] * worker_group_context.num_workers worker_group.shutdown() def test_env_var_propagation(monkeypatch): """Ray Train should automatically propagate some environment variables from the driver to the workers.""" test_env_var = list(ENV_VARS_TO_PROPAGATE)[0] monkeypatch.setenv(test_env_var, "1") wg = _default_inactive_worker_group() wg._start() env_vars = wg.execute(lambda: os.environ.get(test_env_var)) wg.shutdown() assert env_vars == ["1"] * 4 def test_actor_start_failure(): class FailingWorker(RayTrainWorker): def __init__(self): raise RuntimeError("Worker failed to start.") wg = _default_inactive_worker_group() wg._worker_cls = FailingWorker with pytest.raises(WorkerGroupStartupFailedError): wg._start() # TODO: this and other tests should verify that we shut down the worker group. def test_callback_start_failure(): class FailingCallback(WorkerGroupCallback): def after_worker_group_start(self, worker_group): raise RuntimeError("Worker failed to start.") wg = _default_inactive_worker_group(callbacks=[FailingCallback()]) with pytest.raises(RuntimeError): wg._start() wg.shutdown() def test_start_timeout(monkeypatch): from ray.train.v2._internal.execution.worker_group.placement_group_handle import ( DefaultPlacementGroupHandle, ) monkeypatch.setenv(WORKER_GROUP_START_TIMEOUT_S_ENV_VAR, "0.1") monkeypatch.setattr( DefaultPlacementGroupHandle, "wait", lambda self, timeout_seconds=None: False, ) wg = _default_inactive_worker_group() with pytest.raises(WorkerGroupStartupTimeoutError): # Not enough CPU resources are available, so the workers will not start. wg._start() def test_tpu_slice_reservation_timeout_is_retryable(monkeypatch): """TPU head reservation timeouts should surface as the retryable ``WorkerGroupStartupTimeoutError`` so the controller transitions SCHEDULING -> RESCHEDULING instead of failing the run, matching the behavior of CPU/GPU placement-group timeouts. Also verifies that Ray Train's worker-group-start timeout is forwarded to ``SlicePlacementGroup`` so users have a single knob governing how long the cluster has to provide capacity. """ from ray.train.v2._internal.execution.worker_group import worker_group as wg_mod monkeypatch.setenv(WORKER_GROUP_START_TIMEOUT_S_ENV_VAR, "0.1") # SlicePlacementGroup blocks synchronously on a TPU head PG; simulate the # "cluster still autoscaling" scenario where reserve_tpu_slice times out # and capture the timeout that Ray Train passed in. captured_kwargs = {} def _raise_timeout(*args, **kwargs): captured_kwargs.update(kwargs) raise TimeoutError( "Failed to reserve TPU head for slice with shape: v5litepod-8 " "after 0.1 seconds." ) monkeypatch.setattr(wg_mod, "SlicePlacementGroup", _raise_timeout) monkeypatch.setattr( wg_mod, "get_tpu_version_from_type", lambda accelerator_type: "v5litepod" ) tpu_scaling_config = ScalingConfig( num_workers=4, resources_per_worker={"TPU": 4}, accelerator_type="TPU-V5LITEPOD", topology="2x4", use_tpu=True, ) wg = _default_inactive_worker_group( train_run_context=create_dummy_run_context(scaling_config=tpu_scaling_config), worker_group_context=_default_worker_group_context( num_workers=4, resources_per_worker={"TPU": 4}, num_slices=2, ), ) with pytest.raises(WorkerGroupStartupTimeoutError): wg._create_placement_group(tpu_scaling_config, wg._worker_group_context) # Ray Train should forward its own startup timeout to the TPU head wait # rather than relying on the hard-coded default in reserve_tpu_slice. assert captured_kwargs.get("head_reservation_timeout_s") == 0.1 def test_tpu_slice_reservation_non_timeout_failure_is_retryable(monkeypatch): """Non-timeout failures from ``SlicePlacementGroup`` (e.g. transient RPC errors) should surface as the retryable ``WorkerGroupStartupFailedError``, matching the precedent of the worker-actor startup path (``RayActorError`` -> ``WorkerGroupStartupFailedError``) so non-timeout startup failures retry consistently across CPU/GPU/TPU. """ from ray.train.v2._internal.execution.worker_group import worker_group as wg_mod def _raise_runtime_error(*args, **kwargs): raise RuntimeError("transient placement group reservation error") monkeypatch.setattr(wg_mod, "SlicePlacementGroup", _raise_runtime_error) monkeypatch.setattr( wg_mod, "get_tpu_version_from_type", lambda accelerator_type: "v5litepod" ) tpu_scaling_config = ScalingConfig( num_workers=4, resources_per_worker={"TPU": 4}, accelerator_type="TPU-V5LITEPOD", topology="2x4", use_tpu=True, ) wg = _default_inactive_worker_group( train_run_context=create_dummy_run_context(scaling_config=tpu_scaling_config), worker_group_context=_default_worker_group_context( num_workers=4, resources_per_worker={"TPU": 4}, num_slices=2, ), ) with pytest.raises( WorkerGroupStartupFailedError, match="Failed to reserve TPU slice" ): wg._create_placement_group(tpu_scaling_config, wg._worker_group_context) def test_zombie_actor_termination(ray_start_4_cpus): """This test checks that RayTrainWorker actors are terminated correctly even if python garbage collection hangs on actor shutdown.""" NUM_WORKERS = 4 def is_process_alive(pid: int) -> bool: try: os.kill(pid, 0) except ProcessLookupError: return False except PermissionError: return True else: return True class Node: def __init__(self, name): self.name = name self.other = None def __del__(self): # Simulate hang during garbage collection while True: time.sleep(1) def train_fn(): # Create a circular reference to delay garbage collection a, b = Node("a"), Node("b") a.other = b b.other = a train_fn_ref = ObjectRefWrapper(train_fn) train_run_context = create_dummy_run_context( scaling_config=ScalingConfig(num_workers=NUM_WORKERS) ) worker_group_context = _default_worker_group_context( train_fn_ref=train_fn_ref, num_workers=NUM_WORKERS, ) # Starts the worker group and runs the train function worker_group = WorkerGroup.create( train_run_context=train_run_context, worker_group_context=worker_group_context, callbacks=[], ) train_worker_pids = [ actor.pid for actor in list_actors() if actor.class_name == RayTrainWorker.__name__ and actor.state == "ALIVE" ] assert len(train_worker_pids) == NUM_WORKERS worker_group.shutdown() # ray.kill is async, allow some time for the processes to terminate TIMEOUT_S = 5 deadline = time.monotonic() + TIMEOUT_S remaining = set(train_worker_pids) while remaining and time.monotonic() < deadline: remaining = {pid for pid in remaining if is_process_alive(pid)} if remaining: time.sleep(0.1) assert not remaining def test_insufficient_cluster_resources_startup_failure(monkeypatch): """Test that WorkerGroup startup fails when cluster has insufficient resources. This test mocks the cluster resources to match the test environment and verifies that the resource check properly catches insufficient resources. """ # Mock the cluster resources to return the test cluster configuration (4 CPUs) monkeypatch.setattr( ray_state, "get_max_resources_from_cluster_config", lambda: {"CPU": 4.0} ) # The test cluster has 4 CPUs, so requesting 8 workers with 1 CPU each should fail worker_group_context = _default_worker_group_context( num_workers=8, # More workers than available CPUs resources_per_worker={"CPU": 1.0}, ) wg = _default_inactive_worker_group(worker_group_context=worker_group_context) # This should fail during startup due to insufficient resources with pytest.raises( InsufficientClusterResourcesError, match="Insufficient cluster resources" ): wg._start() # TODO: consider test_poll_status methods that verify that _world_rank_to_ongoing_poll # is updated correctly. def test_poll_status_running(): worker_group_context = _default_worker_group_context( train_fn_ref=DummyObjectRefWrapper(lambda: time.sleep(60)), ) wg = _default_inactive_worker_group(worker_group_context=worker_group_context) wg._start() status = wg.poll_status() wg.shutdown() assert len(status.worker_statuses) == 4 assert not status.finished assert not status.errors assert status.worker_rank_to_replica_group_rank == {0: 0, 1: 1, 2: 2, 3: 3} assert status.failing_replica_group_indices == set() def test_poll_status_finished(): worker_group_context = _default_worker_group_context( train_fn_ref=DummyObjectRefWrapper(lambda: "done"), ) wg = _default_inactive_worker_group(worker_group_context=worker_group_context) wg._start() # Wait for the workers to finish the training fn before polling. # Otherwise, the poll_status call may return before the workers finish. while not wg.poll_status().finished: time.sleep(0.01) status = wg.poll_status() wg.shutdown() assert len(status.worker_statuses) == 4 assert status.finished assert not status.errors assert status.worker_rank_to_replica_group_rank == {0: 0, 1: 1, 2: 2, 3: 3} assert status.failing_replica_group_indices == set() @pytest.mark.parametrize("actor_failure", [True, False]) def test_poll_status_failures(monkeypatch, tmp_path, actor_failure): """Tests that the worker group raises the correct errors when the actor fails or the user code raises an error on any worker.""" dummy_file = tmp_path / "dummy.txt" def train_fn(): # Error when the worker group initialization is finished. while not dummy_file.exists(): time.sleep(0.01) if actor_failure: os._exit(1) else: raise RuntimeError("Mock user code error") worker_group_context = _default_worker_group_context( train_fn_ref=DummyObjectRefWrapper(train_fn), ) wg = _default_inactive_worker_group(worker_group_context=worker_group_context) wg._start() dummy_file.touch() while not wg.poll_status().finished: time.sleep(0.01) status = wg.poll_status() wg.shutdown() assert len(status.worker_statuses) == 4 assert status.finished assert status.worker_rank_to_replica_group_rank == {0: 0, 1: 1, 2: 2, 3: 3} assert status.failing_replica_group_indices == {0, 1, 2, 3} if actor_failure: assert len(status.errors) == 4 assert [ isinstance(error, WorkerHealthCheckFailedError) for error in status.errors.values() ] assert [ isinstance(error.health_check_failure, RuntimeError) for error in status.errors.values() ] else: assert len(status.errors) == 4 assert all( ["user code error" in str(error) for error in status.errors.values()] ) def test_poll_status_healthcheck_timeout(monkeypatch): monkeypatch.setenv(WORKER_HEALTH_CHECK_TIMEOUT_S_ENV_VAR, "0") def hanging_poll_status(worker_self): time.sleep(60) monkeypatch.setattr(RayTrainWorker, "poll_status", hanging_poll_status) wg = _default_inactive_worker_group() # Try 2x to ensure that shutdown clears the health-check hanging timer. for _ in range(2): wg._start() status = wg.poll_status(timeout=0.01) assert len(status.errors) == 4 assert all( [ isinstance(error, WorkerHealthCheckTimeoutError) for error in status.errors.values() ] ) assert status.failing_replica_group_indices == {0, 1, 2, 3} wg.shutdown() @pytest.mark.parametrize("queue_backlog_length", [0, 1, 3]) def test_flush_worker_result_queue(queue_backlog_length): """Test that the worker group is still considered running while the result queue is not fully consumed.""" wg = _default_inactive_worker_group() wg._start() def populate_result_queue(): # Note that the result queue is a thread-safe queue of maxsize 1. get_train_context().get_result_queue().put("result") for _ in range(queue_backlog_length): wg.execute(populate_result_queue) status = wg.poll_status() assert all( worker_status.training_report for worker_status in status.worker_statuses.values() ) assert not status.finished # Wait for the workers to finish the training fn and for any pending # training_report(s) to be flushed/consumed. timeout_s = 5 deadline = time.monotonic() + timeout_s while True: status = wg.poll_status() if status.finished: break assert ( time.monotonic() < deadline ), f"Timed out waiting for worker group to finish. Last status: {status}" time.sleep(0.01) assert all( worker_status.training_report is None for worker_status in status.worker_statuses.values() ) wg.shutdown() def test_group_workers_by_ip(): def create_workers(node_ids): return [ Worker( actor=None, metadata=ActorMetadata( node_id=node_id, node_ip="dummy", hostname="dummy", accelerator_ids={}, pid=0, ), resources={"CPU": 1}, ) for node_id in node_ids ] workers = create_workers(["2", "3", "1", "4", "2", "1", "3", "3", "4", "2"]) workers = WorkerGroup._sort_workers_by_gpu_id_grouped_by_node(workers) expected = ["2", "2", "2", "3", "3", "3", "1", "1", "4", "4"] ips = [w.metadata.node_id for w in workers] assert ips == expected, ( "Workers should be grouped by node ID " "and follow the same original order of IDs encountered (2, 3, 1, 4)." ) workers = create_workers(["2", "3", "1", "4", "2", "1", "3", "3", "4", "2"]) workers = WorkerGroup._sort_workers_by_gpu_id_grouped_by_node( workers, _first_id="1" ) expected = ["1", "1", "2", "2", "2", "3", "3", "3", "4", "4"] ips = [w.metadata.node_id for w in workers] assert ( ips == expected ), "Workers should be grouped by ID, with the first ID being 1." def test_local_rank_assignment(): def create_workers(pids, node_ids, gpu_ids): return [ Worker( actor=None, metadata=ActorMetadata( node_id=node_id, node_ip="dummy", hostname="dummy", accelerator_ids={"GPU": gpu_id.split(",") if gpu_id else []}, pid=pid, ), resources={"CPU": 1}, ) for pid, node_id, gpu_id in zip(pids, node_ids, gpu_ids) ] def setup_and_check_worker_group( pids: List[int], node_ids: List[str], gpu_ids: List[Optional[str]], expected_local_ranks: Dict[int, int], ): """ Create a worker group, group workers by IP, and check local ranks assignment. Args: pids: List of unique process IDs. node_ids: List of node ids corresponding to each PID. gpu_ids: List of GPU IDs or None for each PID. expected_local_ranks: Dictionary mapping PID to the expected local rank. """ workers = create_workers(pids=pids, node_ids=node_ids, gpu_ids=gpu_ids) workers = WorkerGroup._sort_workers_by_gpu_id_grouped_by_node(workers) # Build local ranks according to the logics in # TODO: Replace this with the actual implementation later node_id_dict = collections.defaultdict(int) local_ranks_map = collections.defaultdict(int) for w in workers: local_ranks_map[w.metadata.pid] = node_id_dict[w.metadata.node_id] node_id_dict[w.metadata.node_id] += 1 local_ranks = [local_ranks_map[pid] for pid in pids] assert ( local_ranks == expected_local_ranks ), "Incorrect local ranks allocation!\n" f"Expect: {expected_local_ranks}\nGot: {local_ranks}" # Define the worker configurations for different scenarios # For workers without GPU resources, their original order will be preserved cpu_workers_config = { "pids": [0, 1, 2, 3, 4, 5, 6, 7], "node_ids": ["2", "2", "1", "1", "2", "1", "1", "2"], "gpu_ids": [None] * 8, "expected_local_ranks": [0, 1, 0, 1, 2, 2, 3, 3], } gpu_workers_single_gpu_config = { "pids": [0, 1, 2, 3, 4, 5, 6, 7], "node_ids": ["2", "2", "1", "1", "2", "1", "1", "2"], "gpu_ids": ["1", "0", "3", "2", "2", "0", "1", "3"], "expected_local_ranks": [1, 0, 3, 2, 2, 0, 1, 3], } # For workers with multiple gpus, sort by their lowest gpu id gpu_workers_multiple_gpus_config = { "pids": [0, 1, 2, 3], "node_ids": ["2", "1", "1", "2"], "gpu_ids": ["1,3", "2,1", "0,3", "0,2"], "expected_local_ranks": [1, 1, 0, 0], } # Setup and check worker groups for each configuration setup_and_check_worker_group(**cpu_workers_config) setup_and_check_worker_group(**gpu_workers_single_gpu_config) setup_and_check_worker_group(**gpu_workers_multiple_gpus_config) @pytest.mark.parametrize("replace_rg", [False, True], ids=["start", "replace_rg"]) def test_setup_worker_group(tmp_path, replace_rg): num_workers = 4 worker_group = WorkerGroup( train_run_context=create_dummy_run_context( run_config=RunConfig(name="test", storage_path=str(tmp_path)) ), worker_group_context=_default_worker_group_context(num_workers=num_workers), ) if replace_rg: worker_group._manages_replica_groups = True worker_group._start() if replace_rg: worker_group.replace_replica_group(0) def get_world_size(): return ray.train.get_context().get_world_size() def get_world_rank(): return ray.train.get_context().get_world_rank() def get_storage_context_name(): return ray.train.get_context().get_storage().experiment_dir_name def get_local_rank(): return ray.train.get_context().get_local_rank() def get_local_world_size(): return ray.train.get_context().get_local_world_size() def get_node_rank(): return ray.train.get_context().get_node_rank() if replace_rg: assert worker_group.execute(get_local_rank) == [0] * num_workers assert worker_group.execute(get_local_world_size) == [1] * num_workers else: assert worker_group.execute(get_local_rank) == list(range(num_workers)) assert worker_group.execute(get_local_world_size) == [num_workers] * num_workers assert worker_group.execute(get_node_rank) == [0] * num_workers assert worker_group.execute(get_world_size) == [num_workers] * num_workers assert sorted(worker_group.execute(get_world_rank)) == list(range(num_workers)) assert worker_group.execute(get_storage_context_name) == ["test"] * num_workers worker_group.shutdown() def test_worker_group_callback(): """Check that all worker group callback hooks are called.""" class AssertCallback(WorkerGroupCallback): def __init__(self): self.start_hook_called = False self.training_start_hook_called = False self.shutdown_hook_called = False self.poll_status_hook_called = False self.abort_hook_called = False def after_worker_group_start(self, worker_group): self.start_hook_called = True def after_worker_group_training_start(self, worker_group): self.training_start_hook_called = True def before_worker_group_shutdown(self, worker_group): self.shutdown_hook_called = True def after_worker_group_shutdown(self, worker_group_context): self.after_worker_group_shutdown_hook_called = True def after_worker_group_poll_status(self, worker_group_status): assert len(worker_group_status.worker_statuses) == 4 self.poll_status_hook_called = True hooks = AssertCallback() wg = _default_inactive_worker_group(callbacks=[hooks]) wg._start() assert hooks.start_hook_called assert hooks.training_start_hook_called wg.poll_status() assert hooks.poll_status_hook_called wg.shutdown() assert hooks.shutdown_hook_called assert hooks.after_worker_group_shutdown_hook_called def _make_backend_setup_callback_with_failing_shutdown( error: Exception, ) -> BackendSetupCallback: """Build a `BackendSetupCallback` whose backend raises `error` from `on_shutdown`.""" failing_backend = MagicMock() failing_backend.on_shutdown.side_effect = error backend_config = MagicMock() backend_config.backend_cls.return_value = failing_backend cb = BackendSetupCallback(backend_config) cb._backend = failing_backend return cb @pytest.mark.parametrize( "shutdown_error", [ RayActorError(actor_id="abc", error_msg="actor died"), RayTaskError( function_name="_shutdown_torch", traceback_str="traceback", cause=RuntimeError("NCCL error: remote process exited"), proctitle="test", pid=1, ip="127.0.0.1", ), ], ids=["RayActorError", "RayTaskError"], ) def test_backend_setup_callback_swallows_shutdown_failure(shutdown_error): """Test `BackendSetupCallback` swallows both RayActorError and RayTaskError so `WorkerGroup.shutdown()` does not propagate the cleanup failure. """ cb = _make_backend_setup_callback_with_failing_shutdown(shutdown_error) failing_backend = cb._backend wg = _default_inactive_worker_group(callbacks=[cb]) wg._start() with patch.object(backend_setup, "logger") as mock_logger: wg.shutdown() # must not raise failing_backend.on_shutdown.assert_called_once() mock_logger.warning.assert_called_once() msg = mock_logger.warning.call_args.args[0] assert "Graceful shutdown of backend failed" in msg # exc_info=True keeps the underlying NCCL/actor failure in the logs. assert mock_logger.warning.call_args.kwargs.get("exc_info") is True def test_backend_setup_callback_propagates_unexpected_shutdown_error(): """Non-Ray exceptions from `on_shutdown` must propagate so they aren't silently masked.""" cb = _make_backend_setup_callback_with_failing_shutdown( ValueError("unexpected backend bug") ) with pytest.raises(ValueError, match="unexpected backend bug"): cb.before_execution_group_shutdown(MagicMock()) @pytest.mark.parametrize("replace_rg", [False, True], ids=["start", "replace_rg"]) def test_worker_log_file_paths(replace_rg): """Test that log file paths are correctly assigned to workers.""" wg = _default_inactive_worker_group() wg._start() if replace_rg: wg.replace_replica_group(0) # Check that all workers have log file paths assigned workers = wg.get_workers() for worker in workers: assert worker.log_file_path is not None assert "ray-train-app-worker" in worker.log_file_path wg.shutdown() def test_replica_group_callback(): """Check that replica group callback hooks are called during replace_replica_group.""" class AssertCallback(ReplicaGroupCallback): def __init__(self): self.shutdown_rg = None self.start_rg = None self.init_context_workers = None def before_replica_group_shutdown(self, replica_group): self.shutdown_rg = replica_group def after_replica_group_start(self, replica_group): self.start_rg = replica_group def before_init_train_context(self, workers): self.init_context_workers = workers return {} hooks = AssertCallback() wg = _default_inactive_worker_group(callbacks=[hooks]) wg._start() old_rg = wg.get_replica_groups()[0] wg.replace_replica_group(0) new_rg = wg.get_replica_groups()[0] assert hooks.shutdown_rg is old_rg assert hooks.start_rg is new_rg assert hooks.start_rg is not hooks.shutdown_rg assert hooks.init_context_workers == new_rg.get_workers() wg.shutdown() def test_worker_group_abort(monkeypatch): class AssertCallback(WorkerGroupCallback): def __init__(self): self.abort_hook_called = False def before_worker_group_abort(self, worker_group_context): self.abort_hook_called = True def after_worker_group_abort(self, worker_group_context): self.after_worker_group_abort_hook_called = True hooks = AssertCallback() wg = _default_inactive_worker_group(callbacks=[hooks]) wg._start() # Track shutdown calls without preventing actual cleanup shutdown_call_count = 0 original_shutdown = WorkerGroupState.shutdown def track_shutdown_calls(self): nonlocal shutdown_call_count shutdown_call_count += 1 return original_shutdown(self) monkeypatch.setattr(WorkerGroupState, "shutdown", track_shutdown_calls) wg.abort() assert ( shutdown_call_count == 1 ), f"Expected shutdown to be called once, but was called {shutdown_call_count} times" assert hooks.abort_hook_called assert hooks.after_worker_group_abort_hook_called # Bypass _assert_active method, allowing for shutdown monkeypatch.setattr(wg, "_assert_active", lambda: None) wg.shutdown() def test_shutdown_hook_with_dead_actors(): """Check that the shutdown hook raises correctly if run on a mix of alive and dead actors.""" class ShutdownCallback(WorkerGroupCallback): def before_worker_group_shutdown(self, worker_group): # Mock a hanging collective call on the remaining workers. def f(): print(ray.train.get_context().get_world_rank()) time.sleep(10) wg.execute(f) def conditional_failure(): if ray.train.get_context().get_world_rank() % 2 == 0: ray.actor.exit_actor() wg = _default_inactive_worker_group(callbacks=[ShutdownCallback()]) wg._start() # Kill some of the actors try: wg.execute(conditional_failure) except RayActorError: pass # The shutdown hook should not hang here and should immediately raise. start = time.monotonic() with pytest.raises(RayActorError): wg.shutdown() # Should not wait for the full 10 seconds. assert time.monotonic() - start < 1 # TODO: This test leaves the WorkerGroup in a bad state. # If more tests are added below this, they may not be able to run. def test_check_cluster_resources_and_raise_if_insufficient(monkeypatch): """Test _check_cluster_resources_and_raise_if_insufficient static method.""" def _assert_resource_check( available_resources, resources_per_worker, num_workers, should_raise ): """Helper to test resource checking with different scenarios.""" monkeypatch.setattr( ray_state, "get_max_resources_from_cluster_config", lambda: available_resources, ) if should_raise: with pytest.raises( InsufficientClusterResourcesError, match="Insufficient cluster resources", ): WorkerGroup._check_cluster_resources_and_raise_if_insufficient( resources_per_worker=resources_per_worker, num_workers=num_workers ) else: # Should not raise WorkerGroup._check_cluster_resources_and_raise_if_insufficient( resources_per_worker=resources_per_worker, num_workers=num_workers ) # Test case 1: Sufficient resources - should not raise _assert_resource_check( available_resources={"CPU": 8.0, "GPU": 4.0}, resources_per_worker={"CPU": 1.0, "GPU": 0.5}, num_workers=4, should_raise=False, ) # Test case 2: Insufficient CPU resources - should raise _assert_resource_check( available_resources={"CPU": 8.0, "GPU": 4.0}, resources_per_worker={"CPU": 3.0}, num_workers=4, # Requires 12 CPU but only 8 available should_raise=True, ) # Test case 3: Insufficient GPU resources - should raise _assert_resource_check( available_resources={"CPU": 8.0, "GPU": 4.0}, resources_per_worker={"GPU": 2.0}, num_workers=3, # Requires 6 GPU but only 4 available should_raise=True, ) # Test case 4: Missing resource type in cluster - should raise _assert_resource_check( available_resources={"CPU": 8.0, "GPU": 4.0}, resources_per_worker={"TPU": 1.0}, num_workers=1, # TPU not available in cluster should_raise=True, ) # Test case 5: Resource available but zero - should raise _assert_resource_check( available_resources={"CPU": 8.0, "GPU": 0}, resources_per_worker={"GPU": 1.0}, num_workers=1, should_raise=True, ) # Test case 6: Empty cluster resources - should not raise _assert_resource_check( available_resources={}, resources_per_worker={"CPU": 1.0}, num_workers=2, should_raise=False, ) # Test case 7: None cluster resources - should not raise _assert_resource_check( available_resources=None, resources_per_worker={"CPU": 1.0}, num_workers=2, should_raise=False, ) # Test case 8: Edge case with zero resources - should not raise _assert_resource_check( available_resources={"CPU": 4.0}, resources_per_worker={"CPU": 0.0}, num_workers=10, should_raise=False, ) # Test case 9: Exact resource match - should not raise _assert_resource_check( available_resources={"CPU": 4.0}, resources_per_worker={"CPU": 1.0}, num_workers=4, # Exactly matches 4.0 CPU available should_raise=False, ) def _make_worker(node_id, node_ip, gpu_ids=None): """Helper to create a Worker with minimal metadata for rank assignment tests.""" return Worker( actor=None, metadata=ActorMetadata( node_id=node_id, node_ip=node_ip, hostname="dummy", accelerator_ids={"GPU": gpu_ids} if gpu_ids else {}, pid=0, ), resources={"GPU": 1} if gpu_ids else {"CPU": 1}, ) @pytest.mark.parametrize( "workers, starting_world_rank, world_size, replica_group_size, " "expected_contexts", [ pytest.param( # 4 workers on 2 nodes, 2 GPUs each [ _make_worker("node0", "10.0.0.1", ["1"]), _make_worker("node1", "10.0.0.2", ["1"]), _make_worker("node0", "10.0.0.1", ["0"]), _make_worker("node1", "10.0.0.2", ["0"]), ], 0, None, None, # After sorting: node0/gpu0, node0/gpu1, node1/gpu0, node1/gpu1 [ DistributedContext( local_rank=0, local_world_size=2, world_rank=0, world_size=4, node_rank=0, ), DistributedContext( local_rank=1, local_world_size=2, world_rank=1, world_size=4, node_rank=0, ), DistributedContext( local_rank=0, local_world_size=2, world_rank=2, world_size=4, node_rank=1, ), DistributedContext( local_rank=1, local_world_size=2, world_rank=3, world_size=4, node_rank=1, ), ], id="no_replica_groups", ), pytest.param( # 4 workers on 2 nodes — each worker is its own replica group [ _make_worker("node0", "10.0.0.1", ["1"]), _make_worker("node1", "10.0.0.2", ["0"]), _make_worker("node0", "10.0.0.1", ["0"]), _make_worker("node1", "10.0.0.2", ["1"]), ], 0, None, 1, # After sorting: node0/gpu0, node0/gpu1, node1/gpu0, node1/gpu1 # Each worker is its own replica group, so local_rank=0, # local_world_size=1, node_rank=0 for all. [ DistributedContext( local_rank=0, local_world_size=1, world_rank=0, world_size=4, node_rank=0, ), DistributedContext( local_rank=0, local_world_size=1, world_rank=1, world_size=4, node_rank=0, ), DistributedContext( local_rank=0, local_world_size=1, world_rank=2, world_size=4, node_rank=0, ), DistributedContext( local_rank=0, local_world_size=1, world_rank=3, world_size=4, node_rank=0, ), ], id="replica_group_size_1", ), pytest.param( # 8 workers across 3 nodes (2-4-2 GPUs), replica_group_size=4 [ _make_worker("nodeA", "10.0.0.1", ["1"]), _make_worker("nodeB", "10.0.0.2", ["3"]), _make_worker("nodeA", "10.0.0.1", ["0"]), _make_worker("nodeB", "10.0.0.2", ["0"]), _make_worker("nodeC", "10.0.0.3", ["1"]), _make_worker("nodeB", "10.0.0.2", ["2"]), _make_worker("nodeB", "10.0.0.2", ["1"]), _make_worker("nodeC", "10.0.0.3", ["0"]), ], 0, None, 4, [ # RG0: A/gpu0, A/gpu1, B/gpu0, B/gpu1 DistributedContext( local_rank=0, local_world_size=2, world_rank=0, world_size=8, node_rank=0, ), DistributedContext( local_rank=1, local_world_size=2, world_rank=1, world_size=8, node_rank=0, ), DistributedContext( local_rank=0, local_world_size=2, world_rank=2, world_size=8, node_rank=1, ), DistributedContext( local_rank=1, local_world_size=2, world_rank=3, world_size=8, node_rank=1, ), # RG1: B/gpu2, B/gpu3, C/gpu0, C/gpu1 DistributedContext( local_rank=0, local_world_size=2, world_rank=4, world_size=8, node_rank=0, ), DistributedContext( local_rank=1, local_world_size=2, world_rank=5, world_size=8, node_rank=0, ), DistributedContext( local_rank=0, local_world_size=2, world_rank=6, world_size=8, node_rank=1, ), DistributedContext( local_rank=1, local_world_size=2, world_rank=7, world_size=8, node_rank=1, ), ], id="replica_group_size_4_node_straddling", ), pytest.param( # Simulates replacing replica group 1 in a 4-worker setup with replica_group_size=2. [ _make_worker("node0", "10.0.0.1", ["0"]), _make_worker("node1", "10.0.0.2", ["0"]), ], 2, 4, 2, [ DistributedContext( local_rank=0, local_world_size=1, world_rank=2, world_size=4, node_rank=0, ), DistributedContext( local_rank=0, local_world_size=1, world_rank=3, world_size=4, node_rank=1, ), ], id="replica_group_size_2_replace", ), ], ) def test_assign_worker_ranks( workers, starting_world_rank, world_size, replica_group_size, expected_contexts, ): result = WorkerGroup._assign_worker_ranks( workers, starting_world_rank=starting_world_rank, world_size=world_size, replica_group_size=replica_group_size, ) assert len(result) == len(expected_contexts) for i, (worker, expected) in enumerate(zip(result, expected_contexts)): ctx = worker.distributed_context assert ctx == expected, f"Worker {i}: expected {expected}, got {ctx}" if __name__ == "__main__": import sys sys.exit(pytest.main(["-v", "-x", __file__]))