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

1553 lines
57 KiB
Python

import copy
import json
import logging
import sys
from typing import Dict, List, Optional, Union
import pytest
from pydantic import ValidationError
import ray
from ray import serve
from ray.serve._private.config import DeploymentConfig, ReplicaConfig
from ray.serve._private.deploy_utils import get_app_code_version
from ray.serve._private.utils import DEFAULT
from ray.serve.config import (
AutoscalingConfig,
DeploymentActorConfig,
GangPlacementStrategy,
GangRuntimeFailurePolicy,
GangSchedulingConfig,
)
from ray.serve.deployment import Deployment, deployment_to_schema, schema_to_deployment
from ray.serve.schema import (
ControllerHealthMetrics,
DeploymentSchema,
LoggingConfig,
RayActorOptionsSchema,
ServeApplicationSchema,
ServeDeploySchema,
ServeInstanceDetails,
)
from ray.serve.tests.common.remote_uris import (
TEST_DEPLOY_GROUP_PINNED_URI,
TEST_MODULE_PINNED_URI,
)
from ray.util.accelerators.accelerators import NVIDIA_TESLA_P4, NVIDIA_TESLA_V100
@ray.remote
class _SchemaTestDummyActor:
"""Dummy actor class for deployment_actors schema tests."""
def ping(self):
"""Dummy method to verify class is deserialized correctly."""
return "pong"
def get_valid_runtime_envs() -> List[Dict]:
"""Get list of runtime environments allowed in Serve config/REST API."""
return [
# Empty runtime_env
{},
# Runtime_env with remote_URIs
{
"working_dir": TEST_MODULE_PINNED_URI,
"py_modules": [TEST_DEPLOY_GROUP_PINNED_URI],
},
# Runtime_env with extra options
{
"working_dir": TEST_MODULE_PINNED_URI,
"py_modules": [TEST_DEPLOY_GROUP_PINNED_URI],
"pip": ["pandas", "numpy"],
"env_vars": {"OMP_NUM_THREADS": "32", "EXAMPLE_VAR": "hello"},
"excludes": "imaginary_file.txt",
},
]
def get_invalid_runtime_envs() -> List[Dict]:
"""Get list of runtime environments not allowed in Serve config/REST API."""
return [
# Local URIs in working_dir and py_modules
{
"working_dir": ".",
"py_modules": [
"/Desktop/my_project",
TEST_DEPLOY_GROUP_PINNED_URI,
],
}
]
def get_valid_import_paths() -> List[str]:
"""Get list of import paths allowed in Serve config/REST API."""
return [
"module.deployment_graph",
"module.submodule1.deploymentGraph",
"module.submodule1.submodule_2.DeploymentGraph",
"module:deploymentgraph",
"module.submodule1:deploymentGraph",
"module.submodule1.submodule_2:DeploymentGraph",
]
def get_invalid_import_paths() -> List[str]:
"""Get list of import paths not allowed in Serve config/REST API."""
return [
# Empty import path
"",
# Only a dot
".",
# Only a colon,
":",
# Import path with no dot or colon
"module_deployment_graph",
# Import path with empty deployment graph
"module.",
"module.submodule.",
# Import path with no deployment graph
".module",
# Import paths with more than 1 colon
"module:submodule1:deploymentGraph",
"module.submodule1:deploymentGraph:",
"module:submodule1:submodule_2:DeploymentGraph",
"module.submodule_1:submodule2:deployment_Graph",
"module.submodule_1:submodule2:sm3.dg",
]
class TestRayActorOptionsSchema:
def get_valid_ray_actor_options_schema(self):
return {
"runtime_env": {
"working_dir": TEST_MODULE_PINNED_URI,
},
"num_cpus": 0.2,
"num_gpus": 50,
"memory": 3,
"resources": {"custom_asic": 12},
"accelerator_type": NVIDIA_TESLA_V100,
}
def test_valid_ray_actor_options_schema(self):
# Ensure a valid RayActorOptionsSchema can be generated
ray_actor_options_schema = self.get_valid_ray_actor_options_schema()
RayActorOptionsSchema.model_validate(ray_actor_options_schema)
def test_ge_zero_ray_actor_options_schema(self):
# Ensure ValidationError is raised when any fields that must be greater
# than zero is set to zero.
ge_zero_fields = ["num_cpus", "num_gpus", "memory"]
for field in ge_zero_fields:
with pytest.raises(ValidationError):
RayActorOptionsSchema.model_validate({field: -1})
@pytest.mark.parametrize("env", get_valid_runtime_envs())
def test_ray_actor_options_valid_runtime_env(self, env):
# Test valid runtime_env configurations
ray_actor_options_schema = self.get_valid_ray_actor_options_schema()
ray_actor_options_schema["runtime_env"] = env
original_runtime_env = copy.deepcopy(env)
schema = RayActorOptionsSchema.model_validate(ray_actor_options_schema)
# Make sure runtime environment is unchanged by the validation
assert schema.runtime_env == original_runtime_env
@pytest.mark.parametrize("env", get_invalid_runtime_envs())
def test_ray_actor_options_invalid_runtime_env(self, env):
# Test invalid runtime_env configurations
ray_actor_options_schema = self.get_valid_ray_actor_options_schema()
ray_actor_options_schema["runtime_env"] = env
# By default, runtime_envs with local URIs should be rejected.
with pytest.raises(ValueError):
RayActorOptionsSchema.model_validate(ray_actor_options_schema)
def test_extra_fields_invalid_ray_actor_options(self):
# Undefined fields should be forbidden in the schema
ray_actor_options_schema = {
"runtime_env": {},
"num_cpus": None,
"num_gpus": None,
"memory": None,
"resources": {},
"accelerator_type": None,
}
# Schema should be createable with valid fields
RayActorOptionsSchema.model_validate(ray_actor_options_schema)
# Schema should NOT raise error when extra field is included
ray_actor_options_schema["extra_field"] = None
RayActorOptionsSchema.model_validate(ray_actor_options_schema)
def test_dict_defaults_ray_actor_options(self):
# Dictionary fields should have empty dictionaries as defaults, not None
ray_actor_options_schema = {}
schema = RayActorOptionsSchema.model_validate(ray_actor_options_schema)
d = schema.model_dump()
assert d["runtime_env"] == {}
assert d["resources"] == {}
class TestDeploymentSchema:
def get_minimal_deployment_schema(self):
# Generate a DeploymentSchema with the fewest possible attributes set
return {"name": "deep"}
def test_valid_deployment_schema(self):
# Ensure a valid DeploymentSchema can be generated
deployment_schema = {
"name": "shallow",
"num_replicas": 2,
"route_prefix": "/shallow",
"max_queued_requests": 12,
"user_config": {"threshold": 0.2, "pattern": "rainbow"},
"autoscaling_config": None,
"graceful_shutdown_wait_loop_s": 17,
"graceful_shutdown_timeout_s": 49,
"health_check_period_s": 11,
"health_check_timeout_s": 11,
"max_ongoing_requests": 32,
"ray_actor_options": {
"runtime_env": {
"working_dir": TEST_MODULE_PINNED_URI,
"py_modules": [TEST_DEPLOY_GROUP_PINNED_URI],
},
"num_cpus": 3,
"num_gpus": 4.2,
"memory": 5,
"resources": {"custom_asic": 8},
"accelerator_type": NVIDIA_TESLA_P4,
},
}
DeploymentSchema.model_validate(deployment_schema)
def test_gt_zero_deployment_schema(self):
# Ensure ValidationError is raised when any fields that must be greater
# than zero is set to zero.
deployment_schema = self.get_minimal_deployment_schema()
gt_zero_fields = [
"num_replicas",
"max_ongoing_requests",
"health_check_period_s",
"health_check_timeout_s",
]
for field in gt_zero_fields:
deployment_schema[field] = 0
with pytest.raises(ValidationError):
DeploymentSchema.model_validate(deployment_schema)
deployment_schema[field] = None
def test_ge_zero_deployment_schema(self):
# Ensure ValidationError is raised when any fields that must be greater
# than or equal to zero is set to -1.
deployment_schema = self.get_minimal_deployment_schema()
ge_zero_fields = [
"graceful_shutdown_wait_loop_s",
"graceful_shutdown_timeout_s",
]
for field in ge_zero_fields:
deployment_schema[field] = -1
with pytest.raises(ValidationError):
DeploymentSchema.model_validate(deployment_schema)
deployment_schema[field] = None
def test_validate_max_queued_requests(self):
# Ensure ValidationError is raised when max_queued_requests is not -1 or > 1.
deployment_schema = self.get_minimal_deployment_schema()
deployment_schema["max_queued_requests"] = -1
DeploymentSchema.model_validate(deployment_schema)
deployment_schema["max_queued_requests"] = 1
DeploymentSchema.model_validate(deployment_schema)
deployment_schema["max_queued_requests"] = 100
DeploymentSchema.model_validate(deployment_schema)
deployment_schema["max_queued_requests"] = "hi"
with pytest.raises(ValidationError):
DeploymentSchema.model_validate(deployment_schema)
deployment_schema["max_queued_requests"] = 1.5
with pytest.raises(ValidationError):
DeploymentSchema.model_validate(deployment_schema)
deployment_schema["max_queued_requests"] = 0
with pytest.raises(ValidationError):
DeploymentSchema.model_validate(deployment_schema)
deployment_schema["max_queued_requests"] = -2
with pytest.raises(ValidationError):
DeploymentSchema.model_validate(deployment_schema)
deployment_schema["max_queued_requests"] = -100
with pytest.raises(ValidationError):
DeploymentSchema.model_validate(deployment_schema)
def test_mutually_exclusive_num_replicas_and_autoscaling_config(self):
# num_replicas and autoscaling_config cannot be set at the same time
deployment_schema = self.get_minimal_deployment_schema()
deployment_schema["num_replicas"] = 5
deployment_schema["autoscaling_config"] = None
DeploymentSchema.model_validate(deployment_schema)
deployment_schema["num_replicas"] = None
deployment_schema["autoscaling_config"] = AutoscalingConfig().model_dump()
DeploymentSchema.model_validate(deployment_schema)
deployment_schema["num_replicas"] = 5
deployment_schema["autoscaling_config"] = AutoscalingConfig().model_dump()
with pytest.raises(ValueError):
DeploymentSchema.model_validate(deployment_schema)
def test_mutually_exclusive_max_replicas_per_node_and_placement_group_bundles(self):
# max_replicas_per_node and placement_group_bundles
# cannot be set at the same time
deployment_schema = self.get_minimal_deployment_schema()
deployment_schema["max_replicas_per_node"] = 5
deployment_schema.pop("placement_group_bundles", None)
DeploymentSchema.model_validate(deployment_schema)
deployment_schema.pop("max_replicas_per_node", None)
deployment_schema["placement_group_bundles"] = [{"GPU": 1}, {"GPU": 1}]
DeploymentSchema.model_validate(deployment_schema)
deployment_schema["max_replicas_per_node"] = 5
deployment_schema["placement_group_bundles"] = [{"GPU": 1}, {"GPU": 1}]
with pytest.raises(
ValueError,
match=(
"Setting max_replicas_per_node is not allowed when "
"placement_group_bundles is provided."
),
):
DeploymentSchema.model_validate(deployment_schema)
def test_num_replicas_auto(self):
deployment_schema = self.get_minimal_deployment_schema()
deployment_schema["num_replicas"] = "auto"
deployment_schema["autoscaling_config"] = None
DeploymentSchema.model_validate(deployment_schema)
deployment_schema["num_replicas"] = "auto"
deployment_schema["autoscaling_config"] = {"max_replicas": 99}
DeploymentSchema.model_validate(deployment_schema)
deployment_schema["num_replicas"] = "random_str"
deployment_schema["autoscaling_config"] = None
with pytest.raises(ValueError):
DeploymentSchema.model_validate(deployment_schema)
def test_extra_fields_invalid_deployment_schema(self):
# Undefined fields should be forbidden in the schema
deployment_schema = self.get_minimal_deployment_schema()
# Schema should be createable with valid fields
DeploymentSchema.model_validate(deployment_schema)
# Schema should NOT raise error when extra field is included
deployment_schema["extra_field"] = None
DeploymentSchema.model_validate(deployment_schema)
def test_user_config_nullable(self):
deployment_options = {"name": "test", "user_config": None}
DeploymentSchema.model_validate(deployment_options)
def test_autoscaling_config_nullable(self):
deployment_options = {
"name": "test",
"autoscaling_config": None,
"num_replicas": 5,
}
DeploymentSchema.model_validate(deployment_options)
def test_route_prefix_nullable(self):
deployment_options = {"name": "test", "route_prefix": None}
DeploymentSchema.model_validate(deployment_options)
def test_num_replicas_nullable(self):
deployment_options = {
"name": "test",
"num_replicas": None,
"autoscaling_config": {
"min_replicas": 1,
"max_replicas": 5,
"target_ongoing_requests": 5,
},
}
DeploymentSchema.model_validate(deployment_options)
def test_validate_bundle_label_selector(self):
"""Test validation for placement_group_bundle_label_selector."""
deployment_schema = self.get_minimal_deployment_schema()
# Validate bundle_label_selector provided without bundles raises.
deployment_schema["placement_group_bundle_label_selector"] = [{"a": "b"}]
with pytest.raises(
ValidationError,
match="Setting bundle_label_selector is not allowed when placement_group_bundles is not provided",
):
DeploymentSchema.model_validate(deployment_schema)
# Validate mismatched lengths for bundles and bundle_label_selector raises.
deployment_schema["placement_group_bundles"] = [{"CPU": 1}, {"CPU": 1}]
deployment_schema["placement_group_bundle_label_selector"] = [
{"a": "b"},
{"c": "d"},
{"e": "f"},
]
with pytest.raises(
ValidationError,
match=r"list must contain either a single selector \(to apply to all bundles\) or match the number of `placement_group_bundles`",
):
DeploymentSchema.model_validate(deployment_schema)
# Valid config - 2 bundles and 2 placement_group_bundle_label_selector.
deployment_schema["placement_group_bundle_label_selector"] = [
{"a": "b"},
{"c": "d"},
]
DeploymentSchema.model_validate(deployment_schema)
# Valid config - single placement_group_bundle_label_selector.
deployment_schema["placement_group_bundle_label_selector"] = [
{"a": "b"},
]
DeploymentSchema.model_validate(deployment_schema)
def test_gang_scheduling_config_basic(self):
deployment_schema = self.get_minimal_deployment_schema()
deployment_schema["num_replicas"] = 8
deployment_schema["gang_scheduling_config"] = {
"gang_size": 4,
"gang_placement_strategy": "SPREAD",
}
schema = DeploymentSchema.model_validate(deployment_schema)
assert isinstance(schema.gang_scheduling_config, GangSchedulingConfig)
assert schema.gang_scheduling_config.gang_size == 4
assert (
schema.gang_scheduling_config.gang_placement_strategy
== GangPlacementStrategy.SPREAD
)
assert (
schema.gang_scheduling_config.runtime_failure_policy
== GangRuntimeFailurePolicy.RESTART_GANG
)
def test_gang_scheduling_config_unset(self):
deployment_schema = self.get_minimal_deployment_schema()
deployment_schema["num_replicas"] = 2
schema = DeploymentSchema.model_validate(deployment_schema)
assert schema.gang_scheduling_config is DEFAULT.VALUE
def test_gang_scheduling_config_auto_replicas_accepted(self):
deployment_schema = self.get_minimal_deployment_schema()
deployment_schema["num_replicas"] = "auto"
deployment_schema["gang_scheduling_config"] = {"gang_size": 4}
deployment_schema["autoscaling_config"] = {
"min_replicas": 4,
"max_replicas": 8,
}
schema = DeploymentSchema.model_validate(deployment_schema)
assert schema.gang_scheduling_config.gang_size == 4
assert schema.num_replicas == "auto"
@pytest.mark.parametrize(
"autoscaling_config,invalid_field",
[
({"min_replicas": 3, "max_replicas": 8}, "min_replicas"),
({"min_replicas": 4, "max_replicas": 9}, "max_replicas"),
(
{"min_replicas": 4, "max_replicas": 8, "initial_replicas": 5},
"initial_replicas",
),
],
)
def test_gang_scheduling_config_auto_replicas_invalid_bounds(
self, autoscaling_config, invalid_field
):
deployment_schema = self.get_minimal_deployment_schema()
deployment_schema["num_replicas"] = "auto"
deployment_schema["gang_scheduling_config"] = {"gang_size": 4}
deployment_schema["autoscaling_config"] = autoscaling_config
with pytest.raises(
ValueError, match=f"autoscaling_config.{invalid_field}.*must be a multiple"
):
DeploymentSchema.model_validate(deployment_schema)
def test_gang_scheduling_config_scale_to_zero_rejected(self):
deployment_schema = self.get_minimal_deployment_schema()
deployment_schema["num_replicas"] = "auto"
deployment_schema["gang_scheduling_config"] = {"gang_size": 3}
deployment_schema["autoscaling_config"] = {
"min_replicas": 0,
"max_replicas": 9,
}
with pytest.raises(
ValueError,
match="Scale to zero isn't supported for gang scheduling",
):
DeploymentSchema.model_validate(deployment_schema)
def test_gang_scheduling_config_invalid_num_replicas(self):
deployment_schema = self.get_minimal_deployment_schema()
deployment_schema["num_replicas"] = 5
deployment_schema["gang_scheduling_config"] = {"gang_size": 4}
with pytest.raises(
ValueError, match="num_replicas.*must be a multiple of gang_size"
):
DeploymentSchema.model_validate(deployment_schema)
@pytest.mark.parametrize("gang_size", [0, -1])
def test_gang_scheduling_config_invalid_gang_size(self, gang_size):
deployment_schema = self.get_minimal_deployment_schema()
deployment_schema["num_replicas"] = 4
deployment_schema["gang_scheduling_config"] = {"gang_size": gang_size}
with pytest.raises(ValidationError):
DeploymentSchema.model_validate(deployment_schema)
def test_mutually_exclusive_max_replicas_per_node_and_gang_scheduling_config(self):
deployment_schema = self.get_minimal_deployment_schema()
deployment_schema["max_replicas_per_node"] = 2
deployment_schema["gang_scheduling_config"] = {"gang_size": 2}
with pytest.raises(
ValueError,
match=(
"Setting max_replicas_per_node is not allowed when "
"gang_scheduling_config is provided."
),
):
DeploymentSchema.model_validate(deployment_schema)
def test_mutually_exclusive_placement_group_strategy_and_gang_scheduling_config(
self,
):
deployment_schema = self.get_minimal_deployment_schema()
deployment_schema["placement_group_strategy"] = "SPREAD"
deployment_schema["gang_scheduling_config"] = {"gang_size": 2}
with pytest.raises(
ValueError,
match=(
"Setting placement_group_strategy is not allowed when "
"gang_scheduling_config is provided."
),
):
DeploymentSchema.model_validate(deployment_schema)
def test_deployment_actors_schema_validation(self):
"""Test that DeploymentSchema accepts deployment_actors as list of dicts."""
deployment_schema = self.get_minimal_deployment_schema()
deployment_schema["deployment_actors"] = [
{
"name": "actor1",
"actor_class": "ray.serve.tests.unit.test_schema:_SchemaTestDummyActor",
"init_kwargs": {"x": 1},
},
]
schema = DeploymentSchema.model_validate(deployment_schema)
assert schema.deployment_actors is not None
assert len(schema.deployment_actors) == 1
item = schema.deployment_actors[0]
assert (item["name"] if isinstance(item, dict) else item.name) == "actor1"
assert (
item.get("init_kwargs", {}) if isinstance(item, dict) else item.init_kwargs
) == {"x": 1}
def test_deployment_actors_schema_unset(self):
"""Test that deployment_actors defaults to DEFAULT.VALUE when unset."""
deployment_schema = self.get_minimal_deployment_schema()
schema = DeploymentSchema.model_validate(deployment_schema)
assert schema.deployment_actors is DEFAULT.VALUE
def test_deployment_actors_schema_nullable(self):
"""Test that deployment_actors can be explicitly set to None."""
deployment_schema = self.get_minimal_deployment_schema()
deployment_schema["deployment_actors"] = None
schema = DeploymentSchema.model_validate(deployment_schema)
assert schema.deployment_actors is None
class TestServeApplicationSchema:
def get_valid_serve_application_schema(self):
return {
"import_path": "module.graph",
"runtime_env": {},
"deployments": [
{
"name": "shallow",
"num_replicas": 2,
"route_prefix": "/shallow",
"max_ongoing_requests": 32,
"user_config": None,
"autoscaling_config": None,
"graceful_shutdown_wait_loop_s": 17,
"graceful_shutdown_timeout_s": 49,
"health_check_period_s": 11,
"health_check_timeout_s": 11,
"ray_actor_options": {
"runtime_env": {
"working_dir": TEST_MODULE_PINNED_URI,
"py_modules": [TEST_DEPLOY_GROUP_PINNED_URI],
},
"num_cpus": 3,
"num_gpus": 4.2,
"memory": 5,
"resources": {"custom_asic": 8},
"accelerator_type": NVIDIA_TESLA_P4,
},
},
{
"name": "deep",
},
],
}
def test_valid_serve_application_schema(self):
# Ensure a valid ServeApplicationSchema can be generated
serve_application_schema = self.get_valid_serve_application_schema()
ServeApplicationSchema.model_validate(serve_application_schema)
def test_extra_fields_invalid_serve_application_schema(self):
# Undefined fields should be forbidden in the schema
serve_application_schema = self.get_valid_serve_application_schema()
# Schema should be createable with valid fields
ServeApplicationSchema.model_validate(serve_application_schema)
# Schema should NOT raise error when extra field is included
serve_application_schema["extra_field"] = None
ServeApplicationSchema.model_validate(serve_application_schema)
@pytest.mark.parametrize("env", get_valid_runtime_envs())
def test_serve_application_valid_runtime_env(self, env):
# Test valid runtime_env configurations
serve_application_schema = self.get_valid_serve_application_schema()
serve_application_schema["runtime_env"] = env
original_runtime_env = copy.deepcopy(env)
schema = ServeApplicationSchema.model_validate(serve_application_schema)
# Make sure runtime environment is unchanged by the validation
assert schema.runtime_env == original_runtime_env
@pytest.mark.parametrize("env", get_invalid_runtime_envs())
def test_serve_application_invalid_runtime_env(self, env):
# Test invalid runtime_env configurations
serve_application_schema = self.get_valid_serve_application_schema()
serve_application_schema["runtime_env"] = env
with pytest.raises(ValueError):
ServeApplicationSchema.model_validate(serve_application_schema)
# By default, runtime_envs with local URIs should be rejected.
with pytest.raises(ValueError):
ServeApplicationSchema.model_validate(serve_application_schema)
@pytest.mark.parametrize("path", get_valid_import_paths())
def test_serve_application_valid_import_path(self, path):
# Test valid import path formats
serve_application_schema = self.get_valid_serve_application_schema()
serve_application_schema["import_path"] = path
ServeApplicationSchema.model_validate(serve_application_schema)
@pytest.mark.parametrize("path", get_invalid_import_paths())
def test_serve_application_invalid_import_path(self, path):
# Test invalid import path formats
serve_application_schema = self.get_valid_serve_application_schema()
serve_application_schema["import_path"] = path
with pytest.raises(ValidationError):
ServeApplicationSchema.model_validate(serve_application_schema)
def test_serve_application_import_path_required(self):
# If no import path is specified, this should not parse successfully
with pytest.raises(ValidationError):
ServeApplicationSchema.model_validate({"host": "127.0.0.1", "port": 8000})
def test_external_scaler_enabled_defaults_to_false(self):
# Ensure external_scaler_enabled defaults to False
serve_application_schema = self.get_valid_serve_application_schema()
schema = ServeApplicationSchema.model_validate(serve_application_schema)
assert schema.external_scaler_enabled is False
def test_external_scaler_enabled_with_fixed_replicas(self):
# external_scaler_enabled=True should work with fixed num_replicas
serve_application_schema = self.get_valid_serve_application_schema()
serve_application_schema["external_scaler_enabled"] = True
serve_application_schema["deployments"] = [
{
"name": "deployment1",
"num_replicas": 5,
},
{
"name": "deployment2",
"num_replicas": 3,
},
]
# This should parse successfully
schema = ServeApplicationSchema.model_validate(serve_application_schema)
assert schema.external_scaler_enabled is True
def test_external_scaler_enabled_conflicts_with_autoscaling(self):
# external_scaler_enabled=True should conflict with autoscaling_config
serve_application_schema = self.get_valid_serve_application_schema()
serve_application_schema["external_scaler_enabled"] = True
serve_application_schema["deployments"] = [
{
"name": "deployment1",
"num_replicas": None,
"autoscaling_config": {
"min_replicas": 1,
"max_replicas": 10,
"target_ongoing_requests": 5,
},
},
]
# This should raise a validation error
with pytest.raises(ValueError) as exc_info:
ServeApplicationSchema.model_validate(serve_application_schema)
error_message = str(exc_info.value)
assert "external_scaler_enabled is set to True" in error_message
assert "deployment1" in error_message
def test_external_scaler_enabled_conflicts_with_multiple_deployments(self):
# Test that validation catches multiple deployments with autoscaling
serve_application_schema = self.get_valid_serve_application_schema()
serve_application_schema["external_scaler_enabled"] = True
serve_application_schema["deployments"] = [
{
"name": "deployment1",
"num_replicas": 5, # Fixed replicas - OK
},
{
"name": "deployment2",
"num_replicas": None,
"autoscaling_config": {
"min_replicas": 1,
"max_replicas": 10,
"target_ongoing_requests": 5,
},
},
{
"name": "deployment3",
"autoscaling_config": {
"min_replicas": 2,
"max_replicas": 20,
},
},
]
# This should raise a validation error mentioning both problematic deployments
with pytest.raises(ValueError) as exc_info:
ServeApplicationSchema.model_validate(serve_application_schema)
error_message = str(exc_info.value)
assert "external_scaler_enabled is set to True" in error_message
assert "deployment2" in error_message
assert "deployment3" in error_message
# deployment1 should not be mentioned since it doesn't have autoscaling
assert (
"deployment1" not in error_message or '"deployment1"' not in error_message
)
def test_external_scaler_enabled_with_num_replicas_auto(self):
# external_scaler_enabled=True with num_replicas="auto" should conflict
# since "auto" implies autoscaling
serve_application_schema = self.get_valid_serve_application_schema()
serve_application_schema["external_scaler_enabled"] = True
serve_application_schema["deployments"] = [
{
"name": "deployment1",
"num_replicas": "auto",
"autoscaling_config": {
"min_replicas": 1,
"max_replicas": 10,
},
},
]
# This should raise a validation error
with pytest.raises(ValueError) as exc_info:
ServeApplicationSchema.model_validate(serve_application_schema)
error_message = str(exc_info.value)
assert "external_scaler_enabled is set to True" in error_message
assert "deployment1" in error_message
class TestServeDeploySchema:
def test_controller_options_default_is_none(self):
cfg = ServeDeploySchema.model_validate({"applications": []})
assert cfg.controller_options is None
def test_controller_options_passthrough(self):
cfg = ServeDeploySchema.model_validate(
{
"applications": [],
"controller_options": {
"runtime_env": {
"env_vars": {
"RAY_SERVE_HAPROXY_TCP_NODELAY": "1",
"RAY_SERVE_HAPROXY_NBTHREAD": "16",
}
}
},
}
)
assert (
cfg.controller_options.runtime_env["env_vars"][
"RAY_SERVE_HAPROXY_TCP_NODELAY"
]
== "1"
)
def test_controller_options_rejects_disallowed_runtime_env_keys(self):
# The ControllerOptions validator runs through the nested schema,
# so bad runtime_env keys fail at YAML / JSON parse time -- not at
# controller-creation time.
with pytest.raises(ValidationError) as e:
ServeDeploySchema.model_validate(
{
"applications": [],
"controller_options": {"runtime_env": {"pip": ["numpy"]}},
}
)
msg = str(e.value)
assert "only supports ['env_vars']" in msg
assert "pip" in msg
def test_controller_options_rejects_non_str_env_value(self):
with pytest.raises(ValidationError) as e:
ServeDeploySchema.model_validate(
{
"applications": [],
"controller_options": {"runtime_env": {"env_vars": {"FOO": 1}}},
}
)
assert "must be str" in str(e.value)
def test_deploy_config_duplicate_apps(self):
deploy_config_dict = {
"applications": [
{
"name": "app1",
"route_prefix": "/alice",
"import_path": "module.graph",
},
{
"name": "app2",
"route_prefix": "/charlie",
"import_path": "module.graph",
},
],
}
ServeDeploySchema.model_validate(deploy_config_dict)
# Duplicate app1
deploy_config_dict["applications"].append(
{"name": "app1", "route_prefix": "/bob", "import_path": "module.graph"},
)
with pytest.raises(ValidationError) as e:
ServeDeploySchema.model_validate(deploy_config_dict)
assert "app1" in str(e.value) and "app2" not in str(e.value)
# Duplicate app2
deploy_config_dict["applications"].append(
{"name": "app2", "route_prefix": "/david", "import_path": "module.graph"}
)
with pytest.raises(ValidationError) as e:
ServeDeploySchema.model_validate(deploy_config_dict)
assert "app1" in str(e.value) and "app2" in str(e.value)
def test_deploy_config_duplicate_routes1(self):
"""Test that apps with duplicate route prefixes raises validation error"""
deploy_config_dict = {
"applications": [
{
"name": "app1",
"route_prefix": "/alice",
"import_path": "module.graph",
},
{"name": "app2", "route_prefix": "/bob", "import_path": "module.graph"},
],
}
ServeDeploySchema.model_validate(deploy_config_dict)
# Duplicate route prefix /alice
deploy_config_dict["applications"].append(
{"name": "app3", "route_prefix": "/alice", "import_path": "module.graph"},
)
with pytest.raises(ValidationError) as e:
ServeDeploySchema.model_validate(deploy_config_dict)
assert "alice" in str(e.value) and "bob" not in str(e.value)
# Duplicate route prefix /bob
deploy_config_dict["applications"].append(
{"name": "app4", "route_prefix": "/bob", "import_path": "module.graph"},
)
with pytest.raises(ValidationError) as e:
ServeDeploySchema.model_validate(deploy_config_dict)
assert "alice" in str(e.value) and "bob" in str(e.value)
def test_deploy_config_duplicate_routes2(self):
"""Test that multiple apps with route_prefix set to None parses with no error"""
deploy_config_dict = {
"applications": [
{
"name": "app1",
"route_prefix": "/app1",
"import_path": "module.graph",
},
{"name": "app2", "route_prefix": None, "import_path": "module.graph"},
{"name": "app3", "route_prefix": None, "import_path": "module.graph"},
],
}
ServeDeploySchema.model_validate(deploy_config_dict)
@pytest.mark.parametrize("option,value", [("host", "127.0.0.1"), ("port", 8000)])
def test_deploy_config_nested_http_options(self, option, value):
"""
The application configs inside a deploy config should not have http options set.
"""
deploy_config_dict = {
"http_options": {
"host": "127.0.0.1",
"port": 8000,
},
"applications": [
{
"name": "app1",
"route_prefix": "/app1",
"import_path": "module.graph",
},
],
}
deploy_config_dict["applications"][0][option] = value
with pytest.raises(ValidationError) as e:
ServeDeploySchema.model_validate(deploy_config_dict)
assert option in str(e.value)
def test_deploy_empty_name(self):
"""The application configs inside a deploy config should have nonempty names."""
deploy_config_dict = {
"applications": [
{
"name": "",
"route_prefix": "/app1",
"import_path": "module.graph",
},
],
}
with pytest.raises(ValidationError) as e:
ServeDeploySchema.model_validate(deploy_config_dict)
# Error message should be descriptive, mention name must be nonempty
assert "name" in str(e.value) and "empty" in str(e.value)
def test_deploy_no_applications(self):
"""Applications must be specified."""
deploy_config_dict = {
"http_options": {
"host": "127.0.0.1",
"port": 8000,
},
}
with pytest.raises(ValidationError):
ServeDeploySchema.model_validate(deploy_config_dict)
def test_deploy_with_grpc_options(self):
"""gRPC options can be specified."""
deploy_config_dict = {
"grpc_options": {
"port": 9000,
"grpc_servicer_functions": ["foo.bar"],
},
"applications": [],
}
ServeDeploySchema.model_validate(deploy_config_dict)
@pytest.mark.parametrize(
"input_val,error,output_val",
[
# Can be omitted and defaults to `None`.
(None, False, None),
# Can be an int or a float.
(50, False, 50),
(33.33, False, 33.33), # "... repeating, of course."
# Can be 0 or 100, inclusive.
(0, False, 0.0),
(0.0, False, 0.0),
(100, False, 100.0),
(100.0, False, 100.0),
# Cannot be < 0 or > 100.
(-0.1, True, None),
(-1, True, None),
(100.1, True, None),
(101, True, None),
],
)
def test_target_capacity(
self,
input_val: Union[None, int, float],
error: bool,
output_val: Optional[float],
):
"""Test validation of `target_capacity` field."""
deploy_config_dict = {
"applications": [],
}
if input_val is not None:
deploy_config_dict["target_capacity"] = input_val
if error:
with pytest.raises(ValidationError):
ServeDeploySchema.model_validate(deploy_config_dict)
else:
s = ServeDeploySchema.model_validate(deploy_config_dict)
assert s.target_capacity == output_val
class TestLoggingConfig:
def test_parse_dict(self):
schema = LoggingConfig.model_validate(
{
"log_level": logging.DEBUG,
"encoding": "JSON",
"logs_dir": "/my_dir",
"enable_access_log": True,
}
)
assert schema.log_level == "DEBUG"
assert schema.encoding == "JSON"
assert schema.logs_dir == "/my_dir"
assert schema.enable_access_log
assert schema.additional_log_standard_attrs == []
# Test string values for log_level.
schema = LoggingConfig.model_validate(
{
"log_level": "DEBUG",
}
)
assert schema.log_level == "DEBUG"
def test_wrong_encoding_type(self):
with pytest.raises(ValidationError):
LoggingConfig.model_validate(
{
"logging_level": logging.INFO,
"encoding": "NOT_EXIST",
"logs_dir": "/my_dir",
"enable_access_log": True,
}
)
def test_default_values(self):
schema = LoggingConfig.model_validate({})
assert schema.log_level == "INFO"
assert schema.encoding == "TEXT"
assert schema.logs_dir is None
assert schema.enable_access_log
assert schema.additional_log_standard_attrs == []
def test_additional_log_standard_attrs_type(self):
schema = LoggingConfig.model_validate(
{"additional_log_standard_attrs": ["name"]}
)
assert isinstance(schema.additional_log_standard_attrs, list)
assert schema.additional_log_standard_attrs == ["name"]
def test_additional_log_standard_attrs_type_error(self):
with pytest.raises(ValidationError):
LoggingConfig.model_validate({"additional_log_standard_attrs": "name"})
def test_additional_log_standard_attrs_deduplicate(self):
schema = LoggingConfig.model_validate(
{"additional_log_standard_attrs": ["name", "name"]}
)
assert schema.additional_log_standard_attrs == ["name"]
# This function is defined globally to be accessible via import path
def global_f():
return "Hello world!"
def test_deployment_to_schema_to_deployment():
@serve.deployment(
num_replicas=3,
ray_actor_options={
"runtime_env": {
"working_dir": TEST_MODULE_PINNED_URI,
"py_modules": [TEST_DEPLOY_GROUP_PINNED_URI],
}
},
)
def f():
# The body of this function doesn't matter. It gets replaced by
# global_f() when the import path in f._func_or_class is overwritten.
# This function is used as a convenience to apply the @serve.deployment
# decorator without converting global_f() into a Deployment object.
pass
deployment = schema_to_deployment(deployment_to_schema(f))
deployment = deployment.options(
func_or_class="ray.serve.tests.test_schema.global_f"
)
assert deployment.num_replicas == 3
assert (
deployment.ray_actor_options["runtime_env"]["working_dir"]
== TEST_MODULE_PINNED_URI
)
assert deployment.ray_actor_options["runtime_env"]["py_modules"] == [
TEST_DEPLOY_GROUP_PINNED_URI,
]
def test_unset_fields_schema_to_deployment_ray_actor_options():
# Ensure unset fields are excluded from ray_actor_options
@serve.deployment(
num_replicas=3,
ray_actor_options={},
)
def f():
pass
deployment = schema_to_deployment(deployment_to_schema(f))
deployment = deployment.options(
func_or_class="ray.serve.tests.test_schema.global_f"
)
# Serve will set num_cpus to 1 if it's not set.
assert len(deployment.ray_actor_options) == 1
assert deployment.ray_actor_options["num_cpus"] == 1
def test_gang_scheduling_config_deployment_schema_roundtrip():
# Ensure deployment_to_schema -> schema_to_deployment preserves gang config
gang_config = GangSchedulingConfig(gang_size=2, gang_placement_strategy="SPREAD")
dc = DeploymentConfig.from_default(
num_replicas=4,
gang_scheduling_config=gang_config,
)
dc.user_configured_option_names = {"num_replicas", "gang_scheduling_config"}
rc = ReplicaConfig.create(deployment_def="", init_args=(), init_kwargs={})
dep = Deployment(
name="GangDep",
deployment_config=dc,
replica_config=rc,
_internal=True,
)
schema = deployment_to_schema(dep)
assert isinstance(schema.gang_scheduling_config, GangSchedulingConfig)
assert schema.gang_scheduling_config.gang_size == 2
assert (
schema.gang_scheduling_config.gang_placement_strategy
== GangPlacementStrategy.SPREAD
)
assert schema.num_replicas == 4
dep2 = schema_to_deployment(schema)
gc2 = dep2._deployment_config.gang_scheduling_config
assert isinstance(gc2, GangSchedulingConfig)
assert gc2.gang_size == 2
assert gc2.gang_placement_strategy == GangPlacementStrategy.SPREAD
assert dep2.num_replicas == 4
def test_schema_to_deployment_gang_scheduling_config_from_dict():
# Ensure schema_to_deployment works when gang_scheduling_config
# comes from a parsed dict (the YAML / declarative API path)
schema = DeploymentSchema.model_validate(
{
"name": "GangDep",
"num_replicas": 6,
"gang_scheduling_config": {
"gang_size": 3,
"gang_placement_strategy": "PACK",
},
}
)
assert isinstance(schema.gang_scheduling_config, GangSchedulingConfig)
dep = schema_to_deployment(schema)
gc = dep._deployment_config.gang_scheduling_config
assert isinstance(gc, GangSchedulingConfig)
assert gc.gang_size == 3
assert gc.gang_placement_strategy == GangPlacementStrategy.PACK
assert dep.num_replicas == 6
def test_deployment_actors_deployment_schema_roundtrip():
"""Ensure deployment_to_schema -> schema_to_deployment preserves deployment_actors."""
actor_config = DeploymentActorConfig(
name="prefix_tree",
actor_class=_SchemaTestDummyActor,
init_kwargs={"max_depth": 100},
actor_options={"num_cpus": 0.1},
)
dc = DeploymentConfig.from_default(
num_replicas=2,
deployment_actors=[actor_config],
)
dc.user_configured_option_names = {"num_replicas", "deployment_actors"}
rc = ReplicaConfig.create(deployment_def="", init_args=(), init_kwargs={})
dep = Deployment(
name="ActorDep",
deployment_config=dc,
replica_config=rc,
_internal=True,
)
schema = deployment_to_schema(dep)
assert schema.deployment_actors is not None
assert len(schema.deployment_actors) == 1
assert schema.deployment_actors[0].name == "prefix_tree"
assert schema.deployment_actors[0].init_kwargs == {"max_depth": 100}
dep2 = schema_to_deployment(schema)
actors = dep2._deployment_config.deployment_actors
assert actors is not None
assert len(actors) == 1
assert actors[0].name == "prefix_tree"
assert actors[0].init_kwargs == {"max_depth": 100}
resolved = actors[0].get_actor_class()
resolved_name = resolved.__ray_actor_class__.__name__
assert resolved_name == "_SchemaTestDummyActor"
# Verify we can instantiate and invoke methods (class serialized properly)
underlying = resolved.__ray_actor_class__
instance = underlying()
assert instance.ping() == "pong"
def test_schema_to_deployment_deployment_actors_from_dict():
"""Ensure schema_to_deployment works when deployment_actors comes from a parsed dict."""
schema = DeploymentSchema.model_validate(
{
"name": "ActorDep",
"num_replicas": 2,
"deployment_actors": [
{
"name": "shared_actor",
"actor_class": "ray.serve.tests.unit.test_schema:_SchemaTestDummyActor",
"init_kwargs": {"depth": 5},
},
],
}
)
dep = schema_to_deployment(schema)
actors = dep._deployment_config.deployment_actors
assert actors is not None
assert len(actors) == 1
assert actors[0].name == "shared_actor"
assert actors[0].init_kwargs == {"depth": 5}
# actor_class stays as import path string when provided via dict/YAML
assert (
actors[0].actor_class
== "ray.serve.tests.unit.test_schema:_SchemaTestDummyActor"
)
assert dep.num_replicas == 2
def test_get_app_code_version_includes_deployment_actors():
"""Test that get_app_code_version changes when deployment_actors changes."""
base_config = {
"import_path": "module.graph",
"deployments": [{"name": "dep1"}],
}
base_version = get_app_code_version(
ServeApplicationSchema.model_validate(base_config)
)
with_actors = copy.deepcopy(base_config)
with_actors["deployments"][0]["deployment_actors"] = [
{
"name": "tree",
"actor_class": "my_module:TreeActor",
"init_kwargs": {"depth": 10},
},
]
actors_version = get_app_code_version(
ServeApplicationSchema.model_validate(with_actors)
)
assert base_version != actors_version
same_actors = copy.deepcopy(with_actors)
same_actors_version = get_app_code_version(
ServeApplicationSchema.model_validate(same_actors)
)
assert actors_version == same_actors_version
changed_actors = copy.deepcopy(with_actors)
changed_actors["deployments"][0]["deployment_actors"][0]["init_kwargs"] = {
"depth": 20
}
changed_actors_version = get_app_code_version(
ServeApplicationSchema.model_validate(changed_actors)
)
assert actors_version != changed_actors_version
# Adding a deployment actor changes version
added_actor = copy.deepcopy(with_actors)
added_actor["deployments"][0]["deployment_actors"].append(
{
"name": "cache",
"actor_class": "my_module:CacheActor",
"init_kwargs": {},
}
)
added_version = get_app_code_version(
ServeApplicationSchema.model_validate(added_actor)
)
assert actors_version != added_version
# Removing a deployment actor changes version
removed_actor = copy.deepcopy(added_actor)
removed_actor["deployments"][0]["deployment_actors"].pop()
removed_version = get_app_code_version(
ServeApplicationSchema.model_validate(removed_actor)
)
assert actors_version == removed_version # back to single-actor config
assert added_version != removed_version
# Reordering deployment_actors changes version
two_actors = copy.deepcopy(added_actor)
reordered_actors = copy.deepcopy(added_actor)
reordered_actors["deployments"][0]["deployment_actors"] = list(
reversed(two_actors["deployments"][0]["deployment_actors"])
)
reordered_version = get_app_code_version(
ServeApplicationSchema.model_validate(reordered_actors)
)
assert added_version != reordered_version
def test_serve_instance_details_is_json_serializable():
"""Test that ServeInstanceDetails is json serializable."""
serialized_policy_def = (
b"\x80\x05\x95L\x00\x00\x00\x00\x00\x00\x00\x8c\x1cray."
b"serve.autoscaling_policy\x94\x8c'replica_queue_length_"
b"autoscaling_policy\x94\x93\x94."
)
details = ServeInstanceDetails(
controller_info={"node_id": "fake_node_id"},
proxy_location="EveryNode",
proxies={"node1": {"status": "HEALTHY"}},
applications={
"app1": {
"name": "app1",
"route_prefix": "/app1",
"docs_path": "/docs/app1",
"status": "RUNNING",
"message": "fake_message",
"last_deployed_time_s": 123,
"source": "imperative",
"deployments": {
"deployment1": {
"name": "deployment1",
"status": "HEALTHY",
"status_trigger": "AUTOSCALING",
"message": "fake_message",
"deployment_config": {
"name": "deployment1",
"autoscaling_config": {
# Byte object will cause json serializable error
"_serialized_policy_def": serialized_policy_def
},
},
"target_num_replicas": 0,
"required_resources": {"CPU": 1},
"replicas": [],
}
},
"external_scaler_enabled": False,
}
},
)._get_user_facing_json_serializable_dict(exclude_unset=True)
details_json = json.dumps(details)
expected_json = json.dumps(
{
"controller_info": {"node_id": "fake_node_id"},
"proxy_location": "EveryNode",
"proxies": {"node1": {"status": "HEALTHY"}},
"applications": {
"app1": {
"name": "app1",
"route_prefix": "/app1",
"docs_path": "/docs/app1",
"status": "RUNNING",
"message": "fake_message",
"last_deployed_time_s": 123.0,
"source": "imperative",
"deployments": {
"deployment1": {
"name": "deployment1",
"status": "HEALTHY",
"status_trigger": "AUTOSCALING",
"message": "fake_message",
"deployment_config": {
"name": "deployment1",
"autoscaling_config": {},
},
"target_num_replicas": 0,
"required_resources": {"CPU": 1},
"replicas": [],
}
},
"external_scaler_enabled": False,
}
},
}
)
assert details_json == expected_json
# ensure internal field, serialized_policy_def, is not exposed
application = details["applications"]["app1"]
deployment = application["deployments"]["deployment1"]
autoscaling_config = deployment["deployment_config"]["autoscaling_config"]
assert "_serialized_policy_def" not in autoscaling_config
def test_serve_instance_details_default_controller_health_metrics():
"""ServeInstanceDetails.controller_health_metrics defaults to a
ControllerHealthMetrics instance with zeroed values."""
details = ServeInstanceDetails(
controller_info={"node_id": "fake_node_id"},
proxy_location="EveryNode",
proxies={},
applications={},
)
assert isinstance(details.controller_health_metrics, ControllerHealthMetrics)
assert details.controller_health_metrics.timestamp == 0.0
assert details.controller_health_metrics.num_control_loops == 0
assert details.controller_health_metrics.last_control_loop_time == 0.0
def test_serve_instance_details_includes_controller_health_metrics():
"""When controller_health_metrics is explicitly set, it should appear in the
user-facing JSON-serializable representation."""
health_metrics = ControllerHealthMetrics(
timestamp=1000.0,
controller_start_time=900.0,
uptime_s=100.0,
num_control_loops=50,
last_control_loop_time=999.5,
)
details = ServeInstanceDetails(
controller_info={"node_id": "fake_node_id"},
proxy_location="EveryNode",
proxies={},
applications={},
controller_health_metrics=health_metrics,
)._get_user_facing_json_serializable_dict(exclude_unset=True)
assert "controller_health_metrics" in details
serialized = details["controller_health_metrics"]
assert serialized["timestamp"] == 1000.0
assert serialized["controller_start_time"] == 900.0
assert serialized["uptime_s"] == 100.0
assert serialized["num_control_loops"] == 50
assert serialized["last_control_loop_time"] == 999.5
# Should be JSON serializable end-to-end.
json.dumps(details)
def test_deployment_info_to_schema_includes_max_replicas_per_node():
"""_deployment_info_to_schema should propagate max_replicas_per_node
from ReplicaConfig into the resulting DeploymentSchema."""
from ray.serve._private.deployment_info import DeploymentInfo
from ray.serve.schema import _deployment_info_to_schema
rc = ReplicaConfig.create(
deployment_def="",
init_args=(),
init_kwargs={},
max_replicas_per_node=3,
)
dc = DeploymentConfig.from_default(num_replicas=2)
info = DeploymentInfo(
deployment_config=dc,
replica_config=rc,
start_time_ms=0,
deployer_job_id="fake_job_id",
)
schema = _deployment_info_to_schema("test_deployment", info)
assert schema.max_replicas_per_node == 3
def test_deployment_info_to_schema_omits_max_replicas_per_node_when_none():
"""When max_replicas_per_node is None (default), the schema field should
remain at its default (DEFAULT.VALUE), i.e. unset."""
from ray.serve._private.deployment_info import DeploymentInfo
from ray.serve.schema import _deployment_info_to_schema
rc = ReplicaConfig.create(
deployment_def="",
init_args=(),
init_kwargs={},
)
dc = DeploymentConfig.from_default(num_replicas=2)
info = DeploymentInfo(
deployment_config=dc,
replica_config=rc,
start_time_ms=0,
deployer_job_id="fake_job_id",
)
schema = _deployment_info_to_schema("test_deployment", info)
assert schema.max_replicas_per_node is DEFAULT.VALUE
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))