978 lines
30 KiB
Python
978 lines
30 KiB
Python
import asyncio
|
|
import os
|
|
import sys
|
|
from typing import Any, List
|
|
|
|
import pytest
|
|
|
|
import ray
|
|
from ray import serve
|
|
from ray._common.test_utils import (
|
|
SignalActor,
|
|
async_wait_for_condition,
|
|
wait_for_condition,
|
|
)
|
|
from ray._common.utils import get_or_create_event_loop
|
|
from ray.exceptions import RayActorError
|
|
from ray.serve._private.constants import (
|
|
RAY_SERVE_FORCE_LOCAL_TESTING_MODE,
|
|
RAY_SERVE_USE_GRPC_BY_DEFAULT,
|
|
)
|
|
from ray.serve._private.test_utils import check_num_replicas_eq
|
|
from ray.serve.exceptions import RayServeException
|
|
from ray.serve.handle import (
|
|
DeploymentHandle,
|
|
DeploymentResponse,
|
|
DeploymentResponseGenerator,
|
|
)
|
|
|
|
|
|
def test_basic(serve_instance):
|
|
@serve.deployment
|
|
def downstream():
|
|
return "hello"
|
|
|
|
@serve.deployment
|
|
class Deployment:
|
|
def __init__(self, handle: DeploymentHandle):
|
|
self._handle = handle
|
|
assert isinstance(self._handle, DeploymentHandle)
|
|
|
|
async def __call__(self):
|
|
response = self._handle.remote()
|
|
assert isinstance(response, DeploymentResponse)
|
|
val = await response
|
|
|
|
# Check that the response can be awaited multiple times.
|
|
for _ in range(10):
|
|
assert (await response) == val
|
|
|
|
return val
|
|
|
|
handle: DeploymentHandle = serve.run(Deployment.bind(downstream.bind()))
|
|
assert isinstance(handle, DeploymentHandle)
|
|
r = handle.remote()
|
|
assert r.result() == "hello"
|
|
|
|
# Check that `.result()` can be called multiple times.
|
|
for _ in range(10):
|
|
assert r.result() == "hello"
|
|
|
|
|
|
def test_result_timeout(serve_instance):
|
|
"""Test `.result()` timeout parameter."""
|
|
signal_actor = SignalActor.remote()
|
|
|
|
@serve.deployment
|
|
class Deployment:
|
|
async def __call__(self):
|
|
await signal_actor.wait.remote()
|
|
return "hi"
|
|
|
|
handle = serve.run(Deployment.bind())
|
|
ref = handle.remote()
|
|
with pytest.raises(TimeoutError):
|
|
ref.result(timeout_s=0.1)
|
|
|
|
ray.get(signal_actor.send.remote())
|
|
assert ref.result() == "hi"
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
RAY_SERVE_FORCE_LOCAL_TESTING_MODE,
|
|
reason="local_testing_mode doesn't support getting dynamic handles",
|
|
)
|
|
def test_get_app_and_deployment_handle(serve_instance):
|
|
"""Test the `get_app_handle` and `get_deployment_handle` APIs."""
|
|
|
|
@serve.deployment
|
|
def downstream():
|
|
return "hello"
|
|
|
|
@serve.deployment
|
|
class Deployment:
|
|
def __init__(self, handle: DeploymentHandle):
|
|
pass
|
|
|
|
async def check_get_deployment_handle(self):
|
|
handle = serve.get_deployment_handle(deployment_name="downstream")
|
|
assert isinstance(handle, DeploymentHandle)
|
|
|
|
ref = handle.remote()
|
|
assert isinstance(ref, DeploymentResponse)
|
|
return await ref
|
|
|
|
serve.run(Deployment.bind(downstream.bind()))
|
|
handle = serve.get_app_handle("default")
|
|
assert isinstance(handle, DeploymentHandle)
|
|
assert handle.check_get_deployment_handle.remote().result() == "hello"
|
|
|
|
handle = serve.get_deployment_handle("downstream", app_name="default")
|
|
assert isinstance(handle, DeploymentHandle)
|
|
assert handle.remote().result() == "hello"
|
|
|
|
|
|
@pytest.mark.parametrize("arg_type", ["args", "kwargs"])
|
|
def test_compose_deployments_in_app(serve_instance, arg_type: str):
|
|
"""Test composing deployment handle refs within a deployment."""
|
|
|
|
@serve.deployment
|
|
class Downstream:
|
|
def __init__(self, msg: str):
|
|
self._msg = msg
|
|
|
|
def __call__(self, inp: str):
|
|
return f"{self._msg}|{inp}"
|
|
|
|
@serve.deployment
|
|
class Deployment:
|
|
def __init__(self, handle1: DeploymentHandle, handle2: DeploymentHandle):
|
|
self._handle1 = handle1
|
|
self._handle2 = handle2
|
|
|
|
async def __call__(self):
|
|
if arg_type == "args":
|
|
result = await self._handle1.remote(self._handle2.remote("hi"))
|
|
else:
|
|
result = await self._handle1.remote(inp=self._handle2.remote(inp="hi"))
|
|
return f"driver|{result}"
|
|
|
|
handle = serve.run(
|
|
Deployment.bind(
|
|
Downstream.options(name="downstream1").bind("downstream1"),
|
|
Downstream.options(name="downstream2").bind("downstream2"),
|
|
),
|
|
)
|
|
assert handle.remote().result() == "driver|downstream1|downstream2|hi"
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
RAY_SERVE_FORCE_LOCAL_TESTING_MODE,
|
|
reason="local_testing_mode only supports single apps",
|
|
)
|
|
@pytest.mark.parametrize("arg_type", ["args", "kwargs"])
|
|
def test_compose_apps(serve_instance, arg_type):
|
|
"""Test composing deployment handle refs outside of a deployment."""
|
|
|
|
@serve.deployment
|
|
class Deployment:
|
|
def __init__(self, msg: str):
|
|
self._msg = msg
|
|
|
|
def __call__(self, inp: str):
|
|
return f"{self._msg}|{inp}"
|
|
|
|
handle1 = serve.run(Deployment.bind("app1"), name="app1", route_prefix="/app1")
|
|
handle2 = serve.run(Deployment.bind("app2"), name="app2", route_prefix="/app2")
|
|
|
|
if arg_type == "args":
|
|
assert handle1.remote(handle2.remote("hi")).result() == "app1|app2|hi"
|
|
else:
|
|
assert handle1.remote(inp=handle2.remote(inp="hi")).result() == "app1|app2|hi"
|
|
|
|
|
|
def test_compose_args_and_kwargs(serve_instance):
|
|
"""Test composing deployment handle refs outside of a deployment."""
|
|
|
|
@serve.deployment
|
|
class Downstream:
|
|
def __init__(self, msg: str):
|
|
self._msg = msg
|
|
|
|
def __call__(self, *args, **kwargs):
|
|
return {"args": args, "kwargs": kwargs}
|
|
|
|
@serve.deployment
|
|
class Deployment:
|
|
def __init__(self, handle1: DeploymentHandle, handle2: DeploymentHandle):
|
|
self._handle1 = handle1
|
|
self._handle2 = handle2
|
|
|
|
async def __call__(self):
|
|
return await self._handle1.remote(
|
|
"0",
|
|
"100",
|
|
self._handle2.remote("1", "2", a="a", b="b"),
|
|
x="x",
|
|
y=self._handle2.remote("3", "4", c="c", d="d"),
|
|
z="z",
|
|
)
|
|
|
|
handle = serve.run(
|
|
Deployment.bind(
|
|
Downstream.bind("downstream1"),
|
|
Downstream.bind("downstream2"),
|
|
),
|
|
)
|
|
|
|
result = handle.remote().result()
|
|
assert result["args"] == (
|
|
"0",
|
|
"100",
|
|
{
|
|
"args": ("1", "2"),
|
|
"kwargs": {"a": "a", "b": "b"},
|
|
},
|
|
)
|
|
assert result["kwargs"] == {
|
|
"x": "x",
|
|
"y": {
|
|
"args": ("3", "4"),
|
|
"kwargs": {"c": "c", "d": "d"},
|
|
},
|
|
"z": "z",
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("await_order", ["b_first", "c_first"])
|
|
@pytest.mark.asyncio
|
|
async def test_chained_deployment_response_await_order(
|
|
serve_instance, await_order: str
|
|
):
|
|
@serve.deployment
|
|
class DeploymentA:
|
|
async def __call__(self, number: int) -> int:
|
|
await asyncio.sleep(0.01)
|
|
return number * 2
|
|
|
|
@serve.deployment
|
|
class DeploymentB:
|
|
async def __call__(self, number: int) -> int:
|
|
await asyncio.sleep(0.01)
|
|
return number * 3
|
|
|
|
@serve.deployment
|
|
class DeploymentC:
|
|
async def __call__(self, number: int) -> int:
|
|
await asyncio.sleep(0.01)
|
|
return number * 4
|
|
|
|
@serve.deployment
|
|
class ComposedDeployment:
|
|
def __init__(self, deployment_a, deployment_b, deployment_c):
|
|
self.deployment_a = deployment_a
|
|
self.deployment_b = deployment_b
|
|
self.deployment_c = deployment_c
|
|
|
|
async def __call__(self, number: int, await_order: str) -> int:
|
|
a = self.deployment_a.remote(number)
|
|
b = self.deployment_b.remote(a)
|
|
c = self.deployment_c.remote(b)
|
|
|
|
# Test different await orders - both should work without hanging
|
|
if await_order == "b_first":
|
|
true_b = await b
|
|
true_c = await c
|
|
else:
|
|
true_c = await c
|
|
true_b = await b
|
|
|
|
return true_b * true_c
|
|
|
|
handle = serve.run(
|
|
ComposedDeployment.bind(
|
|
DeploymentA.bind(), DeploymentB.bind(), DeploymentC.bind()
|
|
),
|
|
)
|
|
|
|
# Use a timeout to detect hangs
|
|
result = await handle.remote(5, await_order)
|
|
|
|
# Verify the result is correct:
|
|
# a = 5 * 2 = 10
|
|
# b = 10 * 3 = 30 (true_b)
|
|
# c = 30 * 4 = 120 (true_c)
|
|
# result = 30 * 120 = 3600
|
|
assert result == 3600, f"Expected 3600, got {result}"
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform == "win32",
|
|
reason="ActorDiedError not raised properly on Windows.",
|
|
)
|
|
@pytest.mark.skipif(
|
|
RAY_SERVE_USE_GRPC_BY_DEFAULT,
|
|
reason="Chained object refs use Ray actor transport; gRPC path differs.",
|
|
)
|
|
@pytest.mark.skipif(
|
|
RAY_SERVE_FORCE_LOCAL_TESTING_MODE,
|
|
reason="local_testing_mode has different failure handling.",
|
|
)
|
|
def test_chained_deployment_response_upstream_crash_downstream_stays_in_rotation(
|
|
serve_instance,
|
|
):
|
|
"""
|
|
When upstream (Adder) crashes with os._exit in a chained DeploymentResponse
|
|
scenario, the downstream (Multiplier) must NOT be incorrectly marked as dead.
|
|
Subsequent requests should succeed after the upstream is restarted.
|
|
"""
|
|
|
|
@serve.deployment
|
|
class Adder:
|
|
def __init__(self, increment: int):
|
|
self._increment = increment
|
|
|
|
def __call__(self, val: int) -> int:
|
|
if val == 0:
|
|
os._exit(0)
|
|
return val + self._increment
|
|
|
|
@serve.deployment
|
|
class Multiplier:
|
|
def __init__(self, multiple: int):
|
|
self._multiple = multiple
|
|
|
|
def __call__(self, val: int) -> int:
|
|
return val * self._multiple
|
|
|
|
def get_actor_id(self) -> str:
|
|
return ray.get_runtime_context().get_actor_id()
|
|
|
|
@serve.deployment
|
|
class Ingress:
|
|
def __init__(self, adder: DeploymentHandle, multiplier: DeploymentHandle):
|
|
self._adder = adder
|
|
self._multiplier = multiplier
|
|
|
|
async def __call__(self, input: int) -> int:
|
|
adder_response: DeploymentResponse = self._adder.remote(input)
|
|
multiplier_response: DeploymentResponse = self._multiplier.remote(
|
|
adder_response
|
|
)
|
|
return await multiplier_response
|
|
|
|
app = Ingress.bind(
|
|
Adder.bind(increment=1),
|
|
Multiplier.bind(multiple=2),
|
|
)
|
|
handle: DeploymentHandle = serve.run(app)
|
|
|
|
# Warmup: (5 + 1) * 2 = 12
|
|
assert handle.remote(5).result(timeout_s=10) == 12
|
|
|
|
multiplier_handle = serve.get_deployment_handle("Multiplier", "default")
|
|
multiplier_actor_id_before = multiplier_handle.get_actor_id.remote().result()
|
|
|
|
# Trigger Adder crash with os._exit(0)
|
|
crash_response = handle.remote(0)
|
|
with pytest.raises((RayActorError, Exception)):
|
|
crash_response.result(timeout_s=5)
|
|
|
|
# Wait for Adder to be restarted by controller
|
|
wait_for_condition(
|
|
check_num_replicas_eq,
|
|
name="Adder",
|
|
target=1,
|
|
timeout=30,
|
|
)
|
|
|
|
# Multiplier should still be in rotation (don't mark it dead when error is
|
|
# from upstream). A new request should succeed. (2 + 1) * 2 = 6
|
|
result = handle.remote(2).result(timeout_s=30)
|
|
assert result == 6, f"Expected 6, got {result}"
|
|
|
|
# Multiplier replica must not have been restarted (same actor_id).
|
|
multiplier_actor_id_after = multiplier_handle.get_actor_id.remote().result()
|
|
assert multiplier_actor_id_before == multiplier_actor_id_after, (
|
|
f"Multiplier was incorrectly marked dead and restarted: "
|
|
f"actor_id {multiplier_actor_id_before} -> {multiplier_actor_id_after}"
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform == "win32",
|
|
reason="ActorDiedError not raised properly on Windows.",
|
|
)
|
|
@pytest.mark.skipif(
|
|
RAY_SERVE_USE_GRPC_BY_DEFAULT,
|
|
reason="Chained object refs use Ray actor transport; gRPC path differs.",
|
|
)
|
|
@pytest.mark.skipif(
|
|
RAY_SERVE_FORCE_LOCAL_TESTING_MODE,
|
|
reason="local_testing_mode has different failure handling.",
|
|
)
|
|
def test_chained_deployment_response_upstream_crash_fails_fast(serve_instance):
|
|
"""When upstream dies in a chained DeploymentResponse, the request should
|
|
fail immediately with an error — not hang until the caller's timeout.
|
|
"""
|
|
|
|
@serve.deployment(num_replicas=2)
|
|
class Adder:
|
|
def __init__(self, increment: int):
|
|
self._increment = increment
|
|
|
|
def __call__(self, val: int) -> int:
|
|
context = serve.get_replica_context()
|
|
if val == 0 and context.rank.rank == 0:
|
|
os._exit(0)
|
|
return val + self._increment
|
|
|
|
@serve.deployment
|
|
class Multiplier:
|
|
def __init__(self, multiple: int):
|
|
self._multiple = multiple
|
|
|
|
def __call__(self, val: int) -> int:
|
|
return val * self._multiple
|
|
|
|
@serve.deployment
|
|
class Ingress:
|
|
def __init__(self, adder: DeploymentHandle, multiplier: DeploymentHandle):
|
|
self._adder = adder
|
|
self._multiplier = multiplier
|
|
|
|
async def __call__(self, input: int) -> int:
|
|
adder_response: DeploymentResponse = self._adder.remote(input)
|
|
multiplier_response: DeploymentResponse = self._multiplier.remote(
|
|
adder_response
|
|
)
|
|
return await multiplier_response
|
|
|
|
app = Ingress.bind(
|
|
Adder.bind(increment=1),
|
|
Multiplier.bind(multiple=2),
|
|
)
|
|
handle: DeploymentHandle = serve.run(app)
|
|
|
|
# Warmup: (5 + 1) * 2 = 12
|
|
assert handle.remote(5).result(timeout_s=10) == 12
|
|
|
|
# Send requests until we hit the rank-0 replica that will die.
|
|
# The request must raise a RayServeException (not a TimeoutError), with
|
|
# Ray's ActorDiedError text embedded (actor id / replica name, etc.).
|
|
with pytest.raises(RayServeException) as exc_info:
|
|
while True:
|
|
handle.remote(0).result(timeout_s=10)
|
|
err_text = str(exc_info.value)
|
|
assert "Multiplier" in err_text
|
|
assert "Adder" in err_text
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform == "win32",
|
|
reason="ActorDiedError not raised properly on Windows.",
|
|
)
|
|
@pytest.mark.skipif(
|
|
RAY_SERVE_USE_GRPC_BY_DEFAULT,
|
|
reason="Chained object refs use Ray actor transport; gRPC path differs.",
|
|
)
|
|
@pytest.mark.skipif(
|
|
RAY_SERVE_FORCE_LOCAL_TESTING_MODE,
|
|
reason="local_testing_mode has different failure handling.",
|
|
)
|
|
def test_chained_deployment_response_downstream_crash_replica_marked_dead(
|
|
serve_instance,
|
|
):
|
|
"""
|
|
When downstream (Multiplier) crashes in a chained DeploymentResponse
|
|
scenario, the Multiplier replica MUST be correctly marked as dead.
|
|
Subsequent requests should succeed after the Multiplier is restarted.
|
|
"""
|
|
# Adder returns input+1. Multiplier crashes when input==0.
|
|
# So input=-1 -> Adder returns 0 -> Multiplier gets 0 and crashes.
|
|
|
|
@serve.deployment
|
|
class Adder:
|
|
def __init__(self, increment: int):
|
|
self._increment = increment
|
|
|
|
def __call__(self, val: int) -> int:
|
|
return val + self._increment
|
|
|
|
@serve.deployment
|
|
class Multiplier:
|
|
def __init__(self, multiple: int):
|
|
self._multiple = multiple
|
|
|
|
def __call__(self, val: int) -> int:
|
|
if val == 0:
|
|
os._exit(0)
|
|
return val * self._multiple
|
|
|
|
def get_actor_id(self) -> str:
|
|
return ray.get_runtime_context().get_actor_id()
|
|
|
|
@serve.deployment
|
|
class Ingress:
|
|
def __init__(self, adder: DeploymentHandle, multiplier: DeploymentHandle):
|
|
self._adder = adder
|
|
self._multiplier = multiplier
|
|
|
|
async def __call__(self, input: int) -> int:
|
|
adder_response: DeploymentResponse = self._adder.remote(input)
|
|
multiplier_response: DeploymentResponse = self._multiplier.remote(
|
|
adder_response
|
|
)
|
|
return await multiplier_response
|
|
|
|
app = Ingress.bind(
|
|
Adder.bind(increment=1),
|
|
Multiplier.bind(multiple=2),
|
|
)
|
|
handle: DeploymentHandle = serve.run(app)
|
|
|
|
# Warmup: (-1 + 1) * 2 = 0, but Multiplier crashes on 0. Use val=5: (5+1)*2=12
|
|
assert handle.remote(5).result(timeout_s=10) == 12
|
|
|
|
multiplier_handle = serve.get_deployment_handle("Multiplier", "default")
|
|
multiplier_actor_id_before = multiplier_handle.get_actor_id.remote().result()
|
|
|
|
# Trigger Multiplier crash: Adder returns 0, Multiplier gets 0 and os._exit(0)
|
|
crash_response = handle.remote(-1)
|
|
with pytest.raises((RayActorError, Exception)):
|
|
crash_response.result(timeout_s=5)
|
|
|
|
# Wait for Multiplier to be restarted by controller
|
|
wait_for_condition(
|
|
check_num_replicas_eq,
|
|
name="Multiplier",
|
|
target=1,
|
|
timeout=30,
|
|
)
|
|
|
|
# New request should succeed with restarted Multiplier. (2 + 1) * 2 = 6
|
|
result = handle.remote(2).result(timeout_s=30)
|
|
assert result == 6, f"Expected 6, got {result}"
|
|
|
|
# Multiplier replica must have been restarted (different actor_id).
|
|
multiplier_actor_id_after = multiplier_handle.get_actor_id.remote().result()
|
|
assert multiplier_actor_id_before != multiplier_actor_id_after, (
|
|
f"Multiplier was not correctly marked dead and restarted: "
|
|
f"actor_id unchanged at {multiplier_actor_id_before}"
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform == "win32",
|
|
reason="ActorDiedError not raised properly on Windows.",
|
|
)
|
|
@pytest.mark.skipif(
|
|
RAY_SERVE_USE_GRPC_BY_DEFAULT,
|
|
reason="Chained object refs use Ray actor transport; gRPC path differs.",
|
|
)
|
|
@pytest.mark.skipif(
|
|
RAY_SERVE_FORCE_LOCAL_TESTING_MODE,
|
|
reason="local_testing_mode has different failure handling.",
|
|
)
|
|
def test_chained_deployment_response_middle_of_chain_crash(serve_instance):
|
|
"""
|
|
When the middle deployment (B) crashes in a 3-hop chain Ingress->A->B->C,
|
|
only B should be marked dead. A and C must NOT be incorrectly marked dead.
|
|
Subsequent requests should succeed after B is restarted.
|
|
"""
|
|
|
|
@serve.deployment
|
|
class ServiceA:
|
|
def __call__(self, val: int) -> int:
|
|
return val * 2
|
|
|
|
@serve.deployment
|
|
class ServiceB:
|
|
def __call__(self, val: int) -> int:
|
|
if val == 0:
|
|
os._exit(0)
|
|
return val * 3
|
|
|
|
@serve.deployment
|
|
class ServiceC:
|
|
def __call__(self, val: int) -> int:
|
|
return val * 5
|
|
|
|
@serve.deployment
|
|
class Ingress:
|
|
def __init__(
|
|
self,
|
|
a: DeploymentHandle,
|
|
b: DeploymentHandle,
|
|
c: DeploymentHandle,
|
|
):
|
|
self._a = a
|
|
self._b = b
|
|
self._c = c
|
|
|
|
async def __call__(self, val: int) -> int:
|
|
a_resp: DeploymentResponse = self._a.remote(val)
|
|
b_resp: DeploymentResponse = self._b.remote(a_resp)
|
|
c_resp: DeploymentResponse = self._c.remote(b_resp)
|
|
return await c_resp
|
|
|
|
app = Ingress.bind(
|
|
ServiceA.bind(),
|
|
ServiceB.bind(),
|
|
ServiceC.bind(),
|
|
)
|
|
handle: DeploymentHandle = serve.run(app)
|
|
|
|
# Warmup: 5 -> A=10 -> B=30 -> C=150
|
|
assert handle.remote(5).result(timeout_s=10) == 150
|
|
|
|
# Trigger B crash: val=0 -> A=0 -> B gets 0, crashes
|
|
crash_response = handle.remote(0)
|
|
with pytest.raises((RayActorError, Exception)):
|
|
crash_response.result(timeout_s=5)
|
|
|
|
# Wait for B to be restarted
|
|
wait_for_condition(
|
|
check_num_replicas_eq,
|
|
name="ServiceB",
|
|
target=1,
|
|
timeout=30,
|
|
)
|
|
|
|
# A and C should still be in rotation. New request: 2 -> A=4 -> B=12 -> C=60
|
|
result = handle.remote(2).result(timeout_s=30)
|
|
assert result == 60, f"Expected 60, got {result}"
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
RAY_SERVE_USE_GRPC_BY_DEFAULT,
|
|
reason="Chained object refs use Ray actor transport; gRPC path differs.",
|
|
)
|
|
@pytest.mark.skipif(
|
|
RAY_SERVE_FORCE_LOCAL_TESTING_MODE,
|
|
reason="local_testing_mode has different failure handling.",
|
|
)
|
|
def test_chained_deployment_response_upstream_raises_exception(serve_instance):
|
|
"""
|
|
When upstream raises a normal exception (not ActorDiedError), the error
|
|
propagates correctly and the downstream replica remains in rotation.
|
|
"""
|
|
# No Windows skip: we use raise ValueError, not os._exit
|
|
|
|
@serve.deployment
|
|
class Adder:
|
|
def __init__(self, increment: int):
|
|
self._increment = increment
|
|
|
|
def __call__(self, val: int) -> int:
|
|
if val == 0:
|
|
raise ValueError("Adder rejects 0")
|
|
return val + self._increment
|
|
|
|
@serve.deployment
|
|
class Multiplier:
|
|
def __init__(self, multiple: int):
|
|
self._multiple = multiple
|
|
|
|
def __call__(self, val: int) -> int:
|
|
return val * self._multiple
|
|
|
|
@serve.deployment
|
|
class Ingress:
|
|
def __init__(self, adder: DeploymentHandle, multiplier: DeploymentHandle):
|
|
self._adder = adder
|
|
self._multiplier = multiplier
|
|
|
|
async def __call__(self, input: int) -> int:
|
|
adder_response: DeploymentResponse = self._adder.remote(input)
|
|
multiplier_response: DeploymentResponse = self._multiplier.remote(
|
|
adder_response
|
|
)
|
|
return await multiplier_response
|
|
|
|
app = Ingress.bind(
|
|
Adder.bind(increment=1),
|
|
Multiplier.bind(multiple=2),
|
|
)
|
|
handle: DeploymentHandle = serve.run(app)
|
|
|
|
# Warmup
|
|
assert handle.remote(5).result(timeout_s=10) == 12
|
|
|
|
# Upstream raises ValueError (may be wrapped in RayTaskError)
|
|
error_response = handle.remote(0)
|
|
with pytest.raises(Exception):
|
|
error_response.result(timeout_s=5)
|
|
|
|
# Multiplier should still be in rotation. Next request succeeds.
|
|
result = handle.remote(2).result(timeout_s=30)
|
|
assert result == 6, f"Expected 6, got {result}"
|
|
|
|
|
|
def test_nested_deployment_response_error(serve_instance):
|
|
"""Test that passing a deployment response in a nested object to a downstream
|
|
handle call errors, and with an informative error message."""
|
|
|
|
@serve.deployment
|
|
class Downstream:
|
|
def __call__(self, *args):
|
|
pass
|
|
|
|
@serve.deployment
|
|
class Upstream:
|
|
def __init__(self, h1: DeploymentHandle, h2: DeploymentHandle):
|
|
self._h1 = h1
|
|
self._h2 = h2
|
|
|
|
async def __call__(self):
|
|
with pytest.raises(
|
|
RayServeException, match="`DeploymentResponse` is not serializable"
|
|
):
|
|
await self._h2.remote([self._h2.remote()])
|
|
|
|
h = serve.run(
|
|
Upstream.bind(Downstream.bind(), Downstream.bind()),
|
|
)
|
|
h.remote().result()
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
RAY_SERVE_USE_GRPC_BY_DEFAULT,
|
|
reason="Cannot get object ref when using gRPC.",
|
|
)
|
|
@pytest.mark.skipif(
|
|
RAY_SERVE_FORCE_LOCAL_TESTING_MODE,
|
|
reason="local_testing_mode doesn't support _to_object_ref",
|
|
)
|
|
def test_convert_to_object_ref(serve_instance):
|
|
"""Test converting deployment handle refs to Ray object refs."""
|
|
|
|
@ray.remote
|
|
def identity_task(inp: Any):
|
|
return inp
|
|
|
|
@serve.deployment
|
|
def downstream():
|
|
return "hello"
|
|
|
|
@serve.deployment
|
|
class Deployment:
|
|
def __init__(self, handle: DeploymentHandle):
|
|
self._handle = handle
|
|
|
|
async def __call__(self):
|
|
ref = self._handle.remote()
|
|
return await identity_task.remote(await ref._to_object_ref())
|
|
|
|
handle = serve.run(Deployment.bind(downstream.bind()))
|
|
|
|
ref = handle.remote()
|
|
assert ray.get(identity_task.remote(ref._to_object_ref_sync())) == "hello"
|
|
|
|
|
|
def test_generators(serve_instance):
|
|
"""Test generators inside and outside a deployment."""
|
|
|
|
@serve.deployment
|
|
def downstream():
|
|
for i in range(10):
|
|
yield i
|
|
|
|
@serve.deployment
|
|
class Deployment:
|
|
def __init__(self, handle: DeploymentHandle):
|
|
self._handle = handle.options(stream=True)
|
|
|
|
async def __call__(self):
|
|
gen = self._handle.remote()
|
|
assert isinstance(gen, DeploymentResponseGenerator)
|
|
async for i in gen:
|
|
yield i
|
|
|
|
handle = serve.run(Deployment.bind(downstream.bind()))
|
|
|
|
gen = handle.options(stream=True).remote()
|
|
assert isinstance(gen, DeploymentResponseGenerator)
|
|
assert list(gen) == list(range(10))
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
RAY_SERVE_USE_GRPC_BY_DEFAULT,
|
|
reason="Cannot get object ref when using gRPC.",
|
|
)
|
|
@pytest.mark.skipif(
|
|
RAY_SERVE_FORCE_LOCAL_TESTING_MODE,
|
|
reason="local_testing_mode doesn't support _to_object_ref",
|
|
)
|
|
def test_convert_to_object_ref_gen(serve_instance):
|
|
"""Test converting generators to obj ref gens inside and outside a deployment."""
|
|
|
|
@serve.deployment
|
|
def downstream():
|
|
for i in range(10):
|
|
yield i
|
|
|
|
@serve.deployment
|
|
class Deployment:
|
|
def __init__(self, handle: DeploymentHandle):
|
|
self._handle = handle.options(stream=True)
|
|
|
|
async def __call__(self):
|
|
gen = self._handle.remote()
|
|
assert isinstance(gen, DeploymentResponseGenerator)
|
|
|
|
obj_ref_gen = await gen._to_object_ref_gen()
|
|
async for obj_ref in obj_ref_gen:
|
|
yield await obj_ref
|
|
|
|
handle = serve.run(Deployment.bind(downstream.bind()))
|
|
|
|
gen = handle.options(stream=True).remote()
|
|
assert isinstance(gen, DeploymentResponseGenerator)
|
|
obj_ref_gen = gen._to_object_ref_gen_sync()
|
|
assert ray.get(list(obj_ref_gen)) == list(range(10))
|
|
|
|
|
|
@pytest.mark.parametrize("stream", [False, True])
|
|
def test_sync_response_methods_fail_in_deployment(serve_instance, stream: bool):
|
|
"""Blocking `DeploymentResponse` (and generator) methods should fail in loop."""
|
|
|
|
if stream:
|
|
|
|
@serve.deployment
|
|
def downstream():
|
|
yield
|
|
|
|
else:
|
|
|
|
@serve.deployment
|
|
def downstream():
|
|
pass
|
|
|
|
@serve.deployment
|
|
class Deployment:
|
|
def __init__(self, handle: DeploymentHandle):
|
|
self._handle = handle.options(stream=stream)
|
|
|
|
async def __call__(self):
|
|
response = self._handle.remote()
|
|
with pytest.raises(
|
|
RuntimeError,
|
|
match="should not be called from within an `asyncio` event loop",
|
|
):
|
|
if stream:
|
|
for _ in response:
|
|
pass
|
|
else:
|
|
response.result()
|
|
|
|
return "OK"
|
|
|
|
handle = serve.run(Deployment.bind(downstream.bind()))
|
|
|
|
assert handle.remote().result() == "OK"
|
|
|
|
|
|
def test_handle_eager_execution(serve_instance):
|
|
"""Handle requests should be sent without fetching the result."""
|
|
|
|
upstream_signal_actor = SignalActor.remote()
|
|
downstream_signal_actor = SignalActor.remote()
|
|
|
|
@serve.deployment
|
|
async def downstream():
|
|
await downstream_signal_actor.send.remote()
|
|
|
|
@serve.deployment
|
|
class Deployment:
|
|
def __init__(self, handle: DeploymentHandle):
|
|
self._handle = handle
|
|
|
|
async def __call__(self):
|
|
# Send a request without awaiting the response. It should still
|
|
# executed (verified via signal actor).
|
|
r = self._handle.remote()
|
|
await upstream_signal_actor.send.remote()
|
|
|
|
await downstream_signal_actor.wait.remote()
|
|
|
|
return await r
|
|
|
|
handle = serve.run(Deployment.bind(downstream.bind()))
|
|
|
|
# Send a request without awaiting the response. It should still
|
|
# executed (verified via signal actor).
|
|
r = handle.remote()
|
|
ray.get(upstream_signal_actor.wait.remote(), timeout=5)
|
|
|
|
r.result() == "OK"
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
RAY_SERVE_FORCE_LOCAL_TESTING_MODE,
|
|
reason="local_testing_mode doesn't respect max_ongoing_requests",
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_max_ongoing_requests_enforced(serve_instance):
|
|
"""Handles should respect max_ongoing_requests enforcement."""
|
|
|
|
loop = get_or_create_event_loop()
|
|
|
|
@ray.remote
|
|
class Waiter:
|
|
def __init__(self):
|
|
self._waiters: List[asyncio.Event] = []
|
|
|
|
async def wait(self):
|
|
event = asyncio.Event()
|
|
self._waiters.append(event)
|
|
await event.wait()
|
|
|
|
def unblock_one(self):
|
|
self._waiters.pop().set()
|
|
|
|
def get_num_waiters(self) -> int:
|
|
return len(self._waiters)
|
|
|
|
waiter = Waiter.remote()
|
|
|
|
@serve.deployment(max_ongoing_requests=1)
|
|
class Deployment:
|
|
async def __call__(self):
|
|
await waiter.wait.remote()
|
|
|
|
handle = serve.run(Deployment.bind())
|
|
|
|
async def _do_request():
|
|
return await handle.remote()
|
|
|
|
async def _assert_one_waiter():
|
|
assert await waiter.get_num_waiters.remote() == 1
|
|
return True
|
|
|
|
# Send a batch of requests. Only one should be able to execute at a time
|
|
# due to `max_ongoing_requests=1`.
|
|
tasks = [loop.create_task(_do_request()) for _ in range(10)]
|
|
for i in range(len(tasks)):
|
|
# Check that only one starts executing.
|
|
await async_wait_for_condition(_assert_one_waiter)
|
|
_, pending = await asyncio.wait(tasks, timeout=0.1)
|
|
assert len(tasks) == len(tasks)
|
|
|
|
# Unblocking the one that is executing should cause it to finish.
|
|
# Another request will then get scheduled.
|
|
await waiter.unblock_one.remote()
|
|
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
assert len(done) == 1
|
|
assert len(pending) == len(tasks) - 1
|
|
tasks = pending
|
|
|
|
|
|
def test_shutdown(serve_instance):
|
|
@serve.deployment
|
|
class Hi:
|
|
def __call__(self):
|
|
return "hi"
|
|
|
|
h = serve.run(Hi.bind())
|
|
assert h.remote().result() == "hi"
|
|
|
|
h.shutdown()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shutdown_async(serve_instance):
|
|
@serve.deployment
|
|
class Hi:
|
|
def __call__(self):
|
|
return "hi"
|
|
|
|
h = serve.run(Hi.bind())
|
|
assert await h.remote() == "hi"
|
|
|
|
await h.shutdown_async()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-v", "-s", __file__]))
|