import json import os import sys import pytest import ray from ray._common.test_utils import SignalActor, wait_for_condition from ray._private.test_utils import ( RPC_FAILURE_MAP, RPC_FAILURE_TYPES, ) from ray.core.generated import autoscaler_pb2 from ray.exceptions import GetTimeoutError, TaskCancelledError from ray.util.placement_group import placement_group, remove_placement_group from ray.util.scheduling_strategies import ( NodeAffinitySchedulingStrategy, PlacementGroupSchedulingStrategy, ) import psutil @pytest.mark.parametrize("deterministic_failure", RPC_FAILURE_TYPES) def test_request_worker_lease_idempotent( monkeypatch, shutdown_only, deterministic_failure, ray_start_cluster ): failure = RPC_FAILURE_MAP[deterministic_failure].copy() failure["num_failures"] = 1 monkeypatch.setenv( "RAY_testing_rpc_failure", json.dumps({"NodeManagerService.grpc_client.RequestWorkerLease": failure}), ) @ray.remote def simple_task_1(): return 0 @ray.remote def simple_task_2(): return 1 # Spin up a two-node cluster where we're targeting scheduling on the # remote node via NodeAffinitySchedulingStrategy to test remote RequestWorkerLease # calls. cluster = ray_start_cluster remote_node = cluster.add_node(num_cpus=1) result_ref1 = simple_task_1.options( scheduling_strategy=NodeAffinitySchedulingStrategy( node_id=remote_node.node_id, soft=False ) ).remote() result_ref2 = simple_task_2.options( scheduling_strategy=NodeAffinitySchedulingStrategy( node_id=remote_node.node_id, soft=False ) ).remote() assert ray.get([result_ref1, result_ref2]) == [0, 1] def test_drain_node_idempotent(monkeypatch, shutdown_only, ray_start_cluster): # NOTE: not testing response failure since the node is already marked as draining and shuts down gracefully. monkeypatch.setenv( "RAY_testing_rpc_failure", json.dumps( { "NodeManagerService.grpc_client.DrainRaylet": { "num_failures": 1, "req_failure_prob": 100, "resp_failure_prob": 0, "in_flight_failure_prob": 0, } } ), ) cluster = ray_start_cluster worker_node = cluster.add_node(num_cpus=1) ray.init(address=cluster.address) worker_node_id = worker_node.node_id gcs_client = ray._raylet.GcsClient(address=cluster.address) is_accepted = gcs_client.drain_node( worker_node_id, autoscaler_pb2.DrainNodeReason.DRAIN_NODE_REASON_IDLE_TERMINATION, "Test drain", 0, ) assert is_accepted # After drain is accepted on an idle node since no tasks are running nor primary objects kept # on that raylet, it should be marked idle and gracefully shut down. def node_is_dead(): nodes = ray.nodes() for node in nodes: if node["NodeID"] == worker_node_id: return not node["Alive"] return True wait_for_condition(node_is_dead, timeout=1) # Bundles can be leaked if the gcs dies before the RemovePlacementGroupBundles RPCs are # propagated to all the raylets. Since this is inherently racy, we block RemovePlacementGroupBundles RPCs # from ever succeeding to make this test deterministic. @pytest.fixture def inject_release_unused_bundles_rpc_failure(monkeypatch, request): deterministic_failure = request.param failure = RPC_FAILURE_MAP[deterministic_failure].copy() failure["num_failures"] = 1 monkeypatch.setenv( "RAY_testing_rpc_failure", json.dumps( { "NodeManagerService.grpc_client.ReleaseUnusedBundles": failure, "NodeManagerService.grpc_client.RemovePlacementGroupBundles": { "num_failures": -1, "req_failure_prob": 100, "resp_failure_prob": 0, "in_flight_failure_prob": 0, }, } ), ) @pytest.mark.parametrize( "inject_release_unused_bundles_rpc_failure", RPC_FAILURE_TYPES, indirect=True, ) @pytest.mark.parametrize( "ray_start_cluster_head_with_external_redis", [{"num_cpus": 1}], indirect=True, ) def test_release_unused_bundles_idempotent( inject_release_unused_bundles_rpc_failure, ray_start_cluster_head_with_external_redis, ): cluster = ray_start_cluster_head_with_external_redis @ray.remote(num_cpus=1) def task(): return "success" pg = placement_group(name="test_pg", strategy="PACK", bundles=[{"CPU": 1}]) result_ref = task.options( scheduling_strategy=PlacementGroupSchedulingStrategy( placement_group=pg, placement_group_bundle_index=0, ) ).remote() assert ray.get(result_ref) == "success" # Remove the placement group. This will trigger RemovePlacementGroupBundles RPCs which need to be blocked # for the placement group bundle to be leaked. remove_placement_group(pg) cluster.head_node.kill_gcs_server() # ReleaseUnusedBundles only triggers after GCS restart to clean up potentially leaked bundles. cluster.head_node.start_gcs_server() # If the leaked bundle wasn't cleaned up, this task will hang due to resource unavailability result = ray.get(task.remote()) assert result == "success" @pytest.fixture def inject_notify_gcs_restart_rpc_failure(monkeypatch, request): deterministic_failure = request.param failure = RPC_FAILURE_MAP[deterministic_failure].copy() failure["num_failures"] = 1 monkeypatch.setenv( "RAY_testing_rpc_failure", json.dumps({"NodeManagerService.grpc_client.NotifyGCSRestart": failure}), ) @pytest.mark.parametrize( "inject_notify_gcs_restart_rpc_failure", RPC_FAILURE_TYPES, indirect=True, ) @pytest.mark.parametrize( "ray_start_cluster_head_with_external_redis", [ { "_system_config": { # Extending the fallback timeout to focus on death # notification received from GCS_ACTOR_CHANNEL pubsub "timeout_ms_task_wait_for_death_info": 10000, } } ], indirect=True, ) def test_notify_gcs_restart_idempotent( inject_notify_gcs_restart_rpc_failure, ray_start_cluster_head_with_external_redis, ): cluster = ray_start_cluster_head_with_external_redis @ray.remote(num_cpus=1, max_restarts=0) class DummyActor: def get_pid(self): return psutil.Process().pid def ping(self): return "pong" actor = DummyActor.remote() ray.get(actor.ping.remote()) actor_pid = ray.get(actor.get_pid.remote()) cluster.head_node.kill_gcs_server() cluster.head_node.start_gcs_server() p = psutil.Process(actor_pid) p.kill() # If the actor death notification is not received from the GCS pubsub, this will timeout since # the fallback via wait_for_death_info_tasks in the actor task submitter will never trigger # since it's set to 10 seconds. with pytest.raises(ray.exceptions.RayActorError): ray.get(actor.ping.remote(), timeout=5) def test_kill_local_actor_rpc_retry_and_idempotency(monkeypatch, shutdown_only): """Test that KillLocalActor RPC retries work correctly and guarantee actor death. Not testing response since the actor is killed either way. """ monkeypatch.setenv( "RAY_testing_rpc_failure", json.dumps( { "NodeManagerService.grpc_client.KillLocalActor": { "num_failures": 1, "req_failure_prob": 100, "resp_failure_prob": 0, "in_flight_failure_prob": 0, } } ), ) ray.init() @ray.remote class SimpleActor: def ping(self): return "pong" def get_pid(self): return os.getpid() actor = SimpleActor.remote() result = ray.get(actor.ping.remote()) assert result == "pong" worker_pid = ray.get(actor.get_pid.remote()) # NOTE: checking the process is still alive rather than checking the actor state from the GCS # since as long as KillActor is sent the GCS will mark the actor as dead even though it may not actually be assert psutil.pid_exists(worker_pid) ray.kill(actor) def verify_process_killed(): return not psutil.pid_exists(worker_pid) wait_for_condition(verify_process_killed, timeout=30) @pytest.fixture def inject_cancel_local_task_rpc_failure(monkeypatch, request): failure = RPC_FAILURE_MAP[request.param].copy() failure["num_failures"] = 1 monkeypatch.setenv( "RAY_testing_rpc_failure", json.dumps( { "NodeManagerService.grpc_client.CancelLocalTask": failure, } ), ) @pytest.mark.parametrize( "inject_cancel_local_task_rpc_failure", RPC_FAILURE_TYPES, indirect=True ) @pytest.mark.parametrize("force_kill", [True, False]) def test_cancel_local_task_rpc_retry_and_idempotency( inject_cancel_local_task_rpc_failure, force_kill, shutdown_only ): """Test that CancelLocalTask RPC retries work correctly. Verify that the RPC is idempotent when network failures occur. When force_kill=True, verify the worker process is actually killed using psutil. """ ray.init(num_cpus=1) signaler = SignalActor.remote() @ray.remote(num_cpus=1) def get_pid(): return os.getpid() @ray.remote(num_cpus=1) def blocking_task(): return ray.get(signaler.wait.remote()) worker_pid = ray.get(get_pid.remote()) blocking_ref = blocking_task.remote() with pytest.raises(GetTimeoutError): ray.get(blocking_ref, timeout=1) ray.cancel(blocking_ref, force=force_kill) with pytest.raises(TaskCancelledError): ray.get(blocking_ref, timeout=10) if force_kill: def verify_process_killed(): return not psutil.pid_exists(worker_pid) wait_for_condition(verify_process_killed, timeout=30) if __name__ == "__main__": sys.exit(pytest.main(["-sv", __file__]))