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

978 lines
36 KiB
Python

import json
from typing import List, Optional, Tuple
from unittest import mock
from unittest.mock import patch
import pytest
from ray._common.test_utils import wait_for_condition
from ray._common.utils import Timer
from ray.serve._private.cluster_node_info_cache import ClusterNodeInfoCache
from ray.serve._private.common import RequestProtocol
from ray.serve._private.constants import (
PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD,
RAY_SERVE_FALLBACK_PROXY_GRPC_PORT,
RAY_SERVE_FALLBACK_PROXY_HTTP_PORT,
)
from ray.serve._private.proxy_state import ProxyState, ProxyStateManager, ProxyWrapper
from ray.serve._private.test_utils import MockTimer
from ray.serve.config import HTTPOptions, ProxyLocation
from ray.serve.schema import LoggingConfig, ProxyStatus
HEAD_NODE_ID = "node_id-index-head"
class MockClusterNodeInfoCache:
def __init__(self):
self.alive_nodes = []
def get_alive_nodes(self):
return self.alive_nodes
def get_alive_node_ids(self):
return {node_id for node_id, _, _ in self.alive_nodes}
class FakeProxyActor:
def __init__(self, *args, **kwargs):
pass
def ready(self):
return json.dumps(["mock_worker_id", "mock_log_file_path"])
def check_health(self):
pass
class FakeProxyWrapper(ProxyWrapper):
def __init__(self, *args, **kwargs):
self.actor_handle = FakeProxyActor(*args, **kwargs)
self.is_ready_response = None
self.is_healthy_response = None
self.is_drained_response = False
self.worker_id = "mock_worker_id"
self.log_file_path = "mock_log_file_path"
self.shutdown = False
self.num_health_checks = 0
self.num_drain_checks = 0
@property
def actor_id(self) -> str:
pass
def is_ready(self, timeout_s: float) -> Optional[bool]:
return self.is_ready_response
def is_healthy(self, timeout_s: float) -> Optional[bool]:
self.num_health_checks += 1
return self.is_healthy_response
def is_drained(self, timeout_s: float) -> Optional[bool]:
self.num_drain_checks += 1
return self.is_drained_response
def is_shutdown(self):
return self.shutdown
def update_draining(self, draining: bool):
pass
def kill(self):
self.shutdown = True
def get_num_health_checks(self):
return self.num_health_checks
def get_num_drain_checks(self):
return self.num_health_checks
def _create_proxy_state_manager(
http_options: HTTPOptions = HTTPOptions(),
head_node_id: str = HEAD_NODE_ID,
cluster_node_info_cache=MockClusterNodeInfoCache(),
actor_proxy_wrapper_class=FakeProxyWrapper,
timer=Timer(),
running_native_proxies: bool = False,
proxy_location=None,
) -> (ProxyStateManager, ClusterNodeInfoCache):
return (
ProxyStateManager(
http_options=http_options,
head_node_id=head_node_id,
cluster_node_info_cache=cluster_node_info_cache,
logging_config=LoggingConfig(),
actor_proxy_wrapper_class=actor_proxy_wrapper_class,
timer=timer,
running_native_proxies=running_native_proxies,
proxy_location=proxy_location,
),
cluster_node_info_cache,
)
def _create_proxy_state(
actor_proxy_wrapper_class=FakeProxyWrapper,
status: ProxyStatus = ProxyStatus.STARTING,
node_id: str = "mock_node_id",
timer=Timer(),
**kwargs,
) -> ProxyState:
state = ProxyState(
actor_proxy_wrapper=actor_proxy_wrapper_class(),
actor_name="alice",
node_id=node_id,
node_ip="mock_node_ip",
node_instance_id="mock_instance_id",
timer=timer,
)
state._set_status(status=status)
return state
@pytest.fixture
def number_of_worker_nodes() -> int:
return 100
@pytest.fixture
def all_nodes(number_of_worker_nodes) -> List[Tuple[str, str, str]]:
return [(HEAD_NODE_ID, "fake-head-ip", "fake-head-instance-id")] + [
(f"worker-node-id-{i}", f"fake-worker-ip-{i}", f"fake-instance-id-{i}")
for i in range(number_of_worker_nodes)
]
def _reconcile_and_check_proxy_status(state: ProxyState, status: ProxyStatus):
state.reconcile()
assert state.status == status
return True
def _update_and_check_proxy_state_manager(
proxy_state_manager: ProxyStateManager,
node_ids: List[str],
statuses: List[ProxyStatus],
**kwargs,
):
proxy_state_manager.update(**kwargs)
proxy_states = proxy_state_manager._proxy_states
assert all(
[
proxy_states[node_ids[idx]].status == statuses[idx]
for idx in range(len(node_ids))
]
), [proxy_state.status for proxy_state in proxy_states.values()]
return True
def test_node_selection_via_proxy_location(all_nodes):
# `proxy_location` is the placement authority (threaded separately from the
# deprecated HTTPOptions.location); covers HTTP and gRPC (single ProxyActor).
all_node_ids = {node_id for node_id, _, _ in all_nodes}
def mgr(**kwargs):
psm, cache = _create_proxy_state_manager(**kwargs)
cache.alive_nodes = all_nodes
return psm
assert (
mgr(proxy_location=ProxyLocation.Disabled)._get_target_nodes(all_node_ids) == []
)
assert (
mgr(proxy_location=ProxyLocation.HeadOnly)._get_target_nodes(all_node_ids)
== all_nodes[:1]
)
assert (
mgr(proxy_location=ProxyLocation.EveryNode)._get_target_nodes(all_node_ids)
== all_nodes
)
# Default (neither location nor proxy_location) resolves to EveryNode.
assert mgr()._get_target_nodes(all_node_ids) == all_nodes
assert mgr().get_proxy_location() == ProxyLocation.EveryNode
# Explicit (deprecated) HTTPOptions.location overrides proxy_location.
with pytest.warns(DeprecationWarning, match="`location` in HTTPOptions"):
override = HTTPOptions(location=ProxyLocation.HeadOnly)
psm = mgr(http_options=override, proxy_location=ProxyLocation.EveryNode)
assert psm._get_target_nodes(all_node_ids) == all_nodes[:1]
assert psm.get_proxy_location() == ProxyLocation.HeadOnly
def test_node_selection(all_nodes):
all_node_ids = {node_id for node_id, _, _ in all_nodes}
# Test NoServer
proxy_state_manager, cluster_node_info_cache = _create_proxy_state_manager(
HTTPOptions(location=ProxyLocation.Disabled)
)
cluster_node_info_cache.alive_nodes = all_nodes
assert proxy_state_manager._get_target_nodes(all_node_ids) == []
# Test HeadOnly
proxy_state_manager, cluster_node_info_cache = _create_proxy_state_manager(
HTTPOptions(location=ProxyLocation.HeadOnly)
)
cluster_node_info_cache.alive_nodes = all_nodes
assert proxy_state_manager._get_target_nodes(all_node_ids) == all_nodes[:1]
# Test EveryNode
proxy_state_manager, cluster_node_info_cache = _create_proxy_state_manager(
HTTPOptions(location=ProxyLocation.EveryNode)
)
cluster_node_info_cache.alive_nodes = all_nodes
assert proxy_state_manager._get_target_nodes(all_node_ids) == all_nodes
# Test specific nodes
proxy_state_manager, cluster_node_info_cache = _create_proxy_state_manager(
HTTPOptions(location=ProxyLocation.EveryNode)
)
cluster_node_info_cache.alive_nodes = all_nodes
assert proxy_state_manager._get_target_nodes({HEAD_NODE_ID}) == [
(HEAD_NODE_ID, "fake-head-ip", "fake-head-instance-id")
]
@patch("ray.serve._private.proxy_state.PROXY_HEALTH_CHECK_PERIOD_S", 5)
def test_proxy_state_manager_restarts_unhealthy_proxies(all_nodes):
"""Test the update method in ProxyStateManager would
kill and restart unhealthy proxies.
"""
timer = MockTimer()
proxy_state_manager, cluster_node_info_cache = _create_proxy_state_manager(
timer=timer
)
cluster_node_info_cache.alive_nodes = all_nodes
# First iteration, refresh state
proxy_state_manager.update(proxy_nodes={HEAD_NODE_ID})
prev_proxy_state = proxy_state_manager._proxy_states[HEAD_NODE_ID]
# Mark existing head-node proxy UNHEALTHY
prev_proxy_state._set_status(ProxyStatus.UNHEALTHY)
old_proxy = prev_proxy_state.actor_handle
# Continuously trigger update and wait for status to be changed to HEALTHY.
for _ in range(1):
proxy_state_manager.update(proxy_nodes={HEAD_NODE_ID})
# Advance timer by 5 (to perform a health-check)
timer.advance(5)
new_proxy_state = proxy_state_manager._proxy_states[HEAD_NODE_ID]
# Previous proxy's state stays UNHEALTHY
assert prev_proxy_state.status == ProxyStatus.UNHEALTHY
# Ensure the old proxy is getting shutdown.
assert prev_proxy_state._shutting_down
# New proxy's state should be STARTING
assert new_proxy_state.status == ProxyStatus.STARTING
assert new_proxy_state.proxy_restart_count == 1
new_proxy = new_proxy_state.actor_handle
# Ensure the new proxy is completely different object than old proxy.
assert new_proxy != old_proxy
def test_proxy_state_reconcile_shutting_down():
proxy_state = _create_proxy_state()
previous_status = proxy_state.status
proxy_state.shutdown()
# This should be no-op. The status of the http proxy state will not be changed.
proxy_state.reconcile()
current_status = proxy_state.status
# Ensure the proxy state is in the shutting down state.
assert proxy_state._shutting_down
# Ensure the status didn't change.
assert previous_status == current_status
def test_proxy_state_reconcile_readiness_check_succeed():
proxy_state = _create_proxy_state()
# Configure is_ready to be true
proxy_state._actor_proxy_wrapper.is_ready_response = True
# Ensure the proxy status before update is STARTING.
assert proxy_state.status == ProxyStatus.STARTING
# Ensure actor_details are set to the initial state when the proxy_state is created.
assert proxy_state.actor_details.worker_id is None
assert proxy_state.actor_details.log_file_path is None
assert proxy_state.actor_details.status == ProxyStatus.STARTING.value
# Continuously trigger update and wait for status to be changed.
proxy_state.reconcile()
assert proxy_state.status == ProxyStatus.HEALTHY
# Ensure actor_details are updated.
assert proxy_state.actor_details.worker_id == "mock_worker_id"
assert proxy_state.actor_details.log_file_path == "mock_log_file_path"
assert proxy_state.actor_details.status == ProxyStatus.HEALTHY.value
def test_proxy_state_reconcile_readiness_check_pending():
proxy_state = _create_proxy_state()
# Ensure the proxy status before update is STARTING.
assert proxy_state.status == ProxyStatus.STARTING
# When the proxy readiness check is pending, the proxy wrapper is_ready
# will return None
proxy_state._actor_proxy_wrapper.is_ready_response = None
# Trigger update. The status do not change, while readiness check is pending
for _ in range(10):
proxy_state.reconcile()
assert proxy_state.status == ProxyStatus.STARTING
# Unblock is_ready call, trigger update, and wait for status change to HEALTHY.
proxy_state._actor_proxy_wrapper.is_ready_response = True
proxy_state.reconcile()
assert proxy_state.status == ProxyStatus.HEALTHY
def test_proxy_state_reconcile_readiness_check_fails():
proxy_state = _create_proxy_state()
# Emulate readiness check failure
proxy_state._actor_proxy_wrapper.is_ready_response = False
# Ensure the proxy status before update is STARTING.
assert proxy_state.status == ProxyStatus.STARTING
# First failure shouldn't trigger state-transition to UNHEALTHY
proxy_state.reconcile()
assert proxy_state.status == ProxyStatus.STARTING
# After PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD failures, state should
# transition to UNHEALTHY
for _ in range(PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD - 1):
proxy_state.reconcile()
assert proxy_state.status == ProxyStatus.UNHEALTHY
@patch("ray.serve._private.proxy_state.PROXY_READY_CHECK_TIMEOUT_S", 5)
def test_proxy_state_reconcile_health_check_succeed():
timer = MockTimer()
proxy_state = _create_proxy_state(time=timer)
# Emulate readiness check succeeding
proxy_state._actor_proxy_wrapper.is_ready_response = True
# Should transition to HEALTHY
proxy_state.reconcile()
assert proxy_state.status == ProxyStatus.HEALTHY
# Trigger update few more times and the status continue to be HEALTHY.
for _ in range(10):
_reconcile_and_check_proxy_status(proxy_state, ProxyStatus.HEALTHY)
# Advance timer by 5s
timer.advance(5)
# Health-checks should have been performed on every iteration
assert proxy_state._actor_proxy_wrapper.num_health_checks == 10
@patch("ray.serve._private.proxy_state.PROXY_HEALTH_CHECK_PERIOD_S", 5)
def test_proxy_state_reconcile_health_check_transient_failures():
timer = MockTimer()
# Start with HEALTHY state
proxy_state = _create_proxy_state(status=ProxyStatus.HEALTHY, timer=timer)
# Simulate health-checks failing
proxy_state._actor_proxy_wrapper.is_healthy_response = False
# Reconcile PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD - 1 times, state should
# continue to stay HEALTHY
for _ in range(PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD - 1):
_reconcile_and_check_proxy_status(proxy_state, ProxyStatus.HEALTHY)
# Advance timer by 5 (to trigger new health-check)
timer.advance(5)
assert (
proxy_state._actor_proxy_wrapper.get_num_health_checks()
== PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD - 1
)
# Simulate health-checks passing
proxy_state._actor_proxy_wrapper.is_healthy_response = True
wait_for_condition(
condition_predictor=_reconcile_and_check_proxy_status,
state=proxy_state,
status=ProxyStatus.HEALTHY,
)
# Ensure _consecutive_health_check_failures is reset
assert proxy_state._consecutive_health_check_failures == 0
@patch("ray.serve._private.proxy_state.PROXY_HEALTH_CHECK_PERIOD_S", 5)
def test_proxy_state_reconcile_health_check_persistent_failures():
timer = MockTimer()
# Start with HEALTHY state
proxy_state = _create_proxy_state(status=ProxyStatus.HEALTHY, timer=timer)
# Simulate health-checks failing
proxy_state._actor_proxy_wrapper.is_healthy_response = False
# Reconcile PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD - 1 times, state should
# continue to stay HEALTHY
for _ in range(PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD - 1):
_reconcile_and_check_proxy_status(proxy_state, ProxyStatus.HEALTHY)
# Advance timer by 5 (to trigger new health-check)
timer.advance(5)
assert (
proxy_state._actor_proxy_wrapper.get_num_health_checks()
== PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD - 1
)
# On PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD iteration, state transitions to
# UNHEALTHY
_reconcile_and_check_proxy_status(proxy_state, ProxyStatus.UNHEALTHY)
# Ensure _consecutive_health_check_failures is correct
assert proxy_state._consecutive_health_check_failures == 3
@patch("ray.serve._private.proxy_state.PROXY_HEALTH_CHECK_PERIOD_S", 0)
@pytest.mark.parametrize("number_of_worker_nodes", [0, 1, 2, 3])
def test_proxy_manager_update_proxies_states(all_nodes, number_of_worker_nodes):
"""Test update draining logics.
When update nodes to inactive, head node http proxy should never be draining while
worker node http proxy should change to draining. When update nodes to active, head
node http proxy should continue to be healthy while worker node http proxy should
be healthy.
"""
manager, cluster_node_info_cache = _create_proxy_state_manager(
HTTPOptions(location=ProxyLocation.EveryNode)
)
cluster_node_info_cache.alive_nodes = all_nodes
for node_id, _, _ in all_nodes:
manager._proxy_states[node_id] = _create_proxy_state(
status=ProxyStatus.HEALTHY,
node_id=node_id,
)
node_ids = [node_id for node_id, _, _ in all_nodes]
# No target proxy nodes
proxy_nodes = {HEAD_NODE_ID}
# Head node proxy should continue to be HEALTHY.
# Worker node proxy should turn DRAINING.
wait_for_condition(
condition_predictor=_update_and_check_proxy_state_manager,
proxy_state_manager=manager,
node_ids=node_ids,
statuses=[ProxyStatus.HEALTHY]
+ [ProxyStatus.DRAINING] * number_of_worker_nodes,
proxy_nodes=proxy_nodes,
)
# All nodes are target proxy nodes
proxy_nodes = set(node_ids)
# Head node proxy should continue to be HEALTHY.
# Worker node proxy should turn HEALTHY.
wait_for_condition(
condition_predictor=_update_and_check_proxy_state_manager,
proxy_state_manager=manager,
node_ids=node_ids,
statuses=[ProxyStatus.HEALTHY] * (number_of_worker_nodes + 1),
proxy_nodes=proxy_nodes,
)
@patch("ray.serve._private.proxy_state.PROXY_HEALTH_CHECK_PERIOD_S", 5)
def test_proxy_state_reconcile_draining_success():
"""Test that the proxy will remain DRAINING even if health check succeeds."""
timer = MockTimer(start_time=0)
# Start with HEALTHY state
proxy_state = _create_proxy_state(status=ProxyStatus.HEALTHY, timer=timer)
# Simulate health-checks passing
proxy_state._actor_proxy_wrapper.is_healthy_response = True
# Simulate is_drained returning false
proxy_state._actor_proxy_wrapper.is_drained_response = False
for _ in range(10):
proxy_state.reconcile(draining=True)
assert proxy_state.status == ProxyStatus.DRAINING
# Advance timer by 5 (to trigger new health-check, drain-check)
timer.advance(5)
assert proxy_state._actor_proxy_wrapper.get_num_health_checks() == 10
assert proxy_state._actor_proxy_wrapper.get_num_drain_checks() == 10
# Make sure the status is still DRAINING
assert proxy_state.status == ProxyStatus.DRAINING
# Simulate is_drained request to ProxyActor pending (for 5 iterations)
proxy_state._actor_proxy_wrapper.is_drained_response = None
for _ in range(5):
proxy_state.reconcile(draining=True)
assert proxy_state.status == ProxyStatus.DRAINING
# Advance timer by 5 (to trigger new health-check, drain-check)
timer.advance(5)
assert proxy_state._actor_proxy_wrapper.get_num_health_checks() == 15
# No new drain checks will occur, since there's a pending one (not completed yet)
assert proxy_state._actor_proxy_wrapper.get_num_drain_checks() == 15
# Simulate draining completed
proxy_state._actor_proxy_wrapper.is_drained_response = True
# Advance timer by 5 (to trigger new health-check, drain-check on next iteration)
timer.advance(5)
proxy_state.reconcile(draining=True)
# State should transition to DRAINED
assert proxy_state.status == ProxyStatus.DRAINED
@patch("ray.serve._private.proxy_state.PROXY_DRAIN_CHECK_PERIOD_S", 5)
@pytest.mark.parametrize("number_of_worker_nodes", [1])
def test_proxy_actor_manager_removing_proxies(all_nodes, number_of_worker_nodes):
"""Test the state transition from DRAINING to UNHEALTHY for the proxy actor."""
assert len(all_nodes) == 2, "There should be 2 nodes in this test"
timer = MockTimer(start_time=0)
manager, cluster_node_info_cache = _create_proxy_state_manager(
HTTPOptions(location=ProxyLocation.EveryNode),
timer=timer,
)
cluster_node_info_cache.alive_nodes = all_nodes
for node_id, _, _ in all_nodes:
manager._proxy_states[node_id] = _create_proxy_state(
status=ProxyStatus.STARTING,
node_id=node_id,
timer=timer,
)
manager._proxy_states[node_id]._actor_proxy_wrapper.is_ready_response = True
# All nodes are target proxy nodes
node_ids = [node_id for node_id, _, _ in all_nodes]
worker_node_id = node_ids[1]
worker_proxy_state = manager._proxy_states[worker_node_id]
# Reconcile all proxies states
manager.update(
proxy_nodes=(set(node_ids)),
)
# Assert all proxies are HEALTHY
proxy_statuses = [manager._proxy_states[node_id].status for node_id in node_ids]
assert [ProxyStatus.HEALTHY, ProxyStatus.HEALTHY] == proxy_statuses
# Reconcile proxies with empty set of target nodes (ie only proxy on the head-node
# should be preserved all the other should be drained)
worker_proxy_state._actor_proxy_wrapper.is_drained_response = False
N = 10
for _ in range(N):
manager.update(
proxy_nodes={HEAD_NODE_ID},
)
timer.advance(5)
# Assert that
# - Head-node proxy is HEALTHY4
# - Worker node proxy is DRAINING
proxy_statuses = [manager._proxy_states[node_id].status for node_id in node_ids]
assert [ProxyStatus.HEALTHY, ProxyStatus.DRAINING] == proxy_statuses
assert worker_proxy_state._actor_proxy_wrapper.get_num_drain_checks() == N
# Mark target proxy as fully drained
worker_proxy_state._actor_proxy_wrapper.is_drained_response = True
# Reconcile proxies with empty set of target nodes (worker node proxy
# will be shutdown by now)
manager.update(
proxy_nodes={HEAD_NODE_ID},
)
assert len(manager._proxy_states) == 1
assert manager._proxy_states[HEAD_NODE_ID].status == ProxyStatus.HEALTHY
def test_is_ready_for_shutdown(all_nodes):
"""Test `is_ready_for_shutdown()` returns True the correct state.
Before `shutdown()` is called, `is_ready_for_shutdown()` should return false. After
`shutdown()` is called and all proxy actor are killed, `is_ready_for_shutdown()`
should return true.
"""
manager, cluster_node_info_cache = _create_proxy_state_manager(
HTTPOptions(location=ProxyLocation.EveryNode)
)
cluster_node_info_cache.alive_nodes = all_nodes
for node_id, _, _ in all_nodes:
manager._proxy_states[node_id] = _create_proxy_state(
status=ProxyStatus.HEALTHY,
node_id=node_id,
)
# Ensure before shutdown, manager is not shutdown
assert not manager.is_ready_for_shutdown()
manager.shutdown()
# Ensure after shutdown, manager is shutdown and all proxy states are shutdown
def check_is_ready_for_shutdown():
return manager.is_ready_for_shutdown()
wait_for_condition(check_is_ready_for_shutdown)
@patch("ray.serve._private.proxy_state.PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD", 1)
@pytest.mark.parametrize("number_of_worker_nodes", [1])
def test_proxy_state_manager_timing_out_on_start(number_of_worker_nodes, all_nodes):
"""Test update method on ProxyStateManager when the proxy state is STARTING and
when the ready call takes longer than PROXY_READY_CHECK_TIMEOUT_S.
The proxy state started with STARTING. After update is called, ready calls takes
some time to finish. The proxy state manager will restart the proxy state after
PROXY_READY_CHECK_TIMEOUT_S. After the next period of check_health call,
the proxy state manager will check on backoff timeout, not immediately
restarting the proxy states, and eventually set the proxy state to HEALTHY.
"""
fake_time = MockTimer()
proxy_state_manager, cluster_node_info_cache = _create_proxy_state_manager(
http_options=HTTPOptions(location=ProxyLocation.EveryNode),
timer=fake_time,
)
cluster_node_info_cache.alive_nodes = all_nodes
node_ids = {node[0] for node in all_nodes}
# Run update to create proxy states.
proxy_state_manager.update(proxy_nodes=node_ids)
# Ensure 2 proxies are created, one for the head node and another for the worker.
assert len(proxy_state_manager._proxy_states) == len(node_ids)
# Ensure the proxy state statuses before update are STARTING, set the
# readiness check to failing
for node_id in node_ids:
proxy_state = proxy_state_manager._proxy_states[node_id]
assert proxy_state.status == ProxyStatus.STARTING
proxy_state._actor_proxy_wrapper.is_ready_response = False
# Capture current proxy states (prior to updating)
prev_proxy_states = dict(proxy_state_manager._proxy_states)
# Trigger PSM to reconcile
proxy_state_manager.update(proxy_nodes=node_ids)
# Ensure the proxy state statuses before update are STARTING, set the
# readiness check to failing
for node_id in node_ids:
proxy_state = proxy_state_manager._proxy_states[node_id]
prev_proxy_state = prev_proxy_states[node_id]
# Assert
# - All proxies are restarted
# - Previous proxy states are UNHEALTHY
# - New proxy states are STARTING
assert proxy_state != prev_proxy_state
assert prev_proxy_state.status == ProxyStatus.UNHEALTHY
assert proxy_state.status == ProxyStatus.STARTING
# Mark new proxy readiness checks as passing
proxy_state._actor_proxy_wrapper.is_ready_response = True
# Capture current proxy states again (prior to updating)
prev_proxy_states = dict(proxy_state_manager._proxy_states)
# Trigger PSM to reconcile
proxy_state_manager.update(proxy_nodes=node_ids)
# Ensure the proxy state statuses before update are STARTING, set the
# readiness check to failing
for node_id in node_ids:
proxy_state = proxy_state_manager._proxy_states[node_id]
prev_proxy_state = prev_proxy_states[node_id]
# Assert
# - All proxies are restarted
# - Previous proxy states are UNHEALTHY
# - New proxy states are STARTING
assert proxy_state == prev_proxy_state
assert prev_proxy_state.status == ProxyStatus.HEALTHY
assert proxy_state.status == ProxyStatus.HEALTHY
def test_proxy_state_manager_get_targets(all_nodes):
"""Test the get_targets method on ProxyStateManager."""
manager, cluster_node_info_cache = _create_proxy_state_manager(
HTTPOptions(location=ProxyLocation.EveryNode)
)
cluster_node_info_cache.alive_nodes = all_nodes
for node_id, _, _ in all_nodes:
manager._proxy_states[node_id] = _create_proxy_state(
status=ProxyStatus.HEALTHY,
node_id=node_id,
)
manager._proxy_states[all_nodes[-1][0]].try_update_status(ProxyStatus.DRAINED)
targets = manager.get_targets(RequestProtocol.HTTP)
assert len(targets) == len(all_nodes) - 1
assert targets[0].ip == "mock_node_ip"
assert targets[0].port == 8000
assert targets[0].instance_id == "mock_instance_id"
assert targets[0].name == "alice"
targets = manager.get_targets(RequestProtocol.GRPC)
assert len(targets) == 0
with pytest.raises(ValueError):
manager.get_targets("invalid_protocol")
class TestFallbackProxy:
"""Tests for fallback proxy lifecycle management in ProxyStateManager."""
HEAD_NODE = (HEAD_NODE_ID, "fake-head-ip", "fake-head-instance-id")
WORKER_NODE = ("worker-node-id-0", "fake-worker-ip-0", "fake-worker-instance-id-0")
def _create_manager_with_fallback(self, timer=None):
"""Helper to create a ProxyStateManager with running_native_proxies=True."""
timer = timer or Timer()
cache = MockClusterNodeInfoCache()
cache.alive_nodes = [self.HEAD_NODE, self.WORKER_NODE]
manager, cluster_node_info_cache = _create_proxy_state_manager(
http_options=HTTPOptions(location=ProxyLocation.HeadOnly),
cluster_node_info_cache=cache,
running_native_proxies=True,
timer=timer,
)
return manager, cluster_node_info_cache
def test_fallback_proxy_starts_on_head_node(self):
"""When running_native_proxies=True, a fallback proxy should be
started on the head node."""
manager, _ = self._create_manager_with_fallback()
assert manager._fallback_proxy_state is None
manager.update(proxy_nodes={HEAD_NODE_ID})
assert manager._fallback_proxy_state is not None
assert manager._fallback_proxy_state._node_id == HEAD_NODE_ID
assert manager._fallback_proxy_state._status == ProxyStatus.STARTING
def test_no_fallback_proxy_when_not_native(self):
"""When running_native_proxies=False, no fallback proxy should be started."""
cache = MockClusterNodeInfoCache()
cache.alive_nodes = [self.HEAD_NODE]
manager, _ = _create_proxy_state_manager(
http_options=HTTPOptions(location=ProxyLocation.HeadOnly),
cluster_node_info_cache=cache,
running_native_proxies=False,
)
manager.update(proxy_nodes={HEAD_NODE_ID})
assert manager._fallback_proxy_state is None
def test_fallback_proxy_uses_correct_ports(self):
"""The fallback proxy should use the fallback ports (8500/9500),
not the default proxy ports (8000/9000)."""
cache = MockClusterNodeInfoCache()
cache.alive_nodes = [self.HEAD_NODE, self.WORKER_NODE]
manager, _ = _create_proxy_state_manager(
http_options=HTTPOptions(location=ProxyLocation.HeadOnly),
cluster_node_info_cache=cache,
running_native_proxies=True,
)
with mock.patch.object(
manager, "_start_proxy", wraps=manager._start_proxy
) as mock_start:
manager.update(proxy_nodes={HEAD_NODE_ID})
fallback_calls = [
call
for call in mock_start.call_args_list
if "fallback" in call.kwargs.get("name", "")
]
assert len(fallback_calls) == 1
kwargs = fallback_calls[0].kwargs
assert kwargs["http_options"].port == RAY_SERVE_FALLBACK_PROXY_HTTP_PORT
assert kwargs["grpc_options"].port == RAY_SERVE_FALLBACK_PROXY_GRPC_PORT
def test_fallback_proxy_not_started_twice(self):
"""Calling update multiple times should not create a second fallback proxy."""
manager, _ = self._create_manager_with_fallback()
manager.update(proxy_nodes={HEAD_NODE_ID})
first_fallback = manager._fallback_proxy_state
manager.update(proxy_nodes={HEAD_NODE_ID})
assert manager._fallback_proxy_state is first_fallback
@patch("ray.serve._private.proxy_state.PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD", 1)
def test_stop_unhealthy_fallback_proxy(self):
"""When the fallback proxy becomes unhealthy, it should be stopped
and the restart count incremented."""
manager, _ = self._create_manager_with_fallback()
manager.update(proxy_nodes={HEAD_NODE_ID})
assert manager._fallback_proxy_state is not None
assert manager._fallback_proxy_restart_count == 0
manager._fallback_proxy_state._set_status(ProxyStatus.UNHEALTHY)
manager.update(proxy_nodes={HEAD_NODE_ID})
# Unhealthy fallback proxy should be stopped and cleared
# A new one should be started in the same update cycle
assert manager._fallback_proxy_restart_count == 1
assert manager._fallback_proxy_state is not None
assert manager._fallback_proxy_state.status == ProxyStatus.STARTING
def test_stop_drained_fallback_proxy(self):
"""When the fallback proxy enters DRAINED status, it should be removed."""
manager, _ = self._create_manager_with_fallback()
manager.update(proxy_nodes={HEAD_NODE_ID})
assert manager._fallback_proxy_state is not None
manager._fallback_proxy_state._set_status(ProxyStatus.DRAINED)
manager.update(proxy_nodes={HEAD_NODE_ID})
# Drained fallback proxy is stopped, then a new one is started
assert manager._fallback_proxy_restart_count == 1
assert manager._fallback_proxy_state is not None
assert manager._fallback_proxy_state.status == ProxyStatus.STARTING
def test_fallback_proxy_included_in_handles(self):
"""get_proxy_handles() should include the fallback proxy."""
manager, _ = self._create_manager_with_fallback()
manager.update(proxy_nodes={HEAD_NODE_ID})
handles = manager.get_proxy_handles()
assert f"fallback-{HEAD_NODE_ID}" in handles
assert (
handles[f"fallback-{HEAD_NODE_ID}"]
is manager._fallback_proxy_state.actor_handle
)
def test_fallback_proxy_included_in_names(self):
"""get_proxy_names() should include the fallback proxy."""
manager, _ = self._create_manager_with_fallback()
manager.update(proxy_nodes={HEAD_NODE_ID})
names = manager.get_proxy_names()
assert f"fallback-{HEAD_NODE_ID}" in names
assert (
names[f"fallback-{HEAD_NODE_ID}"]
== manager._fallback_proxy_state.actor_name
)
def test_fallback_proxy_included_in_alive_actor_ids(self):
"""get_alive_proxy_actor_ids() should include the fallback proxy's actor ID."""
manager, _ = self._create_manager_with_fallback()
manager.update(proxy_nodes={HEAD_NODE_ID})
actor_ids = manager.get_alive_proxy_actor_ids()
assert manager._fallback_proxy_state.actor_id in actor_ids
def test_fallback_proxy_blocks_shutdown_readiness(self):
"""is_ready_for_shutdown() should return False until the fallback
proxy is also shut down."""
manager, _ = self._create_manager_with_fallback()
manager.update(proxy_nodes={HEAD_NODE_ID})
# Shut down regular proxies but not the fallback
for proxy_state in manager._proxy_states.values():
proxy_state.shutdown()
proxy_state._actor_proxy_wrapper.shutdown = True
# Fallback proxy is still alive, so not ready for shutdown
assert not manager.is_ready_for_shutdown()
# Now shut down the fallback proxy
manager._fallback_proxy_state.shutdown()
manager._fallback_proxy_state._actor_proxy_wrapper.shutdown = True
assert manager.is_ready_for_shutdown()
def test_shutdown_includes_fallback_proxy(self):
"""shutdown() should also shut down the fallback proxy."""
manager, _ = self._create_manager_with_fallback()
manager.update(proxy_nodes={HEAD_NODE_ID})
assert manager._fallback_proxy_state is not None
fallback_wrapper = manager._fallback_proxy_state._actor_proxy_wrapper
manager.shutdown()
assert manager._fallback_proxy_state._shutting_down
assert fallback_wrapper.shutdown is True
def test_fallback_proxy_reconciled_during_update(self):
"""The fallback proxy should be reconciled (health checked) during each
update cycle."""
timer = MockTimer()
manager, _ = self._create_manager_with_fallback(timer=timer)
manager.update(proxy_nodes={HEAD_NODE_ID})
# Make fallback proxy ready
manager._fallback_proxy_state._actor_proxy_wrapper.is_ready_response = True
manager.update(proxy_nodes={HEAD_NODE_ID})
assert manager._fallback_proxy_state.status == ProxyStatus.HEALTHY
# Set health check to pass and verify it runs
manager._fallback_proxy_state._actor_proxy_wrapper.is_healthy_response = True
timer.advance(5)
manager.update(proxy_nodes={HEAD_NODE_ID})
assert manager._fallback_proxy_state.status == ProxyStatus.HEALTHY
assert manager._fallback_proxy_state._actor_proxy_wrapper.num_health_checks > 0
def test_get_fallback_proxy_targets(self):
"""get_fallback_proxy_targets() should return the fallback proxy targets
when the fallback proxy is set and healthy."""
manager, _ = self._create_manager_with_fallback()
# The fallback proxy is not set.
targets = manager.get_fallback_proxy_targets()
assert len(targets) == 0
# The fallback proxy is not healthy.
state = _create_proxy_state()
manager._fallback_proxy_state = state
targets = manager.get_fallback_proxy_targets()
assert len(targets) == 0
# The fallback proxy is healthy.
state.try_update_status(ProxyStatus.HEALTHY)
targets = manager.get_fallback_proxy_targets()
assert len(targets) == 1
target = targets[RequestProtocol.HTTP]
assert target.ip == state.actor_details.node_ip
assert target.port == 8500
assert target.instance_id == state.actor_details.node_instance_id
assert target.name == state.actor_name
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))