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

596 lines
19 KiB
Python

import contextlib
import os
import random
import sys
import threading
import time
from concurrent import futures
from typing import Any, Callable, Optional
from unittest.mock import Mock, patch
import grpc
import numpy as np
import pytest
import ray
import ray.core.generated.ray_client_pb2 as ray_client_pb2
import ray.core.generated.ray_client_pb2_grpc as ray_client_pb2_grpc
from ray._common.utils import get_or_create_event_loop
from ray.tests.conftest import call_ray_start_context
from ray.util.client.common import CLIENT_SERVER_MAX_THREADS, GRPC_OPTIONS
@pytest.fixture(scope="module")
def call_ray_start_shared(request):
# Starts Ray with a ray client server listening on port 50051
request = Mock()
request.param = (
"ray start --head --min-worker-port=0 --max-worker-port=0 --port 0 "
"--ray-client-server-port=50051"
)
with call_ray_start_context(request) as address:
yield address
# At a high level, these tests rely on an extra RPC server sitting
# between the client and the real Ray server to inject errors, drop responses
# and drop requests, i.e. at a high level:
# Ray Client <-> Middleman Server <-> Proxy Server
# Type for middleman hooks used to inject errors
Hook = Callable[[Any], None]
class MiddlemanDataServicer(ray_client_pb2_grpc.RayletDataStreamerServicer):
"""
Forwards all requests to the real data servicer. Useful for injecting
errors between a client and server pair.
"""
def __init__(
self, on_response: Optional[Hook] = None, on_request: Optional[Hook] = None
):
"""Initialize the middleman data servicer.
Args:
on_response: Optional hook to inject errors before sending back a
response.
on_request: Optional hook to inject errors before forwarding a
request.
"""
self.stub = None
self.on_response = on_response
self.on_request = on_request
def set_channel(self, channel: grpc.Channel) -> None:
self.stub = ray_client_pb2_grpc.RayletDataStreamerStub(channel)
def _requests(self, request_iterator):
for req in request_iterator:
if self.on_request:
self.on_request(req)
yield req
def Datapath(self, request_iterator, context):
try:
for response in self.stub.Datapath(
self._requests(request_iterator), metadata=context.invocation_metadata()
):
if self.on_response:
self.on_response(response)
yield response
except grpc.RpcError as e:
context.set_code(e.code())
context.set_details(e.details())
class MiddlemanLogServicer(ray_client_pb2_grpc.RayletLogStreamerServicer):
"""
Forwards all requests to the real log servicer. Useful for injecting
errors between a client and server pair.
"""
def __init__(self, on_response: Optional[Hook] = None):
"""Initialize the middleman log servicer.
Args:
on_response: Optional hook to inject errors before sending back a
response.
"""
self.stub = None
self.on_response = on_response
def set_channel(self, channel: grpc.Channel) -> None:
self.stub = ray_client_pb2_grpc.RayletLogStreamerStub(channel)
def Logstream(self, request_iterator, context):
try:
for response in self.stub.Logstream(
request_iterator, metadata=context.invocation_metadata()
):
if self.on_response:
self.on_response(response)
yield response
except grpc.RpcError as e:
context.set_code(e.code())
context.set_details(e.details())
class MiddlemanRayletServicer(ray_client_pb2_grpc.RayletDriverServicer):
"""
Forwards all requests to the raylet driver servicer. Useful for injecting
errors between a client and server pair.
"""
def __init__(
self, on_request: Optional[Hook] = None, on_response: Optional[Hook] = None
):
"""Initialize the middleman raylet servicer.
Args:
on_request: Optional hook to inject errors before forwarding a
request.
on_response: Optional hook to inject errors before sending back a
response.
"""
self.stub = None
self.on_request = on_request
self.on_response = on_response
def set_channel(self, channel: grpc.Channel) -> None:
self.stub = ray_client_pb2_grpc.RayletDriverStub(channel)
def _call_inner_function(
self, request: Any, context, method: str
) -> Optional[ray_client_pb2_grpc.RayletDriverStub]:
if self.on_request:
self.on_request(request)
try:
response = getattr(self.stub, method)(
request, metadata=context.invocation_metadata()
)
except grpc.RpcError as e:
context.set_code(e.code())
context.set_details(e.details())
raise
if self.on_response and method != "GetObject":
# GetObject streams response, handle on_response separately
self.on_response(response)
return response
def Init(self, request, context=None) -> ray_client_pb2.InitResponse:
return self._call_inner_function(request, context, "Init")
def KVPut(self, request, context=None) -> ray_client_pb2.KVPutResponse:
return self._call_inner_function(request, context, "KVPut")
def KVGet(self, request, context=None) -> ray_client_pb2.KVGetResponse:
return self._call_inner_function(request, context, "KVGet")
def KVDel(self, request, context=None) -> ray_client_pb2.KVDelResponse:
return self._call_inner_function(request, context, "KVDel")
def KVList(self, request, context=None) -> ray_client_pb2.KVListResponse:
return self._call_inner_function(request, context, "KVList")
def KVExists(self, request, context=None) -> ray_client_pb2.KVExistsResponse:
return self._call_inner_function(request, context, "KVExists")
def ListNamedActors(
self, request, context=None
) -> ray_client_pb2.ClientListNamedActorsResponse:
return self._call_inner_function(request, context, "ListNamedActors")
def ClusterInfo(self, request, context=None) -> ray_client_pb2.ClusterInfoResponse:
# Cluster info is currently used for health checks and isn't retried, so
# don't inject errors.
# TODO(ckw): update ClusterInfo so that retries are only skipped for PING
try:
return self.stub.ClusterInfo(
request, metadata=context.invocation_metadata()
)
except grpc.RpcError as e:
context.set_code(e.code())
context.set_details(e.details())
raise
def Terminate(self, req, context=None):
return self._call_inner_function(req, context, "Terminate")
def GetObject(self, request, context=None):
for response in self._call_inner_function(request, context, "GetObject"):
if self.on_response:
self.on_response(response)
yield response
def PutObject(
self, request: ray_client_pb2.PutRequest, context=None
) -> ray_client_pb2.PutResponse:
return self._call_inner_function(request, context, "PutObject")
def WaitObject(
self, request: ray_client_pb2.WaitRequest, context=None
) -> ray_client_pb2.WaitResponse:
return self._call_inner_function(request, context, "WaitObject")
def Schedule(
self, task: ray_client_pb2.ClientTask, context=None
) -> ray_client_pb2.ClientTaskTicket:
return self._call_inner_function(task, context, "Schedule")
class MiddlemanServer:
"""
Helper class that wraps the RPC server that middlemans the connection
between the client and the real ray server. Useful for injecting
errors between a client and server pair.
"""
def __init__(
self,
listen_addr: str,
real_addr: str,
on_log_response: Optional[Hook] = None,
on_data_request: Optional[Hook] = None,
on_data_response: Optional[Hook] = None,
on_task_request: Optional[Hook] = None,
on_task_response: Optional[Hook] = None,
):
"""Initialize the middleman server.
Args:
listen_addr: The address the middleman server will listen on.
real_addr: The address of the real ray server.
on_log_response: Optional hook to inject errors before sending back
a log response.
on_data_request: Optional hook to inject errors before forwarding
a data request.
on_data_response: Optional hook to inject errors before sending
back a data response.
on_task_request: Optional hook to inject errors before forwarding
a raylet driver request.
on_task_response: Optional hook to inject errors before sending
back a raylet driver response.
"""
self.listen_addr = listen_addr
self.real_addr = real_addr
self.server = grpc.server(
futures.ThreadPoolExecutor(max_workers=CLIENT_SERVER_MAX_THREADS),
options=GRPC_OPTIONS,
)
self.task_servicer = MiddlemanRayletServicer(
on_response=on_task_response, on_request=on_task_request
)
self.data_servicer = MiddlemanDataServicer(
on_response=on_data_response, on_request=on_data_request
)
self.logs_servicer = MiddlemanLogServicer(on_response=on_log_response)
ray_client_pb2_grpc.add_RayletDriverServicer_to_server(
self.task_servicer, self.server
)
ray_client_pb2_grpc.add_RayletDataStreamerServicer_to_server(
self.data_servicer, self.server
)
ray_client_pb2_grpc.add_RayletLogStreamerServicer_to_server(
self.logs_servicer, self.server
)
self.server.add_insecure_port(self.listen_addr)
self.channel = None
self.reset_channel()
def reset_channel(self) -> None:
"""
Manually close and reopen the channel to the real ray server. This
simulates a disconnection between the client and the server.
"""
if self.channel:
self.channel.close()
self.channel = grpc.insecure_channel(self.real_addr, options=GRPC_OPTIONS)
grpc.channel_ready_future(self.channel)
self.task_servicer.set_channel(self.channel)
self.data_servicer.set_channel(self.channel)
self.logs_servicer.set_channel(self.channel)
def start(self) -> None:
self.server.start()
def stop(self, grace: int) -> None:
self.server.stop(grace)
@contextlib.contextmanager
def start_middleman_server(
on_log_response=None,
on_data_request=None,
on_data_response=None,
on_task_request=None,
on_task_response=None,
):
"""
Helper context that starts a middleman server listening on port 10011,
and a ray client server on port 50051.
"""
ray._inside_client_test = True
middleman = None
try:
middleman = MiddlemanServer(
listen_addr="localhost:10011",
real_addr="localhost:50051",
on_log_response=on_log_response,
on_data_request=on_data_request,
on_data_response=on_data_response,
on_task_request=on_task_request,
on_task_response=on_task_response,
)
middleman.start()
ray.init("ray://localhost:10011")
yield middleman
finally:
ray._inside_client_test = False
ray.util.disconnect()
if middleman:
middleman.stop(0)
def test_disconnect_during_get(call_ray_start_shared):
"""
Disconnect the proxy and the client in the middle of a long running get
"""
@ray.remote
def slow_result():
time.sleep(20)
return 12345
def disconnect(middleman):
time.sleep(3)
middleman.reset_channel()
with start_middleman_server() as middleman:
disconnect_thread = threading.Thread(target=disconnect, args=(middleman,))
disconnect_thread.start()
result = ray.get(slow_result.remote())
assert result == 12345
disconnect_thread.join()
def test_disconnects_during_large_get(call_ray_start_shared):
"""
Disconnect repeatedly during a large (multi-chunk) get.
"""
i = 0
started = False
def fail_every_three(_):
# Inject an error every third time this method is called
nonlocal i, started
if not started:
return
i += 1
if i % 3 == 0:
raise RuntimeError
@ray.remote
def large_result():
# 1024x1024x6 float64 matrix (96 MiB). With 5MiB chunk size,
# it will take at least 16 chunks to transfer this object. Since
# the failure is injected every 3 chunks, this transfer can only
# work if the chunked get request retries at the last received chunk
# (instead of starting from the beginning each retry)
return np.random.random((1024, 1024, 6))
with start_middleman_server(on_task_response=fail_every_three):
started = True
result = ray.get(large_result.remote())
assert result.shape == (1024, 1024, 6)
def test_disconnects_during_large_async_get(call_ray_start_shared):
"""
Disconnect repeatedly during a large (multi-chunk) async get.
"""
i = 0
started = False
def fail_every_three(_):
# Inject an error every third time this method is called
nonlocal i, started
if not started:
return
i += 1
if i % 3 == 0:
raise RuntimeError
@ray.remote
def large_result():
# 1024x1024x6 float64 matrix (96 MiB). With 5MiB chunk size,
# it will take at least 16 chunks to transfer this object. Since
# the failure is injected every 3 chunks, this transfer can only
# work if the chunked get request retries at the last received chunk
# (instead of starting from the beginning each retry)
return np.random.random((1024, 1024, 6))
with start_middleman_server(on_data_response=fail_every_three):
started = True
async def get_large_result():
return await large_result.remote()
result = get_or_create_event_loop().run_until_complete(get_large_result())
assert result.shape == (1024, 1024, 6)
def test_disconnect_during_large_put(call_ray_start_shared):
"""
Disconnect during a large (multi-chunk) put.
"""
i = 0
started = False
def fail_halfway(_):
# Inject an error halfway through the object transfer
nonlocal i, started
if not started:
return
i += 1
if i == 8:
raise RuntimeError
with start_middleman_server(on_data_request=fail_halfway):
started = True
objref = ray.put(np.random.random((1024, 1024, 6)))
assert i > 8 # Check that the failure was injected
result = ray.get(objref)
assert result.shape == (1024, 1024, 6)
def test_disconnect_during_large_schedule(call_ray_start_shared):
"""
Disconnect during a remote call with a large (multi-chunk) argument.
"""
i = 0
started = False
def fail_halfway(_):
# Inject an error halfway through the object transfer
nonlocal i, started
if not started:
return
i += 1
if i == 8:
raise RuntimeError
@ray.remote
def f(a):
return a.shape
with start_middleman_server(on_data_request=fail_halfway):
started = True
a = np.random.random((1024, 1024, 6))
result = ray.get(f.remote(a))
assert i > 8 # Check that the failure was injected
assert result == (1024, 1024, 6)
def test_valid_actor_state(call_ray_start_shared):
"""
Repeatedly inject errors in the middle of mutating actor calls. Check
at the end that the final state of the actor is consistent with what
we would expect had the disconnects not occurred.
"""
@ray.remote
class IncrActor:
def __init__(self):
self.val = 0
def incr(self):
self.val += 1
return self.val
i = 0
# This is to prevent erroring in the initial connection logic.
started = False
def fail_every_seven(_):
# Inject an error every seventh time this method is called
nonlocal i, started
i += 1
if i % 7 == 0 and started:
raise RuntimeError
with start_middleman_server(
on_data_response=fail_every_seven,
on_task_request=fail_every_seven,
on_task_response=fail_every_seven,
):
started = True
actor = IncrActor.remote()
for _ in range(100):
ref = actor.incr.remote()
assert ray.get(ref) == 100
def test_valid_actor_state_2(call_ray_start_shared):
"""
Do a full disconnect (cancel channel) every 11 requests. Failure
happens:
- before request sent: request never reaches server
- before response received: response never reaches server
- while get's are being processed
"""
@ray.remote
class IncrActor:
def __init__(self):
self.val = 0
def incr(self):
self.val += 1
return self.val
i = 0
with start_middleman_server() as middleman:
def fail_every_eleven(_):
nonlocal i
i += 1
if i % 11 == 0:
middleman.reset_channel()
middleman.data_servicer.on_response = fail_every_eleven
middleman.task_servicer.on_request = fail_every_eleven
middleman.task_servicer.on_response = fail_every_eleven
actor = IncrActor.remote()
for _ in range(100):
ref = actor.incr.remote()
assert ray.get(ref) == 100
def test_noisy_puts(call_ray_start_shared):
"""
Randomly kills the data channel with 10% chance when receiving response
(requests made it to server, responses dropped) and checks that final
result is still consistent
"""
random.seed(12345)
with start_middleman_server() as middleman:
def fail_randomly(response: ray_client_pb2.DataResponse):
if random.random() < 0.1:
raise RuntimeError
middleman.data_servicer.on_response = fail_randomly
refs = [ray.put(i * 123) for i in range(500)]
results = ray.get(refs)
for i, result in enumerate(results):
assert result == i * 123
def test_client_reconnect_grace_period(call_ray_start_shared):
"""
Tests that the client gives up attempting to reconnect the channel
after the grace period expires.
"""
# Lower grace period to 5 seconds to save time
with patch.dict(
os.environ, {"RAY_CLIENT_RECONNECT_GRACE_PERIOD": "5"}
), start_middleman_server() as middleman:
assert ray.get(ray.put(42)) == 42
# Close channel
middleman.channel.close()
start_time = time.time()
with pytest.raises(ConnectionError):
ray.get(ray.put(42))
# Connection error should have been raised within a reasonable
# amount of time. Set to significantly higher than 5 seconds
# to account for reconnect backoff timing
assert time.time() - start_time < 20
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))