Files
2026-07-13 13:17:40 +08:00

205 lines
6.0 KiB
Python

import asyncio
import sys
import grpc
import httpx
import pytest
import ray
from ray import serve
from ray._common.test_utils import wait_for_condition
from ray.serve._private.constants import (
RAY_SERVE_ENABLE_DIRECT_INGRESS,
)
from ray.serve._private.test_utils import check_running, get_application_url
from ray.serve.config import RequestRouterConfig
from ray.serve.context import _get_internal_replica_context
from ray.serve.generated import serve_pb2, serve_pb2_grpc
ROUTER_CLASS = "ray.serve.experimental.round_robin_router:RoundRobinRouter"
def _round_robin_config() -> RequestRouterConfig:
return RequestRouterConfig(
request_router_class=ROUTER_CLASS,
initial_backoff_s=0.01,
backoff_multiplier=1.0,
max_backoff_s=0.01,
)
def _get_request_router(handle):
if not handle.is_initialized:
handle._init()
return handle._router._asyncio_router._request_router
@ray.remote(num_cpus=0)
class ReplicaBlocker:
def __init__(self):
self.replica_id = None
self.released = False
def record_replica(self, replica_id: str):
self.replica_id = replica_id
def get_replica(self):
return self.replica_id
def release(self):
self.released = True
def is_released(self):
return self.released
def _assert_round_robin_wraps_and_cycles(call, num_replicas: int) -> None:
wait_for_condition(
lambda: len({call() for _ in range(num_replicas * 2)}) == num_replicas,
timeout=30,
)
replicas = [call() for _ in range(num_replicas * 2)]
expected_cycle = replicas[:num_replicas]
assert len(set(expected_cycle)) == num_replicas
assert replicas[num_replicas] == replicas[0]
assert replicas[num_replicas:] == expected_cycle
def test_handle_options_and_http_round_robin(serve_instance):
num_replicas = 3
@serve.deployment(
request_router_config=_round_robin_config(),
num_replicas=num_replicas,
max_ongoing_requests=100,
ray_actor_options={"num_cpus": 0},
)
class App:
def __init__(self):
self.unique_id = _get_internal_replica_context().replica_id.unique_id
def __call__(self):
return self.unique_id
def via_options(self):
return self.unique_id
handle = serve.run(App.bind())
wait_for_condition(check_running, timeout=30)
def handle_call():
response = (
handle.options(method_name="via_options").remote().result(timeout_s=10)
)
return response
_assert_round_robin_wraps_and_cycles(handle_call, num_replicas)
url = get_application_url()
def http_call():
response = httpx.get(url, timeout=10)
assert response.status_code == 200
return response.text
_assert_round_robin_wraps_and_cycles(http_call, num_replicas)
def test_handle_round_robin_backs_off_from_full_replica(serve_instance):
@serve.deployment(
request_router_config=_round_robin_config(),
num_replicas=2,
max_ongoing_requests=1,
ray_actor_options={"num_cpus": 0},
)
class BlockingApp:
def __init__(self):
self.unique_id = _get_internal_replica_context().replica_id.unique_id
async def __call__(self):
return self.unique_id
async def block(self, blocker):
await blocker.record_replica.remote(self.unique_id)
while not await blocker.is_released.remote():
await asyncio.sleep(0.05)
return self.unique_id
handle = serve.run(BlockingApp.bind())
wait_for_condition(check_running, timeout=30)
blocker = ReplicaBlocker.remote()
blocked_ref = handle.options(method_name="block").remote(blocker)
wait_for_condition(
lambda: ray.get(blocker.get_replica.remote()) is not None,
timeout=30,
)
blocked_replica = ray.get(blocker.get_replica.remote())
request_router = _get_request_router(handle)
def _set_counter_to_blocked_replica():
for index, replica in enumerate(request_router._replicas_list):
if replica.replica_id.unique_id == blocked_replica:
request_router._round_robin_counter = index
return True
return False
wait_for_condition(_set_counter_to_blocked_replica, timeout=30)
try:
fallback_replica = handle.remote().result(timeout_s=10)
assert fallback_replica != blocked_replica
finally:
ray.get(blocker.release.remote())
assert blocked_ref.result(timeout_s=10) == blocked_replica
def test_grpc_round_robin(serve_instance):
if RAY_SERVE_ENABLE_DIRECT_INGRESS:
pytest.skip("Routing doesn't happen in direct ingress mode.")
num_replicas = 3
app_name = "grpc-round-robin"
@serve.deployment(
request_router_config=_round_robin_config(),
num_replicas=num_replicas,
max_ongoing_requests=100,
ray_actor_options={"num_cpus": 0},
)
class GrpcApp:
def __init__(self):
self.unique_id = _get_internal_replica_context().replica_id.unique_id
def __call__(self, user_message):
return serve_pb2.UserDefinedResponse(
greeting=self.unique_id,
num_x2=user_message.num * 2,
)
serve.run(GrpcApp.bind(), name=app_name, route_prefix=f"/{app_name}")
wait_for_condition(lambda: check_running(app_name), timeout=30)
channel = grpc.insecure_channel(
get_application_url("gRPC", app_name=app_name, use_localhost=True)
)
stub = serve_pb2_grpc.UserDefinedServiceStub(channel)
request = serve_pb2.UserDefinedMessage(name="x", num=1, foo="y")
metadata = (("application", app_name),)
def grpc_call():
response, call = stub.__call__.with_call(
request=request,
metadata=metadata,
)
assert call.code() == grpc.StatusCode.OK
return response.greeting
_assert_round_robin_wraps_and_cycles(grpc_call, num_replicas)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))