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

1200 lines
40 KiB
Python

import asyncio
from copy import deepcopy
from typing import Dict, List, Optional, Tuple
from unittest import mock
import pytest
from ray.serve._private.common import (
DeploymentID,
DeploymentStatus,
DeploymentStatusTrigger,
ReplicaID,
ReplicaState,
RequestProtocol,
RunningReplicaInfo,
)
from ray.serve._private.controller import ServeController
from ray.serve._private.node_port_manager import NodePortManager
from ray.serve.config import HTTPOptions, gRPCOptions
from ray.serve.schema import (
DeploymentDetails,
DeploymentSchema,
ReplicaDetails,
Target,
TargetGroup,
)
# Simple test KV store implementation
class FakeKVStore:
def __init__(self):
self.data = {}
def get(self, key):
return self.data.get(key)
def put(self, key, value):
self.data[key] = value
def delete(self, key):
if key in self.data:
del self.data[key]
# Simplified LongPollHost for tests
class FakeLongPollHost:
def __init__(self):
self.notified_changes = {}
def notify_changed(self, changes):
self.notified_changes.update(changes)
# Application State Manager for dependency injection
class FakeApplicationStateManager:
def __init__(
self,
app_statuses,
route_prefixes,
ingress_deployments,
ingress_request_router_deployments=None,
):
self.app_statuses = app_statuses
self.route_prefixes = route_prefixes
self.ingress_deployments = ingress_deployments
self.ingress_request_router_deployments = (
ingress_request_router_deployments or {}
)
def list_app_statuses(self):
return self.app_statuses
def get_route_prefix(self, app_name):
return self.route_prefixes.get(app_name, f"/{app_name}")
def get_ingress_deployment_name(self, app_name):
return self.ingress_deployments.get(app_name, f"{app_name}_ingress")
def get_ingress_request_router_deployment_name(self, app_name):
return self.ingress_request_router_deployments.get(app_name)
class FakeProxyState:
def __init__(self, node_id, node_ip, name):
self.node_id = node_id
self.node_ip = node_ip
self.name = name
class FakeProxyStateManager:
def __init__(self):
self.proxy_details = {}
self._http_options = HTTPOptions()
self._grpc_options = gRPCOptions(
grpc_servicer_functions=["f1"],
)
self._fallback_proxy_targets = {}
self._started_fallback_proxy = False
def add_proxy_details(self, node_id, node_ip, name):
self.proxy_details[node_id] = FakeProxyState(
node_id=node_id,
node_ip=node_ip,
name=name,
)
def add_fallback_proxy_target(
self,
node_ip,
port,
node_instance_id,
actor_name,
protocol,
):
self._fallback_proxy_targets[protocol] = Target(
ip=node_ip,
port=port,
instance_id=node_instance_id,
name=actor_name,
)
def get_proxy_details(self):
return self.proxy_details
def get_targets(self, protocol: RequestProtocol):
if protocol == RequestProtocol.HTTP:
port = self._http_options.port
else:
port = self._grpc_options.port
return [
Target(
ip=proxy_details.node_ip,
port=port,
instance_id="",
name=proxy_details.name,
)
for node_id, proxy_details in self.proxy_details.items()
]
def get_grpc_config(self):
return self._grpc_options
def get_fallback_proxy_targets(self):
return deepcopy(self._fallback_proxy_targets)
def started_fallback_proxy_at_least_once(self):
return self._started_fallback_proxy
# Deployment State Manager for dependency injection
class FakeDeploymentStateManager:
def __init__(
self,
running_replica_infos: Dict[DeploymentID, List[RunningReplicaInfo]],
):
self.running_replica_infos = running_replica_infos
def get_running_replica_infos(self):
return self.running_replica_infos
def get_deployment_ids(self):
return list(self.running_replica_infos.keys())
def get_node_id_to_alive_replica_ids(self):
node_id_to_alive_replica_ids = {}
for replica_infos in self.running_replica_infos.values():
for replica_info in replica_infos:
if replica_info.node_id is None:
continue
node_id_to_alive_replica_ids.setdefault(
replica_info.node_id, set()
).add(replica_info.replica_id.unique_id)
return node_id_to_alive_replica_ids
def get_replica_details(self, replica_info: RunningReplicaInfo) -> ReplicaDetails:
return ReplicaDetails(
replica_id=replica_info.replica_id.unique_id,
node_id=replica_info.node_id,
node_ip=replica_info.node_ip,
node_instance_id="",
start_time_s=0,
state=ReplicaState.RUNNING,
actor_name=replica_info.replica_id.unique_id,
)
def get_deployment_details(self, id: DeploymentID) -> Optional[DeploymentDetails]:
if id not in self.running_replica_infos:
return None
replica_details = [
self.get_replica_details(replica_info)
for replica_info in self.running_replica_infos[id]
]
return DeploymentDetails(
name=id.name,
status=DeploymentStatus.HEALTHY,
status_trigger=DeploymentStatusTrigger.UNSPECIFIED,
message="",
deployment_config=DeploymentSchema(name=id.name),
target_num_replicas=1,
required_resources={},
replicas=replica_details,
)
def get_ingress_replicas_info(self) -> List[Tuple[str, str, int, int]]:
return []
class FakeTestingDeploymentStateManager:
def __init__(self):
self.dumped_deployment_id = None
self.stopped_deployment_id = None
self.replica_states = object()
def _dump_replica_states_for_testing(self, deployment_id: DeploymentID):
self.dumped_deployment_id = deployment_id
return self.replica_states
def _stop_one_running_replica_for_testing(self, deployment_id: DeploymentID):
self.stopped_deployment_id = deployment_id
# Test Controller that overrides methods and dependencies
class FakeDirectIngressController(ServeController):
def __init__(
self,
kv_store,
long_poll_host,
application_state_manager,
deployment_state_manager,
proxy_state_manager,
):
# Skip parent __init__ since we'll set dependencies directly
self.kv_store = kv_store
self.long_poll_host = long_poll_host
self.application_state_manager = application_state_manager
self.deployment_state_manager = deployment_state_manager
self.proxy_state_manager = proxy_state_manager
self._direct_ingress_enabled = True
self._last_ingress_port_tuples = set()
self._ha_proxy_enabled = False
self._controller_node_id = "head_node_id"
self._shutting_down = False
self.done_recovering_event = asyncio.Event()
self.done_recovering_event.set()
self.node_update_duration_gauge_s = mock.Mock()
self._last_broadcasted_fallback_targets = {}
def _update_proxy_nodes(self):
pass
@pytest.fixture(autouse=True)
def setup_env(monkeypatch):
monkeypatch.setenv("RAY_SERVE_ENABLE_DIRECT_INGRESS", "1")
monkeypatch.setenv("RAY_SERVE_DIRECT_INGRESS_MIN_HTTP_PORT", "30000")
monkeypatch.setenv("RAY_SERVE_DIRECT_INGRESS_MAX_HTTP_PORT", "30100")
monkeypatch.setenv("RAY_SERVE_DIRECT_INGRESS_MIN_GRPC_PORT", "40000")
monkeypatch.setenv("RAY_SERVE_DIRECT_INGRESS_MAX_GRPC_PORT", "40100")
yield
# Fixture to clear NodePortManager between tests
@pytest.fixture(autouse=True)
def reset_node_port_manager():
# Save original managers
original_managers = NodePortManager._node_managers.copy()
# Clear all managers
NodePortManager._node_managers = {}
yield
# Restore original managers
NodePortManager._node_managers = original_managers
# Fixture for a minimal controller setup
@pytest.fixture
def direct_ingress_controller():
# Setup test dependencies
kv_store = FakeKVStore()
long_poll_host = FakeLongPollHost()
app_state_manager = FakeApplicationStateManager({}, {}, {})
deployment_state_manager = FakeDeploymentStateManager({})
proxy_state_manager = FakeProxyStateManager()
# Create controller with test dependencies
controller = FakeDirectIngressController(
kv_store=kv_store,
long_poll_host=long_poll_host,
application_state_manager=app_state_manager,
deployment_state_manager=deployment_state_manager,
proxy_state_manager=proxy_state_manager,
)
yield controller
def test_direct_ingress_is_disabled(
direct_ingress_controller: FakeDirectIngressController,
):
"""Test that get_target_groups returns empty list when direct ingress is disabled."""
direct_ingress_controller._direct_ingress_enabled = False
target_groups = direct_ingress_controller.get_target_groups()
assert target_groups == []
# proxy has nodes
direct_ingress_controller.proxy_state_manager.add_proxy_details(
"node1", "10.0.0.1", "proxy1"
)
direct_ingress_controller.proxy_state_manager.add_proxy_details(
"node2", "10.0.0.2", "proxy2"
)
target_groups = direct_ingress_controller.get_target_groups()
expected_target_groups = [
TargetGroup(
protocol=RequestProtocol.HTTP,
route_prefix="/",
targets=[
Target(ip="10.0.0.1", port=8000, instance_id="", name="proxy1"),
Target(ip="10.0.0.2", port=8000, instance_id="", name="proxy2"),
],
),
TargetGroup(
protocol=RequestProtocol.GRPC,
route_prefix="/",
targets=[
Target(ip="10.0.0.1", port=9000, instance_id="", name="proxy1"),
Target(ip="10.0.0.2", port=9000, instance_id="", name="proxy2"),
],
),
]
assert target_groups == expected_target_groups
# Test with empty applications
def test_get_target_groups_empty_when_no_apps(
direct_ingress_controller: FakeDirectIngressController,
):
"""Test that get_target_groups returns empty list when no apps are running."""
target_groups = direct_ingress_controller.get_target_groups()
assert target_groups == []
# proxy has nodes
direct_ingress_controller.proxy_state_manager.add_proxy_details(
"node1", "10.0.0.1", "proxy1"
)
direct_ingress_controller.proxy_state_manager.add_proxy_details(
"node2", "10.0.0.2", "proxy2"
)
target_groups = direct_ingress_controller.get_target_groups()
expected_target_groups = [
TargetGroup(
protocol=RequestProtocol.HTTP,
route_prefix="/",
targets=[
Target(ip="10.0.0.1", port=8000, instance_id="", name="proxy1"),
Target(ip="10.0.0.2", port=8000, instance_id="", name="proxy2"),
],
),
TargetGroup(
protocol=RequestProtocol.GRPC,
route_prefix="/",
targets=[
Target(ip="10.0.0.1", port=9000, instance_id="", name="proxy1"),
Target(ip="10.0.0.2", port=9000, instance_id="", name="proxy2"),
],
),
]
assert target_groups == expected_target_groups
# Test with running applications
def test_get_target_groups_with_running_apps(
direct_ingress_controller: FakeDirectIngressController,
):
"""Test that get_target_groups properly returns target groups for running apps."""
# Setup test data
app_statuses = {
"app1": {},
"app2": {},
}
route_prefixes = {
"app1": "/app1",
"app2": "/app2",
}
ingress_deployments = {
"app1": "app1_ingress",
"app2": "app2_ingress",
}
deployment_id1 = DeploymentID(name="app1_ingress", app_name="app1")
deployment_id2 = DeploymentID(name="app2_ingress", app_name="app2")
# Create replica info
replica_id1 = ReplicaID(unique_id="replica1", deployment_id=deployment_id1)
replica_id2 = ReplicaID(unique_id="replica2", deployment_id=deployment_id2)
replica_info1 = RunningReplicaInfo(
replica_id=replica_id1,
node_id="node1",
node_ip="10.0.0.1",
availability_zone="az1",
actor_name="replica1",
max_ongoing_requests=100,
)
replica_info2 = RunningReplicaInfo(
replica_id=replica_id2,
node_id="node2",
node_ip="10.0.0.2",
availability_zone="az2",
actor_name="replica2",
max_ongoing_requests=100,
)
running_replica_infos = {
deployment_id1: [replica_info1],
deployment_id2: [replica_info2],
}
# Setup test application state manager
direct_ingress_controller.application_state_manager = FakeApplicationStateManager(
app_statuses=app_statuses,
route_prefixes=route_prefixes,
ingress_deployments=ingress_deployments,
)
# Setup test deployment state manager
direct_ingress_controller.deployment_state_manager = FakeDeploymentStateManager(
running_replica_infos=running_replica_infos,
)
# setup proxy state manager
direct_ingress_controller.proxy_state_manager.add_proxy_details(
"node1", "10.0.0.1", "proxy1"
)
direct_ingress_controller.proxy_state_manager.add_proxy_details(
"node2", "10.0.0.2", "proxy2"
)
# Allocate ports for replicas using controller's methods
http_port1 = direct_ingress_controller.allocate_replica_port(
"node1", replica_id1.unique_id, RequestProtocol.HTTP
)
grpc_port1 = direct_ingress_controller.allocate_replica_port(
"node1", replica_id1.unique_id, RequestProtocol.GRPC
)
http_port2 = direct_ingress_controller.allocate_replica_port(
"node2", replica_id2.unique_id, RequestProtocol.HTTP
)
grpc_port2 = direct_ingress_controller.allocate_replica_port(
"node2", replica_id2.unique_id, RequestProtocol.GRPC
)
# Call get_target_groups
target_groups = direct_ingress_controller.get_target_groups()
# Create expected target groups for direct comparison
expected_target_groups = [
TargetGroup(
protocol=RequestProtocol.HTTP,
route_prefix="/app1",
app_name="app1",
targets=[
Target(ip="10.0.0.1", port=http_port1, instance_id="", name="replica1"),
],
),
TargetGroup(
protocol=RequestProtocol.GRPC,
route_prefix="/app1",
app_name="app1",
targets=[
Target(ip="10.0.0.1", port=grpc_port1, instance_id="", name="replica1"),
],
),
TargetGroup(
protocol=RequestProtocol.HTTP,
route_prefix="/app2",
app_name="app2",
targets=[
Target(ip="10.0.0.2", port=http_port2, instance_id="", name="replica2"),
],
),
TargetGroup(
protocol=RequestProtocol.GRPC,
route_prefix="/app2",
app_name="app2",
targets=[
Target(ip="10.0.0.2", port=grpc_port2, instance_id="", name="replica2"),
],
),
]
# Sort both lists to ensure consistent comparison
target_groups.sort(key=lambda g: (g.protocol, g.route_prefix))
expected_target_groups.sort(key=lambda g: (g.protocol, g.route_prefix))
# Direct comparison
assert target_groups == expected_target_groups
# now release some ports
direct_ingress_controller.release_replica_port(
"node1", replica_id1.unique_id, http_port1, RequestProtocol.HTTP
)
direct_ingress_controller.release_replica_port(
"node2", replica_id2.unique_id, grpc_port2, RequestProtocol.GRPC
)
# verify the ports are released
assert not direct_ingress_controller._is_port_allocated(
direct_ingress_controller.deployment_state_manager.get_replica_details(
replica_info1
),
RequestProtocol.HTTP,
)
assert not direct_ingress_controller._is_port_allocated(
direct_ingress_controller.deployment_state_manager.get_replica_details(
replica_info2
),
RequestProtocol.GRPC,
)
# get the target groups again
target_groups = direct_ingress_controller.get_target_groups()
expected_target_groups = [
TargetGroup(
protocol=RequestProtocol.GRPC,
route_prefix="/app1",
app_name="app1",
targets=[
Target(ip="10.0.0.1", port=grpc_port1, instance_id="", name="replica1"),
],
),
TargetGroup(
protocol=RequestProtocol.HTTP,
route_prefix="/app2",
app_name="app2",
targets=[
Target(ip="10.0.0.2", port=http_port2, instance_id="", name="replica2"),
],
),
]
# Sort both lists again for the second comparison
target_groups.sort(key=lambda g: (g.protocol, g.route_prefix))
expected_target_groups.sort(key=lambda g: (g.protocol, g.route_prefix))
assert target_groups == expected_target_groups
def test_get_target_groups_with_port_not_allocated(
direct_ingress_controller: FakeDirectIngressController,
):
"""Test that get_target_groups correctly filters out targets with unallocated ports."""
# Setup test data
app_statuses = {"app1": {}}
route_prefixes = {"app1": "/app1"}
ingress_deployments = {"app1": "app1_ingress"}
# Create replica info for two replicas
deployment_id1 = DeploymentID(name="app1_ingress", app_name="app1")
replica_id1 = ReplicaID(unique_id="replica1", deployment_id=deployment_id1)
replica_id2 = ReplicaID(unique_id="replica2", deployment_id=deployment_id1)
replica_info1 = RunningReplicaInfo(
replica_id=replica_id1,
node_id="node1",
node_ip="10.0.0.1",
availability_zone="az1",
actor_name="replica1",
max_ongoing_requests=100,
)
replica_info2 = RunningReplicaInfo(
replica_id=replica_id2,
node_id="node2",
node_ip="10.0.0.2",
availability_zone="az2",
actor_name="replica2",
max_ongoing_requests=100,
)
running_replica_infos = {
deployment_id1: [replica_info1, replica_info2],
}
# Setup test application state manager
direct_ingress_controller.application_state_manager = FakeApplicationStateManager(
app_statuses=app_statuses,
route_prefixes=route_prefixes,
ingress_deployments=ingress_deployments,
)
# Setup test deployment state manager
direct_ingress_controller.deployment_state_manager = FakeDeploymentStateManager(
running_replica_infos=running_replica_infos,
)
# Only allocate ports for the first replica, leave the second one without ports
http_port1 = direct_ingress_controller.allocate_replica_port(
"node1", replica_id1.unique_id, RequestProtocol.HTTP
)
grpc_port1 = direct_ingress_controller.allocate_replica_port(
"node1", replica_id1.unique_id, RequestProtocol.GRPC
)
# Call get_target_groups
target_groups = direct_ingress_controller.get_target_groups()
# Create expected target groups
expected_target_groups = [
TargetGroup(
protocol=RequestProtocol.HTTP,
route_prefix="/app1",
app_name="app1",
targets=[
Target(ip="10.0.0.1", port=http_port1, instance_id="", name="replica1"),
],
),
TargetGroup(
protocol=RequestProtocol.GRPC,
route_prefix="/app1",
app_name="app1",
targets=[
Target(ip="10.0.0.1", port=grpc_port1, instance_id="", name="replica1"),
],
),
]
# Sort both lists to ensure consistent comparison
target_groups.sort(key=lambda g: (g.protocol, g.route_prefix))
expected_target_groups.sort(key=lambda g: (g.protocol, g.route_prefix))
# Direct comparison
assert target_groups == expected_target_groups
def test_get_target_groups_only_includes_ingress_deployments(
direct_ingress_controller: FakeDirectIngressController,
):
"""Test that get_target_groups only includes ingress deployments and not regular deployments."""
# Setup test data
app_statuses = {"app1": {}}
route_prefixes = {"app1": "/app1"}
ingress_deployments = {"app1": "app1_ingress"}
# Create replica info for ingress deployment
ingress_deployment_id = DeploymentID(name="app1_ingress", app_name="app1")
ingress_replica_id = ReplicaID(
unique_id="ingress_replica", deployment_id=ingress_deployment_id
)
ingress_replica_info = RunningReplicaInfo(
replica_id=ingress_replica_id,
node_id="node1",
node_ip="10.0.0.1",
availability_zone="az1",
actor_name="ingress_replica",
max_ongoing_requests=100,
)
# Create replica info for regular non-ingress deployment
regular_deployment_id = DeploymentID(name="app1_model", app_name="app1")
regular_replica_id = ReplicaID(
unique_id="regular_replica", deployment_id=regular_deployment_id
)
regular_replica_info = RunningReplicaInfo(
replica_id=regular_replica_id,
node_id="node2",
node_ip="10.0.0.2",
availability_zone="az2",
actor_name="regular_replica",
max_ongoing_requests=100,
)
# Set up running replica infos for both deployments
running_replica_infos = {
ingress_deployment_id: [ingress_replica_info],
regular_deployment_id: [regular_replica_info],
}
# Setup test application state manager
direct_ingress_controller.application_state_manager = FakeApplicationStateManager(
app_statuses=app_statuses,
route_prefixes=route_prefixes,
ingress_deployments=ingress_deployments,
)
# Setup test deployment state manager
direct_ingress_controller.deployment_state_manager = FakeDeploymentStateManager(
running_replica_infos=running_replica_infos,
)
# Allocate ports for both replicas
ingress_http_port = direct_ingress_controller.allocate_replica_port(
"node1", ingress_replica_id.unique_id, RequestProtocol.HTTP
)
ingress_grpc_port = direct_ingress_controller.allocate_replica_port(
"node1", ingress_replica_id.unique_id, RequestProtocol.GRPC
)
_ = direct_ingress_controller.allocate_replica_port(
"node2", regular_replica_id.unique_id, RequestProtocol.HTTP
)
_ = direct_ingress_controller.allocate_replica_port(
"node2", regular_replica_id.unique_id, RequestProtocol.GRPC
)
# Call get_target_groups
target_groups = direct_ingress_controller.get_target_groups()
# Create expected target groups - only including the ingress deployment
expected_target_groups = [
TargetGroup(
protocol=RequestProtocol.HTTP,
route_prefix="/app1",
app_name="app1",
targets=[
Target(
ip="10.0.0.1",
port=ingress_http_port,
instance_id="",
name="ingress_replica",
),
],
),
TargetGroup(
protocol=RequestProtocol.GRPC,
route_prefix="/app1",
app_name="app1",
targets=[
Target(
ip="10.0.0.1",
port=ingress_grpc_port,
instance_id="",
name="ingress_replica",
),
],
),
]
# Sort both lists to ensure consistent comparison
target_groups.sort(key=lambda g: (g.protocol, g.route_prefix))
expected_target_groups.sort(key=lambda g: (g.protocol, g.route_prefix))
# Direct comparison - regular deployment should not be included
assert target_groups == expected_target_groups
# Verify all ports are still allocated even though not all are included in target groups
assert direct_ingress_controller._is_port_allocated(
direct_ingress_controller.deployment_state_manager.get_replica_details(
ingress_replica_info
),
RequestProtocol.HTTP,
)
assert direct_ingress_controller._is_port_allocated(
direct_ingress_controller.deployment_state_manager.get_replica_details(
ingress_replica_info
),
RequestProtocol.GRPC,
)
assert direct_ingress_controller._is_port_allocated(
direct_ingress_controller.deployment_state_manager.get_replica_details(
regular_replica_info
),
RequestProtocol.HTTP,
)
assert direct_ingress_controller._is_port_allocated(
direct_ingress_controller.deployment_state_manager.get_replica_details(
regular_replica_info
),
RequestProtocol.GRPC,
)
def test_get_target_groups_populates_ingress_request_router_targets(
direct_ingress_controller: FakeDirectIngressController,
):
app_name = "app1"
route_prefix = "/app1"
ingress_deployment_id = DeploymentID(name="app1_ingress", app_name=app_name)
router_deployment_id = DeploymentID(name="app1_router", app_name=app_name)
ingress_replica_id = ReplicaID(
unique_id="ingress_replica", deployment_id=ingress_deployment_id
)
router_replica_id = ReplicaID(
unique_id="router_replica", deployment_id=router_deployment_id
)
ingress_replica_info = RunningReplicaInfo(
replica_id=ingress_replica_id,
node_id="node1",
node_ip="10.0.0.1",
availability_zone="az1",
actor_name="ingress_replica",
max_ongoing_requests=100,
)
router_replica_info = RunningReplicaInfo(
replica_id=router_replica_id,
node_id="node2",
node_ip="10.0.0.2",
availability_zone="az2",
actor_name="router_replica",
max_ongoing_requests=100,
)
direct_ingress_controller.application_state_manager = FakeApplicationStateManager(
app_statuses={app_name: {}},
route_prefixes={app_name: route_prefix},
ingress_deployments={app_name: ingress_deployment_id.name},
ingress_request_router_deployments={app_name: router_deployment_id.name},
)
direct_ingress_controller.deployment_state_manager = FakeDeploymentStateManager(
running_replica_infos={
ingress_deployment_id: [ingress_replica_info],
router_deployment_id: [router_replica_info],
},
)
ingress_http_port = direct_ingress_controller.allocate_replica_port(
"node1", ingress_replica_id.unique_id, RequestProtocol.HTTP
)
router_http_port = direct_ingress_controller.allocate_replica_port(
"node2", router_replica_id.unique_id, RequestProtocol.HTTP
)
assert direct_ingress_controller.get_target_groups(app_name=app_name) == [
TargetGroup(
protocol=RequestProtocol.HTTP,
route_prefix=route_prefix,
app_name=app_name,
targets=[
Target(
ip="10.0.0.1",
port=ingress_http_port,
instance_id="",
name="ingress_replica",
)
],
ingress_request_router_targets=[
Target(
ip="10.0.0.2",
port=router_http_port,
instance_id="",
name="router_replica",
)
],
)
]
def test_get_target_groups_app_with_no_running_replicas(
direct_ingress_controller: FakeDirectIngressController,
):
"""Test that get_target_groups correctly handles apps with no running replicas."""
# Setup test data for two apps - one with replicas, one without
app_statuses = {
"app1": {}, # App with replicas
"app2": {}, # App with no replicas
}
route_prefixes = {
"app1": "/app1",
"app2": "/app2",
}
ingress_deployments = {
"app1": "app1_ingress",
"app2": "app2_ingress",
}
# Create replica info for app1's ingress deployment
deployment_id1 = DeploymentID(name="app1_ingress", app_name="app1")
replica_id1 = ReplicaID(unique_id="replica1", deployment_id=deployment_id1)
replica_info1 = RunningReplicaInfo(
replica_id=replica_id1,
node_id="node1",
node_ip="10.0.0.1",
availability_zone="az1",
actor_name="replica1",
max_ongoing_requests=100,
)
# Note: No replicas for app2_ingress
# Running replica infos only for app1
running_replica_infos = {
deployment_id1: [replica_info1],
# No entry for app2_ingress
}
# Setup test application state manager
direct_ingress_controller.application_state_manager = FakeApplicationStateManager(
app_statuses=app_statuses,
route_prefixes=route_prefixes,
ingress_deployments=ingress_deployments,
)
# Setup test deployment state manager
direct_ingress_controller.deployment_state_manager = FakeDeploymentStateManager(
running_replica_infos=running_replica_infos,
)
# setup proxy state manager
direct_ingress_controller.proxy_state_manager.add_proxy_details(
"node1", "10.0.0.1", "proxy1"
)
direct_ingress_controller.proxy_state_manager.add_proxy_details(
"node2", "10.0.0.2", "proxy2"
)
# Allocate ports for the only existing replica
http_port = direct_ingress_controller.allocate_replica_port(
"node1", replica_id1.unique_id, RequestProtocol.HTTP
)
grpc_port = direct_ingress_controller.allocate_replica_port(
"node1", replica_id1.unique_id, RequestProtocol.GRPC
)
# Call get_target_groups
target_groups = direct_ingress_controller.get_target_groups()
# Create expected target groups - only including app1, nothing for app2
expected_target_groups = [
TargetGroup(
protocol=RequestProtocol.HTTP,
route_prefix="/app1",
app_name="app1",
targets=[
Target(ip="10.0.0.1", port=http_port, instance_id="", name="replica1"),
],
),
TargetGroup(
protocol=RequestProtocol.GRPC,
route_prefix="/app1",
app_name="app1",
targets=[
Target(ip="10.0.0.1", port=grpc_port, instance_id="", name="replica1"),
],
),
TargetGroup(
protocol=RequestProtocol.HTTP,
route_prefix="/app2",
app_name="app2",
targets=[
Target(ip="10.0.0.1", port=8000, instance_id="", name="proxy1"),
Target(ip="10.0.0.2", port=8000, instance_id="", name="proxy2"),
],
),
TargetGroup(
protocol=RequestProtocol.GRPC,
route_prefix="/app2",
app_name="app2",
targets=[
Target(ip="10.0.0.1", port=9000, instance_id="", name="proxy1"),
Target(ip="10.0.0.2", port=9000, instance_id="", name="proxy2"),
],
),
]
# Sort both lists to ensure consistent comparison
target_groups.sort(key=lambda g: (g.protocol, g.route_prefix))
expected_target_groups.sort(key=lambda g: (g.protocol, g.route_prefix))
# Direct comparison - app2 should have no target groups since it has no replicas
assert target_groups == expected_target_groups
def test_control_loop_pruning(
direct_ingress_controller: FakeDirectIngressController,
):
"""Test that the controller loop properly prunes stale node port managers."""
# Setup replica info for testing
deployment_id = DeploymentID(name="app1_ingress", app_name="app1")
replica_id1 = ReplicaID(unique_id="replica1", deployment_id=deployment_id)
replica_id2 = ReplicaID(unique_id="replica2", deployment_id=deployment_id)
replica_id3 = ReplicaID(unique_id="replica3", deployment_id=deployment_id)
replica_info1 = RunningReplicaInfo(
replica_id=replica_id1,
node_id="node1",
node_ip="10.0.0.1",
availability_zone="az1",
actor_name="replica1",
max_ongoing_requests=100,
)
replica_info2 = RunningReplicaInfo(
replica_id=replica_id2,
node_id="node1",
node_ip="10.0.0.1",
availability_zone="az1",
actor_name="replica2",
max_ongoing_requests=100,
)
replica_info3 = RunningReplicaInfo(
replica_id=replica_id3,
node_id="node2",
node_ip="10.0.0.2",
availability_zone="az2",
actor_name="replica3",
max_ongoing_requests=100,
)
running_replica_infos = {
deployment_id: [replica_info1, replica_info2, replica_info3],
}
# Set up controller with the replica info
direct_ingress_controller.deployment_state_manager = FakeDeploymentStateManager(
running_replica_infos=running_replica_infos,
)
# Allocate ports for testing
direct_ingress_controller.allocate_replica_port(
"node1", replica_id1.unique_id, RequestProtocol.HTTP
)
direct_ingress_controller.allocate_replica_port(
"node1", replica_id2.unique_id, RequestProtocol.HTTP
)
direct_ingress_controller.allocate_replica_port(
"node1", replica_id3.unique_id, RequestProtocol.HTTP
) # This should be pruned
direct_ingress_controller.allocate_replica_port(
"node2", replica_id3.unique_id, RequestProtocol.HTTP
)
direct_ingress_controller.allocate_replica_port(
"node3", "replica4", RequestProtocol.HTTP
) # Node should be pruned
# Verify ports are initially allocated
assert direct_ingress_controller._is_port_allocated(
direct_ingress_controller.deployment_state_manager.get_replica_details(
replica_info1
),
RequestProtocol.HTTP,
)
assert direct_ingress_controller._is_port_allocated(
direct_ingress_controller.deployment_state_manager.get_replica_details(
replica_info2
),
RequestProtocol.HTTP,
)
assert direct_ingress_controller._is_port_allocated(
direct_ingress_controller.deployment_state_manager.get_replica_details(
replica_info3
),
RequestProtocol.HTTP,
)
# We need to use NodePortManager directly for this check since we don't have a ReplicaInfo for stale_replica
node1_manager = NodePortManager.get_node_manager("node1")
node3_manager = NodePortManager.get_node_manager("node3")
assert node1_manager.is_port_allocated(replica_id3.unique_id, RequestProtocol.HTTP)
assert node3_manager.is_port_allocated("replica4", RequestProtocol.HTTP)
# Call the control loop step - this should trigger port pruning
direct_ingress_controller._maybe_update_ingress_ports()
# Verify the active replicas still have their ports
assert direct_ingress_controller._is_port_allocated(
direct_ingress_controller.deployment_state_manager.get_replica_details(
replica_info1
),
RequestProtocol.HTTP,
)
assert direct_ingress_controller._is_port_allocated(
direct_ingress_controller.deployment_state_manager.get_replica_details(
replica_info2
),
RequestProtocol.HTTP,
)
assert direct_ingress_controller._is_port_allocated(
direct_ingress_controller.deployment_state_manager.get_replica_details(
replica_info3
),
RequestProtocol.HTTP,
)
# Verify stale ports were pruned
assert not node1_manager.is_port_allocated(
replica_id3.unique_id, RequestProtocol.HTTP
)
assert "node3" not in NodePortManager._node_managers # Entire node should be pruned
def test_control_loop_pruning_does_not_require_private_deployment_states(
direct_ingress_controller: FakeDirectIngressController,
):
deployment_id = DeploymentID(name="app1_ingress", app_name="app1")
replica_id = ReplicaID(unique_id="replica1", deployment_id=deployment_id)
replica_info = RunningReplicaInfo(
replica_id=replica_id,
node_id="node1",
node_ip="10.0.0.1",
availability_zone="az1",
actor_name="replica1",
max_ongoing_requests=100,
)
direct_ingress_controller.deployment_state_manager = FakeDeploymentStateManager(
running_replica_infos={deployment_id: [replica_info]},
)
direct_ingress_controller.allocate_replica_port(
"node1", replica_id.unique_id, RequestProtocol.HTTP
)
direct_ingress_controller._maybe_update_ingress_ports()
assert direct_ingress_controller._is_port_allocated(
direct_ingress_controller.deployment_state_manager.get_replica_details(
replica_info
),
RequestProtocol.HTTP,
)
def test_dump_replica_states_for_testing_delegates_to_manager(
direct_ingress_controller: FakeDirectIngressController,
):
deployment_id = DeploymentID(name="app1_ingress", app_name="app1")
deployment_state_manager = FakeTestingDeploymentStateManager()
direct_ingress_controller.deployment_state_manager = deployment_state_manager
assert (
direct_ingress_controller._dump_replica_states_for_testing(deployment_id)
is deployment_state_manager.replica_states
)
assert deployment_state_manager.dumped_deployment_id == deployment_id
def test_stop_one_running_replica_for_testing_delegates_to_manager(
direct_ingress_controller: FakeDirectIngressController,
):
deployment_id = DeploymentID(name="app1_ingress", app_name="app1")
deployment_state_manager = FakeTestingDeploymentStateManager()
direct_ingress_controller.deployment_state_manager = deployment_state_manager
direct_ingress_controller._stop_one_running_replica_for_testing(deployment_id)
assert deployment_state_manager.stopped_deployment_id == deployment_id
def test_recon_port_gate_feeds_only_new_ingress_tuples(
direct_ingress_controller: FakeDirectIngressController,
):
"""C3: update_ports is fed only the set-diff of
ingress tuples new since the last sync -- the full set on the first tick (empty
cache) and an empty list on an unchanged second tick."""
tuples = [("node1", "r1", 30000, 40000), ("node2", "r2", 30001, 40001)]
direct_ingress_controller.deployment_state_manager.get_ingress_replicas_info = (
lambda: list(tuples)
)
with mock.patch.object(NodePortManager, "update_ports") as mock_update:
# First tick: cache empty -> full emit.
direct_ingress_controller._maybe_update_ingress_ports()
assert set(mock_update.call_args[0][0]) == set(tuples)
# Second tick: ingress set unchanged -> empty diff.
mock_update.reset_mock()
direct_ingress_controller._maybe_update_ingress_ports()
assert mock_update.call_args[0][0] == []
def test_recon_port_gate_prune_skips_unchanged_node(
direct_ingress_controller: FakeDirectIngressController,
):
"""C4: prune skips a node's O(replicas) reclaim
scan when that node's alive-replica set is unchanged since the last prune."""
deployment_id = DeploymentID(name="app1_ingress", app_name="app1")
replica_id = ReplicaID(unique_id="replica1", deployment_id=deployment_id)
replica_info = RunningReplicaInfo(
replica_id=replica_id,
node_id="node1",
node_ip="10.0.0.1",
availability_zone="az1",
actor_name="replica1",
max_ongoing_requests=100,
)
direct_ingress_controller.deployment_state_manager = FakeDeploymentStateManager(
running_replica_infos={deployment_id: [replica_info]},
)
direct_ingress_controller.allocate_replica_port(
"node1", replica_id.unique_id, RequestProtocol.HTTP
)
node1_manager = NodePortManager.get_node_manager("node1")
with mock.patch.object(
node1_manager,
"_prune_replica_ports",
wraps=node1_manager._prune_replica_ports,
) as spy:
# First tick: fingerprint unset -> reclaim scan runs.
direct_ingress_controller._maybe_update_ingress_ports()
assert spy.call_count == 1
# Second tick: alive set unchanged -> reclaim scan skipped.
direct_ingress_controller._maybe_update_ingress_ports()
assert spy.call_count == 1
if __name__ == "__main__":
pytest.main()