import sys import grpc import pytest import ray from ray import serve from ray._common.network_utils import build_address from ray._common.test_utils import wait_for_condition from ray.actor import ActorHandle from ray.cluster_utils import Cluster from ray.serve._private.constants import ( DEFAULT_UVICORN_KEEP_ALIVE_TIMEOUT_S, SERVE_NAMESPACE, ) from ray.serve._private.test_utils import ( ping_grpc_healthz, ping_grpc_list_applications, request_with_retries, ) from ray.serve.config import gRPCOptions from ray.serve.context import _get_global_client from ray.serve.generated import serve_pb2 from ray.serve.schema import ProxyStatus, ServeInstanceDetails from ray.tests.conftest import call_ray_stop_only # noqa: F401 from ray.util.state import list_actors @pytest.fixture def shutdown_ray(): if ray.is_initialized(): ray.shutdown() yield if ray.is_initialized(): ray.shutdown() class TestTimeoutKeepAliveConfig: """Test setting keep_alive_timeout_s in config and env.""" def get_proxy_actor(self) -> ActorHandle: [proxy_actor] = list_actors(filters=[("class_name", "=", "ProxyActor")]) return ray.get_actor(proxy_actor.name, namespace=SERVE_NAMESPACE) def test_default_keep_alive_timeout_s(self, ray_shutdown): """Test when no keep_alive_timeout_s is set. When the keep_alive_timeout_s is not set, the uvicorn keep alive is 5. """ serve.start() proxy_actor = self.get_proxy_actor() assert ( ray.get(proxy_actor._get_http_options.remote()).keep_alive_timeout_s == DEFAULT_UVICORN_KEEP_ALIVE_TIMEOUT_S ) def test_set_keep_alive_timeout_in_http_configs(self, ray_shutdown): """Test when keep_alive_timeout_s is in http configs. When the keep_alive_timeout_s is set in http configs, the uvicorn keep alive is set correctly. """ keep_alive_timeout_s = 222 serve.start(http_options={"keep_alive_timeout_s": keep_alive_timeout_s}) proxy_actor = self.get_proxy_actor() assert ( ray.get(proxy_actor._get_http_options.remote()).keep_alive_timeout_s == keep_alive_timeout_s ) def test_grpc_proxy_on_draining_nodes(ray_cluster): """Test gRPC request on the draining node. When there are no replicas on head node and some replicas on the worker node, the ListApplications and Healthz methods should respond successfully. When there are no replicas on any nodes, ListApplications and Healthz methods should continue to succeeding on the head node. But should return draining response on the worker node. Also note, this is to ensure the previous fix to serve downscaling also applies to gRPC proxy. Head node will not need to be downscaled and never be in the draining state. Worker nodes will be in draining when there is no replicas. We will fail the health check in this case, so ALB knows not to route to this node anymore. """ head_node_grpc_port = 9000 worker_node_grpc_port = 9001 # Setup worker gRPC proxy to be pointing to port 9001. Head node gRPC proxy will # continue to be pointing to the default port 9000. cluster = ray_cluster cluster.add_node(num_cpus=0) cluster.add_node( num_cpus=2, env_vars={"RAY_SERVE_WORKER_PROXY_GRPC_PORT": str(worker_node_grpc_port)}, ) cluster.wait_for_nodes() ray.init(address=cluster.address) # Start serve with gRPC proxy grpc_servicer_functions = [ "ray.serve.generated.serve_pb2_grpc.add_UserDefinedServiceServicer_to_server", "ray.serve.generated.serve_pb2_grpc.add_FruitServiceServicer_to_server", ] serve.start( http_options={"location": "EveryNode"}, grpc_options=gRPCOptions( port=head_node_grpc_port, grpc_servicer_functions=grpc_servicer_functions, ), ) # Deploy 2 replicas, both should be on the worker node. @serve.deployment(num_replicas=2) class HelloModel: def __call__(self): return serve_pb2.UserDefinedResponse(greeting="hello") model = HelloModel.bind() app_name = "app1" serve.run(model, name=app_name) # Ensure worker node has both replicas. def check_replicas_on_worker_nodes(): return ( len( { a.node_id for a in list_actors(address=cluster.address) if a.class_name.startswith("ServeReplica") } ) == 1 ) wait_for_condition(check_replicas_on_worker_nodes) # Ensure total actors of 2 proxies, 1 controller, and 2 replicas, and 2 nodes exist. wait_for_condition(lambda: len(list_actors(address=cluster.address)) == 5) assert len(ray.nodes()) == 2 # Set up gRPC channels. head_node_channel = grpc.insecure_channel( build_address("localhost", head_node_grpc_port) ) worker_node_channel = grpc.insecure_channel( build_address("localhost", worker_node_grpc_port) ) # Ensures ListApplications method on the head node is succeeding. wait_for_condition( ping_grpc_list_applications, channel=head_node_channel, app_names=[app_name] ) # Ensures Healthz method on the head node is succeeding. ping_grpc_healthz(head_node_channel) # Ensures ListApplications method on the worker node is succeeding. wait_for_condition( ping_grpc_list_applications, channel=worker_node_channel, app_names=[app_name], timeout=30, ) # Ensures Healthz method on the worker node is succeeding. ping_grpc_healthz(worker_node_channel) # Delete the deployment should bring the active actors down to 3 and drop # replicas on all nodes. serve.delete(name=app_name) wait_for_condition( lambda: len( list_actors(address=cluster.address, filters=[("STATE", "=", "ALIVE")]) ) == 3, ) # Ensures ListApplications method on the head node is succeeding. wait_for_condition( ping_grpc_list_applications, channel=head_node_channel, app_names=[] ) # Ensures Healthz method on the head node is succeeding. ping_grpc_healthz(head_node_channel) # Ensures ListApplications method on the worker node is draining. wait_for_condition( ping_grpc_list_applications, channel=worker_node_channel, app_names=[], test_draining=True, ) # Ensures Healthz method on the worker node is draining. ping_grpc_healthz(worker_node_channel, test_draining=True) def test_drain_and_undrain_http_proxy_actors( monkeypatch, shutdown_ray, call_ray_stop_only # noqa: F811 ): """Test the state transtion of the proxy actor between HEALTHY, DRAINING and DRAINED """ monkeypatch.setenv("RAY_SERVE_PROXY_MIN_DRAINING_PERIOD_S", "10") cluster = Cluster() head_node = cluster.add_node(num_cpus=0) cluster.add_node(num_cpus=1) cluster.add_node(num_cpus=1) cluster.wait_for_nodes() ray.init(address=head_node.address) serve.start(http_options={"location": "EveryNode"}) @serve.deployment class HelloModel: def __call__(self): return "hello" serve.run(HelloModel.options(num_replicas=2).bind()) # 3 proxies, 1 controller, 2 replicas. wait_for_condition(lambda: len(list_actors()) == 6) assert len(ray.nodes()) == 3 client = _get_global_client() serve_details = ServeInstanceDetails( **ray.get(client._controller.get_serve_instance_details.remote()) ) proxy_actor_ids = {proxy.actor_id for _, proxy in serve_details.proxies.items()} assert len(proxy_actor_ids) == 3 serve.run(HelloModel.options(num_replicas=1).bind()) # 1 proxy should be draining def check_proxy_status(proxy_status_to_count): serve_details = ServeInstanceDetails( **ray.get(client._controller.get_serve_instance_details.remote()) ) proxy_status_list = [proxy.status for _, proxy in serve_details.proxies.items()] current_status = { status: proxy_status_list.count(status) for status in proxy_status_list } return current_status == proxy_status_to_count, current_status wait_for_condition( condition_predictor=check_proxy_status, proxy_status_to_count={ProxyStatus.HEALTHY: 2, ProxyStatus.DRAINING: 1}, ) serve.run(HelloModel.options(num_replicas=2).bind()) # The draining proxy should become healthy. wait_for_condition( condition_predictor=check_proxy_status, proxy_status_to_count={ProxyStatus.HEALTHY: 3}, ) serve_details = ServeInstanceDetails( **ray.get(client._controller.get_serve_instance_details.remote()) ) assert { proxy.actor_id for _, proxy in serve_details.proxies.items() } == proxy_actor_ids serve.run(HelloModel.options(num_replicas=1).bind()) # 1 proxy should be draining and eventually be drained. wait_for_condition( condition_predictor=check_proxy_status, timeout=40, proxy_status_to_count={ProxyStatus.HEALTHY: 2}, ) # Clean up serve. serve.shutdown() def _kill_http_proxies(): http_proxies = ray.get( serve.context._global_client._controller.get_proxies.remote() ) for http_proxy in http_proxies.values(): ray.kill(http_proxy, no_restart=False) def test_http_proxy_failure(serve_instance): @serve.deployment(name="proxy_failure") def function(_): return "hello1" serve.run(function.bind()) assert request_with_retries(timeout=1.0).text == "hello1" for _ in range(10): response = request_with_retries(timeout=30) assert response.text == "hello1" _kill_http_proxies() def function2(_): return "hello2" serve.run(function.options(func_or_class=function2).bind()) def check_new(): for _ in range(10): response = request_with_retries(timeout=30) if response.text != "hello2": return False return True wait_for_condition(check_new) if __name__ == "__main__": sys.exit(pytest.main(["-v", "-s", __file__]))