724 lines
25 KiB
Python
724 lines
25 KiB
Python
import os
|
|
import sys
|
|
import time
|
|
from collections import defaultdict
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
import ray
|
|
from ray import serve
|
|
from ray._common.test_utils import SignalActor, wait_for_condition
|
|
from ray.cluster_utils import Cluster
|
|
from ray.exceptions import RayActorError
|
|
from ray.serve._private.common import DeploymentID, DeploymentStatus, ReplicaState
|
|
from ray.serve._private.constants import (
|
|
RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY,
|
|
SERVE_DEFAULT_APP_NAME,
|
|
SERVE_NAMESPACE,
|
|
)
|
|
from ray.serve._private.deployment_state import ReplicaStartupStatus
|
|
from ray.serve._private.test_utils import (
|
|
check_deployment_status,
|
|
expected_proxy_actors,
|
|
skip_if_haproxy,
|
|
)
|
|
from ray.serve._private.utils import calculate_remaining_timeout, get_head_node_id
|
|
from ray.serve.config import GangSchedulingConfig
|
|
from ray.serve.context import _get_global_client
|
|
from ray.serve.handle import DeploymentHandle
|
|
from ray.serve.schema import ServeDeploySchema
|
|
from ray.util.state import list_actors
|
|
|
|
|
|
def get_pids(expected, deployment_name="D", app_name="default", timeout=30):
|
|
handle = serve.get_deployment_handle(deployment_name, app_name)
|
|
pids = set()
|
|
start = time.time()
|
|
while len(pids) < expected:
|
|
for r in [handle.remote() for _ in range(10)]:
|
|
try:
|
|
pids.add(
|
|
r.result(
|
|
timeout_s=calculate_remaining_timeout(
|
|
timeout_s=timeout,
|
|
start_time_s=start,
|
|
curr_time_s=time.time(),
|
|
)
|
|
)
|
|
)
|
|
except RayActorError:
|
|
# Handle sent request to dead actor before running replicas were updated
|
|
# This can happen because health check period = 1s
|
|
pass
|
|
|
|
if time.time() - start >= timeout:
|
|
raise TimeoutError("Timed out waiting for pids.")
|
|
|
|
return pids
|
|
|
|
|
|
@serve.deployment(health_check_period_s=1, max_ongoing_requests=1)
|
|
def pid():
|
|
time.sleep(0.1)
|
|
return os.getpid()
|
|
|
|
|
|
pid_app = pid.bind()
|
|
|
|
|
|
def test_scale_up(ray_cluster):
|
|
cluster = ray_cluster
|
|
cluster.add_node(num_cpus=1)
|
|
cluster.connect(namespace=SERVE_NAMESPACE)
|
|
# By default, Serve controller and proxy actors use 0 CPUs,
|
|
# so initially there should only be room for 1 replica.
|
|
|
|
app_config = {
|
|
"name": "default",
|
|
"import_path": "ray.serve.tests.test_cluster.pid_app",
|
|
"deployments": [{"name": "pid", "num_replicas": 1}],
|
|
}
|
|
serve.start()
|
|
client = serve.context._connect()
|
|
client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]}))
|
|
|
|
client._wait_for_application_running("default")
|
|
pids1 = get_pids(1, deployment_name="pid", app_name="default")
|
|
|
|
app_config["deployments"][0]["num_replicas"] = 3
|
|
client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]}))
|
|
|
|
# Check that a new replica has not started in 1.0 seconds. This
|
|
# doesn't guarantee that a new replica won't ever be started, but
|
|
# 1.0 seconds is a reasonable upper bound on replica startup time.
|
|
with pytest.raises(TimeoutError):
|
|
client._wait_for_application_running("default", timeout_s=1)
|
|
assert get_pids(1, deployment_name="pid", app_name="default") == pids1
|
|
|
|
# Add a node with another CPU, another replica should get placed.
|
|
cluster.add_node(num_cpus=1)
|
|
with pytest.raises(TimeoutError):
|
|
client._wait_for_application_running("default", timeout_s=1)
|
|
pids2 = get_pids(2, deployment_name="pid", app_name="default")
|
|
assert pids1.issubset(pids2)
|
|
|
|
# Add a node with another CPU, the final replica should get placed
|
|
# and the deploy goal should be done.
|
|
cluster.add_node(num_cpus=1)
|
|
client._wait_for_application_running("default")
|
|
pids3 = get_pids(3, deployment_name="pid", app_name="default")
|
|
assert pids2.issubset(pids3)
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
|
|
def test_node_failure(ray_cluster):
|
|
cluster = ray_cluster
|
|
|
|
cluster.add_node(num_cpus=3)
|
|
cluster.connect(namespace=SERVE_NAMESPACE)
|
|
|
|
# NOTE(edoakes): we need to start serve before adding the worker node to
|
|
# guarantee that the controller is placed on the head node (we should be
|
|
# able to tolerate being placed on workers, but there's currently a bug).
|
|
# We should add an explicit test for that in the future when it's fixed.
|
|
serve.start()
|
|
|
|
worker_node = cluster.add_node(num_cpus=2)
|
|
|
|
@serve.deployment(num_replicas=5, health_check_period_s=1, max_ongoing_requests=1)
|
|
def D(*args):
|
|
time.sleep(0.1)
|
|
return os.getpid()
|
|
|
|
print("Initial deploy.")
|
|
serve.run(D.bind())
|
|
pids1 = get_pids(5)
|
|
|
|
# Remove the node. There should still be three replicas running.
|
|
print("Kill node.")
|
|
cluster.remove_node(worker_node)
|
|
pids2 = get_pids(3)
|
|
assert pids2.issubset(pids1)
|
|
|
|
# Add a worker node back. One replica should get placed.
|
|
print("Add back first node.")
|
|
cluster.add_node(num_cpus=1)
|
|
pids3 = get_pids(4)
|
|
assert pids2.issubset(pids3)
|
|
|
|
# Add another worker node. One more replica should get placed.
|
|
print("Add back second node.")
|
|
cluster.add_node(num_cpus=1)
|
|
pids4 = get_pids(5)
|
|
assert pids3.issubset(pids4)
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
|
|
def test_replica_startup_status_transitions(ray_cluster):
|
|
cluster = ray_cluster
|
|
cluster.add_node(num_cpus=1)
|
|
cluster.connect(namespace=SERVE_NAMESPACE)
|
|
serve.start()
|
|
client = _get_global_client()
|
|
|
|
signal = SignalActor.remote()
|
|
|
|
@serve.deployment(ray_actor_options={"num_cpus": 2})
|
|
class E:
|
|
async def __init__(self):
|
|
await signal.wait.remote()
|
|
|
|
serve._run(E.bind(), _blocking=False)
|
|
|
|
def get_replicas(replica_state):
|
|
controller = client._controller
|
|
replicas = ray.get(
|
|
controller._dump_replica_states_for_testing.remote(
|
|
DeploymentID(name=E.name)
|
|
)
|
|
)
|
|
return replicas.get([replica_state])
|
|
|
|
# wait for serve to start the replica
|
|
wait_for_condition(lambda: len(get_replicas(ReplicaState.STARTING)) > 0)
|
|
|
|
# currently there are no resources to allocate the replica
|
|
def get_starting_replica():
|
|
replicas = get_replicas(ReplicaState.STARTING)
|
|
return replicas[0] if replicas else None
|
|
|
|
def is_pending_allocation():
|
|
replica = get_starting_replica()
|
|
if replica is None:
|
|
return False
|
|
return replica.check_started()[0] == ReplicaStartupStatus.PENDING_ALLOCATION
|
|
|
|
wait_for_condition(is_pending_allocation)
|
|
|
|
# add the necessary resources to allocate the replica
|
|
cluster.add_node(num_cpus=4)
|
|
wait_for_condition(lambda: (ray.cluster_resources().get("CPU", 0) >= 4))
|
|
wait_for_condition(lambda: (ray.available_resources().get("CPU", 0) >= 2))
|
|
|
|
def is_replica_pending_initialization():
|
|
replica = get_starting_replica()
|
|
if replica is None:
|
|
return False
|
|
status, _ = replica.check_started()
|
|
return status == ReplicaStartupStatus.PENDING_INITIALIZATION
|
|
|
|
wait_for_condition(is_replica_pending_initialization, timeout=25)
|
|
|
|
# send signal to complete replica initialization
|
|
ray.get(signal.send.remote())
|
|
|
|
def check_succeeded():
|
|
# After initialization succeeds, replica transitions to RUNNING state
|
|
# So check both STARTING and RUNNING states
|
|
replica = get_starting_replica()
|
|
if replica:
|
|
status, _ = replica.check_started()
|
|
if status == ReplicaStartupStatus.SUCCEEDED:
|
|
return True
|
|
|
|
# Check if replica has moved to RUNNING state (which means it succeeded)
|
|
running_replicas = get_replicas(ReplicaState.RUNNING)
|
|
if running_replicas and len(running_replicas) > 0:
|
|
return True
|
|
|
|
return False
|
|
|
|
wait_for_condition(check_succeeded)
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
|
|
def test_gang_replica_startup_status_transitions(ray_cluster):
|
|
cluster = ray_cluster
|
|
# Start with only 1 CPU — not enough for a gang of 2 replicas each needing 0.75 CPUs.
|
|
cluster.add_node(num_cpus=1)
|
|
cluster.connect(namespace=SERVE_NAMESPACE)
|
|
serve.start()
|
|
client = _get_global_client()
|
|
|
|
signal = SignalActor.remote()
|
|
|
|
@serve.deployment(
|
|
ray_actor_options={"num_cpus": 0.75},
|
|
num_replicas=2,
|
|
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
|
)
|
|
class GangDeployment:
|
|
async def __init__(self):
|
|
await signal.wait.remote()
|
|
|
|
serve._run(GangDeployment.bind(), _blocking=False)
|
|
|
|
def get_replicas(replica_state):
|
|
controller = client._controller
|
|
replicas = ray.get(
|
|
controller._dump_replica_states_for_testing.remote(
|
|
DeploymentID(name="GangDeployment")
|
|
)
|
|
)
|
|
return replicas.get([replica_state])
|
|
|
|
# Wait for replicas to be created in STARTING state.
|
|
wait_for_condition(lambda: len(get_replicas(ReplicaState.STARTING)) > 0)
|
|
|
|
# With only 1 CPU available and each replica needing 0.75, replicas should
|
|
# be stuck in PENDING_ALLOCATION.
|
|
def is_pending_allocation():
|
|
replicas = get_replicas(ReplicaState.STARTING)
|
|
if not replicas:
|
|
return False
|
|
return all(
|
|
r.check_started()[0] == ReplicaStartupStatus.PENDING_ALLOCATION
|
|
for r in replicas
|
|
)
|
|
|
|
wait_for_condition(is_pending_allocation)
|
|
|
|
# Add enough resources for the gang
|
|
cluster.add_node(num_cpus=1)
|
|
wait_for_condition(lambda: ray.cluster_resources().get("CPU", 0) == 2)
|
|
|
|
# Replicas should transition to PENDING_INITIALIZATION
|
|
def is_pending_initialization():
|
|
replicas = get_replicas(ReplicaState.STARTING)
|
|
if not replicas:
|
|
return False
|
|
return all(
|
|
r.check_started()[0] == ReplicaStartupStatus.PENDING_INITIALIZATION
|
|
for r in replicas
|
|
)
|
|
|
|
wait_for_condition(is_pending_initialization, timeout=30)
|
|
|
|
# Complete initialization
|
|
ray.get(signal.send.remote())
|
|
|
|
# Replicas should transition to RUNNING
|
|
def check_running():
|
|
running_replicas = get_replicas(ReplicaState.RUNNING)
|
|
return len(running_replicas) == 2
|
|
|
|
wait_for_condition(check_running, timeout=30)
|
|
|
|
|
|
@serve.deployment
|
|
def f():
|
|
pass
|
|
|
|
|
|
f_app = f.bind()
|
|
|
|
|
|
def test_intelligent_scale_down(ray_cluster):
|
|
cluster = ray_cluster
|
|
# Head node
|
|
cluster.add_node(num_cpus=0)
|
|
cluster.connect(namespace=SERVE_NAMESPACE)
|
|
cluster.add_node(num_cpus=2)
|
|
cluster.add_node(num_cpus=2)
|
|
serve.start()
|
|
client = _get_global_client()
|
|
|
|
def get_actor_distributions():
|
|
node_to_actors = defaultdict(list)
|
|
for actor in list_actors(
|
|
address=cluster.address, filters=[("STATE", "=", "ALIVE")]
|
|
):
|
|
if "ServeReplica" not in actor.class_name:
|
|
continue
|
|
node_to_actors[actor.node_id].append(actor)
|
|
|
|
return set(map(len, node_to_actors.values()))
|
|
|
|
def check_app_running_with_replicas(num_replicas):
|
|
status = serve.status().applications["default"]
|
|
assert status.status == "RUNNING"
|
|
assert status.deployments["f"].replica_states["RUNNING"] == num_replicas
|
|
return True
|
|
|
|
app_config = {
|
|
"name": "default",
|
|
"import_path": "ray.serve.tests.test_cluster.f_app",
|
|
"deployments": [{"name": "f", "num_replicas": 3}],
|
|
}
|
|
client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]}))
|
|
wait_for_condition(check_app_running_with_replicas, num_replicas=3)
|
|
assert get_actor_distributions() == {2, 1}
|
|
|
|
app_config["deployments"][0]["num_replicas"] = 2
|
|
client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]}))
|
|
wait_for_condition(check_app_running_with_replicas, num_replicas=2)
|
|
assert get_actor_distributions() == {2}
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
|
|
@pytest.mark.skipif(
|
|
RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY, reason="Needs spread strategy."
|
|
)
|
|
def test_replica_spread(ray_cluster):
|
|
cluster = ray_cluster
|
|
|
|
cluster.add_node(num_cpus=2)
|
|
|
|
# NOTE(edoakes): we need to start serve before adding the worker node to
|
|
# guarantee that the controller is placed on the head node (we should be
|
|
# able to tolerate being placed on workers, but there's currently a bug).
|
|
# We should add an explicit test for that in the future when it's fixed.
|
|
cluster.connect(namespace=SERVE_NAMESPACE)
|
|
serve.start()
|
|
|
|
worker_node = cluster.add_node(num_cpus=2)
|
|
|
|
@serve.deployment(
|
|
num_replicas=2,
|
|
health_check_period_s=1,
|
|
)
|
|
def D():
|
|
return "hi"
|
|
|
|
serve.run(D.bind())
|
|
|
|
def get_num_nodes():
|
|
client = _get_global_client()
|
|
details = client.get_serve_details()
|
|
dep = details["applications"]["default"]["deployments"]["D"]
|
|
nodes = {r["node_id"] for r in dep["replicas"]}
|
|
print("replica nodes", nodes)
|
|
return len(nodes)
|
|
|
|
# Check that the two replicas are spread across the two nodes.
|
|
wait_for_condition(lambda: get_num_nodes() == 2)
|
|
|
|
# Kill the worker node. The second replica should get rescheduled on
|
|
# the head node.
|
|
print("Removing worker node. Replica should be rescheduled.")
|
|
cluster.remove_node(worker_node)
|
|
|
|
# Check that the replica on the dead node can be rescheduled.
|
|
wait_for_condition(lambda: get_num_nodes() == 1)
|
|
|
|
|
|
def test_autoscale_upscaling_stuck_then_healthy(ray_cluster):
|
|
"""Test that deployment stuck in upscaling (due to insufficient cluster resources)
|
|
recovers to healthy when ongoing requests drop to zero.
|
|
|
|
Setup: Head with 0 CPUs + 1 worker with 1 CPU. 1 replica using 1 CPU,
|
|
target_ongoing_requests=1. Send 2 requests via handle -> autoscaler wants 2
|
|
replicas but can't add one (no CPU). Deployment stuck in UPSCALING.
|
|
Release requests -> deployment HEALTHY.
|
|
"""
|
|
cluster = ray_cluster
|
|
cluster.add_node(num_cpus=0) # Head node (controller/proxy use 0 CPU)
|
|
cluster.connect(namespace=SERVE_NAMESPACE)
|
|
serve.start() # Start before adding worker so controller goes on head
|
|
cluster.add_node(num_cpus=1) # Worker with 1 CPU for replica
|
|
cluster.wait_for_nodes()
|
|
|
|
signal = SignalActor.remote()
|
|
|
|
@serve.deployment(
|
|
autoscaling_config={
|
|
"min_replicas": 1,
|
|
"max_replicas": 2,
|
|
"target_ongoing_requests": 1,
|
|
"metrics_interval_s": 0.1,
|
|
"look_back_period_s": 0.5,
|
|
"upscale_delay_s": 0,
|
|
# If delay is large then the test will be stuck in UPSCALING state.
|
|
"downscale_delay_s": 1,
|
|
},
|
|
max_ongoing_requests=1,
|
|
ray_actor_options={"num_cpus": 1},
|
|
graceful_shutdown_timeout_s=2,
|
|
)
|
|
def blocking_replica():
|
|
ray.get(signal.wait.remote())
|
|
return "ok"
|
|
|
|
handle = serve.run(blocking_replica.bind())
|
|
wait_for_condition(
|
|
check_deployment_status,
|
|
name="blocking_replica",
|
|
expected_status=DeploymentStatus.HEALTHY,
|
|
)
|
|
|
|
# Send 2 requests - first occupies the replica, second queues. With
|
|
# target_ongoing_requests=1 and 1 replica, 2 requests triggers scale to 2.
|
|
responses = [handle.remote() for _ in range(2)]
|
|
|
|
# Deployment should get stuck in UPSCALING: autoscaler wants 2 replicas
|
|
# but cluster only has 1 CPU (replica uses it all).
|
|
wait_for_condition(
|
|
check_deployment_status,
|
|
name="blocking_replica",
|
|
expected_status=DeploymentStatus.UPSCALING,
|
|
timeout=15,
|
|
)
|
|
|
|
# Release the signal so running requests complete and go to zero.
|
|
ray.get(signal.send.remote())
|
|
for r in responses:
|
|
assert r.result() == "ok"
|
|
|
|
# Deployment should recover to HEALTHY as load drops (may go through
|
|
# DOWNSCALING first if a second replica was briefly added).
|
|
wait_for_condition(
|
|
check_deployment_status,
|
|
name="blocking_replica",
|
|
expected_status=DeploymentStatus.HEALTHY,
|
|
timeout=30,
|
|
)
|
|
|
|
|
|
def test_handle_prefers_replicas_on_same_node(ray_cluster):
|
|
"""Verify that handle calls prefer replicas on the same node when possible.
|
|
|
|
If all replicas on the same node are occupied (at `max_ongoing_requests` limit),
|
|
requests should spill to other nodes.
|
|
"""
|
|
|
|
cluster = ray_cluster
|
|
cluster.add_node(num_cpus=1)
|
|
cluster.add_node(num_cpus=1)
|
|
|
|
signal = SignalActor.remote()
|
|
|
|
@serve.deployment(num_replicas=2, max_ongoing_requests=1)
|
|
def inner(block_on_signal):
|
|
if block_on_signal:
|
|
ray.get(signal.wait.remote())
|
|
|
|
return ray.get_runtime_context().get_node_id()
|
|
|
|
@serve.deployment(num_replicas=1, ray_actor_options={"num_cpus": 0})
|
|
class Outer:
|
|
def __init__(self, inner_handle: DeploymentHandle):
|
|
self._h = inner_handle.options(_prefer_local_routing=True)
|
|
|
|
def get_node_id(self) -> str:
|
|
return ray.get_runtime_context().get_node_id()
|
|
|
|
async def call_inner(self, block_on_signal: bool = False) -> str:
|
|
return await self._h.remote(block_on_signal)
|
|
|
|
# The inner deployment's two replicas will be spread across the two nodes and
|
|
# the outer deployment's single replica will be placed on one of them.
|
|
h = serve.run(Outer.bind(inner.bind()))
|
|
|
|
# When sending requests sequentially, all requests to the inner deployment should
|
|
# go to the replica on the same node as the outer deployment replica.
|
|
outer_node_id = h.get_node_id.remote().result()
|
|
for _ in range(10):
|
|
assert h.call_inner.remote().result() == outer_node_id
|
|
|
|
# Make a blocking request to the inner deployment replica on the same node.
|
|
blocked_response = h.call_inner.remote(block_on_signal=True)
|
|
with pytest.raises(TimeoutError):
|
|
blocked_response.result(timeout_s=1)
|
|
|
|
# Because there's a blocking request and `max_ongoing_requests` is set to 1, all
|
|
# requests should now spill to the other node.
|
|
for _ in range(10):
|
|
assert h.call_inner.remote().result() != outer_node_id
|
|
|
|
ray.get(signal.send.remote())
|
|
assert blocked_response.result() == outer_node_id
|
|
|
|
|
|
# TODO: HAProxy's default ingress balances across all replicas with no
|
|
# node-local preference. prefer-local routing could be wired under HAProxy via
|
|
# the ingress_request_router use-server delegation, then this skip dropped.
|
|
@skip_if_haproxy("balances across replicas without node-local preference")
|
|
@pytest.mark.parametrize("set_flag", [True, False])
|
|
def test_proxy_prefers_replicas_on_same_node(ray_cluster: Cluster, set_flag):
|
|
"""When the feature flag is turned on via env var, verify that http proxy routes to
|
|
replicas on the same node when possible. Otherwise if env var is not set, http proxy
|
|
should route to all replicas equally.
|
|
"""
|
|
|
|
if not set_flag:
|
|
os.environ["RAY_SERVE_PROXY_PREFER_LOCAL_NODE_ROUTING"] = "0"
|
|
|
|
cluster = ray_cluster
|
|
cluster.add_node(num_cpus=1)
|
|
cluster.add_node(num_cpus=1)
|
|
|
|
# Only start one HTTP proxy on the head node.
|
|
serve.start(http_options={"location": "HeadOnly"})
|
|
head_node_id = get_head_node_id()
|
|
|
|
@serve.deployment(num_replicas=2, max_ongoing_requests=1)
|
|
def f():
|
|
return ray.get_runtime_context().get_node_id()
|
|
|
|
# The deployment's two replicas will be spread across the two nodes
|
|
serve.run(f.bind())
|
|
|
|
# Since they're sent sequentially, all requests should be routed to
|
|
# the replica on the head node
|
|
responses = [httpx.post("http://localhost:8000").text for _ in range(10)]
|
|
if set_flag:
|
|
assert all(resp == head_node_id for resp in responses)
|
|
else:
|
|
assert len(set(responses)) == 2
|
|
|
|
if "RAY_SERVE_PROXY_PREFER_LOCAL_NODE_ROUTING" in os.environ:
|
|
del os.environ["RAY_SERVE_PROXY_PREFER_LOCAL_NODE_ROUTING"]
|
|
|
|
|
|
class TestHealthzAndRoutes:
|
|
def test_head_node_proxy_healthy(self, ray_cluster: Cluster):
|
|
"""When a new cluster is started with no replicas, head node proxy should
|
|
respond with 200 at /-/healthz and /-/routes"""
|
|
|
|
cluster = ray_cluster
|
|
cluster.add_node(num_cpus=0) # Head node
|
|
cluster.wait_for_nodes()
|
|
ray.init(address=cluster.address)
|
|
serve.start(http_options={"location": "EveryNode"})
|
|
|
|
@serve.deployment(ray_actor_options={"num_cpus": 0})
|
|
class Dummy:
|
|
pass
|
|
|
|
serve.run(Dummy.bind())
|
|
|
|
# Head node proxy /-/healthz and /-/routes should return 200
|
|
r = httpx.post("http://localhost:8000/-/healthz")
|
|
assert r.status_code == 200
|
|
r = httpx.post("http://localhost:8000/-/routes")
|
|
assert r.status_code == 200
|
|
|
|
def test_head_and_worker_nodes_no_replicas(self, ray_cluster: Cluster):
|
|
"""Test `/-/healthz` and `/-/routes` return the correct responses for head and
|
|
worker nodes.
|
|
|
|
When there are replicas on all nodes, `/-/healthz` and `/-/routes` on all nodes
|
|
should return 200. When there are no replicas on any nodes, `/-/healthz` and
|
|
`/-/routes` on the head node should continue to return 200. `/-/healthz` and
|
|
`/-/routes` on the worker node should start to return 503
|
|
"""
|
|
# Setup worker http proxy to be pointing to port 8001. Head node http proxy will
|
|
# continue to be pointing to the default port 8000.
|
|
cluster = ray_cluster
|
|
cluster.add_node(num_cpus=0)
|
|
cluster.add_node(
|
|
num_cpus=2, env_vars={"RAY_SERVE_WORKER_PROXY_HTTP_PORT": "8001"}
|
|
)
|
|
cluster.wait_for_nodes()
|
|
ray.init(address=cluster.address)
|
|
serve.start(http_options={"location": "EveryNode"})
|
|
|
|
# Deploy 2 replicas, both should be on the worker node.
|
|
@serve.deployment(num_replicas=2)
|
|
class HelloModel:
|
|
def __call__(self):
|
|
return "hello"
|
|
|
|
model = HelloModel.bind()
|
|
serve.run(target=model)
|
|
|
|
# Ensure worker node has both replicas.
|
|
def check_replicas_on_worker_nodes():
|
|
return (
|
|
len(
|
|
{
|
|
a.node_id
|
|
for a in list_actors(address=cluster.address)
|
|
if a.class_name.startswith("ServeReplica")
|
|
}
|
|
)
|
|
== 1
|
|
)
|
|
|
|
wait_for_condition(check_replicas_on_worker_nodes)
|
|
|
|
# Total alive actors: EveryNode proxies on both nodes + 1 controller +
|
|
# 2 replicas. Under HAProxy each proxy node runs an HAProxyManager and
|
|
# the head node adds a fallback ProxyActor.
|
|
expected_num_actors = (
|
|
sum(expected_proxy_actors(num_proxy_nodes=2).values()) + 1 + 2
|
|
)
|
|
wait_for_condition(
|
|
lambda: len(list_actors(address=cluster.address)) == expected_num_actors
|
|
)
|
|
assert len(ray.nodes()) == 2
|
|
|
|
# Ensure `/-/healthz` and `/-/routes` return 200 and expected responses
|
|
# on both nodes.
|
|
def check_request(url: str, expected_code: int, expected_text: str):
|
|
req = httpx.get(url)
|
|
assert req.status_code == expected_code
|
|
assert req.text == expected_text
|
|
return True
|
|
|
|
wait_for_condition(
|
|
condition_predictor=check_request,
|
|
url="http://127.0.0.1:8000/-/healthz",
|
|
expected_code=200,
|
|
expected_text="success",
|
|
)
|
|
assert httpx.get("http://127.0.0.1:8000/-/routes").status_code == 200
|
|
assert httpx.get("http://127.0.0.1:8000/-/routes").text == '{"/":"default"}'
|
|
wait_for_condition(
|
|
condition_predictor=check_request,
|
|
url="http://127.0.0.1:8001/-/healthz",
|
|
expected_code=200,
|
|
expected_text="success",
|
|
)
|
|
assert httpx.get("http://127.0.0.1:8001/-/routes").status_code == 200
|
|
assert httpx.get("http://127.0.0.1:8001/-/routes").text == '{"/":"default"}'
|
|
|
|
# Deleting the deployment drops the replicas on all nodes. The proxies and
|
|
# controller stay alive (the worker proxy drains), so the count is the
|
|
# pre-delete total minus the 2 replicas.
|
|
serve.delete(name=SERVE_DEFAULT_APP_NAME)
|
|
|
|
expected_num_actors_after_delete = (
|
|
sum(expected_proxy_actors(num_proxy_nodes=2).values()) + 1
|
|
)
|
|
wait_for_condition(
|
|
lambda: len(
|
|
list_actors(address=cluster.address, filters=[("STATE", "=", "ALIVE")])
|
|
)
|
|
== expected_num_actors_after_delete,
|
|
)
|
|
|
|
# Ensure head node `/-/healthz` and `/-/routes` continue to
|
|
# return 200 and expected responses. Also, the worker node
|
|
# `/-/healthz` and `/-/routes` should return 503 and unavailable
|
|
# responses.
|
|
wait_for_condition(
|
|
condition_predictor=check_request,
|
|
url="http://127.0.0.1:8000/-/healthz",
|
|
expected_code=200,
|
|
expected_text="success",
|
|
)
|
|
wait_for_condition(
|
|
condition_predictor=check_request,
|
|
url="http://127.0.0.1:8000/-/routes",
|
|
expected_code=200,
|
|
expected_text="{}",
|
|
)
|
|
wait_for_condition(
|
|
condition_predictor=check_request,
|
|
url="http://127.0.0.1:8001/-/healthz",
|
|
expected_code=503,
|
|
expected_text="This node is being drained.",
|
|
)
|
|
wait_for_condition(
|
|
condition_predictor=check_request,
|
|
url="http://127.0.0.1:8001/-/routes",
|
|
expected_code=503,
|
|
expected_text="This node is being drained.",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-v", "-s", __file__]))
|