import logging import os import subprocess import sys from contextlib import contextmanager import httpx import pytest import ray import ray.actor from ray import serve from ray._common.test_utils import SignalActor, wait_for_condition from ray.cluster_utils import AutoscalingCluster, Cluster from ray.exceptions import RayActorError from ray.serve._private.constants import SERVE_DEFAULT_APP_NAME, SERVE_LOGGER_NAME from ray.serve._private.logging_utils import get_serve_logs_dir from ray.serve._private.test_utils import ( SharedCounter, alive_actor_counts, expected_proxy_actors, ) from ray.serve._private.utils import get_head_node_id from ray.serve.context import _get_global_client 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() @contextmanager def start_and_shutdown_ray_cli(): subprocess.check_output( ["ray", "start", "--head"], ) yield subprocess.check_output( ["ray", "stop", "--force"], ) @pytest.fixture(scope="function") def start_and_shutdown_ray_cli_function(): with start_and_shutdown_ray_cli(): yield @pytest.mark.parametrize( "ray_instance", [ { "LISTEN_FOR_CHANGE_REQUEST_TIMEOUT_S_LOWER_BOUND": "0.1", "LISTEN_FOR_CHANGE_REQUEST_TIMEOUT_S_UPPER_BOUND": "0.2", }, ], indirect=True, ) def test_long_poll_timeout_with_max_ongoing_requests(ray_instance): """Test that max_ongoing_requests is respected when there are long poll timeouts. Previously, when a long poll update occurred (e.g., a timeout or new replicas added), ongoing requests would no longer be counted against `max_ongoing_requests`. Issue: https://github.com/ray-project/ray/issues/32652 """ signal_actor = SignalActor.remote() counter_actor = SharedCounter.remote() @serve.deployment(max_ongoing_requests=1) async def f(): await counter_actor.inc.remote() await signal_actor.wait.remote() return "hello" # Issue a blocking request which should occupy the only slot due to # `max_ongoing_requests=1`. serve.run(f.bind()) @ray.remote def do_req(): return httpx.get("http://localhost:8000").text # The request should be hanging waiting on the `SignalActor`. first_ref = do_req.remote() def check_request_started(num_expected_requests: int) -> bool: with pytest.raises(TimeoutError): ray.get(first_ref, timeout=0.1) assert ray.get(counter_actor.get.remote()) == num_expected_requests return True wait_for_condition(check_request_started, timeout=5, num_expected_requests=1) # Now issue 10 more requests and wait for significantly longer than the long poll # timeout. They should all be queued in the handle due to `max_ongoing_requests` # enforcement (verified via the counter). new_refs = [do_req.remote() for _ in range(10)] ready, _ = ray.wait(new_refs, timeout=1) assert len(ready) == 0 assert ray.get(counter_actor.get.remote()) == 1 # Unblock the first request. Now everything should get executed. ray.get(signal_actor.send.remote()) assert ray.get(first_ref) == "hello" assert ray.get(new_refs) == ["hello"] * 10 assert ray.get(counter_actor.get.remote()) == 11 serve.shutdown() @pytest.mark.parametrize( "ray_instance", [], indirect=True, ) def test_replica_health_metric(ray_instance): """Test replica health metrics""" @serve.deployment(num_replicas=2) def f(): return "hello" serve.run(f.bind()) def count_live_replica_metrics(): resp = httpx.get("http://127.0.0.1:9999").text resp = resp.split("\n") count = 0 for metrics in resp: if "# HELP" in metrics or "# TYPE" in metrics: continue if "serve_deployment_replica_healthy" in metrics: if "1.0" in metrics: count += 1 return count wait_for_condition( lambda: count_live_replica_metrics() == 2, timeout=120, retry_interval_ms=500 ) # Add more replicas serve.run(f.options(num_replicas=10).bind()) wait_for_condition( lambda: count_live_replica_metrics() == 10, timeout=120, retry_interval_ms=500 ) # delete the application serve.delete(SERVE_DEFAULT_APP_NAME) wait_for_condition( lambda: count_live_replica_metrics() == 0, timeout=120, retry_interval_ms=500 ) serve.shutdown() def test_shutdown_remote(start_and_shutdown_ray_cli_function, tmp_path): """Check that serve.shutdown() works on a remote Ray cluster.""" deploy_serve_script = ( "import ray\n" "from ray import serve\n" "\n" 'ray.init(address="auto", namespace="x")\n' "serve.start()\n" "\n" "@serve.deployment\n" "def f(*args):\n" ' return "got f"\n' "\n" "serve.run(f.bind())\n" ) shutdown_serve_script = ( "import ray\n" "from ray import serve\n" "\n" 'ray.init(address="auto", namespace="x")\n' "serve.shutdown()\n" ) deploy_file = tmp_path / "deploy.py" shutdown_file = tmp_path / "shutdown.py" deploy_file.write_text(deploy_serve_script) shutdown_file.write_text(shutdown_serve_script) # Ensure Serve can be restarted and shutdown with for loop for _ in range(2): subprocess.check_output([sys.executable, str(deploy_file)]) assert httpx.get("http://localhost:8000/f").text == "got f" subprocess.check_output([sys.executable, str(shutdown_file)]) with pytest.raises(httpx.ConnectError): httpx.get("http://localhost:8000/f") def test_handle_early_detect_failure(shutdown_ray): """Check that handle can be notified about replicas failure. It should detect replica raises ActorError and take them out of the replicas set. """ try: @serve.deployment(num_replicas=2, max_ongoing_requests=1) def f(do_crash: bool = False): if do_crash: os._exit(1) return os.getpid() handle = serve.run(f.bind()) responses = [handle.remote() for _ in range(10)] assert len({r.result() for r in responses}) == 2 client = _get_global_client() # Kill the controller so that the replicas membership won't be updated # through controller health check + long polling. ray.kill(client._controller, no_restart=True) with pytest.raises(RayActorError): handle.remote(do_crash=True).result() responses = [handle.remote() for _ in range(10)] assert len({r.result() for r in responses}) == 1 finally: # Restart the controller, and then clean up all the replicas. serve.shutdown() @pytest.mark.parametrize( "autoscaler_v2", [False, True], ids=["v1", "v2"], ) def test_autoscaler_shutdown_node_http_everynode( autoscaler_v2, monkeypatch, shutdown_ray, call_ray_stop_only # noqa: F811 ): monkeypatch.setenv("RAY_SERVE_PROXY_MIN_DRAINING_PERIOD_S", "1") # Faster health check interval to speed up the test. monkeypatch.setenv("RAY_health_check_failure_threshold", "1") monkeypatch.setenv("RAY_health_check_timeout_ms", "2000") monkeypatch.setenv("RAY_health_check_period_ms", "3000") cluster = AutoscalingCluster( head_resources={"CPU": 4}, worker_node_types={ "cpu_node": { "resources": { "CPU": 4, "IS_WORKER": 100, }, "node_config": {}, "max_workers": 1, }, }, autoscaler_v2=autoscaler_v2, idle_timeout_minutes=0.05, ) cluster.start() ray.init(address="auto") serve.start(http_options={"location": "EveryNode"}) @serve.deployment( num_replicas=2, ray_actor_options={"num_cpus": 3}, ) class A: def __call__(self, *args): return "hi" serve.run(A.bind(), name="app_f") # The second replica needs more CPU than the head node has free, so the autoscaler # brings up the worker node. Requiring two proxies waits for that node to join. expected_actors = { "ServeController": 1, **expected_proxy_actors(num_proxy_nodes=2), "ServeReplica:app_f:A": 2, } wait_for_condition(lambda: alive_actor_counts() == expected_actors) assert len(ray.nodes()) == 2 # Stop all deployment replicas. serve.delete("app_f") # The worker node and its proxy exit, leaving only the head-node proxy. expected_actors = {"ServeController": 1, **expected_proxy_actors(num_proxy_nodes=1)} wait_for_condition(lambda: alive_actor_counts() == expected_actors) client = _get_global_client() def serve_details_proxy_count(): serve_details = ServeInstanceDetails( **ray.get(client._controller.get_serve_instance_details.remote()) ) return len(serve_details.proxies) wait_for_condition(lambda: serve_details_proxy_count() == 1) serve_details = ServeInstanceDetails( **ray.get(client._controller.get_serve_instance_details.remote()) ) assert serve_details.proxies[get_head_node_id()].status == ProxyStatus.HEALTHY # Only head node should exist now. wait_for_condition( lambda: len(list(filter(lambda n: n["Alive"], ray.nodes()))) == 1 ) # Clean up serve. serve.shutdown() cluster.shutdown() ray.shutdown() @pytest.mark.parametrize("wait_for_controller_shutdown", (True, False)) def test_controller_shutdown_gracefully( shutdown_ray, call_ray_stop_only, wait_for_controller_shutdown # noqa: F811 ): """Test controller shutdown gracefully when calling `graceful_shutdown()`. Called `graceful_shutdown()` on the controller, so it will start shutdown and eventually all actors will be in DEAD state. Test both cases whether to wait for the controller shutdown or not should both resolve graceful shutdown. """ # Setup a cluster with 2 nodes cluster = Cluster() cluster.add_node() cluster.wait_for_nodes() ray.init(address=cluster.address) # On Windows, wait for resources to be available before adding second node # to avoid timeout errors when cluster has zero CPU resources if sys.platform == "win32": wait_for_condition( lambda: ray.cluster_resources().get("CPU", 0) > 0, timeout=30, retry_interval_ms=1000, ) cluster.add_node() cluster.wait_for_nodes() # Deploy 2 replicas @serve.deployment(num_replicas=2) class HelloModel: def __call__(self): return "hello" model = HelloModel.bind() serve.run(target=model) expected_actors = { "ServeController": 1, **expected_proxy_actors(num_proxy_nodes=2), f"ServeReplica:{SERVE_DEFAULT_APP_NAME}:HelloModel": 2, } wait_for_condition(lambda: alive_actor_counts() == expected_actors) assert len(ray.nodes()) == 2 # Call `graceful_shutdown()` on the controller, so it will start shutdown. client = _get_global_client() if wait_for_controller_shutdown: # Waiting for controller shutdown will throw RayActorError when the controller # killed itself. with pytest.raises(ray.exceptions.RayActorError): ray.get(client._controller.graceful_shutdown.remote(True)) else: ray.get(client._controller.graceful_shutdown.remote(False)) # Ensure the all resources are shutdown. wait_for_condition( lambda: len(list_actors(filters=[("STATE", "=", "ALIVE")])) == 0, ) # Clean up serve. serve.shutdown() def test_client_shutdown_gracefully_when_timeout( shutdown_ray, call_ray_stop_only, caplog # noqa: F811 ): """Test client shutdown gracefully when timeout. When the controller is taking longer than the timeout to shutdown, the client will log timeout message and exit the process. The controller will continue to shutdown everything gracefully. """ logger = logging.getLogger(SERVE_LOGGER_NAME) caplog.set_level(logging.WARNING, logger=SERVE_LOGGER_NAME) warning_msg = [] class WarningHandler(logging.Handler): def emit(self, record): warning_msg.append(self.format(record)) logger.addHandler(WarningHandler()) # Setup a cluster with 2 nodes cluster = Cluster() cluster.add_node() cluster.add_node() cluster.wait_for_nodes() ray.init(address=cluster.address) # Deploy 2 replicas @serve.deployment(num_replicas=2) class HelloModel: def __call__(self): return "hello" model = HelloModel.bind() serve.run(target=model) expected_actors = { "ServeController": 1, **expected_proxy_actors(num_proxy_nodes=2), f"ServeReplica:{SERVE_DEFAULT_APP_NAME}:HelloModel": 2, } wait_for_condition(lambda: alive_actor_counts() == expected_actors) assert len(ray.nodes()) == 2 # Ensure client times out if the controller does not shutdown within timeout. timeout_s = 0.0 client = _get_global_client() client.shutdown(timeout_s=timeout_s) assert ( f"Controller failed to shut down within {timeout_s}s. " f"Check controller logs for more details." in warning_msg ) # Ensure the all resources are shutdown gracefully. wait_for_condition( lambda: len(list_actors(filters=[("STATE", "=", "ALIVE")])) == 0, ) # Clean up serve. serve.shutdown() def test_serve_shut_down_without_duplicated_logs( shutdown_ray, call_ray_stop_only # noqa: F811 ): """Test Serve shut down without duplicated logs. When Serve shutdown is called and executing the shutdown process, the controller log should not be spamming controller shutdown and deleting app messages. """ cluster = Cluster() cluster.add_node() cluster.wait_for_nodes() ray.init(address=cluster.address) @serve.deployment class HelloModel: def __call__(self): return "hello" model = HelloModel.bind() serve.run(target=model) serve.shutdown() # Ensure the all resources are shutdown gracefully. wait_for_condition( lambda: len(list_actors(filters=[("STATE", "=", "ALIVE")])) == 0, ) all_serve_logs = "" for filename in os.listdir(get_serve_logs_dir()): file_path = os.path.join(get_serve_logs_dir(), filename) if os.path.isfile(file_path): with open(file_path, "r") as f: all_serve_logs += f.read() assert all_serve_logs.count("Controller shutdown started") == 1 assert all_serve_logs.count("Deleting app 'default'") == 1 def test_job_runtime_env_not_leaked(shutdown_ray): # noqa: F811 """https://github.com/ray-project/ray/issues/49074""" @serve.deployment class D: async def __call__(self) -> str: return os.environ["KEY"] app = D.bind() # Initialize Ray with a runtime_env, should get picked up by the app. ray.init(runtime_env={"env_vars": {"KEY": "VAL1"}}) h = serve.run(app) assert h.remote().result() == "VAL1" serve.shutdown() ray.shutdown() # Re-initialize Ray with a different runtime_env, check that the updated one # is picked up by the app. ray.init(runtime_env={"env_vars": {"KEY": "VAL2"}}) h = serve.run(app) assert h.remote().result() == "VAL2" if __name__ == "__main__": sys.exit(pytest.main(["-v", "-s", __file__]))