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__]))