Files
ray-project--ray/python/ray/serve/tests/conftest.py
T
2026-07-13 13:17:40 +08:00

459 lines
13 KiB
Python

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()