import os import sys import time from typing import Dict, Optional import pytest import ray import ray._private.gcs_utils as gcs_utils from ray._common.test_utils import wait_for_condition from ray._private.test_utils import ( make_global_state_accessor, ) from ray._raylet import GcsClient from ray.core.generated import autoscaler_pb2 from ray.util.state import list_actors def test_replenish_resources(ray_start_regular): cluster_resources = ray.cluster_resources() available_resources = ray.available_resources() assert cluster_resources == available_resources @ray.remote def cpu_task(): pass ray.get(cpu_task.remote()) wait_for_condition(lambda: ray.available_resources() == cluster_resources) def test_uses_resources(ray_start_regular): cluster_resources = ray.cluster_resources() @ray.remote(num_cpus=1) class Actor: pass actor = Actor.remote() ray.get(actor.__ray_ready__.remote()) wait_for_condition( lambda: ray.available_resources().get("CPU", 0) == cluster_resources.get("CPU", 0) - 1 ) def test_available_resources_per_node(ray_start_cluster_head): cluster = ray_start_cluster_head @ray.remote def get_node_id(): return ray.get_runtime_context().get_node_id() head_node_id = ray.get(get_node_id.remote()) worker_node = cluster.add_node(num_cpus=3, resources={"worker": 1}) @ray.remote(num_cpus=1, resources={"worker": 1}) class Actor: def ping(self): return ray.get_runtime_context().get_node_id() actor = Actor.remote() worker_node_id = ray.get(actor.ping.remote()) def available_resources_per_node_check1(): available_resources_per_node = ray._private.state.available_resources_per_node() assert len(available_resources_per_node) == 2 assert available_resources_per_node[head_node_id]["CPU"] == 1 assert available_resources_per_node[worker_node_id]["CPU"] == 2 assert available_resources_per_node[worker_node_id].get("worker", 0) == 0 return True wait_for_condition(available_resources_per_node_check1) cluster.remove_node(worker_node) cluster.wait_for_nodes() def available_resources_per_node_check2(): # Make sure worker node is not returned available_resources_per_node = ray._private.state.available_resources_per_node() assert len(available_resources_per_node) == 1 assert available_resources_per_node[head_node_id]["CPU"] == 1 return True wait_for_condition(available_resources_per_node_check2) def test_total_resources_per_node(ray_start_cluster_head): cluster = ray_start_cluster_head @ray.remote def get_node_id(): return ray.get_runtime_context().get_node_id() head_node_id = ray.get(get_node_id.remote()) worker_node = cluster.add_node(num_cpus=3, resources={"worker": 1}) @ray.remote(num_cpus=1, resources={"worker": 1}) class Actor: def ping(self): return ray.get_runtime_context().get_node_id() actor = Actor.remote() worker_node_id = ray.get(actor.ping.remote()) def total_resources_per_node_check1(): total_resources_per_node = ray._private.state.total_resources_per_node() assert len(total_resources_per_node) == 2 assert total_resources_per_node[head_node_id]["CPU"] == 1 assert total_resources_per_node[worker_node_id]["CPU"] == 3 assert total_resources_per_node[worker_node_id].get("worker", 0) == 1 return True wait_for_condition(total_resources_per_node_check1) cluster.remove_node(worker_node) cluster.wait_for_nodes() def total_resources_per_node_check2(): # Make sure worker node is not returned total_resources_per_node = ray._private.state.total_resources_per_node() assert len(total_resources_per_node) == 1 assert total_resources_per_node[head_node_id]["CPU"] == 1 return True wait_for_condition(total_resources_per_node_check2) def test_add_remove_cluster_resources(ray_start_cluster_head): """Tests that Global State API is consistent with actual cluster.""" cluster = ray_start_cluster_head assert ray.cluster_resources()["CPU"] == 1 nodes = [] nodes += [cluster.add_node(num_cpus=1)] cluster.wait_for_nodes() assert ray.cluster_resources()["CPU"] == 2 cluster.remove_node(nodes.pop()) cluster.wait_for_nodes() assert ray.cluster_resources()["CPU"] == 1 for i in range(5): nodes += [cluster.add_node(num_cpus=1)] cluster.wait_for_nodes() assert ray.cluster_resources()["CPU"] == 6 @pytest.mark.parametrize( "ray_start_regular", [{"include_dashboard": True}], indirect=True, ) def test_global_state_actor_table(ray_start_regular): @ray.remote class Actor: def ready(self): return os.getpid() # actor table should be empty at first assert len(list_actors()) == 0 a = Actor.remote() pid = ray.get(a.ready.remote()) assert len(list_actors()) == 1 assert list_actors()[0].pid == pid # actor table should contain only this entry # even when the actor goes out of scope del a for _ in range(10): if list_actors()[0].state == "DEAD": break else: time.sleep(0.5) assert list_actors()[0].state == "DEAD" def test_global_state_worker_table(ray_start_regular): def worker_initialized(): # Get worker table from gcs. workers_data = ray._private.state.workers() return len(workers_data) == 1 wait_for_condition(worker_initialized) @pytest.mark.parametrize( "ray_start_regular", [{"include_dashboard": True}], indirect=True, ) def test_global_state_actor_entry(ray_start_regular): @ray.remote class Actor: def ready(self): pass # actor table should be empty at first assert len(list_actors()) == 0 a = Actor.remote() b = Actor.remote() ray.get(a.ready.remote()) ray.get(b.ready.remote()) assert len(list_actors()) == 2 a_actor_id = a._actor_id.hex() b_actor_id = b._actor_id.hex() assert ray.util.state.get_actor(id=a_actor_id).actor_id == a_actor_id assert ray.util.state.get_actor(id=a_actor_id).state == "ALIVE" assert ray.util.state.get_actor(id=b_actor_id).actor_id == b_actor_id assert ray.util.state.get_actor(id=b_actor_id).state == "ALIVE" def test_node_name_cluster(ray_start_cluster): cluster = ray_start_cluster cluster.add_node(node_name="head_node", include_dashboard=False) head_context = ray.init(address=cluster.address, include_dashboard=False) cluster.add_node(node_name="worker_node", include_dashboard=False) cluster.wait_for_nodes() global_state_accessor = make_global_state_accessor(head_context) node_table = global_state_accessor.get_node_table() assert len(node_table) == 2 for node in node_table: if node["NodeID"] == head_context.address_info["node_id"]: assert node["NodeName"] == "head_node" else: assert node["NodeName"] == "worker_node" ray.shutdown() cluster.shutdown() def test_node_name_init(): # Test ray.init with _node_name directly new_head_context = ray.init(_node_name="new_head_node", include_dashboard=False) global_state_accessor = make_global_state_accessor(new_head_context) node = global_state_accessor.get_node_table()[0] assert node["NodeName"] == "new_head_node" ray.shutdown() def test_no_node_name(): # Test that starting ray with no node name will result in a node_name=ip_address new_head_context = ray.init(include_dashboard=False) global_state_accessor = make_global_state_accessor(new_head_context) node = global_state_accessor.get_node_table()[0] assert node["NodeName"] == ray.util.get_node_ip_address() ray.shutdown() @pytest.mark.parametrize("max_shapes", [0, 2, -1]) def test_load_report(shutdown_only, max_shapes): resource1 = "A" resource2 = "B" cluster = ray.init( num_cpus=1, resources={resource1: 1}, _system_config={ "max_resource_shapes_per_load_report": max_shapes, }, ) global_state_accessor = make_global_state_accessor(cluster) @ray.remote def sleep(): time.sleep(1000) sleep.remote() for _ in range(3): sleep.remote() sleep.options(resources={resource1: 1}).remote() sleep.options(resources={resource2: 1}).remote() class Checker: def __init__(self): self.report = None def check_load_report(self): message = global_state_accessor.get_all_resource_usage() if message is None: return False resource_usage = gcs_utils.ResourceUsageBatchData.FromString(message) self.report = resource_usage.resource_load_by_shape.resource_demands if max_shapes == 0: return True elif max_shapes == 2: return len(self.report) >= 2 else: return len(self.report) >= 3 # Wait for load information to arrive. checker = Checker() wait_for_condition(checker.check_load_report) # Check that we respect the max shapes limit. if max_shapes != -1: assert len(checker.report) <= max_shapes print(checker.report) if max_shapes > 0: # Check that we differentiate between infeasible and ready tasks. for demand in checker.report: if resource2 in demand.shape: assert demand.num_infeasible_requests_queued > 0 assert demand.num_ready_requests_queued == 0 else: assert demand.num_ready_requests_queued > 0 assert demand.num_infeasible_requests_queued == 0 def test_placement_group_load_report(ray_start_cluster): cluster = ray_start_cluster # Add a head node that doesn't have gpu resource. cluster.add_node(num_cpus=4) global_state_accessor = make_global_state_accessor( ray.init(address=cluster.address) ) class PgLoadChecker: def nothing_is_ready(self): resource_usage = self._read_resource_usage() if not resource_usage: return False if resource_usage.HasField("placement_group_load"): pg_load = resource_usage.placement_group_load return len(pg_load.placement_group_data) == 2 return False def only_first_one_ready(self): resource_usage = self._read_resource_usage() if not resource_usage: return False if resource_usage.HasField("placement_group_load"): pg_load = resource_usage.placement_group_load return len(pg_load.placement_group_data) == 1 return False def two_infeasible_pg(self): resource_usage = self._read_resource_usage() if not resource_usage: return False if resource_usage.HasField("placement_group_load"): pg_load = resource_usage.placement_group_load return len(pg_load.placement_group_data) == 2 return False def _read_resource_usage(self): message = global_state_accessor.get_all_resource_usage() if message is None: return False resource_usage = gcs_utils.ResourceUsageBatchData.FromString(message) return resource_usage checker = PgLoadChecker() # Create 2 placement groups that are infeasible. pg_feasible = ray.util.placement_group([{"A": 1}]) pg_infeasible = ray.util.placement_group([{"B": 1}]) _, unready = ray.wait([pg_feasible.ready(), pg_infeasible.ready()], timeout=0) assert len(unready) == 2 wait_for_condition(checker.nothing_is_ready) # Add a node that makes pg feasible. Make sure load include this change. cluster.add_node(resources={"A": 1}) ray.get(pg_feasible.ready()) wait_for_condition(checker.only_first_one_ready) # Create one more infeasible pg and make sure load is properly updated. pg_infeasible_second = ray.util.placement_group([{"C": 1}]) _, unready = ray.wait([pg_infeasible_second.ready()], timeout=0) assert len(unready) == 1 wait_for_condition(checker.two_infeasible_pg) def test_backlog_report(shutdown_only): cluster = ray.init( num_cpus=1, _system_config={ "max_pending_lease_requests_per_scheduling_category": 1, "report_worker_backlog_interval_ms": 100, }, ) global_state_accessor = make_global_state_accessor(cluster) @ray.remote(num_cpus=1) def foo(x): print(".") time.sleep(x) return None def backlog_size_set(): message = global_state_accessor.get_all_resource_usage() if message is None: return False resource_usage = gcs_utils.ResourceUsageBatchData.FromString(message) aggregate_resource_load = resource_usage.resource_load_by_shape.resource_demands if len(aggregate_resource_load) == 1: backlog_size = aggregate_resource_load[0].backlog_size print(backlog_size) # Ideally we'd want to assert backlog_size == 8, but guaranteeing # the order the order that submissions will occur is too # hard/flaky. return backlog_size > 0 return False # We want this first task to finish refs = [foo.remote(0.5)] # These tasks should all start _before_ the first one finishes. refs.extend([foo.remote(1000) for _ in range(9)]) # Now there's 1 request running, 1 queued in the raylet, and 8 queued in # the worker backlog. ray.get(refs[0]) # First request finishes, second request is now running, third lease # request is sent to the raylet with backlog=7 wait_for_condition(backlog_size_set, timeout=2) def test_default_load_reports(shutdown_only): """Despite the fact that default actors release their cpu after being placed, they should still require 1 CPU for laod reporting purposes. https://github.com/ray-project/ray/issues/26806 """ cluster = ray.init( num_cpus=0, ) global_state_accessor = make_global_state_accessor(cluster) @ray.remote def foo(): return None @ray.remote class Foo: pass def actor_and_task_queued_together(): message = global_state_accessor.get_all_resource_usage() if message is None: return False resource_usage = gcs_utils.ResourceUsageBatchData.FromString(message) aggregate_resource_load = resource_usage.resource_load_by_shape.resource_demands print(f"Num shapes {len(aggregate_resource_load)}") if len(aggregate_resource_load) == 1: num_infeasible = aggregate_resource_load[0].num_infeasible_requests_queued print(f"num in shape {num_infeasible}") # Ideally we'd want to assert backlog_size == 8, but guaranteeing # the order the order that submissions will occur is too # hard/flaky. return num_infeasible == 2 return False # Assign to variables to keep the ref counter happy. handle = Foo.remote() ref = foo.remote() wait_for_condition(actor_and_task_queued_together, timeout=2) # Do something with the variables so lint is happy. del handle del ref def test_heartbeat_ip(shutdown_only): cluster = ray.init(num_cpus=1) global_state_accessor = make_global_state_accessor(cluster) self_ip = ray.util.get_node_ip_address() def self_ip_is_set(): message = global_state_accessor.get_all_resource_usage() if message is None: return False resource_usage = gcs_utils.ResourceUsageBatchData.FromString(message) resources_data = resource_usage.batch[0] return resources_data.node_manager_address == self_ip wait_for_condition(self_ip_is_set, timeout=2) def test_next_job_id(ray_start_regular): job_id_1 = ray._private.state.next_job_id() job_id_2 = ray._private.state.next_job_id() assert job_id_1.int() + 1 == job_id_2.int() def test_get_cluster_config(shutdown_only): ray.init(num_cpus=1) gcs_client = GcsClient(address=ray.get_runtime_context().gcs_address) cluster_config = ray._private.state.state.get_cluster_config() assert cluster_config is None cluster_config = autoscaler_pb2.ClusterConfig() cluster_config.max_resources["CPU"] = 100 node_group_config = autoscaler_pb2.NodeGroupConfig() node_group_config.name = "m5.large" node_group_config.resources["CPU"] = 5 node_group_config.max_count = -1 cluster_config.node_group_configs.append(node_group_config) gcs_client.report_cluster_config(cluster_config.SerializeToString()) assert ray._private.state.state.get_cluster_config() == cluster_config @pytest.mark.parametrize( "description, cluster_config, num_cpu", [ ( "should return 0 since empty config is provided", autoscaler_pb2.ClusterConfig(), 0, ), ( "should return 0 since no node_group_config is provided", autoscaler_pb2.ClusterConfig( max_resources={"CPU": 100}, ), 0, ), ( "should return 0 since no CPU is provided under node_group_configs", autoscaler_pb2.ClusterConfig( max_resources={"CPU": 100}, node_group_configs=[autoscaler_pb2.NodeGroupConfig(name="m5.large")], ), 0, ), ( "should return None since 0 instance is provided under node_group_configs", autoscaler_pb2.ClusterConfig( max_resources={"CPU": 100}, node_group_configs=[ autoscaler_pb2.NodeGroupConfig( resources={"CPU": 50}, name="m5.large", max_count=0, ) ], ), 0, ), ( "should return max since max_count=-1 under node_group_configs", autoscaler_pb2.ClusterConfig( max_resources={"CPU": 100}, node_group_configs=[ autoscaler_pb2.NodeGroupConfig( resources={"CPU": 50}, name="m5.large", max_count=-1, ) ], ), sys.maxsize, ), ( "should return the total under node_group_configs since it is less than max_resources", autoscaler_pb2.ClusterConfig( max_resources={"CPU": 100}, node_group_configs=[ autoscaler_pb2.NodeGroupConfig( resources={"CPU": 50}, name="m5.large", max_count=1, ) ], ), 50, ), ( "should return the total under max_resources since it is less than node_group_configs total", autoscaler_pb2.ClusterConfig( max_resources={"CPU": 30}, node_group_configs=[ autoscaler_pb2.NodeGroupConfig( resources={"CPU": 50}, name="m5.large", max_count=1, ) ], ), 30, ), ( "should return the total under node_group_configs - no max_resources", autoscaler_pb2.ClusterConfig( node_group_configs=[ autoscaler_pb2.NodeGroupConfig( resources={"CPU": 50}, name="m5.large", max_count=1, ) ], ), 50, ), ( "should return the total under node_group_configs - multiple node_group_config", autoscaler_pb2.ClusterConfig( node_group_configs=[ autoscaler_pb2.NodeGroupConfig( resources={"CPU": 50}, name="m5.large", max_count=1, ), autoscaler_pb2.NodeGroupConfig( resources={"CPU": 10}, name="m5.small", max_count=4, ), ], ), 90, ), ], ) def test_get_max_cpus_from_cluster_config( shutdown_only, description: str, cluster_config: autoscaler_pb2.ClusterConfig, num_cpu: Optional[int], ): ray.init(num_cpus=1) gcs_client = GcsClient(address=ray.get_runtime_context().gcs_address) gcs_client.report_cluster_config(cluster_config.SerializeToString()) max_resources = ray._private.state.state.get_max_resources_from_cluster_config() num_cpu_from_max_resources = max_resources.get("CPU", 0) if max_resources else 0 assert num_cpu_from_max_resources == num_cpu, description @pytest.mark.parametrize( "description, cluster_config, expected_resources", [ ( "should return CPU/GPU/TPU as None since empty config is provided", autoscaler_pb2.ClusterConfig(), None, ), ( "should return CPU/GPU/TPU as None since no node_group_config is provided", autoscaler_pb2.ClusterConfig( max_resources={"CPU": 100, "memory": 1000}, ), None, ), ( "should return CPU/GPU/TPU plus resources from node_group_configs", autoscaler_pb2.ClusterConfig( node_group_configs=[ autoscaler_pb2.NodeGroupConfig( name="m5.large", resources={"CPU": 50, "memory": 500}, max_count=1, ) ], ), {"CPU": 50, "memory": 500}, ), ( "should return resources from both node_group_configs and max_resources", autoscaler_pb2.ClusterConfig( max_resources={"GPU": 8}, node_group_configs=[ autoscaler_pb2.NodeGroupConfig( name="m5.large", resources={"CPU": 50, "memory": 500}, max_count=1, ) ], ), { "CPU": 50, "memory": 500, }, # GPU and TPU are None because not in node_group_configs ), ( "should return limited by max_resources when node_group total exceeds it", autoscaler_pb2.ClusterConfig( max_resources={"CPU": 30, "memory": 200}, node_group_configs=[ autoscaler_pb2.NodeGroupConfig( name="m5.large", resources={"CPU": 50, "memory": 500}, max_count=1, ) ], ), {"CPU": 30, "memory": 200}, ), ( "should return sys.maxsize when max_count=-1", autoscaler_pb2.ClusterConfig( node_group_configs=[ autoscaler_pb2.NodeGroupConfig( name="m5.large", resources={"CPU": 50, "custom_resource": 10}, max_count=-1, ) ], ), { "CPU": sys.maxsize, "custom_resource": sys.maxsize, }, ), ( "should sum across multiple node_group_configs", autoscaler_pb2.ClusterConfig( node_group_configs=[ autoscaler_pb2.NodeGroupConfig( name="m5.large", resources={"CPU": 50, "memory": 500}, max_count=1, ), autoscaler_pb2.NodeGroupConfig( name="m5.small", resources={"CPU": 10, "GPU": 1}, max_count=4, ), ], ), { "CPU": 90, "GPU": 4, "memory": 500, }, # 50 + (10*4), 500 + 0 ), ( "should return 0 for resources with 0 count or 0 resources", autoscaler_pb2.ClusterConfig( node_group_configs=[ autoscaler_pb2.NodeGroupConfig( name="m5.large", resources={"CPU": 50, "memory": 0}, max_count=0, # This makes all resources None ), autoscaler_pb2.NodeGroupConfig( name="m5.small", resources={"GPU": 1}, max_count=2, ), ], ), { "CPU": 0, "GPU": 2, "memory": 0, }, # CPU is None due to max_count=0, GPU has valid count ), ( "should discover all resource types including custom ones", autoscaler_pb2.ClusterConfig( max_resources={"TPU": 16, "special_resource": 100}, node_group_configs=[ autoscaler_pb2.NodeGroupConfig( name="gpu-node", resources={ "CPU": 32, "GPU": 8, "memory": 1000, "custom_accelerator": 4, }, max_count=2, ), autoscaler_pb2.NodeGroupConfig( name="cpu-node", resources={"CPU": 96, "memory": 2000, "disk": 500}, max_count=1, ), ], ), { "CPU": 160, # (32*2) + (96*1) "GPU": 16, # (8*2) + 0 "memory": 4000, # (1000*2) + (2000*1) "custom_accelerator": 8, # (4*2) + 0 "disk": 500, # 0 + (500*1) }, ), ], ) def test_get_max_resources_from_cluster_config( shutdown_only, description: str, cluster_config: autoscaler_pb2.ClusterConfig, expected_resources: Dict[str, Optional[int]], ): """Test get_max_resources_from_cluster_config method. This test verifies that the method correctly: 1. Always includes CPU/GPU/TPU in the results 2. Discovers additional resource types from node_group_configs and max_resources 3. Calculates maximum values for each resource type 4. Handles edge cases like empty configs, zero counts, unlimited resources 5. Supports resource types beyond CPU/GPU/TPU """ ray.init(num_cpus=1) gcs_client = GcsClient(address=ray.get_runtime_context().gcs_address) gcs_client.report_cluster_config(cluster_config.SerializeToString()) max_resources = ray._private.state.state.get_max_resources_from_cluster_config() assert ( max_resources == expected_resources ), f"{description}\nExpected: {expected_resources}\nActual: {max_resources}" def test_get_draining_nodes(ray_start_cluster): cluster = ray_start_cluster cluster.add_node() ray.init(address=cluster.address) cluster.add_node(resources={"worker1": 1}) cluster.add_node(resources={"worker2": 1}) cluster.wait_for_nodes() @ray.remote def get_node_id(): return ray.get_runtime_context().get_node_id() worker1_node_id = ray.get(get_node_id.options(resources={"worker1": 1}).remote()) worker2_node_id = ray.get(get_node_id.options(resources={"worker2": 1}).remote()) # Initially there is no draining node. assert ray._private.state.state.get_draining_nodes() == {} @ray.remote class Actor: def ping(self): pass actor1 = Actor.options(num_cpus=1, resources={"worker1": 1}).remote() actor2 = Actor.options(num_cpus=1, resources={"worker2": 1}).remote() ray.get(actor1.ping.remote()) ray.get(actor2.ping.remote()) gcs_client = GcsClient(address=ray.get_runtime_context().gcs_address) # Drain the worker nodes. is_accepted, _ = gcs_client.drain_node( worker1_node_id, autoscaler_pb2.DrainNodeReason.Value("DRAIN_NODE_REASON_PREEMPTION"), "preemption", 2**63 - 2, ) assert is_accepted is_accepted, _ = gcs_client.drain_node( worker2_node_id, autoscaler_pb2.DrainNodeReason.Value("DRAIN_NODE_REASON_PREEMPTION"), "preemption", 0, ) assert is_accepted def get_draining_nodes_check(): draining_nodes = ray._private.state.state.get_draining_nodes() if ( draining_nodes[worker1_node_id] == (2**63 - 2) and draining_nodes[worker2_node_id] == 0 ): return True else: return False wait_for_condition(get_draining_nodes_check) # Kill the actors running on the draining worker nodes so # that the worker nodes become idle and can be drained. ray.kill(actor1) ray.kill(actor2) wait_for_condition(lambda: ray._private.state.state.get_draining_nodes() == {}) if __name__ == "__main__": sys.exit(pytest.main(["-sv", __file__]))