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

231 lines
6.9 KiB
Python

import logging
import re
import pytest
import ray
from ray import serve
from ray._common.test_utils import SignalActor, wait_for_condition
from ray.serve._private.common import (
OBJ_REF_NOT_SUPPORTED_ERROR,
DeploymentID,
RequestMetadata,
)
from ray.serve._private.replica_result import (
ActorReplicaResult,
ReplicaResult,
gRPCReplicaResult,
)
from ray.serve._private.request_router.replica_wrapper import ReplicaSelection
from ray.serve.handle import DeploymentHandle
from ray.serve.tests.conftest import * # noqa
from ray.serve.tests.conftest import _shared_serve_instance # noqa
@pytest.mark.parametrize(
"by_reference,expected_result",
[(True, ActorReplicaResult), (False, gRPCReplicaResult)],
)
def test_init_by_reference(
serve_instance, by_reference: bool, expected_result: ReplicaResult
):
@serve.deployment
def f():
return "hi"
h = serve.run(f.bind())
resp = h.options(_by_reference=by_reference).remote()
assert resp.result() == "hi"
assert isinstance(resp._replica_result, expected_result)
@pytest.mark.timeout(60)
async def test_by_reference_false_raises_error(serve_instance):
signal = SignalActor.remote()
@serve.deployment
async def f():
await signal.wait.remote()
return "hi"
h = serve.run(f.bind())
with pytest.raises(
RuntimeError, match=re.escape(OBJ_REF_NOT_SUPPORTED_ERROR.args[0])
):
response = h.options(_by_reference=False).remote()
await response._to_object_ref()
await signal.send.remote()
@pytest.mark.parametrize("inner_by_reference", [True, False])
@pytest.mark.parametrize("outer_by_reference", [True, False])
def test_compose_deployments_in_app(
serve_instance, inner_by_reference, outer_by_reference
):
"""Test composing deployment handle refs within a deployment."""
@serve.deployment
class Downstream:
def __init__(self, msg: str):
self._msg = msg
def __call__(self, inp1: str, inp2: str):
return f"{self._msg}|{inp1}|{inp2}"
@serve.deployment
class Deployment:
def __init__(self, handle1: DeploymentHandle, handle2: DeploymentHandle):
self._handle1 = handle1.options(_by_reference=outer_by_reference)
self._handle2 = handle2.options(_by_reference=inner_by_reference)
async def __call__(self):
result = await self._handle1.remote(
self._handle2.remote("hi1", inp2="hi2"),
inp2=self._handle2.remote("hi3", inp2="hi4"),
)
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|hi1|hi2|downstream2|hi3|hi4"
)
@pytest.mark.parametrize("inner_by_reference", [True, False])
@pytest.mark.parametrize("outer_by_reference", [True, False])
def test_compose_apps(serve_instance, inner_by_reference, outer_by_reference):
"""Test composing deployment handle refs outside of a deployment."""
@serve.deployment
class Deployment:
def __init__(self, msg: str):
self._msg = msg
def __call__(self, inp1: str, inp2: str):
return f"{self._msg}|{inp1}|{inp2}"
handle1 = serve.run(
Deployment.bind("app1"), name="app1", route_prefix="/app1"
).options(_by_reference=outer_by_reference)
handle2 = serve.run(
Deployment.bind("app2"), name="app2", route_prefix="/app2"
).options(_by_reference=inner_by_reference)
assert (
handle1.remote(
handle2.remote("hi1", inp2="hi2"),
inp2=handle2.remote("hi3", inp2="hi4"),
).result()
== "app1|app2|hi1|hi2|app2|hi3|hi4"
)
def test_dispatch_rejects_selection_from_different_deployment():
handle = DeploymentHandle("deployment-a", "app")
selection = ReplicaSelection(
replica_id="replica-1",
node_ip="127.0.0.1",
port=None,
node_id="node-1",
availability_zone=None,
replica_metadata={},
_replica=object(),
_deployment_id=DeploymentID(name="deployment-b", app_name="app"),
_request_metadata=RequestMetadata(
request_id="request-id",
internal_request_id="internal-request-id",
call_method="__call__",
),
_method_name="__call__",
_slot_token="slot-1",
)
with pytest.raises(ValueError, match="different deployment"):
handle.dispatch(selection)
@pytest.mark.asyncio
@pytest.mark.timeout(30)
async def test_non_grpc_exception_no_self_cause(serve_instance):
"""Regression test for https://github.com/ray-project/ray/issues/62358."""
@ray.remote
class _ExceptionChainDetector:
def __init__(self):
self._processed = False
self._cycle_detected = False
def record_result(self, cycle_detected: bool):
self._processed = True
self._cycle_detected = cycle_detected
def processed(self):
return self._processed
def cycle_detected(self):
return self._cycle_detected
detector = _ExceptionChainDetector.remote()
class CauseWalker(logging.Handler):
"""Walk the exception chain to catch self-referential causes."""
def __init__(self, detector_handle):
super().__init__()
self._detector = detector_handle
def emit(self, record):
if not record.exc_info or record.exc_info[1] is None:
return
exc = record.exc_info[1]
if not isinstance(exc, ValueError) or str(exc) != "user code exception":
return
cycle_detected = False
try:
seen = set()
while exc.__cause__ is not None:
if id(exc) in seen:
cycle_detected = True
return
seen.add(id(exc))
exc = exc.__cause__
finally:
self._detector.record_result.remote(cycle_detected)
@serve.deployment
class App:
def __init__(self, detector_handle):
logging.getLogger("ray.serve").addHandler(CauseWalker(detector_handle))
async def fail(self):
raise ValueError("user code exception")
handle = serve.run(App.bind(detector))
with pytest.raises(ValueError, match="user code exception"):
await handle.fail.remote()
wait_for_condition(lambda: ray.get(detector.processed.remote()), timeout=10)
assert not ray.get(detector.cycle_detected.remote()), (
"Self-referential __cause__ chain detected: "
"raise e from e was used on a non-gRPC request"
)
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))