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

421 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import functools
import os
import sys
import threading
import time
from concurrent.futures.thread import ThreadPoolExecutor
from typing import Dict
import httpx
import pytest
import ray
from ray import serve
from ray._common.test_utils import SignalActor, wait_for_condition
from ray.serve._private.common import DeploymentStatus
from ray.serve._private.logging_utils import get_serve_logs_dir
from ray.serve._private.test_utils import (
SharedFlag,
check_deployment_status,
check_num_replicas_eq,
get_application_url,
)
from ray.serve._private.utils import get_component_file_name
from ray.serve.schema import ApplicationStatus
from ray.util.state import list_actors
def test_deploy_zero_cpus(serve_instance):
@serve.deployment(ray_actor_options={"num_cpus": 0})
class D:
def hello(self):
return "hello"
h = serve.run(D.bind())
assert h.hello.remote().result() == "hello"
def test_deployment_error_handling(serve_instance):
@serve.deployment
def f():
pass
with pytest.raises(RuntimeError):
# This is an invalid configuration since dynamic upload of working
# directories is not supported. The error this causes in the controller
# code should be caught and reported back to the `deploy` caller.
serve.run(
f.options(ray_actor_options={"runtime_env": {"working_dir": "."}}).bind()
)
def test_json_serialization_user_config(serve_instance):
"""See https://github.com/ray-project/ray/issues/25345.
See https://github.com/ray-project/ray/pull/26235 for additional context
about this test.
"""
@serve.deployment(name="simple-deployment")
class SimpleDeployment:
value: str
nested_value: str
def reconfigure(self, config: Dict) -> None:
self.value = config["value"]
self.nested_value = config["nested"]["value"]
def get_value(self) -> None:
return self.value
def get_nested_value(self) -> None:
return self.nested_value
app = SimpleDeployment.options(
user_config={
"value": "Success!",
"nested": {"value": "Success!"},
}
).bind()
handle = serve.run(app)
assert handle.get_value.remote().result() == "Success!"
assert handle.get_nested_value.remote().result() == "Success!"
handle = serve.run(
SimpleDeployment.options(
user_config={
"value": "Failure!",
"another-value": "Failure!",
"nested": {"value": "Success!"},
}
).bind()
)
assert handle.get_value.remote().result() == "Failure!"
assert handle.get_nested_value.remote().result() == "Success!"
def test_http_proxy_request_cancellation(serve_instance):
# https://github.com/ray-project/ray/issues/21425
s = SignalActor.remote()
@serve.deployment(max_ongoing_requests=1)
class A:
def __init__(self) -> None:
self.counter = 0
async def __call__(self):
self.counter += 1
ret_val = self.counter
await s.wait.remote()
return ret_val
serve.run(A.bind())
url = get_application_url("HTTP")
# Windows usually resolves "localhost" to the IPv6 loopback ::1 first, but the
# Serve proxy is listening only on IPv4. The initial TCP connect then hangs,
# breaking the shorttimeout logic in this test. Using the literal IPv4 address
# 127.0.0.1 skips the IPv6 attempt and makes the test deterministic on Windows.
if sys.platform == "win32":
url = url.replace("localhost", "127.0.0.1")
with ThreadPoolExecutor() as pool:
# Send the first request, it should block for the result
first_blocking_fut = pool.submit(functools.partial(httpx.get, url, timeout=100))
wait_for_condition(lambda: ray.get(s.cur_num_waiters.remote()) == 1)
assert not first_blocking_fut.done()
# Send more requests, these should be queued in handle.
# But because first request is hanging and these have low timeout.
# They should all disconnect from http connection.
# These requests should never reach the replica.
rest_blocking_futs = [
pool.submit(functools.partial(httpx.get, url, timeout=0.5))
for _ in range(3)
]
wait_for_condition(lambda: all(f.done() for f in rest_blocking_futs))
# Now unblock the first request.
ray.get(s.send.remote())
assert first_blocking_fut.result().text == "1"
# Sending another request to verify that only one request has been
# processed so far.
assert httpx.get(url).text == "2"
def test_nonserializable_deployment(serve_instance):
lock = threading.Lock()
class D:
def hello(self, _):
return lock
# Check that the `inspect_serializability` trace was printed
with pytest.raises(
TypeError,
match=r"Could not serialize the deployment[\s\S]*was found to be non-serializable.*", # noqa
):
serve.deployment(D)
@serve.deployment
class E:
def __init__(self, arg):
self.arg = arg
with pytest.raises(TypeError, match="pickle"):
serve.run(E.bind(lock))
with pytest.raises(TypeError, match="pickle"):
serve.run(E.bind(arg=lock))
def test_deploy_application_unhealthy(serve_instance):
"""Test deploying an application that becomes unhealthy."""
event = SharedFlag.remote()
@serve.deployment(health_check_period_s=1, health_check_timeout_s=3)
class Model:
def __call__(self):
return "hello world"
def check_health(self):
if ray.get(event.is_set.remote()):
raise RuntimeError("Intentionally failing.")
handle = serve.run(Model.bind(), name="app")
assert handle.remote().result() == "hello world"
assert serve.status().applications["app"].status == ApplicationStatus.RUNNING
# When a deployment becomes unhealthy, application should transition -> UNHEALTHY
event.set.remote()
wait_for_condition(
lambda: serve.status().applications["app"].status == ApplicationStatus.UNHEALTHY
)
# Check that application stays unhealthy
for _ in range(10):
assert serve.status().applications["app"].status == ApplicationStatus.UNHEALTHY
time.sleep(0.1)
# At least 10 control loop iterations should have passed. Check that
# the logs from application state manager notifying about unhealthy
# deployments doesn't spam, they should get printed only once.
controller_pid = [
actor["pid"]
for actor in list_actors()
if actor["name"] == "SERVE_CONTROLLER_ACTOR"
][0]
controller_log_file_name = get_component_file_name(
"controller", controller_pid, component_type=None, suffix=".log"
)
controller_log_path = os.path.join(get_serve_logs_dir(), controller_log_file_name)
with open(controller_log_path, "r") as f:
s = f.read()
assert s.count("The deployments ['Model'] are UNHEALTHY.") <= 1
@pytest.mark.skipif(
sys.platform == "win32", reason="Runtime env support experimental on windows"
)
def test_deploy_bad_pip_package_deployment(serve_instance):
"""Test deploying with a bad runtime env at deployment level."""
@serve.deployment(ray_actor_options={"runtime_env": {"pip": ["does_not_exist"]}})
class Model:
def __call__(self):
return "hello world"
serve._run(Model.bind(), _blocking=False)
def check_fail():
app_status = serve.status().applications["default"]
assert app_status.status == ApplicationStatus.DEPLOY_FAILED
deployment_message = app_status.deployments["Model"].message
assert "No matching distribution found for does_not_exist" in deployment_message
return True
wait_for_condition(check_fail, timeout=60)
def test_deploy_same_deployment_name_different_app(serve_instance):
@serve.deployment
class Model:
def __init__(self, name):
self.name = name
def __call__(self):
return f"hello {self.name}"
serve.run(Model.bind("alice"), name="app1", route_prefix="/app1")
serve.run(Model.bind("bob"), name="app2", route_prefix="/app2")
url = get_application_url("HTTP", app_name="app1")
assert httpx.get(f"{url}").text == "hello alice"
url_without_route_prefix = get_application_url(
"HTTP", app_name="app1", exclude_route_prefix=True
)
routes_url = f"{url_without_route_prefix}/-/routes"
routes = httpx.get(routes_url).json()
assert routes["/app1"] == "app1"
url = get_application_url("HTTP", app_name="app2")
assert httpx.get(f"{url}").text == "hello bob"
url_without_route_prefix = get_application_url(
"HTTP", app_name="app2", exclude_route_prefix=True
)
routes_url = f"{url_without_route_prefix}/-/routes"
routes = httpx.get(routes_url).json()
assert routes["/app2"] == "app2"
app1_status = serve.status().applications["app1"]
app2_status = serve.status().applications["app2"]
assert app1_status.status == "RUNNING"
assert app1_status.deployments["Model"].status == "HEALTHY"
assert app2_status.status == "RUNNING"
assert app2_status.deployments["Model"].status == "HEALTHY"
@pytest.mark.parametrize("use_options", [True, False])
def test_num_replicas_auto_api(serve_instance, use_options):
"""Test setting only `num_replicas="auto"`."""
signal = SignalActor.remote()
class A:
async def __call__(self):
await signal.wait.remote()
if use_options:
A = serve.deployment(A).options(num_replicas="auto")
else:
A = serve.deployment(num_replicas="auto")(A)
serve.run(A.bind(), name="default")
wait_for_condition(
check_deployment_status, name="A", expected_status=DeploymentStatus.HEALTHY
)
check_num_replicas_eq("A", 1)
app_details = serve_instance.get_serve_details()["applications"]["default"]
deployment_config = app_details["deployments"]["A"]["deployment_config"]
assert "num_replicas" not in deployment_config
assert deployment_config["max_ongoing_requests"] == 5
assert deployment_config["autoscaling_config"] == {
# Set by `num_replicas="auto"`
"target_ongoing_requests": 2.0,
"min_replicas": 1,
"max_replicas": 100,
# Untouched defaults
"metrics_interval_s": 10.0,
"upscale_delay_s": 30.0,
"look_back_period_s": 30.0,
"downscale_delay_s": 600.0,
"downscale_to_zero_delay_s": None,
"upscale_smoothing_factor": None,
"downscale_smoothing_factor": None,
"upscaling_factor": None,
"downscaling_factor": None,
"smoothing_factor": 1.0,
"initial_replicas": None,
"aggregation_function": "mean",
"policy": {
"policy_function": "ray.serve.autoscaling_policy:default_autoscaling_policy",
"policy_kwargs": {},
},
}
@pytest.mark.parametrize("use_options", [True, False])
def test_num_replicas_auto_basic(serve_instance, use_options):
"""Test `num_replicas="auto"` and the defaults are used by autoscaling."""
signal = SignalActor.remote()
class A:
async def __call__(self):
await signal.wait.remote()
if use_options:
A = serve.deployment(A).options(
num_replicas="auto",
autoscaling_config={
"metrics_interval_s": 1,
"upscale_delay_s": 1,
"look_back_period_s": 2,
# Shrink from the 600s default so the transient extra replica
# (metric reading fractionally over target) downscales back
# within the test's wait window.
"downscale_delay_s": 4,
},
graceful_shutdown_timeout_s=1,
)
else:
A = serve.deployment(
num_replicas="auto",
autoscaling_config={
"metrics_interval_s": 1,
"upscale_delay_s": 1,
"look_back_period_s": 2,
# Shrink from the 600s default so the transient extra replica
# (metric reading fractionally over target) downscales back
# within the test's wait window.
"downscale_delay_s": 4,
},
graceful_shutdown_timeout_s=1,
)(A)
h = serve.run(A.bind(), name="default")
wait_for_condition(
check_deployment_status, name="A", expected_status=DeploymentStatus.HEALTHY
)
check_num_replicas_eq("A", 1)
app_details = serve_instance.get_serve_details()["applications"]["default"]
deployment_config = app_details["deployments"]["A"]["deployment_config"]
assert "num_replicas" not in deployment_config
assert deployment_config["max_ongoing_requests"] == 5
assert deployment_config["autoscaling_config"] == {
# Set by `num_replicas="auto"`
"target_ongoing_requests": 2.0,
"min_replicas": 1,
"max_replicas": 100,
# Overrided by `autoscaling_config`
"metrics_interval_s": 1.0,
"upscale_delay_s": 1.0,
"downscale_delay_s": 4.0,
# Untouched defaults
"look_back_period_s": 2.0,
"downscale_to_zero_delay_s": None,
"upscale_smoothing_factor": None,
"downscale_smoothing_factor": None,
"upscaling_factor": None,
"downscaling_factor": None,
"smoothing_factor": 1.0,
"initial_replicas": None,
"aggregation_function": "mean",
"policy": {
"policy_function": "ray.serve.autoscaling_policy:default_autoscaling_policy",
"policy_kwargs": {},
},
}
for i in range(3):
[h.remote() for _ in range(2)]
def check_num_waiters(target: int):
assert ray.get(signal.cur_num_waiters.remote()) == target
return True
wait_for_condition(check_num_waiters, target=2 * (i + 1), timeout=30)
print(time.time(), f"Number of waiters on signal reached {2*(i+1)}.")
wait_for_condition(check_num_replicas_eq, name="A", target=i + 1, timeout=30)
print(time.time(), f"Confirmed number of replicas are at {i+1}.")
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))