import os import random import socket import subprocess import tempfile from contextlib import contextmanager from copy import deepcopy from typing import Any, Dict, Generator import httpx import pytest import pytest_asyncio import ray from ray import serve from ray._common.test_utils import SignalActor, wait_for_condition from ray._common.usage import usage_lib from ray._common.utils import reset_ray_address from ray.cluster_utils import AutoscalingCluster, Cluster from ray.serve._private.test_utils import ( TELEMETRY_ROUTE_PREFIX, TEST_METRICS_EXPORT_PORT, check_ray_started, check_ray_stopped, start_telemetry_app, ) from ray.serve.config import HTTPOptions, ProxyLocation, gRPCOptions from ray.serve.context import _get_global_client from ray.tests.conftest import ( # noqa external_redis, propagate_logs, pytest_runtest_makereport, ) # https://tools.ietf.org/html/rfc6335#section-6 MIN_DYNAMIC_PORT = 49152 MAX_DYNAMIC_PORT = 65535 TEST_GRPC_SERVICER_FUNCTIONS = [ "ray.serve.generated.serve_pb2_grpc.add_UserDefinedServiceServicer_to_server", "ray.serve.generated.serve_pb2_grpc.add_FruitServiceServicer_to_server", ] if os.environ.get("RAY_SERVE_INTENTIONALLY_CRASH", False) == 1: serve.controller._CRASH_AFTER_CHECKPOINT_PROBABILITY = 0.5 @pytest.fixture(autouse=True) def _clear_stale_ray_address(): # Serve CI runs several test targets per container sharing /tmp/ray; a target # killed mid-run can leave a ray_current_cluster pointing at a dead cluster. # Drop it before each test so an address-less ray.init() starts fresh. reset_ray_address() yield @pytest.fixture def ray_shutdown(): serve.shutdown() if ray.is_initialized(): ray.shutdown() yield serve.shutdown() if ray.is_initialized(): ray.shutdown() @pytest.fixture def ray_cluster(): cluster = Cluster() yield cluster serve.shutdown() ray.shutdown() cluster.shutdown() @pytest.fixture def ray_autoscaling_cluster(request): # NOTE(zcin): We have to make a deepcopy here because AutoscalingCluster # modifies the dictionary that's passed in. params = deepcopy(request.param) cluster = AutoscalingCluster(**params) cluster.start() yield serve.shutdown() ray.shutdown() cluster.shutdown() @pytest.fixture def ray_start(scope="module"): port = random.randint(MIN_DYNAMIC_PORT, MAX_DYNAMIC_PORT) subprocess.check_output( [ "ray", "start", "--head", "--num-cpus", "16", "--ray-client-server-port", f"{port}", ] ) try: yield f"localhost:{port}" finally: subprocess.check_output(["ray", "stop", "--force"]) def _check_ray_stop(): try: httpx.get("http://localhost:8265/api/ray/version") return False except Exception: return True @contextmanager def start_and_shutdown_ray_cli(): subprocess.check_output(["ray", "stop", "--force"]) wait_for_condition(_check_ray_stop, timeout=15) subprocess.check_output(["ray", "start", "--head"]) yield subprocess.check_output(["ray", "stop", "--force"]) wait_for_condition(_check_ray_stop, timeout=15) @pytest.fixture(scope="module") def start_and_shutdown_ray_cli_module(): with start_and_shutdown_ray_cli(): yield @pytest.fixture def tmp_dir(): with tempfile.TemporaryDirectory() as tmp_dir: old_dir = os.getcwd() os.chdir(tmp_dir) yield tmp_dir os.chdir(old_dir) @pytest.fixture(scope="session") def _shared_serve_instance(): # Note(simon): # This line should be not turned on on master because it leads to very # spammy and not useful log in case of a failure in CI. # To run locally, please use this instead. # SERVE_DEBUG_LOG=1 pytest -v -s test_api.py # os.environ["SERVE_DEBUG_LOG"] = "1" <- Do not uncomment this. # Overriding task_retry_delay_ms to relaunch actors more quickly ray.init( address="local", num_cpus=36, namespace="default_test_namespace", _metrics_export_port=9999, _system_config={"metrics_report_interval_ms": 1000, "task_retry_delay_ms": 50}, ) serve.start( proxy_location=ProxyLocation.HeadOnly, http_options={"host": "0.0.0.0"}, grpc_options={ "port": 9000, "grpc_servicer_functions": TEST_GRPC_SERVICER_FUNCTIONS, }, ) yield _get_global_client() # Shutdown Serve and Ray when the session ends so that proxy actors # (e.g. HAProxyManager) run their shutdown logic and stop subprocesses. serve.shutdown() @pytest_asyncio.fixture async def serve_instance_async(_shared_serve_instance): yield _shared_serve_instance # Clear all state for 2.x applications and deployments. _shared_serve_instance.delete_all_apps() # Clear the ServeHandle cache between tests to avoid them piling up. await _shared_serve_instance.shutdown_cached_handles_async() @pytest.fixture def serve_instance(_shared_serve_instance): yield _shared_serve_instance # Clear all state for 2.x applications and deployments. _shared_serve_instance.delete_all_apps() # Clear the ServeHandle cache between tests to avoid them piling up. _shared_serve_instance.shutdown_cached_handles() @pytest.fixture def serve_instance_with_signal(serve_instance): client = serve_instance signal = SignalActor.options(name="signal123").remote() yield client, signal # Delete signal actor so there is no conflict between tests ray.kill(signal) def check_ray_stop(): try: httpx.get("http://localhost:8265/api/ray/version") return False except Exception: return True @pytest.fixture(scope="function") def ray_start_stop(): subprocess.check_output(["ray", "stop", "--force"]) ray.shutdown() wait_for_condition( check_ray_stop, timeout=15, ) subprocess.check_output(["ray", "start", "--head"]) wait_for_condition( lambda: httpx.get("http://localhost:8265/api/ray/version").status_code == 200, timeout=15, ) ray.init("auto") yield serve.shutdown() ray.shutdown() subprocess.check_output(["ray", "stop", "--force"]) wait_for_condition( check_ray_stop, timeout=15, ) @pytest.fixture(scope="function") def ray_start_stop_in_specific_directory(request): original_working_dir = os.getcwd() # Change working directory so Ray will start in the requested directory. new_working_dir = request.param os.chdir(new_working_dir) print(f"\nChanged working directory to {new_working_dir}\n") subprocess.check_output(["ray", "start", "--head"]) wait_for_condition( lambda: httpx.get("http://localhost:8265/api/ray/version").status_code == 200, timeout=15, ) try: yield finally: # Change the directory back to the original one. os.chdir(original_working_dir) print(f"\nChanged working directory back to {original_working_dir}\n") subprocess.check_output(["ray", "stop", "--force"]) wait_for_condition( check_ray_stop, timeout=15, ) @pytest.fixture def ray_instance( request: pytest.FixtureRequest, ) -> Generator[Dict[str, Any], None, None]: """Starts and stops a Ray instance for this test. Args: request: request.param should contain a dictionary of env vars and their values. The Ray instance will be started with these env vars. Yields: Dict[str, Any]: The dict returned by ``ray.init`` for the started cluster. """ original_env_vars = os.environ.copy() try: requested_env_vars = request.param except AttributeError: requested_env_vars = {} os.environ.update(requested_env_vars) yield ray.init( address="local", _metrics_export_port=9999, _system_config={ "metrics_report_interval_ms": 1000, "task_retry_delay_ms": 50, }, ) serve.shutdown() ray.shutdown() os.environ.clear() os.environ.update(original_env_vars) @pytest.fixture def manage_ray_with_telemetry(monkeypatch): with monkeypatch.context() as m: m.setenv("RAY_USAGE_STATS_ENABLED", "1") m.setenv( "RAY_USAGE_STATS_REPORT_URL", f"http://127.0.0.1:8000{TELEMETRY_ROUTE_PREFIX}", ) m.setenv("RAY_USAGE_STATS_REPORT_INTERVAL_S", "1") subprocess.check_output(["ray", "stop", "--force"]) wait_for_condition(check_ray_stopped, timeout=5) subprocess.check_output(["ray", "start", "--head"]) wait_for_condition(check_ray_started, timeout=5) storage = start_telemetry_app() wait_for_condition( lambda: ray.get(storage.get_reports_received.remote()) > 1, timeout=15 ) yield storage # Call Python API shutdown() methods to clear global variable state serve.shutdown() ray.shutdown() # Reset global state (any keys that may have been set and cached while the # workload was running). usage_lib.reset_global_state() # Shut down Ray cluster with CLI subprocess.check_output(["ray", "stop", "--force"]) wait_for_condition(check_ray_stopped, timeout=5) def wait_for_metrics_port_free(port=TEST_METRICS_EXPORT_PORT, timeout=30): """ Ensures the metrics export port is freed. """ def port_free(): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: s.bind(("", port)) return True except OSError: return False finally: s.close() wait_for_condition(port_free, timeout=timeout, retry_interval_ms=200) def wait_for_metrics_endpoint(session_name, port=TEST_METRICS_EXPORT_PORT, timeout=30): """ Ensures the current dashboard agent is serving the metrics endpoint. A timeout indicates another agent is still running and holding the port. """ def ready(): try: resp = httpx.get(f"http://localhost:{port}/metrics", timeout=1.0) except Exception: return False return resp.status_code == 200 and f'SessionName="{session_name}"' in resp.text wait_for_condition(ready, timeout=timeout, retry_interval_ms=500) @pytest.fixture def metrics_start_shutdown(request): param = request.param if hasattr(request, "param") else None request_timeout_s = param if param else None """Fixture provides a fresh Ray cluster to prevent metrics state sharing.""" wait_for_metrics_port_free() ray.init( address="local", _metrics_export_port=TEST_METRICS_EXPORT_PORT, _system_config={ "metrics_report_interval_ms": 100, "task_retry_delay_ms": 50, }, ) try: session_name = ray._private.worker._global_node.session_name wait_for_metrics_endpoint(session_name) grpc_port = 9000 grpc_servicer_functions = [ "ray.serve.generated.serve_pb2_grpc.add_UserDefinedServiceServicer_to_server", "ray.serve.generated.serve_pb2_grpc.add_FruitServiceServicer_to_server", ] yield serve.start( grpc_options=gRPCOptions( port=grpc_port, grpc_servicer_functions=grpc_servicer_functions, request_timeout_s=request_timeout_s, ), http_options=HTTPOptions( host="0.0.0.0", request_timeout_s=request_timeout_s, ), ) finally: serve.shutdown() ray.shutdown() reset_ray_address() # Helper function to return the node ID of a remote worker. @ray.remote(num_cpus=0) def _get_node_id(): return ray.get_runtime_context().get_node_id() # Test fixture to start a Serve instance in a RayCluster with two labeled nodes @pytest.fixture(scope="module") def serve_instance_with_labeled_nodes(): cluster = Cluster() # Unlabeled default node. cluster.add_node(num_cpus=3, resources={"worker0": 1}) # Node 1 - labeled A100 node in us-west. cluster.add_node( num_cpus=3, resources={"worker1": 1}, labels={"region": "us-west", "gpu-type": "A100"}, ) # Node 2 - labeled H100 node in us-east. cluster.add_node( num_cpus=3, resources={"worker2": 1}, labels={"region": "us-east", "gpu-type": "H100"}, ) cluster.wait_for_nodes() if ray.is_initialized(): ray.shutdown() ray.init(address=cluster.address) node_1_id = ray.get(_get_node_id.options(resources={"worker1": 1}).remote()) node_2_id = ray.get(_get_node_id.options(resources={"worker2": 1}).remote()) serve.start() yield _get_global_client(), node_1_id, node_2_id, cluster serve.shutdown() ray.shutdown() cluster.shutdown()