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