Files
2026-07-13 13:17:40 +08:00

422 lines
16 KiB
Python

import json
import time
import pytest
import ray
from ray import serve
from ray._common.test_utils import wait_for_condition
from ray.serve._private.common import DeploymentID
from ray.serve._private.config import DeploymentConfig
from ray.serve._private.constants import (
DEFAULT_AUTOSCALING_POLICY_NAME,
SERVE_DEFAULT_APP_NAME,
)
from ray.serve._private.deployment_info import DeploymentInfo
from ray.serve.autoscaling_policy import default_autoscaling_policy
from ray.serve.context import _get_global_client
from ray.serve.generated.serve_pb2 import DeploymentRoute
from ray.serve.schema import ApplicationStatus, ServeDeploySchema
from ray.serve.tests.conftest import TEST_GRPC_SERVICER_FUNCTIONS
def test_redeploy_start_time(serve_instance):
"""Check that redeploying a deployment doesn't reset its start time."""
controller = _get_global_client()._controller
@serve.deployment
def test(_):
return "1"
serve.run(test.bind())
deployment_route = DeploymentRoute.FromString(
ray.get(controller.get_deployment_info.remote("test", SERVE_DEFAULT_APP_NAME))
)
deployment_info_1 = DeploymentInfo.from_proto(deployment_route.deployment_info)
start_time_ms_1 = deployment_info_1.start_time_ms
time.sleep(0.1)
@serve.deployment
def test(_):
return "2"
serve.run(test.bind())
deployment_route = DeploymentRoute.FromString(
ray.get(controller.get_deployment_info.remote("test", SERVE_DEFAULT_APP_NAME))
)
deployment_info_2 = DeploymentInfo.from_proto(deployment_route.deployment_info)
start_time_ms_2 = deployment_info_2.start_time_ms
assert start_time_ms_1 == start_time_ms_2
def test_deploy_app_custom_exception(serve_instance):
"""Check that controller doesn't deserialize an exception from deploy_app."""
controller = _get_global_client()._controller
config = {
"applications": [
{
"name": "broken_app",
"route_prefix": "/broken",
"import_path": "ray.serve.tests.test_config_files.broken_app:app",
}
]
}
ray.get(
controller.apply_config.remote(config=ServeDeploySchema.model_validate(config))
)
def check_custom_exception() -> bool:
status = serve.status().applications["broken_app"]
assert status.status == ApplicationStatus.DEPLOY_FAILED
assert "custom exception info" in status.message
return True
wait_for_condition(check_custom_exception, timeout=10)
@pytest.mark.parametrize(
"policy_name", [None, DEFAULT_AUTOSCALING_POLICY_NAME, default_autoscaling_policy]
)
def test_get_serve_instance_details_json_serializable(serve_instance, policy_name):
"""Test the result from get_serve_instance_details is json serializable."""
controller = _get_global_client()._controller
autoscaling_config = {
"min_replicas": 1,
"max_replicas": 10,
"_policy": {"name": policy_name},
}
if policy_name is None:
autoscaling_config.pop("_policy")
@serve.deployment(autoscaling_config=autoscaling_config)
def autoscaling_app():
return "1"
serve.run(autoscaling_app.bind())
details = ray.get(controller.get_serve_instance_details.remote())
details_json = json.dumps(details)
controller_details = ray.get(controller.get_actor_details.remote())
node_id = controller_details.node_id
node_ip = controller_details.node_ip
node_instance_id = controller_details.node_instance_id
proxy_details = ray.get(controller.get_proxy_details.remote(node_id=node_id))
deployment_timestamp = ray.get(
controller.get_deployment_timestamps.remote(app_name="default")
)
deployment_details = ray.get(
controller.get_deployment_details.remote("default", "autoscaling_app")
)
replica = deployment_details.replicas[0]
http_port, grpc_port = None, None
for target_group in details["target_groups"]:
if target_group["protocol"] == "HTTP" and target_group["targets"]:
http_port = target_group["targets"][0]["port"]
if target_group["protocol"] == "gRPC" and target_group["targets"]:
grpc_port = target_group["targets"][0]["port"]
expected_json = json.dumps(
{
"controller_info": {
"node_id": node_id,
"node_ip": node_ip,
"node_instance_id": node_instance_id,
"actor_id": controller_details.actor_id,
"actor_name": controller_details.actor_name,
"worker_id": controller_details.worker_id,
"log_file_path": controller_details.log_file_path,
},
"proxy_location": "HeadOnly",
"http_options": {"host": "0.0.0.0"},
"grpc_options": {
"port": 9000,
"grpc_servicer_functions": TEST_GRPC_SERVICER_FUNCTIONS,
},
"proxies": {
node_id: {
"node_id": node_id,
"node_ip": node_ip,
"node_instance_id": node_instance_id,
"actor_id": proxy_details.actor_id,
"actor_name": proxy_details.actor_name,
"worker_id": proxy_details.worker_id,
"log_file_path": proxy_details.log_file_path,
"status": proxy_details.status,
}
},
"applications": {
"default": {
"name": "default",
"route_prefix": "/",
"docs_path": None,
"status": "RUNNING",
"message": "",
"last_deployed_time_s": deployment_timestamp,
"deployed_app_config": None,
"source": "imperative",
"deployments": {
"autoscaling_app": {
"name": "autoscaling_app",
"status": "HEALTHY",
"status_trigger": "CONFIG_UPDATE_COMPLETED",
"message": "",
"deployment_config": {
"name": "autoscaling_app",
"max_ongoing_requests": 5,
"max_queued_requests": -1,
"user_config": None,
"autoscaling_config": {
"min_replicas": 1,
"initial_replicas": None,
"max_replicas": 10,
"target_ongoing_requests": 2.0,
"metrics_interval_s": 10.0,
"look_back_period_s": 30.0,
"smoothing_factor": 1.0,
"upscale_smoothing_factor": None,
"downscale_smoothing_factor": None,
"upscaling_factor": None,
"downscaling_factor": None,
"downscale_delay_s": 600.0,
"downscale_to_zero_delay_s": None,
"upscale_delay_s": 30.0,
"aggregation_function": "mean",
"policy": {
"policy_function": "ray.serve.autoscaling_policy:default_autoscaling_policy",
"policy_kwargs": {},
},
},
"graceful_shutdown_wait_loop_s": 2.0,
"graceful_shutdown_timeout_s": 20.0,
"health_check_period_s": 10.0,
"health_check_timeout_s": 30.0,
"ray_actor_options": {
"num_cpus": 1.0,
},
"request_router_config": {
"request_router_class": "ray.serve._private.request_router:PowerOfTwoChoicesRequestRouter",
"request_router_kwargs": {},
"request_routing_stats_period_s": 10.0,
"request_routing_stats_timeout_s": 30.0,
"initial_backoff_s": 0.025,
"backoff_multiplier": 2.0,
"max_backoff_s": 0.5,
},
"rolling_update_percentage": 0.2,
},
"target_num_replicas": 1,
"required_resources": {"CPU": 1},
"replicas": [
{
"node_id": node_id,
"node_ip": node_ip,
"node_instance_id": node_instance_id,
"actor_id": replica.actor_id,
"actor_name": replica.actor_name,
"worker_id": replica.worker_id,
"log_file_path": replica.log_file_path,
"replica_id": replica.replica_id,
"state": "RUNNING",
"pid": replica.pid,
"start_time_s": replica.start_time_s,
}
],
"recent_dead_replicas": [],
}
},
"external_scaler_enabled": False,
"deployment_topology": {
"app_name": "default",
"nodes": {
"autoscaling_app": {
"name": "autoscaling_app",
"app_name": "default",
"outbound_deployments": [],
"is_ingress": True,
}
},
"ingress_deployment": "autoscaling_app",
},
}
},
"target_capacity": None,
"target_groups": [
{
"targets": [
{
"ip": node_ip,
"port": http_port,
"instance_id": node_instance_id,
"name": proxy_details.actor_name,
},
],
"route_prefix": "/",
"protocol": "HTTP",
"app_name": "",
"ingress_request_router_targets": [],
"ingress_deployment_name": "",
},
{
"targets": [
{
"ip": node_ip,
"port": grpc_port,
"instance_id": node_instance_id,
"name": proxy_details.actor_name,
},
],
"route_prefix": "/",
"protocol": "gRPC",
"app_name": "",
"ingress_request_router_targets": [],
"ingress_deployment_name": "",
},
],
}
)
# Health metrics contain timestamps that change between calls, so verify
# the keys match what get_health_metrics returns rather than exact values.
details_dict = json.loads(details_json)
actual_health_metrics = details_dict.pop("controller_health_metrics")
expected_dict = json.loads(expected_json)
assert details_dict == expected_dict
controller_health_metrics = ray.get(controller.get_health_metrics.remote())
assert set(actual_health_metrics.keys()) == set(controller_health_metrics.keys())
# ensure internal field, serialized_policy_def, is not exposed
application = details["applications"]["default"]
deployment = application["deployments"]["autoscaling_app"]
autoscaling_config = deployment["deployment_config"]["autoscaling_config"]
assert "_serialized_policy_def" not in autoscaling_config
def test_get_deployment_config(serve_instance):
"""Test getting deployment config."""
controller = _get_global_client()._controller
deployment_id = DeploymentID(name="App", app_name="default")
deployment_config = ray.get(
controller.get_deployment_config.remote(deployment_id=deployment_id)
)
# Before any deployment is created, the config should be None.
assert deployment_config is None
@serve.deployment
class App:
pass
serve.run(App.bind())
deployment_config = ray.get(
controller.get_deployment_config.remote(deployment_id=deployment_id)
)
# After the deployment is created, the config should be DeploymentConfig.
assert isinstance(deployment_config, DeploymentConfig)
def test_get_health_metrics(serve_instance):
"""Test that get_health_metrics returns valid controller health metrics."""
controller = _get_global_client()._controller
# Deploy a simple application to ensure controller is active
@serve.deployment
def health_test_app():
return "ok"
serve.run(health_test_app.bind())
# Get health metrics
metrics = ray.get(controller.get_health_metrics.remote())
# Verify it's a dictionary
assert isinstance(metrics, dict)
# Verify all expected fields are present
expected_fields = [
"timestamp",
"controller_start_time",
"uptime_s",
"num_control_loops",
"loop_duration_s",
"loops_per_second",
"last_sleep_duration_s",
"expected_sleep_duration_s",
"event_loop_delay_s",
"num_asyncio_tasks",
"deployment_state_update_duration_s",
"application_state_update_duration_s",
"proxy_state_update_duration_s",
"node_update_duration_s",
"handle_metrics_delay_ms",
"replica_metrics_delay_ms",
"process_memory_mb",
]
for field in expected_fields:
assert field in metrics, f"Missing field: {field}"
# Verify types and basic sanity checks
assert metrics["timestamp"] > 0
assert metrics["controller_start_time"] > 0
assert metrics["uptime_s"] >= 0
assert metrics["expected_sleep_duration_s"] > 0 # Should be CONTROL_LOOP_INTERVAL_S
# Wait for at least one control loop to complete
def has_control_loops():
m = ray.get(controller.get_health_metrics.remote())
return m["num_control_loops"] > 0
wait_for_condition(has_control_loops, timeout=10)
# Get updated metrics after control loops have run
metrics = ray.get(controller.get_health_metrics.remote())
# Verify control loop metrics are populated
assert metrics["num_control_loops"] > 0
assert metrics["loops_per_second"] > 0
# Verify DurationStats structure for loop_duration_s
loop_stats = metrics["loop_duration_s"]
assert loop_stats is not None
assert "mean" in loop_stats
assert "std" in loop_stats
assert "min" in loop_stats
assert "max" in loop_stats
assert loop_stats["mean"] > 0
# Verify DurationStats structure for component update durations
for field in [
"deployment_state_update_duration_s",
"application_state_update_duration_s",
"proxy_state_update_duration_s",
"node_update_duration_s",
]:
stats = metrics[field]
assert stats is not None, f"{field} should not be None"
assert "mean" in stats, f"{field} should have 'mean'"
assert "std" in stats, f"{field} should have 'std'"
assert "min" in stats, f"{field} should have 'min'"
assert "max" in stats, f"{field} should have 'max'"
# Verify the metrics are JSON serializable
metrics_json = json.dumps(metrics)
assert isinstance(metrics_json, str)
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))