Files
ray-project--ray/python/ray/serve/_private/application_state.py
T
2026-07-13 13:17:40 +08:00

1969 lines
79 KiB
Python

import json
import logging
import os
import time
import traceback
from copy import deepcopy
from dataclasses import asdict, dataclass, field
from enum import Enum
from typing import Dict, List, Optional, Tuple
import ray
from ray import cloudpickle
from ray._common.utils import import_attr, import_module_and_attr
from ray.exceptions import RayTaskError, RuntimeEnvSetupError
from ray.serve._private.autoscaling_state import AutoscalingStateManager
from ray.serve._private.build_app import (
CUSTOM_INGRESS_REQUEST_ROUTER_UNSUPPORTED_ERROR,
BuiltApplication,
build_app,
)
from ray.serve._private.common import (
DeploymentID,
DeploymentStatus,
DeploymentStatusInfo,
DeploymentStatusTrigger,
EndpointInfo,
TargetCapacityDirection,
)
from ray.serve._private.config import DeploymentConfig
from ray.serve._private.constants import (
DEFAULT_AUTOSCALING_POLICY_NAME,
DEFAULT_REQUEST_ROUTER_PATH,
RAY_SERVE_ENABLE_HA_PROXY,
RAY_SERVE_ENABLE_TASK_EVENTS,
RAY_SERVE_STATUS_GAUGE_REPORT_INTERVAL_S,
SERVE_LOGGER_NAME,
)
from ray.serve._private.deploy_utils import (
deploy_args_to_deployment_info,
get_app_code_version,
get_deploy_args,
)
from ray.serve._private.deployment_info import DeploymentInfo
from ray.serve._private.deployment_state import DeploymentStateManager
from ray.serve._private.endpoint_state import EndpointState
from ray.serve._private.logging_utils import configure_component_logger
from ray.serve._private.storage.kv_store import KVStoreBase
from ray.serve._private.usage import ServeUsageTag
from ray.serve._private.utils import (
DEFAULT,
_callable_uses_multiplexing,
check_obj_ref_ready_nowait,
override_runtime_envs_except_env_vars,
validate_route_prefix,
)
from ray.serve.config import (
AutoscalingConfig,
AutoscalingPolicy,
GangSchedulingConfig,
RequestRouterConfig,
)
from ray.serve.exceptions import RayServeException
from ray.serve.generated.serve_pb2 import (
ApplicationArgs as ApplicationArgsProto,
ApplicationStatus as ApplicationStatusProto,
ApplicationStatusInfo as ApplicationStatusInfoProto,
DeploymentLanguage,
DeploymentStatusInfoList as DeploymentStatusInfoListProto,
StatusOverview as StatusOverviewProto,
)
from ray.serve.schema import (
APIType,
ApplicationStatus,
DeploymentDetails,
DeploymentNode,
DeploymentTopology,
LoggingConfig,
ServeApplicationSchema,
)
from ray.types import ObjectRef
from ray.util import metrics as ray_metrics
logger = logging.getLogger(SERVE_LOGGER_NAME)
CHECKPOINT_KEY = "serve-application-state-checkpoint"
class BuildAppStatus(Enum):
"""Status of the build application task."""
NO_TASK_IN_PROGRESS = 1
IN_PROGRESS = 2
SUCCEEDED = 3
FAILED = 4
@dataclass
class BuildAppTaskInfo:
"""Stores info on the current in-progress build app task.
We use a class instead of only storing the task object ref because
when a new config is deployed, there can be an outdated in-progress
build app task. We attach the code version to the task info to
distinguish outdated build app tasks.
"""
obj_ref: ObjectRef
code_version: str
config: ServeApplicationSchema
target_capacity: Optional[float]
target_capacity_direction: Optional[TargetCapacityDirection]
finished: bool
@dataclass(eq=True)
class ApplicationStatusInfo:
status: ApplicationStatus
message: str = ""
deployment_timestamp: float = 0
def debug_string(self):
return json.dumps(asdict(self), indent=4)
def to_proto(self):
return ApplicationStatusInfoProto(
status=f"APPLICATION_STATUS_{self.status.name}",
message=self.message,
deployment_timestamp=self.deployment_timestamp,
)
@classmethod
def from_proto(cls, proto: ApplicationStatusInfoProto):
status = ApplicationStatusProto.Name(proto.status)[len("APPLICATION_STATUS_") :]
return cls(
status=ApplicationStatus(status),
message=proto.message,
deployment_timestamp=proto.deployment_timestamp,
)
@dataclass(eq=True)
class StatusOverview:
app_status: ApplicationStatusInfo
name: str = ""
deployment_statuses: List[DeploymentStatusInfo] = field(default_factory=list)
def debug_string(self):
return json.dumps(asdict(self), indent=4)
def get_deployment_status(self, name: str) -> Optional[DeploymentStatusInfo]:
"""Get a deployment's status by name.
Args:
name: Deployment's name.
Returns:
Optional[DeploymentStatusInfo]: The status of the deployment if it exists,
otherwise None.
"""
for deployment_status in self.deployment_statuses:
if name == deployment_status.name:
return deployment_status
return None
def to_proto(self):
# Create a protobuf for the Serve Application info
app_status_proto = self.app_status.to_proto()
# Create protobufs for all individual deployment statuses
deployment_status_protos = map(
lambda status: status.to_proto(), self.deployment_statuses
)
# Create a protobuf list containing all the deployment status protobufs
deployment_status_proto_list = DeploymentStatusInfoListProto()
deployment_status_proto_list.deployment_status_infos.extend(
deployment_status_protos
)
# Return protobuf encapsulating application and deployment protos
return StatusOverviewProto(
name=self.name,
app_status=app_status_proto,
deployment_statuses=deployment_status_proto_list,
)
@classmethod
def from_proto(cls, proto: StatusOverviewProto) -> "StatusOverview":
# Recreate Serve Application info
app_status = ApplicationStatusInfo.from_proto(proto.app_status)
# Recreate deployment statuses
deployment_statuses = []
for info_proto in proto.deployment_statuses.deployment_status_infos:
deployment_statuses.append(DeploymentStatusInfo.from_proto(info_proto))
# Recreate StatusInfo
return cls(
app_status=app_status,
deployment_statuses=deployment_statuses,
name=proto.name,
)
@dataclass
class ApplicationTargetState:
"""Defines target state of application.
Target state can become inconsistent if the code version doesn't
match that of the config. When that happens, a new build app task
should be kicked off to reconcile the inconsistency.
deployment_infos: map of deployment name to deployment info. This is
- None if a config was deployed but the app hasn't finished
building yet,
- An empty dict if the app is deleting.
code_version: Code version of all deployments in target state. None
if application was deployed through serve.run.
config: application config deployed by user. None if application was
deployed through serve.run.
target_capacity: the target_capacity to use when adjusting num_replicas.
target_capacity_direction: the scale direction to use when
running the Serve autoscaler.
deleting: whether the application is being deleted.
external_scaler_enabled: whether external autoscaling is enabled for
this application.
serialized_application_autoscaling_policy_def: Optional[bytes]
"""
deployment_infos: Optional[Dict[str, DeploymentInfo]]
code_version: Optional[str]
config: Optional[ServeApplicationSchema]
target_capacity: Optional[float]
target_capacity_direction: Optional[TargetCapacityDirection]
deleting: bool
api_type: APIType
serialized_application_autoscaling_policy_def: Optional[bytes]
external_scaler_enabled: bool
class ApplicationState:
"""Manage single application states with all operations"""
def __init__(
self,
name: str,
deployment_state_manager: DeploymentStateManager,
autoscaling_state_manager: AutoscalingStateManager,
endpoint_state: EndpointState,
logging_config: LoggingConfig,
external_scaler_enabled: bool,
):
"""
Initialize an ApplicationState instance.
Args:
name: Application name.
deployment_state_manager: Manages the state of all deployments in the cluster.
autoscaling_state_manager: Manages autoscaling decisions in the cluster.
endpoint_state: Manages endpoints in the system.
logging_config: Logging configuration schema.
external_scaler_enabled: Whether external autoscaling is enabled for
this application.
"""
self._name = name
self._status_msg = ""
self._deployment_state_manager = deployment_state_manager
self._autoscaling_state_manager = autoscaling_state_manager
self._endpoint_state = endpoint_state
self._route_prefix: Optional[str] = None
self._ingress_deployment_name: Optional[str] = None
self._ingress_request_router_deployment_name: Optional[str] = None
self._status: ApplicationStatus = ApplicationStatus.DEPLOYING
self._deployment_timestamp = time.time()
self._build_app_task_info: Optional[BuildAppTaskInfo] = None
# Before a deploy app task finishes, we don't know what the
# target deployments are, so set deployment_infos=None
self._target_state: ApplicationTargetState = ApplicationTargetState(
deployment_infos=None,
code_version=None,
config=None,
target_capacity=None,
target_capacity_direction=None,
deleting=False,
api_type=APIType.UNKNOWN,
external_scaler_enabled=external_scaler_enabled,
serialized_application_autoscaling_policy_def=None,
)
self._logging_config = logging_config
@property
def route_prefix(self) -> Optional[str]:
return self._route_prefix
@property
def external_scaler_enabled(self) -> bool:
return self._target_state.external_scaler_enabled
@property
def docs_path(self) -> Optional[str]:
# get the docs path from the running deployments
# we are making an assumption that the docs path can only be set
# on ingress deployments with fastapi.
ingress_deployment = DeploymentID(self._ingress_deployment_name, self._name)
return self._deployment_state_manager.get_deployment_docs_path(
ingress_deployment
)
@property
def status(self) -> ApplicationStatus:
"""Status of the application.
DEPLOYING: The build task is still running, or the deployments
have started deploying but aren't healthy yet.
RUNNING: All deployments are healthy.
DEPLOY_FAILED: The build task failed or one or more deployments
became unhealthy in the process of deploying
UNHEALTHY: While the application was running, one or more
deployments transition from healthy to unhealthy.
DELETING: Application and its deployments are being deleted.
"""
return self._status
@property
def deployment_timestamp(self) -> float:
return self._deployment_timestamp
@property
def target_deployments(self) -> List[str]:
"""List of target deployment names in application."""
if self._target_state.deployment_infos is None:
return []
return list(self._target_state.deployment_infos.keys())
@property
def ingress_deployment(self) -> Optional[str]:
return self._ingress_deployment_name
@property
def ingress_request_router_deployment(self) -> Optional[str]:
return self._ingress_request_router_deployment_name
@property
def api_type(self) -> APIType:
return self._target_state.api_type
def recover_target_state_from_checkpoint(
self, checkpoint_data: ApplicationTargetState
):
logger.info(
f"Recovering target state for application '{self._name}' from checkpoint."
)
self._set_target_state(
checkpoint_data.deployment_infos,
api_type=checkpoint_data.api_type,
code_version=checkpoint_data.code_version,
target_config=checkpoint_data.config,
target_capacity=checkpoint_data.target_capacity,
target_capacity_direction=checkpoint_data.target_capacity_direction,
deleting=checkpoint_data.deleting,
external_scaler_enabled=checkpoint_data.external_scaler_enabled,
)
# Restore route prefix and docs path from checkpointed deployments when
# the imperatively started application is restarting with controller.
if checkpoint_data.deployment_infos is not None:
self._route_prefix = self._check_routes(checkpoint_data.deployment_infos)
# Restore app-level autoscaling policy from checkpoint
if (
checkpoint_data.config
and checkpoint_data.config.autoscaling_policy is not None
):
self._autoscaling_state_manager.register_application(
self._name,
AutoscalingPolicy(
_serialized_policy_def=checkpoint_data.serialized_application_autoscaling_policy_def,
**checkpoint_data.config.autoscaling_policy,
),
)
def _set_target_state(
self,
deployment_infos: Optional[Dict[str, DeploymentInfo]],
*,
api_type: APIType,
code_version: Optional[str],
target_config: Optional[ServeApplicationSchema],
target_capacity: Optional[float] = None,
target_capacity_direction: Optional[TargetCapacityDirection] = None,
deleting: bool = False,
external_scaler_enabled: bool = False,
serialized_application_autoscaling_policy_def: Optional[bytes] = None,
):
"""Set application target state.
While waiting for build task to finish, this should be
(None, False)
When build task has finished and during normal operation, this should be
(target_deployments, False)
When a request to delete the application has been received, this should be
({}, True)
"""
if deleting:
self._update_status(ApplicationStatus.DELETING)
else:
self._update_status(ApplicationStatus.DEPLOYING)
ingress_deployment_name = None
ingress_request_router_deployment_name = None
if deployment_infos is not None:
for name, info in deployment_infos.items():
if info.ingress:
ingress_deployment_name = name
if info.ingress_request_router:
ingress_request_router_deployment_name = name
target_state = ApplicationTargetState(
deployment_infos,
code_version,
target_config,
target_capacity,
target_capacity_direction,
deleting,
api_type=api_type,
external_scaler_enabled=external_scaler_enabled,
serialized_application_autoscaling_policy_def=serialized_application_autoscaling_policy_def,
)
if (
ingress_request_router_deployment_name is not None
and ingress_request_router_deployment_name
!= self._ingress_request_router_deployment_name
):
logger.info(
f"Application '{self._name}' has ingress request router "
f"deployment '{ingress_request_router_deployment_name}' configured."
)
self._ingress_deployment_name = ingress_deployment_name
self._ingress_request_router_deployment_name = (
ingress_request_router_deployment_name
)
self._target_state = target_state
def _set_target_state_deleting(self):
"""Set target state to deleting.
Wipes the target deployment infos, code version, and config.
"""
self._set_target_state(
deployment_infos={},
api_type=self._target_state.api_type,
code_version=None,
target_config=None,
deleting=True,
external_scaler_enabled=self.external_scaler_enabled,
)
def _clear_target_state_and_store_config(
self,
target_config: Optional[ServeApplicationSchema],
):
"""Clears the target state and stores the config.
NOTE: this currently assumes that this method is *only* called when managing
apps deployed with the declarative API.
"""
self._set_target_state(
deployment_infos=None,
api_type=APIType.DECLARATIVE,
code_version=None,
target_config=target_config,
deleting=False,
external_scaler_enabled=target_config.external_scaler_enabled
if target_config
else False,
)
def _delete_deployment(self, name: str) -> bool:
"""Delete a deployment in the application.
Args:
name: The name of the deployment to delete.
Returns:
Whether the target state has changed.
"""
id = DeploymentID(name=name, app_name=self._name)
self._endpoint_state.delete_endpoint(id)
return self._deployment_state_manager.delete_deployment(id)
def delete(self):
"""Delete the application"""
if self._status != ApplicationStatus.DELETING:
logger.info(
f"Deleting app '{self._name}'.",
extra={"log_to_stderr": False},
)
self._set_target_state_deleting()
def is_deleted(self) -> bool:
"""Check whether the application is already deleted.
For an application to be considered deleted, the target state has to be set to
deleting and all deployments have to be deleted.
"""
return self._target_state.deleting and len(self._get_live_deployments()) == 0
def should_autoscale(self) -> bool:
"""Determine if autoscaling is enabled for the application.
Returns:
Autoscaling is enabled for the application if any of the deployments have autoscaling enabled.
"""
return self._autoscaling_state_manager.should_autoscale_application(self._name)
def autoscale(self) -> bool:
"""
Apply the autoscaling decisions for the application.
If the application has deployment-level autoscaling, it will apply the autoscaling decisions for each deployment.
Returns:
True if there is a change to number of replicas for any deployment. False otherwise.
"""
target_deployments = self.target_deployments
if len(target_deployments) == 0:
return False
deployment_to_target_num_replicas: Dict[DeploymentID, int] = {}
for deployment_name in target_deployments:
deployment_id = DeploymentID(name=deployment_name, app_name=self._name)
target_num_replicas = (
self._deployment_state_manager.get_deployment_target_num_replicas(
deployment_id
)
)
if target_num_replicas is None:
continue
deployment_to_target_num_replicas[deployment_id] = target_num_replicas
if len(deployment_to_target_num_replicas) == 0:
return False
decisions: Dict[
DeploymentID, int
] = self._autoscaling_state_manager.get_decision_num_replicas(
self._name, deployment_to_target_num_replicas
)
target_state_changed = False
for deployment_id, decision_num_replicas in decisions.items():
target_state_changed = (
self._deployment_state_manager.autoscale(
deployment_id, decision_num_replicas
)
or target_state_changed
)
return target_state_changed
def apply_deployment_info(
self,
deployment_name: str,
deployment_info: DeploymentInfo,
) -> bool:
"""Deploys a deployment in the application.
Args:
deployment_name: The name of the deployment to apply.
deployment_info: The deployment info to apply.
Returns:
Whether the target state has changed.
"""
route_prefix = deployment_info.route_prefix
if route_prefix is not None and not route_prefix.startswith("/"):
raise RayServeException(
f'Invalid route prefix "{route_prefix}", it must start with "/"'
)
deployment_id = DeploymentID(name=deployment_name, app_name=self._name)
target_state_changed = self._deployment_state_manager.deploy(
deployment_id, deployment_info
)
if deployment_info.route_prefix is not None:
config = deployment_info.deployment_config
# Try to get route_patterns from deployment state first (most up-to-date),
# otherwise fall back to existing endpoint patterns
route_patterns = (
self._deployment_state_manager.get_deployment_route_patterns(
deployment_id
)
)
self._endpoint_state.update_endpoint(
deployment_id,
# The current meaning of the "is_cross_language" field is ambiguous.
# We will work on optimizing and removing this field in the future.
# Instead of using the "is_cross_language" field, we will directly
# compare if the replica is Python, which will assist the Python
# router in determining if the replica invocation is a cross-language
# operation.
EndpointInfo(
route=deployment_info.route_prefix,
app_is_cross_language=config.deployment_language
!= DeploymentLanguage.PYTHON,
route_patterns=route_patterns,
),
)
else:
self._endpoint_state.delete_endpoint(deployment_id)
return target_state_changed
def deploy_app(
self,
deployment_infos: Dict[str, DeploymentInfo],
external_scaler_enabled: bool,
):
"""(Re-)deploy the application from list of deployment infos.
This function should only be called to deploy an app from an
imperative API (i.e., `serve.run` or Java API).
Raises: RayServeException if there is more than one route prefix
or docs path.
"""
# Check routes are unique in deployment infos
self._route_prefix = self._check_routes(deployment_infos)
self._set_target_state(
deployment_infos=deployment_infos,
api_type=APIType.IMPERATIVE,
code_version=None,
target_config=None,
target_capacity=None,
target_capacity_direction=None,
external_scaler_enabled=external_scaler_enabled,
)
def apply_app_config(
self,
config: ServeApplicationSchema,
target_capacity: Optional[float],
target_capacity_direction: Optional[TargetCapacityDirection],
deployment_time: float,
) -> None:
"""Apply the config to the application.
If the code version matches that of the current live deployments
then it only applies the updated config to the deployment state
manager. If the code version doesn't match, this will re-build
the application.
This function should only be called to (re-)deploy an app from
the declarative API (i.e., through the REST API).
"""
self._deployment_timestamp = deployment_time
config_version = get_app_code_version(config)
if config_version == self._target_state.code_version:
try:
overrided_infos = override_deployment_info(
self._target_state.deployment_infos,
config,
)
self._route_prefix = self._check_routes(overrided_infos)
self._set_target_state(
# Code version doesn't change.
code_version=self._target_state.code_version,
api_type=APIType.DECLARATIVE,
# Everything else must reflect the new config.
deployment_infos=overrided_infos,
target_config=config,
target_capacity=target_capacity,
target_capacity_direction=target_capacity_direction,
external_scaler_enabled=config.external_scaler_enabled,
)
except (TypeError, ValueError, RayServeException):
self._clear_target_state_and_store_config(config)
self._update_status(
ApplicationStatus.DEPLOY_FAILED, traceback.format_exc()
)
except Exception:
self._clear_target_state_and_store_config(config)
self._update_status(
ApplicationStatus.DEPLOY_FAILED,
(
f"Unexpected error occurred while applying config for "
f"application '{self._name}': \n{traceback.format_exc()}"
),
)
else:
# If there is an in progress build task, cancel it.
if self._build_app_task_info and not self._build_app_task_info.finished:
logger.info(
f"Received new config for application '{self._name}'. "
"Cancelling previous request."
)
ray.cancel(self._build_app_task_info.obj_ref)
# Halt reconciliation of target deployments. A new target state
# will be set once the new app has finished building.
self._clear_target_state_and_store_config(config)
# Record telemetry for container runtime env feature
if self._target_state.config.runtime_env.get(
"container"
) or self._target_state.config.runtime_env.get("image_uri"):
ServeUsageTag.APP_CONTAINER_RUNTIME_ENV_USED.record("1")
if isinstance(config.autoscaling_policy, dict):
application_autoscaling_policy_function = config.autoscaling_policy.get(
"policy_function"
)
else:
application_autoscaling_policy_function = None
deployment_to_autoscaling_policy_function = {
deployment.name: deployment.autoscaling_config.get("policy", {}).get(
"policy_function", DEFAULT_AUTOSCALING_POLICY_NAME
)
for deployment in config.deployments
if isinstance(deployment.autoscaling_config, dict)
}
deployment_to_request_router_cls = {
deployment.name: deployment.request_router_config.get(
"request_router_class", DEFAULT_REQUEST_ROUTER_PATH
)
for deployment in config.deployments
if isinstance(deployment.request_router_config, dict)
}
deployment_to_deployment_actor_classes = {}
for deployment in config.deployments:
actors = getattr(deployment, "deployment_actors", None)
if actors and actors is not DEFAULT.VALUE:
actor_classes = {}
for actor_cfg in actors:
if isinstance(actor_cfg, dict):
name = actor_cfg.get("name")
cls_path = actor_cfg.get("actor_class")
if isinstance(cls_path, str):
actor_classes[name] = cls_path
if actor_classes:
deployment_to_deployment_actor_classes[
deployment.name
] = actor_classes
# Kick off new build app task
logger.info(f"Importing and building app '{self._name}'.")
build_app_obj_ref = build_serve_application.options(
runtime_env=config.runtime_env,
enable_task_events=RAY_SERVE_ENABLE_TASK_EVENTS,
).remote(
config.import_path,
config_version,
config.name,
config.args,
self._logging_config,
application_autoscaling_policy_function,
deployment_to_autoscaling_policy_function,
deployment_to_request_router_cls,
deployment_to_deployment_actor_classes,
)
self._build_app_task_info = BuildAppTaskInfo(
obj_ref=build_app_obj_ref,
code_version=config_version,
config=config,
target_capacity=target_capacity,
target_capacity_direction=target_capacity_direction,
finished=False,
)
def _get_live_deployments(self) -> List[str]:
return self._deployment_state_manager.get_deployments_in_application(self._name)
def _determine_app_status(self) -> Tuple[ApplicationStatus, str]:
"""Check deployment statuses and target state, and determine the
corresponding application status.
Returns:
Status (ApplicationStatus):
RUNNING: all deployments are healthy or autoscaling.
DEPLOYING: there is one or more updating deployments,
and there are no unhealthy deployments.
DEPLOY_FAILED: one or more deployments became unhealthy
while the application was deploying.
UNHEALTHY: one or more deployments became unhealthy
while the application was running.
DELETING: the application is being deleted.
Error message (str):
Non-empty string if status is DEPLOY_FAILED or UNHEALTHY
"""
if self._target_state.deleting:
return ApplicationStatus.DELETING, ""
# Get the lowest rank, i.e. highest priority, deployment status info object
# The deployment status info with highest priority determines the corresponding
# application status to set.
deployment_statuses = self.get_deployments_statuses()
lowest_rank_status = min(deployment_statuses, key=lambda info: info.rank)
if lowest_rank_status.status == DeploymentStatus.DEPLOY_FAILED:
failed_deployments = [
s.name
for s in deployment_statuses
if s.status == DeploymentStatus.DEPLOY_FAILED
]
return (
ApplicationStatus.DEPLOY_FAILED,
f"Failed to update the deployments {failed_deployments}.",
)
elif lowest_rank_status.status == DeploymentStatus.UNHEALTHY:
unhealthy_deployment_names = [
s.name
for s in deployment_statuses
if s.status == DeploymentStatus.UNHEALTHY
]
return (
ApplicationStatus.UNHEALTHY,
f"The deployments {unhealthy_deployment_names} are UNHEALTHY.",
)
elif lowest_rank_status.status == DeploymentStatus.UPDATING:
return ApplicationStatus.DEPLOYING, ""
elif (
lowest_rank_status.status
in [DeploymentStatus.UPSCALING, DeploymentStatus.DOWNSCALING]
and lowest_rank_status.status_trigger
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
):
return ApplicationStatus.DEPLOYING, ""
else:
return ApplicationStatus.RUNNING, ""
def _reconcile_build_app_task(
self,
) -> Tuple[Optional[bytes], Optional[Dict], BuildAppStatus, str]:
"""If necessary, reconcile the in-progress build task.
Returns:
Serialized application autoscaling policy def (bytes):
The serialized application autoscaling policy def returned from the build app task
if it was built successfully, otherwise None.
Deploy arguments (Dict[str, DeploymentInfo]):
The deploy arguments returned from the build app task
and their code version.
Status (BuildAppStatus):
NO_TASK_IN_PROGRESS: There is no build task to reconcile.
SUCCEEDED: Task finished successfully.
FAILED: An error occurred during execution of build app task
IN_PROGRESS: Task hasn't finished yet.
Error message (str):
Non-empty string if status is DEPLOY_FAILED or UNHEALTHY
"""
if self._build_app_task_info is None or self._build_app_task_info.finished:
return None, None, BuildAppStatus.NO_TASK_IN_PROGRESS, ""
if not check_obj_ref_ready_nowait(self._build_app_task_info.obj_ref):
return None, None, BuildAppStatus.IN_PROGRESS, ""
# Retrieve build app task result
self._build_app_task_info.finished = True
try:
serialized_application_autoscaling_policy_def, args, err = ray.get(
self._build_app_task_info.obj_ref
)
if err is None:
logger.info(f"Imported and built app '{self._name}' successfully.")
else:
return (
None,
None,
BuildAppStatus.FAILED,
f"Deploying app '{self._name}' failed with exception:\n{err}",
)
except RuntimeEnvSetupError:
error_msg = (
f"Runtime env setup for app '{self._name}' failed:\n"
+ traceback.format_exc()
)
return None, None, BuildAppStatus.FAILED, error_msg
except RayTaskError:
return (
None,
None,
BuildAppStatus.FAILED,
f"Deploying app '{self._name}' failed with exception:\n"
f"{traceback.format_exc()}",
)
except Exception:
error_msg = (
f"Unexpected error occurred while deploying application "
f"'{self._name}': \n{traceback.format_exc()}"
)
return None, None, BuildAppStatus.FAILED, error_msg
# Convert serialized deployment args (returned by build app task)
# to deployment infos and apply option overrides from config
try:
deployment_infos = {
params["deployment_name"]: deploy_args_to_deployment_info(
**params, app_name=self._name
)
for params in args
}
deployment_to_serialized_autoscaling_policy_def = {
params["deployment_name"]: params["serialized_autoscaling_policy_def"]
for params in args
if params["serialized_autoscaling_policy_def"] is not None
}
deployment_to_serialized_request_router_cls = {
params["deployment_name"]: params["serialized_request_router_cls"]
for params in args
if params["serialized_request_router_cls"] is not None
}
deployment_to_serialized_deployment_actors = {}
for params in args:
dep_name = params["deployment_name"]
# From proto roundtrip (code-defined actors)
info = deployment_infos[dep_name]
if info.deployment_config.deployment_actors:
for cfg in info.deployment_config.deployment_actors:
if cfg._serialized_actor_class:
deployment_to_serialized_deployment_actors.setdefault(
dep_name, {}
)[cfg.name] = cfg._serialized_actor_class
# From build task (config-only actors)
if params.get("serialized_deployment_actors"):
deployment_to_serialized_deployment_actors.setdefault(
dep_name, {}
).update(params["serialized_deployment_actors"])
overrided_infos = override_deployment_info(
deployment_infos,
self._build_app_task_info.config,
deployment_to_serialized_autoscaling_policy_def,
deployment_to_serialized_request_router_cls,
deployment_to_serialized_deployment_actors,
)
self._route_prefix = self._check_routes(overrided_infos)
return (
serialized_application_autoscaling_policy_def,
overrided_infos,
BuildAppStatus.SUCCEEDED,
"",
)
except (TypeError, ValueError, RayServeException):
return None, None, BuildAppStatus.FAILED, traceback.format_exc()
except Exception:
error_msg = (
f"Unexpected error occurred while applying config for application "
f"'{self._name}': \n{traceback.format_exc()}"
)
return None, None, BuildAppStatus.FAILED, error_msg
def _check_routes(
self, deployment_infos: Dict[str, DeploymentInfo]
) -> Tuple[str, str]:
"""Check route prefixes of deployments in app.
There should only be one non-null route prefix. If there is one,
set it as the application route prefix. This function must be
run every control loop iteration because the target config could
be updated without kicking off a new task.
Returns: route prefix.
Raises: RayServeException if more than one route prefix is found among deployments.
"""
num_route_prefixes = 0
route_prefix = None
for info in deployment_infos.values():
# Update route prefix of application, which may be updated
# through a redeployed config.
if info.route_prefix is not None:
route_prefix = info.route_prefix
num_route_prefixes += 1
if num_route_prefixes > 1:
raise RayServeException(
f'Found multiple route prefixes from application "{self._name}",'
" Please specify only one route prefix for the application "
"to avoid this issue."
)
return route_prefix
def _reconcile_target_deployments(self) -> None:
"""Reconcile target deployments in application target state.
Ensure each deployment is running on up-to-date info, and
remove outdated deployments from the application.
"""
target_state_changed = False
# Set target state for each deployment
for deployment_name, info in self._target_state.deployment_infos.items():
deploy_info = deepcopy(info)
# Apply the target capacity information to the deployment info.
deploy_info.set_target_capacity(
new_target_capacity=self._target_state.target_capacity,
new_target_capacity_direction=(
self._target_state.target_capacity_direction
),
)
# Apply the application logging config to the deployment logging config
# if it is not set.
if (
self._target_state.config
and self._target_state.config.logging_config
and deploy_info.deployment_config.logging_config is None
):
deploy_info.deployment_config.logging_config = (
self._target_state.config.logging_config.model_dump()
)
target_state_changed = (
self.apply_deployment_info(deployment_name, deploy_info)
or target_state_changed
)
# Delete outdated deployments
for deployment_name in self._get_live_deployments():
if deployment_name not in self.target_deployments:
target_state_changed = (
self._delete_deployment(deployment_name) or target_state_changed
)
return target_state_changed
def get_deployment_topology(self) -> Optional[DeploymentTopology]:
"""Get the deployment topology for this application.
Returns:
The deployment topology, or None if not yet built.
"""
if not self.target_deployments:
return None
nodes = {}
# Using target deployments because we wish to build best effort topology based on current state.
for deployment_name in self.target_deployments:
deployment_id = DeploymentID(name=deployment_name, app_name=self._name)
# Get outbound deployment names from deployment state
outbound_deployment = (
self._deployment_state_manager.get_deployment_outbound_deployments(
deployment_id
)
) or []
# Create node for this deployment
node = DeploymentNode(
name=deployment_name,
app_name=self._name,
outbound_deployments=[
{"name": dep.name, "app_name": dep.app_name}
for dep in outbound_deployment
],
is_ingress=(deployment_name == self._ingress_deployment_name),
)
nodes[deployment_name] = node
return DeploymentTopology(
app_name=self._name,
ingress_deployment=self._ingress_deployment_name,
nodes=nodes,
)
def update(self) -> Tuple[bool, bool]:
"""Attempts to reconcile this application to match its target state.
Updates the application status and status message based on the
current state of the system.
Returns:
Whether the target state has changed.
"""
target_state_changed = False
# If the application is being deleted, ignore any build task results to
# avoid flipping the state back to DEPLOYING/RUNNING.
if not self._target_state.deleting:
(
serialized_application_autoscaling_policy_def,
infos,
task_status,
msg,
) = self._reconcile_build_app_task()
if task_status == BuildAppStatus.SUCCEEDED:
target_state_changed = True
self._set_target_state(
deployment_infos=infos,
code_version=self._build_app_task_info.code_version,
api_type=self._target_state.api_type,
target_config=self._build_app_task_info.config,
target_capacity=self._build_app_task_info.target_capacity,
target_capacity_direction=(
self._build_app_task_info.target_capacity_direction
),
external_scaler_enabled=self._target_state.external_scaler_enabled,
serialized_application_autoscaling_policy_def=serialized_application_autoscaling_policy_def,
)
# Handling the case where the user turns off/turns on app-level autoscaling policy,
# between app deployment.
if (
self._target_state.config is not None
and self._target_state.config.autoscaling_policy is not None
):
self._autoscaling_state_manager.register_application(
self._name,
AutoscalingPolicy(
_serialized_policy_def=serialized_application_autoscaling_policy_def,
**self._target_state.config.autoscaling_policy,
),
)
else:
self._autoscaling_state_manager.deregister_application(self._name)
elif task_status == BuildAppStatus.FAILED:
self._update_status(ApplicationStatus.DEPLOY_FAILED, msg)
# Only reconcile deployments when the build app task is finished. If
# it's not finished, we don't know what the target list of deployments
# is, so we don't perform any reconciliation.
if self._target_state.deployment_infos is not None:
target_state_changed = (
self._reconcile_target_deployments() or target_state_changed
)
status, status_msg = self._determine_app_status()
self._update_status(status, status_msg)
# Check if app is ready to be deleted
if self._target_state.deleting:
return self.is_deleted(), target_state_changed
return False, target_state_changed
def get_checkpoint_data(self) -> ApplicationTargetState:
return self._target_state
def get_deployments_statuses(self) -> List[DeploymentStatusInfo]:
"""Return all deployment status information"""
deployments = [
DeploymentID(name=deployment, app_name=self._name)
for deployment in self.target_deployments
]
return self._deployment_state_manager.get_deployment_statuses(deployments)
def get_application_status_info(self) -> ApplicationStatusInfo:
"""Return the application status information"""
return ApplicationStatusInfo(
self._status,
message=self._status_msg,
deployment_timestamp=self._deployment_timestamp,
)
def list_deployment_details(self) -> Dict[str, DeploymentDetails]:
"""Gets detailed info on all live deployments in this application.
(Does not include deleted deployments.)
Returns:
A dictionary of deployment infos. The set of deployment info returned
may not be the full list of deployments that are part of the application.
This can happen when the application is still deploying and bringing up
deployments, or when the application is deleting and some deployments have
been deleted.
"""
details = {
deployment_name: self._deployment_state_manager.get_deployment_details(
DeploymentID(name=deployment_name, app_name=self._name)
)
for deployment_name in self.target_deployments
}
return {k: v for k, v in details.items() if v is not None}
def _update_status(self, status: ApplicationStatus, status_msg: str = "") -> None:
if (
status_msg
and status
in [
ApplicationStatus.DEPLOY_FAILED,
ApplicationStatus.UNHEALTHY,
]
and status_msg != self._status_msg
):
logger.error(status_msg)
self._status = status
self._status_msg = status_msg
class ApplicationStateManager:
def __init__(
self,
deployment_state_manager: DeploymentStateManager,
autoscaling_state_manager: AutoscalingStateManager,
endpoint_state: EndpointState,
kv_store: KVStoreBase,
logging_config: LoggingConfig,
):
self._deployment_state_manager = deployment_state_manager
self._autoscaling_state_manager = autoscaling_state_manager
self._endpoint_state = endpoint_state
self._kv_store = kv_store
self._logging_config = logging_config
self._shutting_down = False
self._application_states: Dict[str, ApplicationState] = {}
# Metric for tracking application status
self._application_status_gauge = ray_metrics.Gauge(
"serve_application_status",
description=(
"Numeric status of application. "
"0=UNKNOWN, 1=DEPLOY_FAILED, 2=UNHEALTHY, 3=NOT_STARTED, "
"4=DELETING, 5=DEPLOYING, 6=RUNNING."
),
tag_keys=("application",),
)
self._app_status_gauge_cache: Dict[str, Tuple[int, float]] = {}
self._recover_from_checkpoint()
def _recover_from_checkpoint(self):
checkpoint = self._kv_store.get(CHECKPOINT_KEY)
if checkpoint is not None:
application_state_info = cloudpickle.loads(checkpoint)
for app_name, checkpoint_data in application_state_info.items():
app_state = ApplicationState(
app_name,
self._deployment_state_manager,
self._autoscaling_state_manager,
self._endpoint_state,
self._logging_config,
checkpoint_data.external_scaler_enabled,
)
app_state.recover_target_state_from_checkpoint(checkpoint_data)
self._application_states[app_name] = app_state
def delete_app(self, name: str) -> None:
"""Delete application by name"""
if name not in self._application_states:
return
self._application_states[name].delete()
def deploy_apps(
self,
name_to_deployment_args: Dict[str, List[Dict]],
name_to_application_args: Dict[str, ApplicationArgsProto],
) -> None:
live_route_prefixes: Dict[str, str] = {
app_state.route_prefix: app_name
for app_name, app_state in self._application_states.items()
if app_state.route_prefix is not None
and not app_state.status == ApplicationStatus.DELETING
}
for name, deployment_args in name_to_deployment_args.items():
for deploy_param in deployment_args:
# Make sure route_prefix is not being used by other application.
deploy_app_prefix = deploy_param.get("route_prefix")
if deploy_app_prefix is None:
continue
existing_app_name = live_route_prefixes.get(deploy_app_prefix)
# It's ok to redeploy an app with the same prefix
# if it has the same name as the app already using that prefix.
if existing_app_name is not None and existing_app_name != name:
raise RayServeException(
f"Prefix {deploy_app_prefix} is being used by application "
f'"{existing_app_name}". Failed to deploy application "{name}".'
)
# We might be deploying more than one app,
# so we need to add this app's prefix to the
# set of live route prefixes that we're checking
# against during this batch operation.
live_route_prefixes[deploy_app_prefix] = name
application_args = name_to_application_args.get(name)
external_scaler_enabled = application_args.external_scaler_enabled
if name not in self._application_states:
self._application_states[name] = ApplicationState(
name,
self._deployment_state_manager,
self._autoscaling_state_manager,
self._endpoint_state,
self._logging_config,
external_scaler_enabled,
)
ServeUsageTag.NUM_APPS.record(str(len(self._application_states)))
deployment_infos = {
params["deployment_name"]: deploy_args_to_deployment_info(
**params, app_name=name
)
for params in deployment_args
}
self._application_states[name].deploy_app(
deployment_infos, external_scaler_enabled
)
def deploy_app(
self,
name: str,
deployment_args: List[Dict],
application_args: ApplicationArgsProto,
) -> None:
"""Deploy the specified app to the list of deployment arguments.
This function should only be called if the app is being deployed
through serve.run instead of from a config.
Args:
name: application name
deployment_args: arguments for deploying a list of deployments.
application_args: application arguments.
"""
self.deploy_apps({name: deployment_args}, {name: application_args})
def apply_app_configs(
self,
app_configs: List[ServeApplicationSchema],
*,
deployment_time: float = 0,
target_capacity: Optional[float] = None,
target_capacity_direction: Optional[TargetCapacityDirection] = None,
):
"""Declaratively apply the list of application configs.
The applications will be reconciled to match the target state of the config.
Any applications previously deployed declaratively that are *not* present in
the list will be deleted.
"""
for app_config in app_configs:
if app_config.name not in self._application_states:
logger.info(f"Deploying new app '{app_config.name}'.")
self._application_states[app_config.name] = ApplicationState(
app_config.name,
self._deployment_state_manager,
self._autoscaling_state_manager,
endpoint_state=self._endpoint_state,
logging_config=self._logging_config,
external_scaler_enabled=app_config.external_scaler_enabled,
)
self._application_states[app_config.name].apply_app_config(
app_config,
target_capacity,
target_capacity_direction,
deployment_time=deployment_time,
)
# Delete all apps that were previously deployed via the declarative API
# but are not in the config being applied.
existing_apps = {
name
for name, app_state in self._application_states.items()
if app_state.api_type == APIType.DECLARATIVE
}
apps_in_config = {app_config.name for app_config in app_configs}
for app_to_delete in existing_apps - apps_in_config:
self.delete_app(app_to_delete)
ServeUsageTag.NUM_APPS.record(str(len(self._application_states)))
def get_deployments(self, app_name: str) -> List[str]:
"""Return all deployment names by app name"""
if app_name not in self._application_states:
return []
return self._application_states[app_name].target_deployments
def get_deployments_statuses(self, app_name: str) -> List[DeploymentStatusInfo]:
"""Return all deployment statuses by app name"""
if app_name not in self._application_states:
return []
return self._application_states[app_name].get_deployments_statuses()
def get_app_status(self, name: str) -> ApplicationStatus:
if name not in self._application_states:
return ApplicationStatus.NOT_STARTED
return self._application_states[name].status
def does_app_exist(self, name: str) -> bool:
return name in self._application_states
def get_app_status_info(self, name: str) -> ApplicationStatusInfo:
if name not in self._application_states:
return ApplicationStatusInfo(
ApplicationStatus.NOT_STARTED,
message=f"Application {name} doesn't exist",
deployment_timestamp=0,
)
return self._application_states[name].get_application_status_info()
def get_docs_path(self, app_name: str) -> Optional[str]:
return self._application_states[app_name].docs_path
def get_route_prefix(self, name: str) -> Optional[str]:
return self._application_states[name].route_prefix
def get_ingress_deployment_name(self, name: str) -> Optional[str]:
if name not in self._application_states:
return None
return self._application_states[name].ingress_deployment
def get_ingress_request_router_deployment_name(self, name: str) -> Optional[str]:
if name not in self._application_states:
return None
return self._application_states[name].ingress_request_router_deployment
def get_app_source(self, name: str) -> APIType:
return self._application_states[name].api_type
def get_external_scaler_enabled(self, app_name: str) -> bool:
"""Check if external scaler is enabled for the application.
Args:
app_name: Name of the application.
Returns:
True if external_scaler_enabled is set for the application, False otherwise.
"""
return (
self.does_app_exist(app_name)
and self._application_states[app_name].external_scaler_enabled
)
def list_app_statuses(
self, source: Optional[APIType] = None
) -> Dict[str, ApplicationStatusInfo]:
"""Return a dictionary with {app name: application info}
Args:
source: Optional API type filter. If provided, only returns apps
deployed via the specified API type.
Returns:
Dict[str, ApplicationStatusInfo]: A dictionary mapping application names
to their corresponding status information.
"""
if source is None:
return {
name: self._application_states[name].get_application_status_info()
for name in self._application_states
}
else:
return {
name: self._application_states[name].get_application_status_info()
for name in self._application_states
if self.get_app_source(name) is source
}
def list_deployment_details(self, name: str) -> Dict[str, DeploymentDetails]:
"""Gets detailed info on all deployments in specified application."""
if name not in self._application_states:
return {}
return self._application_states[name].list_deployment_details()
def get_deployment_topology(self, app_name: str) -> Optional[DeploymentTopology]:
"""Get the deployment topology for an application.
Args:
app_name: Name of the application.
Returns:
The deployment topology for the application, or None if the application
doesn't exist or the topology hasn't been built yet.
"""
if app_name not in self._application_states:
return None
return self._application_states[app_name].get_deployment_topology()
def update(self):
"""Update each application state."""
apps_to_be_deleted = []
any_target_state_changed = False
for name, app in self._application_states.items():
if app.should_autoscale():
any_target_state_changed = app.autoscale() or any_target_state_changed
ready_to_be_deleted, app_target_state_changed = app.update()
any_target_state_changed = (
any_target_state_changed or app_target_state_changed
)
if ready_to_be_deleted:
apps_to_be_deleted.append(name)
logger.debug(f"Application '{name}' deleted successfully.")
# Record application status metrics
now = time.time()
for name, app in self._application_states.items():
cached = self._app_status_gauge_cache.get(name)
value = app.status.to_numeric()
# Throttle gauge reporting to avoid redundant FFI calls each control loop.
# Two independent conditions trigger a write:
# - value_changed: reports status transitions (e.g. DEPLOYING -> RUNNING)
# immediately, without waiting for the interval to expire.
# - interval_elapsed: refreshes the gauge periodically even when status is
# unchanged, preventing stale/empty time series in Grafana/Prometheus.
# We skip ONLY when both say it is safe — value unchanged AND reported recently.
value_changed = cached is None or cached[0] != value
interval_elapsed = (
cached is None
or (now - cached[1]) >= RAY_SERVE_STATUS_GAUGE_REPORT_INTERVAL_S
)
if not value_changed and not interval_elapsed:
continue
self._application_status_gauge.set(value, tags={"application": name})
self._app_status_gauge_cache[name] = (value, now)
if len(apps_to_be_deleted) > 0:
for app_name in apps_to_be_deleted:
self._autoscaling_state_manager.deregister_application(app_name)
del self._application_states[app_name]
self._app_status_gauge_cache.pop(app_name, None)
ServeUsageTag.NUM_APPS.record(str(len(self._application_states)))
if any_target_state_changed:
self.save_checkpoint()
self._deployment_state_manager.save_checkpoint()
def shutdown(self) -> None:
self._shutting_down = True
for app_state in self._application_states.values():
app_state.delete()
def is_ready_for_shutdown(self) -> bool:
"""Return whether all applications have shut down.
Iterate through all application states and check if all their applications
are deleted.
"""
return self._shutting_down and all(
app_state.is_deleted() for app_state in self._application_states.values()
)
def delete_checkpoint(self) -> None:
"""Delete the application state checkpoint from KV store."""
self._kv_store.delete(CHECKPOINT_KEY)
def save_checkpoint(self) -> None:
"""Write a checkpoint of all application states."""
if self._shutting_down:
# Once we're told to shut down, stop writing checkpoints.
# Calling .shutdown() deletes any existing checkpoint.
return
application_state_info = {
app_name: app_state.get_checkpoint_data()
for app_name, app_state in self._application_states.items()
}
self._kv_store.put(
CHECKPOINT_KEY,
cloudpickle.dumps(application_state_info),
)
@ray.remote(num_cpus=0, max_calls=1, max_retries=3, retry_exceptions=True)
def build_serve_application(
import_path: str,
code_version: str,
name: str,
args: Dict,
logging_config: LoggingConfig,
application_autoscaling_policy_function: Optional[str],
deployment_to_autoscaling_policy_function: Dict[str, str],
deployment_to_request_router_cls: Dict[str, str],
deployment_to_deployment_actor_classes: Dict[str, Dict[str, str]],
) -> Tuple[Optional[bytes], Optional[List[Dict]], Optional[str]]:
"""Import and build a Serve application.
Args:
import_path: import path to a top-level Serve application object or
application builder return value.
code_version: code version inferred from app config. All
deployment versions are set to this code version.
name: application name. If specified, application will be deployed
without removing existing applications.
args: Arguments to be passed to the application builder.
logging_config: the logging config for the build app task.
application_autoscaling_policy_function: the application autoscaling policy function name
deployment_to_autoscaling_policy_function: a dictionary mapping deployment names to autoscaling policy function names
deployment_to_request_router_cls: a dictionary mapping deployment names to request router class names
deployment_to_deployment_actor_classes: mapping of deployment name to
{actor_name: actor_class_import_path} for deployment actors
specified in the config override (not in code)
Returns:
Serialized application autoscaling policy def: a serialized autoscaling
policy def for the application if it was built successfully, otherwise None.
Deploy arguments: a list of deployment arguments if application
was built successfully, otherwise None.
Error message: a string if an error was raised, otherwise None.
"""
configure_component_logger(
component_name="controller",
component_id=f"build_{name}_{os.getpid()}",
logging_config=logging_config,
)
try:
from ray.serve._private.api import call_user_app_builder_with_args_if_necessary
# Import and build the application.
args_info_str = f" with arguments {args}" if args else ""
logger.info(f"Importing application '{name}'{args_info_str}.")
app = call_user_app_builder_with_args_if_necessary(
import_attr(import_path), args
)
deploy_args_list = []
built_app: BuiltApplication = build_app(
app,
name=name,
default_runtime_env=ray.get_runtime_context().runtime_env,
)
built_app.validate_single_fastapi_ingress()
def _get_serialized_def(attr_path: str) -> bytes:
module, attr = import_module_and_attr(attr_path)
cloudpickle.register_pickle_by_value(module)
serialized = cloudpickle.dumps(attr)
cloudpickle.unregister_pickle_by_value(module)
return serialized
application_serialized_autoscaling_policy_def = None
if application_autoscaling_policy_function is not None:
application_serialized_autoscaling_policy_def = _get_serialized_def(
application_autoscaling_policy_function
)
def _append_deploy_args(
deployment,
*,
is_ingress: bool,
is_ingress_request_router: bool,
) -> None:
if deployment._deployment_config.deployment_actors:
for cfg in deployment._deployment_config.deployment_actors:
if not cfg._serialized_actor_class:
cfg._serialize_actor_class()
serialized_deployment_actors = None
if (
deployment_to_deployment_actor_classes
and deployment.name in deployment_to_deployment_actor_classes
):
serialized_deployment_actors = {
actor_name: _get_serialized_def(actor_class_path)
for actor_name, actor_class_path in (
deployment_to_deployment_actor_classes[deployment.name].items()
)
}
deployment_to_serialized_autoscaling_policy_def = None
deployment_to_serialized_request_router_cls = None
if deployment.name in deployment_to_autoscaling_policy_function:
deployment_to_serialized_autoscaling_policy_def = _get_serialized_def(
deployment_to_autoscaling_policy_function[deployment.name]
)
if deployment.name in deployment_to_request_router_cls:
deployment_to_serialized_request_router_cls = _get_serialized_def(
deployment_to_request_router_cls[deployment.name]
)
deploy_args_list.append(
get_deploy_args(
name=deployment._name,
replica_config=deployment._replica_config,
ingress=is_ingress,
ingress_request_router=is_ingress_request_router,
deployment_config=deployment._deployment_config,
version=code_version,
route_prefix="/" if is_ingress else None,
serialized_autoscaling_policy_def=deployment_to_serialized_autoscaling_policy_def,
serialized_request_router_cls=deployment_to_serialized_request_router_cls,
serialized_deployment_actors=serialized_deployment_actors,
uses_multiplexing=_callable_uses_multiplexing(
deployment.func_or_class
),
)
)
for deployment in built_app.deployments:
_append_deploy_args(
deployment,
is_ingress=deployment.name == built_app.ingress_deployment_name,
is_ingress_request_router=False,
)
if built_app.ingress_request_router_deployment is not None:
_append_deploy_args(
built_app.ingress_request_router_deployment,
is_ingress=False,
is_ingress_request_router=True,
)
return application_serialized_autoscaling_policy_def, deploy_args_list, None
except RayServeException as e:
return None, None, str(e)
except KeyboardInterrupt:
# Error is raised when this task is canceled with ray.cancel(), which
# happens when deploy_apps() is called.
logger.info(
"Existing config deployment request terminated because of keyboard "
"interrupt."
)
return None, None, None
except Exception:
# Wrap the user traceback in a RuntimeError so that user exceptions
# which are unpickleable (e.g. NonserializableException) or lose detail
# through Ray's serialization round-trip (e.g. SyntaxError) still
# propagate their original traceback intact through Ray's retry path.
err_str = traceback.format_exc()
logger.warning(f"Exception importing application '{name}'.")
raise RuntimeError(err_str) from None
def override_deployment_info(
deployment_infos: Dict[str, DeploymentInfo],
override_config: Optional[ServeApplicationSchema],
deployment_to_serialized_autoscaling_policy_def: Optional[Dict[str, bytes]] = None,
deployment_to_serialized_request_router_cls: Optional[Dict[str, bytes]] = None,
deployment_to_serialized_deployment_actors: Optional[
Dict[str, Dict[str, bytes]]
] = None,
) -> Dict[str, DeploymentInfo]:
"""Override deployment infos with options from app config.
Args:
deployment_infos: deployment info loaded from code
override_config: application config deployed by user with
options to override those loaded from code.
deployment_to_serialized_autoscaling_policy_def: serialized autoscaling policy def for each deployment
deployment_to_serialized_request_router_cls: serialized request router cls for each deployment
deployment_to_serialized_deployment_actors: mapping of deployment name
to {actor_name: serialized_actor_class_bytes} for each deployment
actor, produced by the build task
Returns:
The updated deployment infos.
Raises:
ValueError: If config options have invalid values.
TypeError: If config options have invalid types.
"""
deployment_infos = deepcopy(deployment_infos)
if override_config is None:
return deployment_infos
config_dict = override_config.model_dump(exclude_unset=True)
deployment_override_options = config_dict.get("deployments", [])
# Override options for each deployment listed in the config.
for options in deployment_override_options:
if "max_ongoing_requests" in options:
options["max_ongoing_requests"] = options.get("max_ongoing_requests")
deployment_name = options["name"]
if deployment_name not in deployment_infos:
raise ValueError(
f"Deployment '{deployment_name}' does not exist. "
f"Available: {list(deployment_infos.keys())}"
)
info = deployment_infos[deployment_name]
original_options = info.deployment_config.model_dump()
original_options["user_configured_option_names"].update(set(options))
# Override `max_ongoing_requests` and `autoscaling_config` if
# `num_replicas="auto"`
if options.get("num_replicas") == "auto":
options["num_replicas"] = None
new_config = AutoscalingConfig.default().model_dump()
# If `autoscaling_config` is specified, its values override
# the default `num_replicas="auto"` configuration
autoscaling_config = (
options.get("autoscaling_config")
or info.deployment_config.autoscaling_config
)
if autoscaling_config:
new_config.update(autoscaling_config)
if (
deployment_to_serialized_autoscaling_policy_def
and deployment_name in deployment_to_serialized_autoscaling_policy_def
):
# By setting the serialized policy def, AutoscalingConfig constructor will not
# try to import the policy from the string import path
policy_obj = AutoscalingPolicy.from_serialized_policy_def(
new_config["policy"],
deployment_to_serialized_autoscaling_policy_def[deployment_name],
)
new_config["policy"] = policy_obj
options["autoscaling_config"] = AutoscalingConfig(**new_config)
ServeUsageTag.AUTO_NUM_REPLICAS_USED.record("1")
# What to pass to info.update
override_options = {}
# Merge app-level and deployment-level runtime_envs.
replica_config = info.replica_config
app_runtime_env = override_config.runtime_env
if "ray_actor_options" in options:
# If specified, get ray_actor_options from config
override_actor_options = options.pop("ray_actor_options", {})
else:
# Otherwise, get options from application code (and default to {}
# if the code sets options to None).
override_actor_options = replica_config.ray_actor_options or {}
override_placement_group_bundles = options.pop(
"placement_group_bundles", replica_config.placement_group_bundles
)
override_placement_group_strategy = options.pop(
"placement_group_strategy", replica_config.placement_group_strategy
)
override_max_replicas_per_node = options.pop(
"max_replicas_per_node", replica_config.max_replicas_per_node
)
override_bundle_label_selector = options.pop(
"placement_group_bundle_label_selector",
replica_config.placement_group_bundle_label_selector,
)
override_fallback_strategy = options.pop(
"placement_group_fallback_strategy",
replica_config.placement_group_fallback_strategy,
)
# Record telemetry for container runtime env feature at deployment level
if override_actor_options.get("runtime_env") and (
override_actor_options["runtime_env"].get("container")
or override_actor_options["runtime_env"].get("image_uri")
):
ServeUsageTag.DEPLOYMENT_CONTAINER_RUNTIME_ENV_USED.record("1")
child_runtime_env = override_actor_options.get("runtime_env", {})
# Avoid materializing an empty runtime_env; it changes the actor options
# hash and causes an unnecessary rolling update.
if app_runtime_env or child_runtime_env:
merged_env = override_runtime_envs_except_env_vars(
app_runtime_env, child_runtime_env
)
override_actor_options.update({"runtime_env": merged_env})
replica_config.update(
ray_actor_options=override_actor_options,
placement_group_bundles=override_placement_group_bundles,
placement_group_strategy=override_placement_group_strategy,
max_replicas_per_node=override_max_replicas_per_node,
placement_group_bundle_label_selector=override_bundle_label_selector,
placement_group_fallback_strategy=override_fallback_strategy,
)
override_options["replica_config"] = replica_config
if "gang_scheduling_config" in options:
gang_scheduling_config = options.get("gang_scheduling_config")
if gang_scheduling_config and isinstance(gang_scheduling_config, dict):
options["gang_scheduling_config"] = GangSchedulingConfig(
**gang_scheduling_config
)
if "request_router_config" in options:
request_router_config = options.get("request_router_config")
if request_router_config:
if (
deployment_to_serialized_request_router_cls
and deployment_name in deployment_to_serialized_request_router_cls
):
# By setting the serialized request router cls, RequestRouterConfig constructor will not
# try to import the request router cls from the string import path
options[
"request_router_config"
] = RequestRouterConfig.from_serialized_request_router_cls(
request_router_config,
deployment_to_serialized_request_router_cls[deployment_name],
)
else:
options["request_router_config"] = RequestRouterConfig(
**request_router_config
)
if (
deployment_to_serialized_deployment_actors
and deployment_name in deployment_to_serialized_deployment_actors
):
serialized_actors = deployment_to_serialized_deployment_actors[
deployment_name
]
actors_list = options.get(
"deployment_actors",
original_options.get("deployment_actors"),
)
if actors_list:
for actor_cfg in actors_list:
if isinstance(actor_cfg, dict):
actor_name = actor_cfg.get("name")
if (
isinstance(actor_name, str)
and actor_name in serialized_actors
):
actor_cfg["_serialized_actor_class"] = serialized_actors[
actor_name
]
# Override deployment config options
options.pop("name", None)
original_options.update(options)
override_options["deployment_config"] = DeploymentConfig(**original_options)
deployment_infos[deployment_name] = info.update(**override_options)
deployment_config = deployment_infos[deployment_name].deployment_config
if (
deployment_config.autoscaling_config is not None
and deployment_config.max_ongoing_requests
< deployment_config.autoscaling_config.get_target_ongoing_requests()
):
logger.warning(
"Autoscaling will never happen, "
"because 'max_ongoing_requests' is less than "
"'target_ongoing_requests' now."
)
# Overwrite ingress route prefix
app_route_prefix = config_dict.get("route_prefix", DEFAULT.VALUE)
validate_route_prefix(app_route_prefix)
for deployment in list(deployment_infos.values()):
if (
app_route_prefix is not DEFAULT.VALUE
and deployment.route_prefix is not None
):
deployment.route_prefix = app_route_prefix
# build_app cannot see config overrides, so re-check the post-override
# ingress router here.
if RAY_SERVE_ENABLE_HA_PROXY and not any(
info.ingress_request_router for info in deployment_infos.values()
):
for info in deployment_infos.values():
request_router_config = info.deployment_config.request_router_config
if info.ingress and not request_router_config.is_default_request_router():
raise RayServeException(CUSTOM_INGRESS_REQUEST_ROUTER_UNSUPPORTED_ERROR)
return deployment_infos