import logging import sys import time import pytest import ray from ray._common.test_utils import SignalActor, wait_for_condition from ray._private.test_utils import ( generate_system_config_map, ) from ray.autoscaler._private.monitor import Monitor from ray.autoscaler.sdk import request_resources from ray.cluster_utils import Cluster from ray.util.placement_group import placement_group, remove_placement_group logger = logging.getLogger(__name__) def test_cluster(): """Basic test for adding and removing nodes in cluster.""" g = Cluster(initialize_head=False) node = g.add_node() node2 = g.add_node() assert node.remaining_processes_alive() assert node2.remaining_processes_alive() g.remove_node(node2) g.remove_node(node) assert not any(n.any_processes_alive() for n in [node, node2]) g.shutdown() def test_shutdown(): g = Cluster(initialize_head=False) node = g.add_node() node2 = g.add_node() g.shutdown() assert not any(n.any_processes_alive() for n in [node, node2]) @pytest.mark.parametrize( "ray_start_cluster_head", [ generate_system_config_map( health_check_initial_delay_ms=0, health_check_period_ms=1000, health_check_failure_threshold=3, object_timeout_milliseconds=12345, ), ], indirect=True, ) def test_system_config(ray_start_cluster_head): """Checks that the internal configuration setting works. We set the cluster to timeout nodes after 2 seconds of no timeouts. We then remove a node, wait for 1 second to check that the cluster is out of sync, then wait another 2 seconds (giving 1 second of leeway) to check that the client has timed out. We also check to see if the config is set. """ cluster = ray_start_cluster_head worker = cluster.add_node() cluster.wait_for_nodes() @ray.remote def f(): assert ray._config.object_timeout_milliseconds() == 12345 assert ray._config.health_check_initial_delay_ms() == 0 assert ray._config.health_check_failure_threshold() == 3 assert ray._config.health_check_period_ms() == 1000 ray.get([f.remote() for _ in range(5)]) cluster.remove_node(worker, allow_graceful=False) time.sleep(1) assert ray.cluster_resources()["CPU"] == 2 def _node_removed(): return ray.cluster_resources()["CPU"] == 1 wait_for_condition(_node_removed, timeout=3) def setup_monitor(address): monitor = Monitor(address, None) return monitor def assert_correct_pg(pg_response_data, pg_demands, strategy): assert len(pg_response_data) == 1 pg_response_data = pg_response_data[0] strategy_mapping_dict_protobuf = { "PACK": 0, "SPREAD": 1, "STRICT_PACK": 2, "STRICT_SPREAD": 3, } assert pg_response_data.strategy == strategy_mapping_dict_protobuf[strategy] assert pg_response_data.creator_job_id assert pg_response_data.creator_actor_id assert pg_response_data.creator_actor_dead assert pg_response_data.placement_group_id for i, bundle in enumerate(pg_demands): assert pg_response_data.bundles[i].unit_resources == bundle assert pg_response_data.bundles[i].bundle_id.placement_group_id # DO NOT CHANGE THIS VERIFICATION WITHOUT NOTIFYING (Eric/Ameer/Alex). def verify_load_metrics(monitor, expected_resource_usage=None, timeout=30): request_resources(num_cpus=42) # add placement groups. pg_demands = [{"GPU": 2}, {"extra_resource": 2}] strategy = "STRICT_PACK" pg = placement_group(pg_demands, strategy=strategy) pg.ready() time.sleep(2) # wait for placement groups to propagate. # Disable event clearing for test. monitor.event_summarizer.clear = lambda *a: None visited_atleast_once = [set(), set()] while True: monitor.update_load_metrics() monitor.update_resource_requests() monitor.update_event_summary() resource_usage = monitor.load_metrics._get_resource_usage() # Check resource request propagation. # For v1 autoscaler, resource_requests should be in old format (ResourceDict) # because commands.py extracts resources field for v1 compatibility. req = monitor.load_metrics.resource_requests assert req == [{"CPU": 1}] * 42, req # Call summary() to trigger freq_of_dicts() which will fail if resource_requests # contains new format with nested dicts (label_selector). # This ensures the fix in commands.py is working correctly. summary = monitor.load_metrics.summary() assert summary is not None pg_response_data = monitor.load_metrics.pending_placement_groups assert_correct_pg(pg_response_data, pg_demands, strategy) if "memory" in resource_usage[0]: del resource_usage[0]["memory"] visited_atleast_once[0].add("memory") if "object_store_memory" in resource_usage[0]: del resource_usage[0]["object_store_memory"] visited_atleast_once[0].add("object_store_memory") if "memory" in resource_usage[1]: del resource_usage[1]["memory"] visited_atleast_once[1].add("memory") if "object_store_memory" in resource_usage[1]: del resource_usage[1]["object_store_memory"] visited_atleast_once[1].add("object_store_memory") for key in list(resource_usage[0].keys()): if key.startswith("node:"): del resource_usage[0][key] visited_atleast_once[0].add("node:") for key in list(resource_usage[1].keys()): if key.startswith("node:"): del resource_usage[1][key] visited_atleast_once[1].add("node:") if expected_resource_usage is None: if all(x for x in resource_usage[0:]): break elif all(x == y for x, y in zip(resource_usage, expected_resource_usage)): break else: timeout -= 1 time.sleep(1) if timeout <= 0: raise ValueError( "Timeout. {} != {}".format(resource_usage, expected_resource_usage) ) # Sanity check we emitted a resize event. assert any("Resized to" in x for x in monitor.event_summarizer.summary()) assert visited_atleast_once[0] == {"memory", "object_store_memory", "node:"} assert visited_atleast_once[0] == visited_atleast_once[1] remove_placement_group(pg) return resource_usage @pytest.mark.parametrize( "ray_start_cluster_head", [ { "num_cpus": 1, }, { "num_cpus": 2, }, ], indirect=True, ) def test_heartbeats_single(ray_start_cluster_head): """Unit test for `Cluster.wait_for_nodes`. Test proper metrics. """ cluster = ray_start_cluster_head monitor = setup_monitor(cluster.gcs_address) total_cpus = ray._private.state.cluster_resources()["CPU"] verify_load_metrics(monitor, ({"CPU": 0.0}, {"CPU": total_cpus})) @ray.remote def work(signal): wait_signal = signal.wait.remote() while True: ready, not_ready = ray.wait([wait_signal], timeout=0) if len(ready) == 1: break time.sleep(1) signal = SignalActor.remote() work_handle = work.remote(signal) verify_load_metrics(monitor, ({"CPU": 1.0}, {"CPU": total_cpus})) ray.get(signal.send.remote()) ray.get(work_handle) @ray.remote(num_cpus=1) class Actor: def work(self, signal): wait_signal = signal.wait.remote() while True: ready, not_ready = ray.wait([wait_signal], timeout=0) if len(ready) == 1: break time.sleep(1) signal = SignalActor.remote() test_actor = Actor.remote() work_handle = test_actor.work.remote(signal) time.sleep(1) # Time for actor to get placed and the method to start. verify_load_metrics(monitor, ({"CPU": 1.0}, {"CPU": total_cpus})) ray.get(signal.send.remote()) ray.get(work_handle) del monitor def test_wait_for_nodes(ray_start_cluster_head): """Unit test for `Cluster.wait_for_nodes`. Adds 4 workers, waits, then removes 4 workers, waits, then adds 1 worker, waits, and removes 1 worker, waits. """ cluster = ray_start_cluster_head workers = [cluster.add_node() for i in range(4)] cluster.wait_for_nodes() [cluster.remove_node(w) for w in workers] cluster.wait_for_nodes() assert ray.cluster_resources()["CPU"] == 1 worker2 = cluster.add_node() cluster.wait_for_nodes() cluster.remove_node(worker2) cluster.wait_for_nodes() assert ray.cluster_resources()["CPU"] == 1 @pytest.mark.parametrize( "call_ray_start", [ "ray start --head --ray-client-server-port 20000 " + "--min-worker-port=0 --max-worker-port=0 --port 0" ], indirect=True, ) def test_ray_client(call_ray_start): from ray.util.client import ray as ray_client ray.client("localhost:20000").connect() @ray.remote def f(): return "hello client" assert ray_client.get(f.remote()) == "hello client" def test_detached_actor_autoscaling(ray_start_cluster_head): """Make sure that a detached actor, which belongs to a dead job, can start workers on nodes that were added after the job ended. """ cluster = ray_start_cluster_head cluster.add_node(num_cpus=2) cluster.wait_for_nodes(2) @ray.remote(num_cpus=1) class Actor: def __init__(self): self.handles = [] def start_actors(self, n): self.handles.extend([Actor.remote() for _ in range(n)]) def get_children(self): return self.handles def ping(self): pass main_actor = Actor.options(lifetime="detached", name="main").remote() ray.get(main_actor.ping.remote()) ray.shutdown() ray.init(address=cluster.address, namespace="default_test_namespace") main_actor = ray.get_actor("main") num_to_start = int(ray.available_resources().get("CPU", 0) + 1) print(f"Starting {num_to_start} actors") ray.get(main_actor.start_actors.remote(num_to_start)) actor_handles = ray.get(main_actor.get_children.remote()) up, down = ray.wait( [actor.ping.remote() for actor in actor_handles], timeout=5, num_returns=len(actor_handles), ) assert len(up) == len(actor_handles) - 1 assert len(down) == 1 cluster.add_node(num_cpus=1) cluster.wait_for_nodes(3) up, down = ray.wait( [actor.ping.remote() for actor in actor_handles], timeout=5, num_returns=len(actor_handles), ) assert len(up) == len(actor_handles) assert len(down) == 0 def test_multi_node_pgs(ray_start_cluster): cluster = ray_start_cluster cluster.add_node(num_cpus=2) cluster.wait_for_nodes(2) ray.init(address=cluster.address) pgs = [ray.util.placement_group([{"CPU": 1}]) for _ in range(4)] ready, not_ready = ray.wait([pg.ready() for pg in pgs], timeout=5, num_returns=4) assert len(ready) == 2 assert len(not_ready) == 2 cluster.add_node(num_cpus=2) cluster.wait_for_nodes(3) ready, not_ready = ray.wait([pg.ready() for pg in pgs], timeout=5, num_returns=4) assert len(ready) == 4 assert len(not_ready) == 0 for i in range(4, 10): cluster.add_node(num_cpus=2) cluster.wait_for_nodes(i) print(".") more_pgs = [ray.util.placement_group([{"CPU": 1}]) for _ in range(2)] ready, not_ready = ray.wait( [pg.ready() for pg in more_pgs], timeout=5, num_returns=2 ) assert len(ready) == 2 if __name__ == "__main__": sys.exit(pytest.main(["-sv", __file__]))