390 lines
13 KiB
Python
390 lines
13 KiB
Python
import importlib
|
|
import os
|
|
import sys
|
|
from typing import Callable, Optional
|
|
|
|
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 SERVE_DEFAULT_APP_NAME
|
|
from ray.serve._private.storage.kv_store import KVStoreError, RayInternalKVStore
|
|
from ray.serve._private.test_utils import check_apps_running
|
|
from ray.serve.config import DeploymentActorConfig
|
|
from ray.serve.context import _get_global_client
|
|
from ray.serve.handle import DeploymentHandle
|
|
from ray.serve.schema import ServeDeploySchema
|
|
from ray.tests.conftest import external_redis # noqa: F401
|
|
|
|
|
|
@ray.remote
|
|
class _GcsFailureDeploymentActor:
|
|
"""Minimal deployment-scoped actor for ``test_deployment_actor_survives_gcs_failure``."""
|
|
|
|
def __init__(self, start: int = 0):
|
|
self._value = start
|
|
|
|
def get(self) -> int:
|
|
return self._value
|
|
|
|
def ray_actor_id(self) -> str:
|
|
return ray.get_runtime_context().get_actor_id()
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def serve_ha(external_redis, monkeypatch): # noqa: F811
|
|
monkeypatch.setenv("RAY_SERVE_KV_TIMEOUT_S", "1")
|
|
importlib.reload(ray.serve._private.constants) # to reload the constants set above
|
|
address_info = ray.init(
|
|
num_cpus=36,
|
|
namespace="default_test_namespace",
|
|
_metrics_export_port=9999,
|
|
_system_config={"metrics_report_interval_ms": 1000, "task_retry_delay_ms": 50},
|
|
)
|
|
serve.start()
|
|
yield (address_info, _get_global_client())
|
|
|
|
# When GCS is down, right now some core worker members are not cleared
|
|
# properly in ray.shutdown.
|
|
ray.worker._global_node.start_gcs_server()
|
|
|
|
# Clear cache and global serve client
|
|
serve.shutdown()
|
|
ray.shutdown()
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform == "win32",
|
|
reason="Failing on Windows, 'ForkedFunc' object has no attribute 'pid'",
|
|
)
|
|
def test_ray_internal_kv_timeout(serve_ha): # noqa: F811
|
|
# Firstly make sure it's working
|
|
kv1 = RayInternalKVStore()
|
|
kv1.put("1", b"1")
|
|
assert kv1.get("1") == b"1"
|
|
|
|
# Kill the GCS
|
|
ray.worker._global_node.kill_gcs_server()
|
|
|
|
with pytest.raises(KVStoreError) as e:
|
|
kv1.put("2", b"2")
|
|
assert e.value.rpc_code in (
|
|
ray._raylet.GRPC_STATUS_CODE_UNAVAILABLE,
|
|
ray._raylet.GRPC_STATUS_CODE_DEADLINE_EXCEEDED,
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform == "win32",
|
|
reason="Failing on Windows, 'ForkedFunc' object has no attribute 'pid'",
|
|
)
|
|
@pytest.mark.parametrize("use_handle", [False, True])
|
|
def test_controller_gcs_failure(serve_ha, use_handle): # noqa: F811
|
|
@serve.deployment
|
|
def d(*args):
|
|
return f"{os.getpid()}"
|
|
|
|
def call():
|
|
if use_handle:
|
|
handle = serve.get_app_handle(SERVE_DEFAULT_APP_NAME)
|
|
ret = handle.remote().result()
|
|
else:
|
|
ret = httpx.get("http://localhost:8000/d").text
|
|
return ret
|
|
|
|
serve.run(d.bind())
|
|
pid = call()
|
|
|
|
# Kill the GCS.
|
|
print("Kill GCS")
|
|
ray.worker._global_node.kill_gcs_server()
|
|
|
|
# Make sure pid doesn't change within 5s.
|
|
with pytest.raises(Exception):
|
|
wait_for_condition(lambda: pid != call(), timeout=5, retry_interval_ms=1)
|
|
|
|
print("Start GCS")
|
|
ray.worker._global_node.start_gcs_server()
|
|
|
|
# Make sure nothing changed even when GCS is back.
|
|
with pytest.raises(Exception):
|
|
wait_for_condition(lambda: call() != pid, timeout=4)
|
|
|
|
serve.run(d.bind())
|
|
|
|
# Make sure redeploy happens.
|
|
for _ in range(10):
|
|
assert pid != call()
|
|
|
|
pid = call()
|
|
|
|
print("Kill GCS")
|
|
ray.worker._global_node.kill_gcs_server()
|
|
|
|
# TODO(abrar): The following block of code causes the pytest process to crash
|
|
# abruptly. It's unclear why this is happening. Check with ray core team.
|
|
# Skipping this for now to unblock CI.
|
|
|
|
# # Redeploy should fail without a change going through.
|
|
# with pytest.raises(KVStoreError):
|
|
# serve.run(d.options().bind())
|
|
|
|
for _ in range(10):
|
|
assert pid == call()
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform == "win32",
|
|
reason="Failing on Windows, 'ForkedFunc' object has no attribute 'pid'",
|
|
)
|
|
def test_deployment_actor_survives_gcs_failure(serve_ha): # noqa: F811
|
|
"""Deployment-scoped actors stay callable from replicas while GCS is down.
|
|
|
|
Replicas cache the :func:`serve.get_deployment_actor` handle in ``__init__`` so
|
|
traffic does not depend on fresh ``get_actor`` lookups during the outage (same idea
|
|
as keeping replica PIDs stable in ``test_controller_gcs_failure``). Responses
|
|
include the deployment actor's Ray id so we assert the same process survives (no
|
|
Serve-driven recreation during the outage).
|
|
"""
|
|
|
|
@serve.deployment(
|
|
deployment_actors=[
|
|
DeploymentActorConfig(
|
|
name="shared",
|
|
actor_class=_GcsFailureDeploymentActor,
|
|
init_kwargs={"start": 12345},
|
|
),
|
|
],
|
|
)
|
|
class WithDeploymentActor:
|
|
def __init__(self):
|
|
self._actor = serve.get_deployment_actor("shared")
|
|
|
|
def __call__(self):
|
|
val = ray.get(self._actor.get.remote())
|
|
aid = ray.get(self._actor.ray_actor_id.remote())
|
|
return f"{val},{aid}"
|
|
|
|
serve.run(WithDeploymentActor.bind(), route_prefix="/da_gcs_survives")
|
|
url = "http://localhost:8000/da_gcs_survives"
|
|
|
|
def parse_val_actor_id(text: str) -> tuple[str, str]:
|
|
val, aid = text.split(",", 1)
|
|
return val, aid
|
|
|
|
wait_for_condition(
|
|
lambda: parse_val_actor_id(httpx.get(url, timeout=5.0).text)[0] == "12345"
|
|
)
|
|
_, actor_id_before = parse_val_actor_id(httpx.get(url, timeout=5.0).text)
|
|
|
|
ray.worker._global_node.kill_gcs_server()
|
|
|
|
for _ in range(15):
|
|
val, aid = parse_val_actor_id(httpx.get(url, timeout=3.0).text)
|
|
assert val == "12345"
|
|
assert aid == actor_id_before
|
|
|
|
|
|
def router_populated_with_replicas(
|
|
threshold: int,
|
|
handle: Optional[DeploymentHandle] = None,
|
|
get_replicas_func: Optional[Callable] = None,
|
|
check_cache_populated: bool = False,
|
|
get_cache_func: Optional[Callable] = None,
|
|
):
|
|
"""Either get router's replica set from `handle` directly, or use
|
|
`get_replicas_func` to get replica set. Then check that the number
|
|
of replicas in set is at least `threshold`.
|
|
"""
|
|
if handle:
|
|
router = handle._router._asyncio_router
|
|
replicas = router._request_router._replica_id_set
|
|
else:
|
|
replicas = get_replicas_func()
|
|
|
|
print(f"Replica set in router: {replicas}")
|
|
assert len(replicas) >= threshold
|
|
|
|
# Return early if we don't need to check cache
|
|
if not check_cache_populated:
|
|
return True
|
|
|
|
if handle:
|
|
router = handle._router._asyncio_router
|
|
cache = router._request_router.replica_queue_len_cache
|
|
for replica_id in replicas:
|
|
assert (
|
|
cache.get(replica_id) is not None
|
|
), f"{replica_id} missing from cache {cache._cache}"
|
|
elif get_cache_func:
|
|
cached_replicas = get_cache_func()
|
|
assert len(cached_replicas) >= threshold, (
|
|
f"Expected at least {threshold} replicas in cache, "
|
|
f"got {len(cached_replicas)}: {cached_replicas}"
|
|
)
|
|
|
|
return True
|
|
|
|
|
|
@pytest.mark.parametrize("use_proxy", [True, False])
|
|
def test_new_router_on_gcs_failure(serve_ha, use_proxy: bool):
|
|
"""Test that a new router can send requests to replicas when GCS is down.
|
|
|
|
Specifically, if a proxy was just brought up or a deployment handle
|
|
was just created, and the GCS goes down BEFORE the router is able to
|
|
send its first request, new incoming requests should successfully get
|
|
sent to replicas during GCS downtime.
|
|
"""
|
|
|
|
_, client = serve_ha
|
|
|
|
@serve.deployment
|
|
class Dummy:
|
|
def __call__(self):
|
|
return os.getpid()
|
|
|
|
h = serve.run(Dummy.options(num_replicas=2).bind())
|
|
# TODO(zcin): We want to test the behavior for when the router
|
|
# didn't get a chance to send even a single request yet. However on
|
|
# the very first request we record telemetry for whether the
|
|
# deployment handle API was used, which will hang when the GCS is
|
|
# down. As a workaround for now, avoid recording telemetry so we
|
|
# can properly test router behavior when GCS is down. We should look
|
|
# into adding a timeout on the kv cache operation. For now, the proxy
|
|
# doesn't run into this because we don't record telemetry on proxy
|
|
h._recorded_telemetry = True
|
|
# Eagerly create router so it receives the replica set instead of
|
|
# waiting for the first request
|
|
h._init()
|
|
|
|
if use_proxy:
|
|
proxy_handles = ray.get(client._controller.get_proxies.remote())
|
|
proxy_handle = list(proxy_handles.values())[0]
|
|
wait_for_condition(
|
|
router_populated_with_replicas,
|
|
threshold=2,
|
|
get_replicas_func=lambda: ray.get(
|
|
proxy_handle._dump_ingress_replicas_for_testing.remote("/")
|
|
),
|
|
)
|
|
else:
|
|
wait_for_condition(router_populated_with_replicas, threshold=2, handle=h)
|
|
|
|
# Kill GCS server before a single request is sent.
|
|
ray.worker._global_node.kill_gcs_server()
|
|
|
|
returned_pids = set()
|
|
if use_proxy:
|
|
for _ in range(10):
|
|
returned_pids.add(int(httpx.get("http://localhost:8000", timeout=3.0).text))
|
|
else:
|
|
for _ in range(10):
|
|
returned_pids.add(int(h.remote().result(timeout_s=3.0)))
|
|
|
|
print("Returned pids:", returned_pids)
|
|
assert len(returned_pids) == 2
|
|
|
|
|
|
def test_handle_router_updated_replicas_then_gcs_failure(serve_ha):
|
|
"""Test the router's replica set is updated from 1 to 2 replicas, with the first
|
|
replica staying the same. Verify that if the GCS goes down before the router
|
|
gets a chance to send a request to the second replica, requests can be handled
|
|
during GCS failure.
|
|
|
|
This test uses a plain handle to send requests.
|
|
"""
|
|
|
|
_, client = serve_ha
|
|
|
|
config = {
|
|
"name": "default",
|
|
"import_path": "ray.serve._private.test_utils:get_pid_entrypoint",
|
|
"route_prefix": "/",
|
|
"deployments": [{"name": "GetPID", "num_replicas": 1}],
|
|
}
|
|
client.deploy_apps(ServeDeploySchema(**{"applications": [config]}))
|
|
wait_for_condition(check_apps_running, apps=["default"])
|
|
|
|
h = serve.get_app_handle("default")
|
|
print(h.remote().result())
|
|
|
|
config["deployments"][0]["num_replicas"] = 2
|
|
client.deploy_apps(ServeDeploySchema(**{"applications": [config]}))
|
|
|
|
wait_for_condition(
|
|
router_populated_with_replicas,
|
|
threshold=2,
|
|
handle=h,
|
|
check_cache_populated=True,
|
|
)
|
|
|
|
# Kill GCS server before router gets to send request to second replica
|
|
ray.worker._global_node.kill_gcs_server()
|
|
|
|
returned_pids = set()
|
|
for _ in range(20):
|
|
returned_pids.add(int(h.remote().result(timeout_s=1.0)))
|
|
|
|
print("Returned pids:", returned_pids)
|
|
assert len(returned_pids) == 2
|
|
|
|
|
|
def test_proxy_router_updated_replicas_then_gcs_failure(serve_ha):
|
|
"""Test the router's replica set is updated from 1 to 2 replicas, with the first
|
|
replica staying the same. Verify that if the GCS goes down before the router
|
|
gets a chance to send a request to the second replica, requests can be handled
|
|
during GCS failure.
|
|
|
|
This test sends http requests to the proxy.
|
|
"""
|
|
_, client = serve_ha
|
|
|
|
config = {
|
|
"name": "default",
|
|
"import_path": "ray.serve._private.test_utils:get_pid_entrypoint",
|
|
"route_prefix": "/",
|
|
"deployments": [{"name": "GetPID", "num_replicas": 1}],
|
|
}
|
|
client.deploy_apps(ServeDeploySchema(**{"applications": [config]}), _blocking=True)
|
|
check_apps_running(apps=["default"])
|
|
|
|
r = httpx.post("http://localhost:8000")
|
|
assert r.status_code == 200, r.text
|
|
print(r.text)
|
|
|
|
config["deployments"][0]["num_replicas"] = 2
|
|
client.deploy_apps(ServeDeploySchema(**{"applications": [config]}))
|
|
|
|
proxy_handles = ray.get(client._controller.get_proxies.remote())
|
|
proxy_handle = list(proxy_handles.values())[0]
|
|
|
|
wait_for_condition(
|
|
router_populated_with_replicas,
|
|
threshold=2,
|
|
get_replicas_func=lambda: ray.get(
|
|
proxy_handle._dump_ingress_replicas_for_testing.remote("/")
|
|
),
|
|
check_cache_populated=True,
|
|
get_cache_func=lambda: ray.get(
|
|
proxy_handle._dump_ingress_cache_for_testing.remote("/")
|
|
),
|
|
)
|
|
|
|
# Kill GCS server before router gets to send request to second replica
|
|
ray.worker._global_node.kill_gcs_server()
|
|
|
|
returned_pids = set()
|
|
for _ in range(20):
|
|
r = httpx.post("http://localhost:8000", timeout=3.0)
|
|
assert r.status_code == 200
|
|
returned_pids.add(int(r.text))
|
|
|
|
print("Returned pids:", returned_pids)
|
|
assert len(returned_pids) == 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-v", "-s", __file__]))
|