1979 lines
80 KiB
Python
1979 lines
80 KiB
Python
import asyncio
|
|
import logging
|
|
import os
|
|
import pickle
|
|
import time
|
|
from typing import (
|
|
Any,
|
|
Dict,
|
|
Iterable,
|
|
List,
|
|
Optional,
|
|
Set,
|
|
Tuple,
|
|
Union,
|
|
)
|
|
|
|
import ray
|
|
from ray._common.network_utils import build_address, get_all_interfaces_ip
|
|
from ray._common.utils import run_background_task
|
|
from ray._raylet import GcsClient
|
|
from ray.actor import ActorHandle
|
|
from ray.serve._private.application_state import ApplicationStateManager, StatusOverview
|
|
from ray.serve._private.autoscaling_state import AutoscalingStateManager
|
|
from ray.serve._private.common import (
|
|
AsyncInferenceTaskQueueMetricReport,
|
|
DeploymentID,
|
|
HandleMetricReport,
|
|
NodeId,
|
|
ReplicaMetricReport,
|
|
RequestProtocol,
|
|
RequestRoutingInfo,
|
|
RunningReplicaInfo,
|
|
TargetCapacityDirection,
|
|
)
|
|
from ray.serve._private.config import DeploymentConfig
|
|
from ray.serve._private.constants import (
|
|
CONTROL_LOOP_INTERVAL_S,
|
|
DEFAULT_LATENCY_BUCKET_MS,
|
|
RAY_SERVE_CONTROLLER_CALLBACK_IMPORT_PATH,
|
|
RAY_SERVE_ENABLE_DIRECT_INGRESS,
|
|
RAY_SERVE_ENABLE_HA_PROXY,
|
|
RAY_SERVE_LOG_TO_STDERR,
|
|
RAY_SERVE_REQUEST_PATH_LOG_BUFFER_SIZE,
|
|
RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP,
|
|
RAY_SERVE_RUN_USER_CODE_IN_SEPARATE_THREAD,
|
|
RAY_SERVE_THROUGHPUT_OPTIMIZED,
|
|
RAY_SERVE_USE_GRPC_BY_DEFAULT,
|
|
RECOVERING_LONG_POLL_BROADCAST_TIMEOUT_S,
|
|
SERVE_CONTROLLER_NAME,
|
|
SERVE_DEFAULT_APP_NAME,
|
|
SERVE_LOGGER_NAME,
|
|
SERVE_NAMESPACE,
|
|
)
|
|
from ray.serve._private.controller_health_metrics_tracker import (
|
|
ControllerHealthMetricsTracker,
|
|
)
|
|
from ray.serve._private.default_impl import (
|
|
create_cluster_node_info_cache,
|
|
)
|
|
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.exceptions import ExternalScalerDisabledError
|
|
from ray.serve._private.grpc_util import set_proxy_default_grpc_options
|
|
from ray.serve._private.haproxy import HAProxyManager
|
|
from ray.serve._private.http_util import (
|
|
configure_http_options_with_defaults,
|
|
)
|
|
from ray.serve._private.logging_utils import (
|
|
configure_component_logger,
|
|
configure_component_memory_profiler,
|
|
get_component_logger_file_path,
|
|
)
|
|
from ray.serve._private.long_poll import LongPollHost, LongPollNamespace
|
|
from ray.serve._private.node_port_manager import NodePortManager
|
|
from ray.serve._private.proxy import ProxyActor
|
|
from ray.serve._private.proxy_state import ProxyStateManager
|
|
from ray.serve._private.storage.kv_store import RayInternalKVStore
|
|
from ray.serve._private.usage import ServeUsageTag
|
|
from ray.serve._private.utils import (
|
|
call_function_from_import_path,
|
|
decompress_metric_report,
|
|
get_all_live_placement_group_names,
|
|
get_head_node_id,
|
|
is_grpc_enabled,
|
|
)
|
|
from ray.serve.config import HTTPOptions, ProxyLocation, gRPCOptions
|
|
from ray.serve.generated.serve_pb2 import (
|
|
ActorNameList,
|
|
ApplicationArgs,
|
|
DeploymentArgs,
|
|
DeploymentRoute,
|
|
EndpointInfo as EndpointInfoProto,
|
|
EndpointSet,
|
|
)
|
|
from ray.serve.schema import (
|
|
APIType,
|
|
ApplicationDetails,
|
|
DeploymentDetails,
|
|
HTTPOptionsSchema,
|
|
LoggingConfig,
|
|
ProxyDetails,
|
|
ReplicaDetails,
|
|
ReplicaRank,
|
|
ServeActorDetails,
|
|
ServeApplicationSchema,
|
|
ServeDeploySchema,
|
|
ServeInstanceDetails,
|
|
Target,
|
|
TargetGroup,
|
|
gRPCOptionsSchema,
|
|
)
|
|
from ray.util import metrics
|
|
|
|
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
|
|
|
|
|
# Used for testing purposes only. If this is set, the controller will crash
|
|
# after writing each checkpoint with the specified probability.
|
|
_CRASH_AFTER_CHECKPOINT_PROBABILITY = 0
|
|
|
|
CONFIG_CHECKPOINT_KEY = "serve-app-config-checkpoint"
|
|
LOGGING_CONFIG_CHECKPOINT_KEY = "serve-logging-config-checkpoint"
|
|
SHUTDOWN_IN_PROGRESS_KEY = "serve-shutdown-in-progress"
|
|
|
|
|
|
class ServeController:
|
|
"""Responsible for managing the state of the serving system.
|
|
|
|
The controller implements fault tolerance by persisting its state in
|
|
a new checkpoint each time a state change is made. If the actor crashes,
|
|
the latest checkpoint is loaded and the state is recovered. Checkpoints
|
|
are written/read using a provided KV-store interface.
|
|
|
|
All hard state in the system is maintained by this actor and persisted via
|
|
these checkpoints. Soft state required by other components is fetched by
|
|
those actors from this actor on startup and updates are pushed out from
|
|
this actor.
|
|
|
|
All other actors started by the controller are named, detached actors
|
|
so they will not fate share with the controller if it crashes.
|
|
|
|
The following guarantees are provided for state-changing calls to the
|
|
controller:
|
|
- If the call succeeds, the change was made and will be reflected in
|
|
the system even if the controller or other actors die unexpectedly.
|
|
- If the call fails, the change may have been made but isn't guaranteed
|
|
to have been. The client should retry in this case. Note that this
|
|
requires all implementations here to be idempotent.
|
|
"""
|
|
|
|
async def __init__(
|
|
self,
|
|
*,
|
|
http_options: HTTPOptions,
|
|
global_logging_config: LoggingConfig,
|
|
grpc_options: Optional[gRPCOptions] = None,
|
|
proxy_location: Optional[ProxyLocation] = None,
|
|
):
|
|
if RAY_SERVE_THROUGHPUT_OPTIMIZED:
|
|
self._log_throughput_opt_message()
|
|
|
|
self._controller_node_id = ray.get_runtime_context().get_node_id()
|
|
assert (
|
|
self._controller_node_id == get_head_node_id()
|
|
), "Controller must be on the head node."
|
|
|
|
self.ray_worker_namespace = ray.get_runtime_context().namespace
|
|
self.gcs_client = GcsClient(address=ray.get_runtime_context().gcs_address)
|
|
kv_store_namespace = f"ray-serve-{self.ray_worker_namespace}"
|
|
self.kv_store = RayInternalKVStore(kv_store_namespace, self.gcs_client)
|
|
|
|
self.long_poll_host = LongPollHost()
|
|
self.done_recovering_event = asyncio.Event()
|
|
|
|
# Try to read config from checkpoint
|
|
# logging config from checkpoint take precedence over the one passed in
|
|
# the constructor.
|
|
self.global_logging_config = None
|
|
log_config_checkpoint = self.kv_store.get(LOGGING_CONFIG_CHECKPOINT_KEY)
|
|
if log_config_checkpoint is not None:
|
|
global_logging_config = pickle.loads(log_config_checkpoint)
|
|
self.reconfigure_global_logging_config(global_logging_config)
|
|
|
|
configure_component_memory_profiler(
|
|
component_name="controller", component_id=str(os.getpid())
|
|
)
|
|
|
|
if RAY_SERVE_CONTROLLER_CALLBACK_IMPORT_PATH:
|
|
logger.info(
|
|
"Calling user-provided callback from import path "
|
|
f"{RAY_SERVE_CONTROLLER_CALLBACK_IMPORT_PATH}."
|
|
)
|
|
call_function_from_import_path(RAY_SERVE_CONTROLLER_CALLBACK_IMPORT_PATH)
|
|
|
|
# Used to read/write checkpoints.
|
|
self.cluster_node_info_cache = create_cluster_node_info_cache(self.gcs_client)
|
|
self.cluster_node_info_cache.update()
|
|
|
|
self._ha_proxy_enabled = RAY_SERVE_ENABLE_HA_PROXY
|
|
self._direct_ingress_enabled = RAY_SERVE_ENABLE_DIRECT_INGRESS
|
|
# Last full set of ingress-port tuples fed to update_ports (for the per-tick set-diff).
|
|
self._last_ingress_port_tuples: set = set()
|
|
if self._ha_proxy_enabled:
|
|
logger.info(
|
|
"HAProxy is enabled in ServeController, replacing Serve proxy "
|
|
"with HAProxy."
|
|
)
|
|
all_interfaces = get_all_interfaces_ip()
|
|
if http_options.host != all_interfaces:
|
|
logger.warning(
|
|
f"HTTPOptions.host={http_options.host!r} won't accept "
|
|
"connections from HAProxy on other nodes; cross-node "
|
|
"routing will fail with connection refused. Set host to "
|
|
f"{all_interfaces!r} or omit it to use the HAProxy-mode "
|
|
"default."
|
|
)
|
|
elif self._direct_ingress_enabled:
|
|
logger.info(
|
|
"Direct ingress is enabled in ServeController, enabling proxy "
|
|
"on head node only."
|
|
)
|
|
proxy_location = ProxyLocation.HeadOnly
|
|
http_options = http_options.model_copy(update={"location": None})
|
|
|
|
# Configure proxy default HTTP and gRPC options.
|
|
self.proxy_state_manager = ProxyStateManager(
|
|
http_options=configure_http_options_with_defaults(http_options),
|
|
head_node_id=self._controller_node_id,
|
|
cluster_node_info_cache=self.cluster_node_info_cache,
|
|
logging_config=self.global_logging_config,
|
|
grpc_options=set_proxy_default_grpc_options(grpc_options),
|
|
proxy_location=proxy_location,
|
|
proxy_actor_class=HAProxyManager if self._ha_proxy_enabled else ProxyActor,
|
|
running_native_proxies=self._ha_proxy_enabled,
|
|
)
|
|
# We modify the HTTP and gRPC options above, so delete them to avoid
|
|
del http_options, grpc_options
|
|
|
|
self.endpoint_state = EndpointState(self.kv_store, self.long_poll_host)
|
|
|
|
# Fetch all running actors in current cluster as source of current
|
|
# replica state for controller failure recovery
|
|
all_current_actors = ray.util.list_named_actors(all_namespaces=True)
|
|
all_serve_actor_names = [
|
|
actor["name"]
|
|
for actor in all_current_actors
|
|
if actor["namespace"] == SERVE_NAMESPACE
|
|
]
|
|
|
|
self.autoscaling_state_manager = AutoscalingStateManager()
|
|
self.deployment_state_manager = DeploymentStateManager(
|
|
self.kv_store,
|
|
self.long_poll_host,
|
|
all_serve_actor_names,
|
|
get_all_live_placement_group_names(),
|
|
self.cluster_node_info_cache,
|
|
self.autoscaling_state_manager,
|
|
)
|
|
|
|
# Manage all applications' state
|
|
self.application_state_manager = ApplicationStateManager(
|
|
self.deployment_state_manager,
|
|
self.autoscaling_state_manager,
|
|
self.endpoint_state,
|
|
self.kv_store,
|
|
self.global_logging_config,
|
|
)
|
|
|
|
# Controller actor details
|
|
self._actor_details = ServeActorDetails(
|
|
node_id=ray.get_runtime_context().get_node_id(),
|
|
node_ip=ray.util.get_node_ip_address(),
|
|
node_instance_id=ray.util.get_node_instance_id(),
|
|
actor_id=ray.get_runtime_context().get_actor_id(),
|
|
actor_name=SERVE_CONTROLLER_NAME,
|
|
worker_id=ray.get_runtime_context().get_worker_id(),
|
|
log_file_path=get_component_logger_file_path(),
|
|
)
|
|
self._shutting_down = False
|
|
self._shutdown_flag_persisted = False
|
|
if self.kv_store.get(SHUTDOWN_IN_PROGRESS_KEY) is not None:
|
|
self._shutting_down = True
|
|
self._shutdown_flag_persisted = True
|
|
self._shutdown_event = asyncio.Event()
|
|
self._shutdown_start_time = None
|
|
|
|
# Actors registered for cleanup on serve.shutdown(), keyed by actor ID
|
|
self._registered_cleanup_actors: Dict[str, ActorHandle] = {}
|
|
|
|
# Initialize health metrics tracker
|
|
self._health_metrics_tracker = ControllerHealthMetricsTracker(
|
|
controller_start_time=time.time()
|
|
)
|
|
|
|
self._create_control_loop_metrics()
|
|
run_background_task(self.run_control_loop())
|
|
|
|
# The target capacity percentage for all deployments across the cluster.
|
|
self._target_capacity: Optional[float] = None
|
|
self._target_capacity_direction: Optional[TargetCapacityDirection] = None
|
|
self._recover_state_from_checkpoint()
|
|
|
|
# Nodes where proxy actors should run.
|
|
self._proxy_nodes = set()
|
|
self._update_proxy_nodes()
|
|
|
|
# Initialize to None (not []) to ensure the first broadcast always happens,
|
|
# even if target_groups is empty (e.g., route_prefix=None deployments).
|
|
self._last_broadcasted_target_groups: Optional[List[TargetGroup]] = None
|
|
|
|
self._last_broadcasted_fallback_targets: Dict[RequestProtocol, Target] = {}
|
|
|
|
def _log_throughput_opt_message(self) -> None:
|
|
msg = "Throughput optimized Ray Serve enabled with the following configurations:\n"
|
|
if RAY_SERVE_ENABLE_DIRECT_INGRESS:
|
|
msg += " • Direct ingress enabled\n"
|
|
if RAY_SERVE_USE_GRPC_BY_DEFAULT:
|
|
msg += " • gRPC communication enabled\n"
|
|
if not RAY_SERVE_RUN_USER_CODE_IN_SEPARATE_THREAD:
|
|
msg += " • User code running in main thread (not separate)\n"
|
|
if not RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP:
|
|
msg += " • Router running in main thread (not separate)\n"
|
|
if not RAY_SERVE_LOG_TO_STDERR:
|
|
msg += " • Log to stderr disabled\n"
|
|
msg += f" • Request path log buffer size: {RAY_SERVE_REQUEST_PATH_LOG_BUFFER_SIZE}\n"
|
|
logger.info(msg)
|
|
|
|
def reconfigure_global_logging_config(self, global_logging_config: LoggingConfig):
|
|
if (
|
|
self.global_logging_config
|
|
and self.global_logging_config == global_logging_config
|
|
):
|
|
return
|
|
self.kv_store.put(
|
|
LOGGING_CONFIG_CHECKPOINT_KEY, pickle.dumps(global_logging_config)
|
|
)
|
|
self.global_logging_config = global_logging_config
|
|
|
|
self.long_poll_host.notify_changed(
|
|
{LongPollNamespace.GLOBAL_LOGGING_CONFIG: global_logging_config}
|
|
)
|
|
configure_component_logger(
|
|
component_name="controller",
|
|
component_id=str(os.getpid()),
|
|
logging_config=global_logging_config,
|
|
)
|
|
|
|
logger.info(
|
|
f"Controller starting (version='{ray.__version__}').",
|
|
extra={"log_to_stderr": False},
|
|
)
|
|
logger.debug(
|
|
"Configure the serve controller logger "
|
|
f"with logging config: {self.global_logging_config}"
|
|
)
|
|
|
|
def check_alive(self) -> None:
|
|
"""No-op to check if this controller is alive."""
|
|
return
|
|
|
|
def get_pid(self) -> int:
|
|
return os.getpid()
|
|
|
|
def record_autoscaling_metrics_from_replica(
|
|
self, replica_metric_report: Union[ReplicaMetricReport, bytes]
|
|
):
|
|
if isinstance(replica_metric_report, bytes):
|
|
replica_metric_report = decompress_metric_report(replica_metric_report)
|
|
latency = time.time() - replica_metric_report.timestamp
|
|
latency_ms = latency * 1000
|
|
deployment = replica_metric_report.replica_id.deployment_id.name
|
|
application = replica_metric_report.replica_id.deployment_id.app_name
|
|
|
|
# Record the metrics delay for observability. A histogram lets Prometheus
|
|
# aggregate reports from all replicas of a deployment, so we omit the
|
|
# per-replica tag to keep cardinality bounded.
|
|
self.replica_metrics_delay_histogram.observe(
|
|
latency_ms,
|
|
tags={
|
|
"deployment": deployment,
|
|
"application": application,
|
|
},
|
|
)
|
|
# Track in health metrics
|
|
self._health_metrics_tracker.record_replica_metrics_delay(latency_ms)
|
|
self.autoscaling_state_manager.record_request_metrics_for_replica(
|
|
replica_metric_report
|
|
)
|
|
|
|
def record_autoscaling_metrics_from_handle(
|
|
self, handle_metric_report: Union[HandleMetricReport, bytes]
|
|
):
|
|
if isinstance(handle_metric_report, bytes):
|
|
handle_metric_report = decompress_metric_report(handle_metric_report)
|
|
latency = time.time() - handle_metric_report.timestamp
|
|
latency_ms = latency * 1000
|
|
deployment = handle_metric_report.deployment_id.name
|
|
application = handle_metric_report.deployment_id.app_name
|
|
|
|
# Record the metrics delay for observability. A histogram lets Prometheus
|
|
# aggregate reports from all handles of a deployment, so we omit the
|
|
# per-handle tag to keep cardinality bounded.
|
|
self.handle_metrics_delay_histogram.observe(
|
|
latency_ms,
|
|
tags={
|
|
"deployment": deployment,
|
|
"application": application,
|
|
},
|
|
)
|
|
# Track in health metrics
|
|
self._health_metrics_tracker.record_handle_metrics_delay(latency_ms)
|
|
self.autoscaling_state_manager.record_request_metrics_for_handle(
|
|
handle_metric_report
|
|
)
|
|
|
|
def record_autoscaling_metrics_from_async_inference_task_queue(
|
|
self, report: AsyncInferenceTaskQueueMetricReport
|
|
):
|
|
"""Record async inference task queue metrics pushed from QueueMonitor."""
|
|
latency = time.time() - report.timestamp_s
|
|
latency_ms = latency * 1000
|
|
# Record the metrics delay for observability
|
|
self.async_inference_task_queue_metrics_delay_gauge.set(
|
|
latency_ms,
|
|
tags={
|
|
"deployment": report.deployment_id.name,
|
|
"application": report.deployment_id.app_name,
|
|
},
|
|
)
|
|
self.autoscaling_state_manager.record_async_inference_task_queue_metrics(report)
|
|
|
|
def _get_total_num_requests_for_deployment_for_testing(
|
|
self, deployment_id: DeploymentID
|
|
):
|
|
return self.autoscaling_state_manager.get_total_num_requests_for_deployment(
|
|
deployment_id
|
|
)
|
|
|
|
def _get_metrics_for_deployment_for_testing(self, deployment_id: DeploymentID):
|
|
return self.autoscaling_state_manager.get_metrics_for_deployment(deployment_id)
|
|
|
|
def _dump_replica_states_for_testing(self, deployment_id: DeploymentID):
|
|
return self.deployment_state_manager._dump_replica_states_for_testing(
|
|
deployment_id
|
|
)
|
|
|
|
def _stop_one_running_replica_for_testing(self, deployment_id):
|
|
self.deployment_state_manager._stop_one_running_replica_for_testing(
|
|
deployment_id
|
|
)
|
|
|
|
async def listen_for_change(self, keys_to_snapshot_ids: Dict[str, int]):
|
|
"""Proxy long pull client's listen request.
|
|
|
|
Args:
|
|
keys_to_snapshot_ids: Snapshot IDs are used to determine whether or
|
|
not the host should immediately return the data or wait for the
|
|
value to be changed.
|
|
|
|
Returns:
|
|
The result of the underlying long-poll host's listen call (an
|
|
``UpdatedObject`` map for changed keys, returned to the client).
|
|
"""
|
|
if not self.done_recovering_event.is_set():
|
|
await self.done_recovering_event.wait()
|
|
|
|
return await self.long_poll_host.listen_for_change(keys_to_snapshot_ids)
|
|
|
|
async def listen_for_change_java(self, keys_to_snapshot_ids_bytes: bytes):
|
|
"""Proxy long pull client's listen request.
|
|
|
|
Args:
|
|
keys_to_snapshot_ids_bytes: the protobuf-serialized bytes of the
|
|
``keys_to_snapshot_ids`` (``Dict[str, int]``) mapping.
|
|
|
|
Returns:
|
|
The protobuf-serialized response of the underlying long-poll host's
|
|
Java listen call, suitable for return to a Java client.
|
|
"""
|
|
if not self.done_recovering_event.is_set():
|
|
await self.done_recovering_event.wait()
|
|
|
|
return await self.long_poll_host.listen_for_change_java(
|
|
keys_to_snapshot_ids_bytes
|
|
)
|
|
|
|
def notify_long_poll_client_disabled(self, client_id: str, reason: str) -> None:
|
|
"""Surfaces the disabled reason from LongPollClient in the logs."""
|
|
self.long_poll_host.notify_client_disabled(client_id, reason)
|
|
|
|
def get_all_endpoints(self) -> Dict[DeploymentID, Dict[str, Any]]:
|
|
"""Returns a dictionary of deployment name to config."""
|
|
return self.endpoint_state.get_endpoints()
|
|
|
|
def get_all_endpoints_java(self) -> bytes:
|
|
"""Returns a dictionary of deployment name to config."""
|
|
endpoints = self.get_all_endpoints()
|
|
# NOTE(zcin): Java only supports 1.x deployments, so only return
|
|
# a dictionary of deployment name -> endpoint info
|
|
data = {
|
|
endpoint_tag.name: EndpointInfoProto(route=endpoint_dict["route"])
|
|
for endpoint_tag, endpoint_dict in endpoints.items()
|
|
}
|
|
return EndpointSet(endpoints=data).SerializeToString()
|
|
|
|
def get_proxies(self) -> Dict[NodeId, ActorHandle]:
|
|
"""Returns a dictionary of node ID to proxy actor handles."""
|
|
if self.proxy_state_manager is None:
|
|
return {}
|
|
return self.proxy_state_manager.get_proxy_handles()
|
|
|
|
def get_proxy_names(self) -> bytes:
|
|
"""Returns the proxy actor name list serialized by protobuf."""
|
|
if self.proxy_state_manager is None:
|
|
return None
|
|
|
|
actor_name_list = ActorNameList(
|
|
names=self.proxy_state_manager.get_proxy_names().values()
|
|
)
|
|
return actor_name_list.SerializeToString()
|
|
|
|
def _update_proxy_nodes(self):
|
|
"""Update the nodes set where proxy actors should run.
|
|
|
|
Controller decides where proxy actors should run
|
|
(head node and nodes with deployment replicas).
|
|
"""
|
|
new_proxy_nodes = self.deployment_state_manager.get_active_node_ids()
|
|
new_proxy_nodes = new_proxy_nodes - set(
|
|
self.cluster_node_info_cache.get_draining_nodes()
|
|
)
|
|
new_proxy_nodes.add(self._controller_node_id)
|
|
self._proxy_nodes = new_proxy_nodes
|
|
|
|
async def run_control_loop(self) -> None:
|
|
# NOTE(edoakes): we catch all exceptions here and simply log them,
|
|
# because an unhandled exception would cause the main control loop to
|
|
# halt, which should *never* happen.
|
|
recovering_timeout = RECOVERING_LONG_POLL_BROADCAST_TIMEOUT_S
|
|
num_loops = 0
|
|
start_time = time.time()
|
|
while True:
|
|
loop_start_time = time.time()
|
|
try:
|
|
await self.run_control_loop_step(
|
|
start_time, recovering_timeout, num_loops
|
|
)
|
|
except Exception as e:
|
|
# we never expect this to happen, but adding this to be safe
|
|
logger.exception(f"There was an exception in the control loop: {e}")
|
|
await asyncio.sleep(1)
|
|
|
|
loop_duration = time.time() - loop_start_time
|
|
if loop_duration > 10:
|
|
logger.warning(
|
|
f"The last control loop was slow (took {loop_duration}s). "
|
|
"This is likely caused by running a large number of "
|
|
"replicas in a single Ray cluster. Consider using "
|
|
"multiple Ray clusters.",
|
|
extra={"log_to_stderr": False},
|
|
)
|
|
self.control_loop_duration_gauge_s.set(loop_duration)
|
|
# Track in health metrics
|
|
self._health_metrics_tracker.record_loop_duration(loop_duration)
|
|
|
|
num_loops += 1
|
|
self.num_control_loops_gauge.set(num_loops)
|
|
self._health_metrics_tracker.num_control_loops = num_loops
|
|
self._health_metrics_tracker.last_control_loop_time = time.time()
|
|
|
|
sleep_start_time = time.time()
|
|
await asyncio.sleep(CONTROL_LOOP_INTERVAL_S)
|
|
sleep_duration = time.time() - sleep_start_time
|
|
self.sleep_duration_gauge_s.set(sleep_duration)
|
|
self._health_metrics_tracker.last_sleep_duration_s = sleep_duration
|
|
|
|
async def run_control_loop_step(
|
|
self, start_time: float, recovering_timeout: float, num_loops: int
|
|
):
|
|
try:
|
|
self.cluster_node_info_cache.update()
|
|
except Exception:
|
|
logger.exception("Exception updating cluster node info cache.")
|
|
|
|
if self._shutting_down:
|
|
try:
|
|
self.shutdown()
|
|
except Exception:
|
|
logger.exception("Exception during shutdown.")
|
|
|
|
if (
|
|
not self.done_recovering_event.is_set()
|
|
and time.time() - start_time > recovering_timeout
|
|
):
|
|
logger.warning(
|
|
f"Replicas still recovering after {recovering_timeout}s, "
|
|
"setting done recovering event to broadcast long poll updates."
|
|
)
|
|
self.done_recovering_event.set()
|
|
|
|
# initialize any_recovering to None to indicate that we don't know if
|
|
# we've recovered anything yet
|
|
any_recovering: Optional[bool] = None
|
|
try:
|
|
dsm_update_start_time = time.time()
|
|
any_recovering = self.deployment_state_manager.update()
|
|
|
|
dsm_duration = time.time() - dsm_update_start_time
|
|
self.dsm_update_duration_gauge_s.set(dsm_duration)
|
|
self._health_metrics_tracker.record_dsm_update_duration(dsm_duration)
|
|
if not self.done_recovering_event.is_set() and not any_recovering:
|
|
self.done_recovering_event.set()
|
|
if num_loops > 0:
|
|
# Only log if we actually needed to recover anything.
|
|
logger.info(
|
|
"Finished recovering deployments after "
|
|
f"{(time.time() - start_time):.2f}s.",
|
|
extra={"log_to_stderr": False},
|
|
)
|
|
except Exception:
|
|
logger.exception("Exception updating deployment state.")
|
|
|
|
try:
|
|
asm_update_start_time = time.time()
|
|
self.application_state_manager.update()
|
|
asm_duration = time.time() - asm_update_start_time
|
|
self.asm_update_duration_gauge_s.set(asm_duration)
|
|
self._health_metrics_tracker.record_asm_update_duration(asm_duration)
|
|
except Exception:
|
|
logger.exception("Exception updating application state.")
|
|
|
|
# Update the proxy nodes set before updating the proxy states,
|
|
# so they are more consistent.
|
|
node_update_start_time = time.time()
|
|
self._update_proxy_nodes()
|
|
node_update_duration = time.time() - node_update_start_time
|
|
self.node_update_duration_gauge_s.set(node_update_duration)
|
|
self._health_metrics_tracker.record_node_update_duration(node_update_duration)
|
|
|
|
# Don't update proxy_state until after the done recovering event is set,
|
|
# otherwise we may start a new proxy but not broadcast it any
|
|
# info about available deployments & their replicas.
|
|
if self.proxy_state_manager and self.done_recovering_event.is_set():
|
|
try:
|
|
proxy_update_start_time = time.time()
|
|
self.proxy_state_manager.update(proxy_nodes=self._proxy_nodes)
|
|
proxy_update_duration = time.time() - proxy_update_start_time
|
|
self.proxy_update_duration_gauge_s.set(proxy_update_duration)
|
|
self._health_metrics_tracker.record_proxy_update_duration(
|
|
proxy_update_duration
|
|
)
|
|
except Exception:
|
|
logger.exception("Exception updating proxy state.")
|
|
|
|
# When the controller is done recovering, drop invalid handle metrics
|
|
# that may be stale for autoscaling
|
|
if any_recovering is False:
|
|
self.autoscaling_state_manager.drop_stale_handle_metrics(
|
|
self.deployment_state_manager.get_alive_replica_actor_ids()
|
|
| self.proxy_state_manager.get_alive_proxy_actor_ids()
|
|
)
|
|
|
|
self._maybe_update_ingress_ports()
|
|
|
|
# HAProxy handling
|
|
if self._ha_proxy_enabled:
|
|
# Right after a controller restart, the replica details may be incomplete,
|
|
# so we wait until recovery is finished before sending any updated target
|
|
# groups to HAProxy.
|
|
if self.done_recovering_event.is_set():
|
|
self.broadcast_target_groups_if_changed()
|
|
|
|
# Wait until the fallback proxy has transitioned out of STARTING at
|
|
# least once before broadcasting. After a controller restart, the proxy
|
|
# starts as STARTING even if it's already healthy. If we broadcast
|
|
# before the first health check, the fallback target will be None and
|
|
# HAProxy will remove the fallback server from its config.
|
|
if (
|
|
self.proxy_state_manager
|
|
and self.proxy_state_manager.started_fallback_proxy_at_least_once()
|
|
):
|
|
self.broadcast_fallback_targets_if_changed()
|
|
|
|
def _maybe_update_ingress_ports(self) -> None:
|
|
"""Update ingress ports if direct ingress is enabled."""
|
|
# Direct ingress port management
|
|
if self._direct_ingress_enabled:
|
|
# Update port values for ingress replicas.
|
|
# Ingress request router replicas also need direct-ingress ports.
|
|
ingress_replicas_info_list: List[
|
|
Tuple[str, str, int, int]
|
|
] = self.deployment_state_manager.get_ingress_replicas_info()
|
|
|
|
# update_port_if_missing is additive and idempotent, so we send update_ports
|
|
# only the tuples added since the last tick (the set difference) instead of
|
|
# the full set every tick -- work proportional to what changed rather than to
|
|
# the replica count. The full set is recomputed and cached each tick, so after
|
|
# a controller restart the empty cache re-sends everything on the first tick.
|
|
fresh = set(ingress_replicas_info_list)
|
|
NodePortManager.update_ports(list(fresh - self._last_ingress_port_tuples))
|
|
self._last_ingress_port_tuples = fresh
|
|
|
|
# Clean up stale ports
|
|
# get all alive replica ids and their node ids.
|
|
NodePortManager.prune(self._get_node_id_to_alive_replica_ids())
|
|
|
|
def broadcast_target_groups_if_changed(self) -> None:
|
|
"""Broadcast target groups over long poll if they have changed.
|
|
|
|
Keeps an in-memory record of the last target groups that were broadcast
|
|
to determine if they have changed.
|
|
"""
|
|
target_groups: List[TargetGroup] = self.get_target_groups(
|
|
from_proxy_manager=True,
|
|
)
|
|
|
|
# Check if target groups have changed by comparing the objects directly
|
|
if self._last_broadcasted_target_groups == target_groups:
|
|
return
|
|
|
|
self.long_poll_host.notify_changed(
|
|
{LongPollNamespace.TARGET_GROUPS: target_groups}
|
|
)
|
|
self._last_broadcasted_target_groups = target_groups
|
|
|
|
def broadcast_fallback_targets_if_changed(self) -> None:
|
|
"""Broadcast the fallback targets over long poll if they have changed."""
|
|
fallback_targets = self.proxy_state_manager.get_fallback_proxy_targets()
|
|
|
|
if self._last_broadcasted_fallback_targets == fallback_targets:
|
|
return
|
|
|
|
self.long_poll_host.notify_changed(
|
|
{LongPollNamespace.FALLBACK_TARGETS: fallback_targets}
|
|
)
|
|
self._last_broadcasted_fallback_targets = fallback_targets
|
|
|
|
def _create_control_loop_metrics(self):
|
|
self.node_update_duration_gauge_s = metrics.Gauge(
|
|
"serve_controller_node_update_duration_s",
|
|
description="The control loop time spent on collecting proxy node info.",
|
|
)
|
|
self.proxy_update_duration_gauge_s = metrics.Gauge(
|
|
"serve_controller_proxy_state_update_duration_s",
|
|
description="The control loop time spent on updating proxy state.",
|
|
)
|
|
self.dsm_update_duration_gauge_s = metrics.Gauge(
|
|
"serve_controller_deployment_state_update_duration_s",
|
|
description="The control loop time spent on updating deployment state.",
|
|
)
|
|
self.asm_update_duration_gauge_s = metrics.Gauge(
|
|
"serve_controller_application_state_update_duration_s",
|
|
description="The control loop time spent on updating application state.",
|
|
)
|
|
self.sleep_duration_gauge_s = metrics.Gauge(
|
|
"serve_controller_sleep_duration_s",
|
|
description="The duration of the last control loop's sleep.",
|
|
)
|
|
self.control_loop_duration_gauge_s = metrics.Gauge(
|
|
"serve_controller_control_loop_duration_s",
|
|
description="The duration of the last control loop.",
|
|
)
|
|
self.num_control_loops_gauge = metrics.Gauge(
|
|
"serve_controller_num_control_loops",
|
|
description=(
|
|
"The number of control loops performed by the controller. "
|
|
"Increases monotonically over the controller's lifetime."
|
|
),
|
|
tag_keys=("actor_id",),
|
|
)
|
|
self.num_control_loops_gauge.set_default_tags(
|
|
{"actor_id": ray.get_runtime_context().get_actor_id()}
|
|
)
|
|
|
|
# Autoscaling metrics delay gauges
|
|
self.replica_metrics_delay_histogram = metrics.Histogram(
|
|
"serve_autoscaling_replica_metrics_delay_ms",
|
|
description=(
|
|
"Time taken for the replica metrics to be reported to the controller. "
|
|
"High values may indicate a busy controller."
|
|
),
|
|
boundaries=DEFAULT_LATENCY_BUCKET_MS,
|
|
tag_keys=("deployment", "application"),
|
|
)
|
|
self.handle_metrics_delay_histogram = metrics.Histogram(
|
|
"serve_autoscaling_handle_metrics_delay_ms",
|
|
description=(
|
|
"Time taken for the handle metrics to be reported to the controller. "
|
|
"High values may indicate a busy controller."
|
|
),
|
|
boundaries=DEFAULT_LATENCY_BUCKET_MS,
|
|
tag_keys=("deployment", "application"),
|
|
)
|
|
self.async_inference_task_queue_metrics_delay_gauge = metrics.Gauge(
|
|
"serve_autoscaling_async_inference_task_queue_metrics_delay_ms",
|
|
description=(
|
|
"Time taken for the async inference task queue metrics to be reported "
|
|
"to the controller. High values may indicate a busy controller."
|
|
),
|
|
tag_keys=("deployment", "application"),
|
|
)
|
|
|
|
def _recover_state_from_checkpoint(self):
|
|
if self._shutting_down:
|
|
# If we're recovering into a `shutdown-in-progress state, don't
|
|
# re-apply the config.
|
|
return
|
|
|
|
(
|
|
deployment_time,
|
|
serve_config,
|
|
target_capacity_direction,
|
|
) = self._read_config_checkpoint()
|
|
self._target_capacity_direction = target_capacity_direction
|
|
if serve_config is not None:
|
|
logger.info(
|
|
"Recovered config from checkpoint.", extra={"log_to_stderr": False}
|
|
)
|
|
self.apply_config(serve_config, deployment_time=deployment_time)
|
|
|
|
def _read_config_checkpoint(
|
|
self,
|
|
) -> Tuple[float, Optional[ServeDeploySchema], Optional[TargetCapacityDirection]]:
|
|
"""Reads the current Serve config checkpoint.
|
|
|
|
The Serve config checkpoint stores active application configs and
|
|
other metadata.
|
|
|
|
Returns:
|
|
|
|
If the GCS contains a checkpoint, tuple of:
|
|
1. A deployment timestamp.
|
|
2. A Serve config. This Serve config is reconstructed from the
|
|
active application states. It may not exactly match the
|
|
submitted config (e.g. the top-level http options may be
|
|
different).
|
|
3. The target_capacity direction calculated after the Serve
|
|
was submitted.
|
|
|
|
If the GCS doesn't contain a checkpoint, returns (0, None, None).
|
|
"""
|
|
|
|
checkpoint = self.kv_store.get(CONFIG_CHECKPOINT_KEY)
|
|
if checkpoint is not None:
|
|
(
|
|
deployment_time,
|
|
target_capacity,
|
|
target_capacity_direction,
|
|
config_checkpoints_dict,
|
|
) = pickle.loads(checkpoint)
|
|
|
|
return (
|
|
deployment_time,
|
|
ServeDeploySchema(
|
|
applications=list(config_checkpoints_dict.values()),
|
|
target_capacity=target_capacity,
|
|
),
|
|
target_capacity_direction,
|
|
)
|
|
else:
|
|
return (0.0, None, None)
|
|
|
|
def _all_running_replicas(self) -> Dict[DeploymentID, List[RunningReplicaInfo]]:
|
|
"""Used for testing.
|
|
|
|
Returned dictionary maps deployment names to replica infos.
|
|
"""
|
|
|
|
return self.deployment_state_manager.get_running_replica_infos()
|
|
|
|
def get_actor_details(self) -> ServeActorDetails:
|
|
"""Returns the actor details for this controller.
|
|
|
|
Currently used for test only.
|
|
"""
|
|
return self._actor_details
|
|
|
|
def get_health_metrics(self) -> Dict[str, Any]:
|
|
"""Returns comprehensive health metrics for the controller.
|
|
|
|
This method provides detailed performance metrics to help diagnose
|
|
controller health issues, especially as cluster size increases.
|
|
|
|
Returns:
|
|
Dictionary containing health metrics including:
|
|
- Control loop performance (iteration speed, durations)
|
|
- Event loop health (task count, scheduling delay)
|
|
- Component update latencies
|
|
- Autoscaling metrics latency (handle/replica)
|
|
- Memory usage
|
|
"""
|
|
try:
|
|
return self._health_metrics_tracker.collect_metrics().model_dump()
|
|
except Exception:
|
|
logger.exception("Exception collecting controller health metrics.")
|
|
raise
|
|
|
|
def get_proxy_details(self, node_id: str) -> Optional[ProxyDetails]:
|
|
"""Returns the proxy details for the proxy on the given node.
|
|
|
|
Currently used for test only. Will return None if the proxy doesn't exist on
|
|
the given node.
|
|
"""
|
|
if self.proxy_state_manager is None:
|
|
return None
|
|
|
|
return self.proxy_state_manager.get_proxy_details().get(node_id)
|
|
|
|
def get_deployment_timestamps(self, app_name: str) -> float:
|
|
"""Returns the deployment timestamp for the given app.
|
|
|
|
Currently used for test only.
|
|
"""
|
|
for (
|
|
_app_name,
|
|
app_status_info,
|
|
) in self.application_state_manager.list_app_statuses().items():
|
|
if app_name == _app_name:
|
|
return app_status_info.deployment_timestamp
|
|
|
|
def get_deployment_details(
|
|
self, app_name: str, deployment_name: str
|
|
) -> DeploymentDetails:
|
|
"""Returns the deployment details for the app and deployment.
|
|
|
|
Currently used for test only.
|
|
"""
|
|
return self.application_state_manager.list_deployment_details(app_name)[
|
|
deployment_name
|
|
]
|
|
|
|
def get_http_config(self) -> HTTPOptions:
|
|
"""Return the HTTP proxy configuration."""
|
|
if self.proxy_state_manager is None:
|
|
return HTTPOptions()
|
|
return self.proxy_state_manager.get_config()
|
|
|
|
def get_proxy_location(self) -> Optional[ProxyLocation]:
|
|
"""Return the resolved proxy placement (the ingress placement authority)."""
|
|
if self.proxy_state_manager is None:
|
|
return None
|
|
return self.proxy_state_manager.get_proxy_location()
|
|
|
|
def get_grpc_config(self) -> gRPCOptions:
|
|
"""Return the gRPC proxy configuration."""
|
|
if self.proxy_state_manager is None:
|
|
return gRPCOptions()
|
|
return self.proxy_state_manager.get_grpc_config()
|
|
|
|
def get_root_url(self):
|
|
"""Return the root url for the serve instance."""
|
|
if self.proxy_state_manager is None:
|
|
return None
|
|
http_config = self.get_http_config()
|
|
if http_config.root_url == "":
|
|
# HTTP is disabled
|
|
if http_config.host is None:
|
|
return ""
|
|
return (
|
|
f"http://{build_address(http_config.host, http_config.port)}"
|
|
f"{http_config.root_path}"
|
|
)
|
|
return http_config.root_url
|
|
|
|
def config_checkpoint_deleted(self) -> bool:
|
|
"""Returns whether the config checkpoint has been deleted.
|
|
|
|
Get the config checkpoint from the kv store. If it is None, then it has been
|
|
deleted.
|
|
"""
|
|
return self.kv_store.get(CONFIG_CHECKPOINT_KEY) is None
|
|
|
|
def _register_shutdown_cleanup_actor(self, actor_handle: ActorHandle) -> None:
|
|
"""Register an actor to be killed on serve.shutdown().
|
|
|
|
This allows deployments to register auxiliary actors (like caches,
|
|
coordinators, etc.) that should be cleaned up when Serve shuts down.
|
|
The actors must use lifetime="detached" to survive replica restarts,
|
|
but will be explicitly killed during serve.shutdown().
|
|
|
|
Note: Registered actors are NOT persisted across controller restarts.
|
|
For full persistence, use controller-managed deployment-scoped actors
|
|
(see https://github.com/ray-project/ray/issues/60359).
|
|
|
|
If the same actor is registered multiple times (e.g., from multiple
|
|
router instances sharing a tree actor via get_if_exists=True), it will
|
|
only be stored once.
|
|
|
|
Args:
|
|
actor_handle: The actor handle to register for cleanup.
|
|
"""
|
|
actor_id = actor_handle._actor_id.hex()
|
|
self._registered_cleanup_actors[actor_id] = actor_handle
|
|
|
|
def _kill_registered_cleanup_actors(self) -> None:
|
|
"""Kill all actors registered for shutdown cleanup."""
|
|
for actor in self._registered_cleanup_actors.values():
|
|
try:
|
|
ray.kill(actor, no_restart=True)
|
|
except Exception:
|
|
pass # Actor may already be dead
|
|
|
|
def shutdown(self):
|
|
"""Shuts down the serve instance completely.
|
|
|
|
This method will only be triggered when `self._shutting_down` is true. It
|
|
deletes the kv store for config checkpoints, sets application state to deleting,
|
|
delete all deployments, and shuts down all proxies. Once all these
|
|
resources are released, it then kills the controller actor.
|
|
"""
|
|
if not self._shutting_down:
|
|
return
|
|
|
|
if self._shutdown_start_time is None:
|
|
self._shutdown_start_time = time.time()
|
|
logger.info("Controller shutdown started.", extra={"log_to_stderr": False})
|
|
|
|
if not self._shutdown_flag_persisted:
|
|
self.kv_store.put(SHUTDOWN_IN_PROGRESS_KEY, b"1")
|
|
self._shutdown_flag_persisted = True
|
|
self.kv_store.delete(CONFIG_CHECKPOINT_KEY)
|
|
self.kv_store.delete(LOGGING_CONFIG_CHECKPOINT_KEY)
|
|
self.application_state_manager.shutdown()
|
|
self.deployment_state_manager.shutdown()
|
|
self.endpoint_state.shutdown()
|
|
if self.proxy_state_manager:
|
|
self.proxy_state_manager.shutdown()
|
|
|
|
config_checkpoint_deleted = self.config_checkpoint_deleted()
|
|
application_is_shutdown = self.application_state_manager.is_ready_for_shutdown()
|
|
deployment_is_shutdown = self.deployment_state_manager.is_ready_for_shutdown()
|
|
endpoint_is_shutdown = self.endpoint_state.is_ready_for_shutdown()
|
|
proxy_state_is_shutdown = (
|
|
self.proxy_state_manager is None
|
|
or self.proxy_state_manager.is_ready_for_shutdown()
|
|
)
|
|
if (
|
|
config_checkpoint_deleted
|
|
and application_is_shutdown
|
|
and deployment_is_shutdown
|
|
and endpoint_is_shutdown
|
|
and proxy_state_is_shutdown
|
|
):
|
|
self._kill_registered_cleanup_actors()
|
|
self.application_state_manager.delete_checkpoint()
|
|
self.deployment_state_manager.delete_checkpoint()
|
|
self.kv_store.delete(SHUTDOWN_IN_PROGRESS_KEY)
|
|
logger.warning(
|
|
"All resources have shut down, controller exiting.",
|
|
extra={"log_to_stderr": False},
|
|
)
|
|
_controller_actor = ray.get_runtime_context().current_actor
|
|
ray.kill(_controller_actor, no_restart=True)
|
|
elif time.time() - self._shutdown_start_time > 10:
|
|
if not config_checkpoint_deleted:
|
|
logger.warning(
|
|
f"{CONFIG_CHECKPOINT_KEY} not yet deleted",
|
|
extra={"log_to_stderr": False},
|
|
)
|
|
if not application_is_shutdown:
|
|
logger.warning(
|
|
"application not yet shutdown",
|
|
extra={"log_to_stderr": False},
|
|
)
|
|
if not deployment_is_shutdown:
|
|
logger.warning(
|
|
"deployment not yet shutdown",
|
|
extra={"log_to_stderr": False},
|
|
)
|
|
if not endpoint_is_shutdown:
|
|
logger.warning(
|
|
"endpoint not yet shutdown",
|
|
extra={"log_to_stderr": False},
|
|
)
|
|
if not proxy_state_is_shutdown:
|
|
logger.warning(
|
|
"proxy_state not yet shutdown",
|
|
extra={"log_to_stderr": False},
|
|
)
|
|
|
|
def deploy_applications(
|
|
self,
|
|
name_to_deployment_args_list: Dict[str, List[bytes]],
|
|
name_to_application_args: Dict[str, bytes],
|
|
) -> None:
|
|
"""
|
|
Takes in a list of dictionaries that contain deployment arguments.
|
|
If same app name deployed, old application will be overwritten.
|
|
|
|
Args:
|
|
name_to_deployment_args_list: Dictionary mapping application names
|
|
to a list of serialized deployment information. Each item in
|
|
the list is bytes representing the serialized protobuf
|
|
``DeploymentArgs`` object, which contains all the information
|
|
for a single deployment.
|
|
name_to_application_args: Dictionary mapping application names to serialized
|
|
application arguments, where each item is bytes representing the serialized
|
|
protobuf `ApplicationArgs` object. `ApplicationArgs` contains the information
|
|
for the application.
|
|
"""
|
|
if self._shutting_down:
|
|
logger.warning(
|
|
"Ignoring deploy_applications request because Serve controller is shutting down."
|
|
)
|
|
return
|
|
|
|
name_to_deployment_args = {}
|
|
for name, deployment_args_list in name_to_deployment_args_list.items():
|
|
deployment_args_deserialized = []
|
|
for deployment_args_bytes in deployment_args_list:
|
|
args = DeploymentArgs.FromString(deployment_args_bytes)
|
|
deployment_args_deserialized.append(
|
|
{
|
|
"deployment_name": args.deployment_name,
|
|
"deployment_config_proto_bytes": args.deployment_config,
|
|
"replica_config_proto_bytes": args.replica_config,
|
|
"deployer_job_id": args.deployer_job_id,
|
|
"ingress": args.ingress,
|
|
"ingress_request_router": args.ingress_request_router,
|
|
"uses_multiplexing": args.uses_multiplexing,
|
|
"route_prefix": (
|
|
args.route_prefix if args.HasField("route_prefix") else None
|
|
),
|
|
}
|
|
)
|
|
name_to_deployment_args[name] = deployment_args_deserialized
|
|
|
|
name_to_application_args_deserialized = {}
|
|
for name, application_args_bytes in name_to_application_args.items():
|
|
name_to_application_args_deserialized[name] = ApplicationArgs.FromString(
|
|
application_args_bytes
|
|
)
|
|
|
|
self.application_state_manager.deploy_apps(
|
|
name_to_deployment_args, name_to_application_args_deserialized
|
|
)
|
|
|
|
self.application_state_manager.save_checkpoint()
|
|
|
|
def deploy_application(
|
|
self,
|
|
name: str,
|
|
deployment_args_list: List[bytes],
|
|
application_args: bytes,
|
|
) -> None:
|
|
"""
|
|
Deploy a single application
|
|
(as deploy_applications(), but it only takes a single name and deployment args).
|
|
This primarily exists as a shim to avoid
|
|
changing Java code in https://github.com/ray-project/ray/pull/49168,
|
|
and could be removed if the Java code was refactored
|
|
to use the new bulk deploy_applications API.
|
|
"""
|
|
self.deploy_applications(
|
|
{name: deployment_args_list},
|
|
{name: application_args},
|
|
)
|
|
|
|
def apply_config(
|
|
self,
|
|
config: ServeDeploySchema,
|
|
deployment_time: float = 0.0,
|
|
) -> None:
|
|
"""Apply the config described in `ServeDeploySchema`.
|
|
|
|
This will upgrade the applications to the goal state specified in the
|
|
config.
|
|
|
|
If `deployment_time` is not provided, `time.time()` is used.
|
|
"""
|
|
if self._shutting_down:
|
|
logger.warning(
|
|
"Ignoring apply_config request because Serve controller is shutting down."
|
|
)
|
|
return
|
|
|
|
ServeUsageTag.API_VERSION.record("v2")
|
|
if not deployment_time:
|
|
deployment_time = time.time()
|
|
|
|
new_config_checkpoint = {}
|
|
|
|
_, curr_config, _ = self._read_config_checkpoint()
|
|
|
|
self._target_capacity_direction = calculate_target_capacity_direction(
|
|
curr_config=curr_config,
|
|
new_config=config,
|
|
curr_target_capacity_direction=self._target_capacity_direction,
|
|
)
|
|
log_target_capacity_change(
|
|
self._target_capacity,
|
|
config.target_capacity,
|
|
self._target_capacity_direction,
|
|
)
|
|
self._target_capacity = config.target_capacity
|
|
|
|
for app_config in config.applications:
|
|
# If the application logging config is not set, use the global logging
|
|
# config.
|
|
if app_config.logging_config is None and config.logging_config:
|
|
app_config.logging_config = config.logging_config
|
|
|
|
app_config_dict = app_config.model_dump(exclude_unset=True)
|
|
new_config_checkpoint[app_config.name] = app_config_dict
|
|
|
|
self.kv_store.put(
|
|
CONFIG_CHECKPOINT_KEY,
|
|
pickle.dumps(
|
|
(
|
|
deployment_time,
|
|
self._target_capacity,
|
|
self._target_capacity_direction,
|
|
new_config_checkpoint,
|
|
)
|
|
),
|
|
)
|
|
|
|
# Declaratively apply the new set of applications.
|
|
# This will delete any applications no longer in the config that were
|
|
# previously deployed via the REST API.
|
|
self.application_state_manager.apply_app_configs(
|
|
config.applications,
|
|
deployment_time=deployment_time,
|
|
target_capacity=self._target_capacity,
|
|
target_capacity_direction=self._target_capacity_direction,
|
|
)
|
|
|
|
self.application_state_manager.save_checkpoint()
|
|
|
|
def get_deployment_info(self, name: str, app_name: str = "") -> bytes:
|
|
"""Get the current information about a deployment.
|
|
|
|
Args:
|
|
name: the name of the deployment.
|
|
app_name: the name of the application that owns the deployment. The
|
|
empty string targets deployments that are not scoped to an
|
|
application (1.x-style deployments).
|
|
|
|
Returns:
|
|
DeploymentRoute's protobuf serialized bytes
|
|
|
|
Raises:
|
|
KeyError: If the deployment doesn't exist.
|
|
"""
|
|
id = DeploymentID(name=name, app_name=app_name)
|
|
deployment_info = self.deployment_state_manager.get_deployment(id)
|
|
if deployment_info is None:
|
|
app_msg = f" in application '{app_name}'" if app_name else ""
|
|
raise KeyError(f"Deployment '{name}' does not exist{app_msg}.")
|
|
|
|
route = self.endpoint_state.get_endpoint_route(id)
|
|
|
|
deployment_route = DeploymentRoute(
|
|
deployment_info=deployment_info.to_proto(), route=route
|
|
)
|
|
return deployment_route.SerializeToString()
|
|
|
|
def list_deployments_internal(
|
|
self,
|
|
) -> Dict[DeploymentID, Tuple[DeploymentInfo, str]]:
|
|
"""Gets the current information about all deployments.
|
|
|
|
Returns:
|
|
Dict(deployment_id, (DeploymentInfo, route))
|
|
"""
|
|
return {
|
|
id: (info, self.endpoint_state.get_endpoint_route(id))
|
|
for id, info in self.deployment_state_manager.get_deployment_infos().items()
|
|
}
|
|
|
|
def get_deployment_config(
|
|
self, deployment_id: DeploymentID
|
|
) -> Optional[DeploymentConfig]:
|
|
"""Get the deployment config for the given deployment id.
|
|
|
|
Args:
|
|
deployment_id: The deployment id to get the config for.
|
|
|
|
Returns:
|
|
A deployment config object if the deployment id exist,
|
|
None otherwise.
|
|
"""
|
|
deployment_info = self.deployment_state_manager.get_deployment_infos().get(
|
|
deployment_id
|
|
)
|
|
return deployment_info.deployment_config if deployment_info else None
|
|
|
|
def list_deployment_ids(self) -> List[DeploymentID]:
|
|
"""Gets the current list of all deployments' identifiers."""
|
|
return self.deployment_state_manager.get_deployment_ids()
|
|
|
|
def update_deployment_replicas(
|
|
self, deployment_id: DeploymentID, target_num_replicas: int
|
|
) -> None:
|
|
"""Update the target number of replicas for a deployment.
|
|
|
|
Args:
|
|
deployment_id: The deployment to update.
|
|
target_num_replicas: The new target number of replicas.
|
|
|
|
Raises:
|
|
ExternalScalerDisabledError: If external_scaler_enabled is set to False for the application.
|
|
"""
|
|
|
|
# Check if external scaler is enabled for this application
|
|
app_name = deployment_id.app_name
|
|
if not self.application_state_manager.does_app_exist(app_name):
|
|
raise ValueError(f"Application '{app_name}' not found")
|
|
|
|
if not self.application_state_manager.get_external_scaler_enabled(app_name):
|
|
raise ExternalScalerDisabledError(
|
|
f"Cannot update replicas for deployment '{deployment_id.name}' in "
|
|
f"application '{app_name}'. The external scaling API can only be used "
|
|
f"when 'external_scaler_enabled' is set to true in the application "
|
|
f"configuration. Current value: external_scaler_enabled=false. "
|
|
f"To use this API, redeploy your application with "
|
|
f"'external_scaler_enabled: true' in the config."
|
|
)
|
|
|
|
self.deployment_state_manager.set_target_num_replicas(
|
|
deployment_id, target_num_replicas
|
|
)
|
|
|
|
def get_serve_instance_details(self, source: Optional[APIType] = None) -> Dict:
|
|
"""Gets details on all applications on the cluster and system-level info.
|
|
|
|
The information includes application and deployment statuses, config options,
|
|
error messages, etc.
|
|
|
|
Args:
|
|
source: If provided, returns application
|
|
statuses for applications matching this API type.
|
|
Defaults to None, which means all applications are returned.
|
|
|
|
Returns:
|
|
Dict that follows the format of the schema ServeInstanceDetails.
|
|
"""
|
|
|
|
http_config = self.get_http_config()
|
|
grpc_config = self.get_grpc_config()
|
|
applications = {}
|
|
|
|
app_statuses = self.application_state_manager.list_app_statuses(source=source)
|
|
|
|
# If there are no app statuses, there's no point getting the app configs.
|
|
# Moreover, there might be no app statuses because the GCS is down,
|
|
# in which case getting the app configs would fail anyway,
|
|
# since they're stored in the checkpoint in the GCS.
|
|
app_configs = self.get_app_configs() if app_statuses else {}
|
|
|
|
for (
|
|
app_name,
|
|
app_status_info,
|
|
) in app_statuses.items():
|
|
applications[app_name] = ApplicationDetails(
|
|
name=app_name,
|
|
route_prefix=self.application_state_manager.get_route_prefix(app_name),
|
|
docs_path=self.get_docs_path(app_name),
|
|
status=app_status_info.status,
|
|
message=app_status_info.message,
|
|
last_deployed_time_s=app_status_info.deployment_timestamp,
|
|
# This can be none if the app was deployed through
|
|
# serve.run, the app is in deleting state,
|
|
# or a checkpoint hasn't been set yet
|
|
deployed_app_config=app_configs.get(app_name),
|
|
source=self.application_state_manager.get_app_source(app_name),
|
|
deployments=self.application_state_manager.list_deployment_details(
|
|
app_name
|
|
),
|
|
external_scaler_enabled=self.application_state_manager.get_external_scaler_enabled(
|
|
app_name
|
|
),
|
|
deployment_topology=self.application_state_manager.get_deployment_topology(
|
|
app_name
|
|
),
|
|
)
|
|
|
|
# NOTE(zcin): We use exclude_unset here because we explicitly and intentionally
|
|
# fill in all info that should be shown to users.
|
|
http_options = HTTPOptionsSchema.model_validate(
|
|
http_config.model_dump(exclude_unset=True)
|
|
)
|
|
grpc_options = gRPCOptionsSchema.model_validate(
|
|
grpc_config.model_dump(exclude_unset=True)
|
|
)
|
|
|
|
return ServeInstanceDetails(
|
|
target_capacity=self._target_capacity,
|
|
controller_info=self._actor_details,
|
|
proxy_location=(
|
|
self.proxy_state_manager.get_proxy_location()
|
|
if self.proxy_state_manager
|
|
else None
|
|
),
|
|
http_options=http_options,
|
|
grpc_options=grpc_options,
|
|
proxies=(
|
|
self.proxy_state_manager.get_proxy_details()
|
|
if self.proxy_state_manager
|
|
else None
|
|
),
|
|
applications=applications,
|
|
target_groups=self.get_target_groups(),
|
|
controller_health_metrics=self._health_metrics_tracker.collect_metrics(),
|
|
)._get_user_facing_json_serializable_dict(exclude_unset=True)
|
|
|
|
def _get_proxy_target_groups(self) -> List[TargetGroup]:
|
|
"""Get target groups for proxy-based routing."""
|
|
target_groups: List[TargetGroup] = []
|
|
|
|
if self.proxy_state_manager.get_proxy_details():
|
|
# setting prefix route to "/" because in ray serve, proxy
|
|
# accepts requests from the client and routes them to the
|
|
# correct application. This is true for both HTTP and gRPC proxies.
|
|
target_groups.append(
|
|
TargetGroup(
|
|
protocol=RequestProtocol.HTTP,
|
|
route_prefix="/",
|
|
targets=self.proxy_state_manager.get_targets(RequestProtocol.HTTP),
|
|
app_name="",
|
|
ingress_request_router_targets=[],
|
|
ingress_deployment_name="",
|
|
)
|
|
)
|
|
if is_grpc_enabled(self.get_grpc_config()):
|
|
target_groups.append(
|
|
TargetGroup(
|
|
protocol=RequestProtocol.GRPC,
|
|
route_prefix="/",
|
|
targets=self.proxy_state_manager.get_targets(
|
|
RequestProtocol.GRPC
|
|
),
|
|
app_name="",
|
|
ingress_request_router_targets=[],
|
|
ingress_deployment_name="",
|
|
)
|
|
)
|
|
return target_groups
|
|
|
|
def get_target_groups(
|
|
self,
|
|
app_name: Optional[str] = None,
|
|
from_proxy_manager: bool = False,
|
|
) -> List[TargetGroup]:
|
|
"""Get target groups for direct ingress deployments.
|
|
|
|
This returns target groups that point directly to replica ports
|
|
rather than proxy ports when direct ingress is enabled.
|
|
|
|
Following situations are possible:
|
|
1. Direct ingress is not enabled. In this case, we just return the
|
|
target groups from the proxy implementation.
|
|
2. Direct ingress is enabled and there are no applications. In this case,
|
|
we return target groups for proxy. Serve controller is running but there
|
|
are no applications to route traffic to.
|
|
3. Direct ingress is enabled and there are applications. All applications
|
|
have atleast one running replica. In this case, we return target groups
|
|
for all applications with targets pointing to the running replicas.
|
|
4. Direct ingress is enabled and there are applications. Some applications
|
|
have no running replicas. In this case, for applications that have no
|
|
running replicas, we return target groups for proxy and for applications
|
|
that have running replicas, we return target groups for direct ingress.
|
|
If there are multiple applications with no running replicas, we return
|
|
one target group per application with unique route prefix.
|
|
5. HAProxy is enabled and the caller is not an internal proxy manager. In
|
|
this case, we return target groups containing the proxies (e.g. haproxy).
|
|
6. HAProxy is enabled and the caller is an internal proxy manager (e.g.
|
|
haproxy manager). In this case, we return target groups containing the
|
|
ingress replicas and possibly the Serve proxies.
|
|
"""
|
|
proxy_target_groups = self._get_proxy_target_groups()
|
|
if not self._direct_ingress_enabled or (
|
|
self._ha_proxy_enabled and not from_proxy_manager
|
|
):
|
|
return proxy_target_groups
|
|
|
|
# Get all applications and their metadata
|
|
if app_name is None:
|
|
apps = [
|
|
_app_name
|
|
for _app_name, _ in self.application_state_manager.list_app_statuses().items()
|
|
]
|
|
else:
|
|
apps = [app_name]
|
|
|
|
# TODO(landscapepainter): A better way to handle this is to write an API that can tell
|
|
# if the ingress deployment is healthy regardless of the application status.
|
|
apps = [
|
|
app
|
|
for app in apps
|
|
if self.application_state_manager.get_route_prefix(app) is not None
|
|
]
|
|
|
|
if not apps:
|
|
# When HAProxy is enabled and there are no apps, return empty target groups
|
|
# so that all requests fall through to the default_backend (404)
|
|
if self._ha_proxy_enabled and from_proxy_manager:
|
|
return []
|
|
return proxy_target_groups
|
|
|
|
# Create target groups for each application
|
|
target_groups = []
|
|
for app_name in apps:
|
|
route_prefix = self.application_state_manager.get_route_prefix(app_name)
|
|
app_target_groups = self._get_target_groups_for_app(app_name, route_prefix)
|
|
if app_target_groups:
|
|
target_groups.extend(app_target_groups)
|
|
else:
|
|
target_groups.extend(
|
|
self._get_target_groups_for_app_with_no_running_replicas(
|
|
route_prefix, app_name
|
|
)
|
|
)
|
|
|
|
return target_groups
|
|
|
|
def _get_running_replica_details_for_deployment(
|
|
self, app_name: str, deployment_name: str
|
|
) -> List[ReplicaDetails]:
|
|
"""Get running replica details for a specific deployment in an app."""
|
|
deployment_id = DeploymentID(app_name=app_name, name=deployment_name)
|
|
details = self.deployment_state_manager.get_deployment_details(deployment_id)
|
|
if not details:
|
|
return []
|
|
replica_details = details.replicas
|
|
running_replica_ids = {
|
|
replica_info.replica_id.unique_id
|
|
for replica_info in self.deployment_state_manager.get_running_replica_infos().get(
|
|
deployment_id, []
|
|
)
|
|
}
|
|
return [
|
|
replica_detail
|
|
for replica_detail in replica_details
|
|
if replica_detail.replica_id in running_replica_ids
|
|
]
|
|
|
|
def _get_running_replica_details_for_ingress_deployment(
|
|
self, app_name: str
|
|
) -> List[ReplicaDetails]:
|
|
"""Get running replica details for the ingress deployment."""
|
|
ingress_deployment_name = (
|
|
self.application_state_manager.get_ingress_deployment_name(app_name)
|
|
)
|
|
return self._get_running_replica_details_for_deployment(
|
|
app_name, ingress_deployment_name
|
|
)
|
|
|
|
def _get_target_groups_for_app(
|
|
self, app_name: str, route_prefix: str
|
|
) -> List[TargetGroup]:
|
|
"""
|
|
Create HTTP and gRPC target groups for a specific application.
|
|
|
|
This function can return empty list if there are no running replicas.
|
|
Or replicas have not fully initialized yet, where their ports are not
|
|
allocated yet.
|
|
|
|
When an ingress request router deployment is configured (ingress
|
|
bypass), its replicas go into ``ingress_request_router_targets`` for Lua
|
|
routing decisions and the app's ingress replicas remain the main
|
|
targets for data plane traffic.
|
|
"""
|
|
ingress_request_router_deployment_name = (
|
|
self.application_state_manager.get_ingress_request_router_deployment_name(
|
|
app_name
|
|
)
|
|
)
|
|
# Ingress deployment name, threaded into the target groups so HAProxy can
|
|
# tag per-request ingress metrics with it (matching the Python proxy).
|
|
ingress_deployment_name = (
|
|
self.application_state_manager.get_ingress_deployment_name(app_name) or ""
|
|
)
|
|
|
|
# Get running replicas for the ingress deployment
|
|
replica_details = self._get_running_replica_details_for_ingress_deployment(
|
|
app_name
|
|
)
|
|
# Without ingress replicas, HAProxy has no data-plane targets to route to,
|
|
# so suppress router targets too — the app is effectively unreachable.
|
|
if not replica_details:
|
|
return []
|
|
|
|
ingress_request_router_targets = []
|
|
if ingress_request_router_deployment_name is not None:
|
|
ingress_request_router_targets = self._get_targets_for_protocol(
|
|
self._get_running_replica_details_for_deployment(
|
|
app_name, ingress_request_router_deployment_name
|
|
),
|
|
RequestProtocol.HTTP,
|
|
)
|
|
|
|
target_groups = []
|
|
|
|
# Create targets for each protocol
|
|
http_targets = self._get_targets_for_protocol(
|
|
replica_details, RequestProtocol.HTTP
|
|
)
|
|
if http_targets:
|
|
target_groups.append(
|
|
TargetGroup(
|
|
protocol=RequestProtocol.HTTP,
|
|
route_prefix=route_prefix,
|
|
targets=http_targets,
|
|
app_name=app_name,
|
|
ingress_request_router_targets=ingress_request_router_targets,
|
|
ingress_deployment_name=ingress_deployment_name,
|
|
)
|
|
)
|
|
|
|
# Add gRPC targets if enabled
|
|
if is_grpc_enabled(self.get_grpc_config()):
|
|
grpc_targets = self._get_targets_for_protocol(
|
|
replica_details, RequestProtocol.GRPC
|
|
)
|
|
if grpc_targets:
|
|
target_groups.append(
|
|
TargetGroup(
|
|
protocol=RequestProtocol.GRPC,
|
|
route_prefix=route_prefix,
|
|
targets=grpc_targets,
|
|
app_name=app_name,
|
|
ingress_request_router_targets=[],
|
|
ingress_deployment_name=ingress_deployment_name,
|
|
)
|
|
)
|
|
|
|
return target_groups
|
|
|
|
def _get_target_groups_for_app_with_no_running_replicas(
|
|
self, route_prefix: str, app_name: str
|
|
) -> List[TargetGroup]:
|
|
"""
|
|
For applications that have no running replicas, we return target groups
|
|
for proxy. This will allow applications to be discoverable via the
|
|
proxy in situations where their replicas have scaled down to 0.
|
|
"""
|
|
# Ingress deployment name, threaded into the target groups so HAProxy can
|
|
# tag per-request ingress metrics with it (matching the Python proxy).
|
|
ingress_deployment_name = (
|
|
self.application_state_manager.get_ingress_deployment_name(app_name) or ""
|
|
)
|
|
|
|
if self._ha_proxy_enabled:
|
|
http_targets = []
|
|
grpc_targets = []
|
|
include_http = True
|
|
include_grpc = is_grpc_enabled(self.get_grpc_config())
|
|
else:
|
|
http_targets = self.proxy_state_manager.get_targets(RequestProtocol.HTTP)
|
|
grpc_targets = self.proxy_state_manager.get_targets(RequestProtocol.GRPC)
|
|
include_http = len(http_targets) > 0
|
|
include_grpc = len(grpc_targets) > 0
|
|
|
|
target_groups = []
|
|
if include_http:
|
|
target_groups.append(
|
|
TargetGroup(
|
|
protocol=RequestProtocol.HTTP,
|
|
route_prefix=route_prefix,
|
|
targets=http_targets,
|
|
app_name=app_name,
|
|
ingress_request_router_targets=[],
|
|
ingress_deployment_name=ingress_deployment_name,
|
|
)
|
|
)
|
|
if include_grpc:
|
|
target_groups.append(
|
|
TargetGroup(
|
|
protocol=RequestProtocol.GRPC,
|
|
route_prefix=route_prefix,
|
|
targets=grpc_targets,
|
|
app_name=app_name,
|
|
ingress_request_router_targets=[],
|
|
ingress_deployment_name=ingress_deployment_name,
|
|
)
|
|
)
|
|
|
|
return target_groups
|
|
|
|
def _get_targets_for_protocol(
|
|
self, replica_details: List[ReplicaDetails], protocol: RequestProtocol
|
|
) -> List[Target]:
|
|
"""Create targets for a specific protocol from a list of replicas."""
|
|
return [
|
|
Target(
|
|
ip=replica_detail.node_ip,
|
|
port=self._get_port(replica_detail, protocol),
|
|
instance_id=replica_detail.node_instance_id,
|
|
name=replica_detail.actor_name,
|
|
)
|
|
for replica_detail in replica_details
|
|
if self._is_port_allocated(replica_detail, protocol)
|
|
]
|
|
|
|
def _get_node_id_to_alive_replica_ids(self) -> Dict[str, Set[str]]:
|
|
return self.deployment_state_manager.get_node_id_to_alive_replica_ids()
|
|
|
|
def allocate_replica_port(
|
|
self, node_id: str, replica_id: str, protocol: RequestProtocol
|
|
) -> int:
|
|
"""Allocate an HTTP port for a replica in direct ingress mode."""
|
|
node_manager = NodePortManager.get_node_manager(node_id)
|
|
return node_manager.allocate_port(replica_id, protocol)
|
|
|
|
def release_replica_port(
|
|
self,
|
|
node_id: str,
|
|
replica_id: str,
|
|
port: int,
|
|
protocol: RequestProtocol,
|
|
block_port: bool = False,
|
|
):
|
|
"""Release an HTTP port for a replica in direct ingress mode."""
|
|
node_manager = NodePortManager.get_node_manager(node_id)
|
|
node_manager.release_port(replica_id, port, protocol, block_port)
|
|
|
|
def _get_port(
|
|
self, replica_detail: ReplicaDetails, protocol: RequestProtocol
|
|
) -> int:
|
|
"""Get the port for a replica."""
|
|
node_manager = NodePortManager.get_node_manager(replica_detail.node_id)
|
|
return node_manager.get_port(replica_detail.replica_id, protocol)
|
|
|
|
def _is_port_allocated(
|
|
self, replica_detail: ReplicaDetails, protocol: RequestProtocol
|
|
) -> bool:
|
|
"""Check if the port for a replica is allocated."""
|
|
node_manager = NodePortManager.get_node_manager(replica_detail.node_id)
|
|
return node_manager.is_port_allocated(replica_detail.replica_id, protocol)
|
|
|
|
def get_serve_status(self, name: str = SERVE_DEFAULT_APP_NAME) -> bytes:
|
|
"""Return application status.
|
|
|
|
Args:
|
|
name: application name. If application name doesn't exist, app_status
|
|
is NOT_STARTED.
|
|
|
|
Returns:
|
|
Protobuf-serialized bytes of the ``StatusOverview`` for the named
|
|
application (including app status and per-deployment statuses).
|
|
"""
|
|
|
|
app_status = self.application_state_manager.get_app_status_info(name)
|
|
deployment_statuses = self.application_state_manager.get_deployments_statuses(
|
|
name
|
|
)
|
|
status_info = StatusOverview(
|
|
name=name,
|
|
app_status=app_status,
|
|
deployment_statuses=deployment_statuses,
|
|
)
|
|
return status_info.to_proto().SerializeToString()
|
|
|
|
def get_serve_statuses(self, names: List[str]) -> List[bytes]:
|
|
statuses = []
|
|
for name in names:
|
|
statuses.append(self.get_serve_status(name))
|
|
return statuses
|
|
|
|
def list_serve_statuses(self) -> List[bytes]:
|
|
statuses = []
|
|
for name in self.application_state_manager.list_app_statuses():
|
|
statuses.append(self.get_serve_status(name))
|
|
return statuses
|
|
|
|
def get_app_configs(self) -> Dict[str, ServeApplicationSchema]:
|
|
checkpoint = self.kv_store.get(CONFIG_CHECKPOINT_KEY)
|
|
if checkpoint is None:
|
|
return {}
|
|
|
|
_, _, _, config_checkpoints_dict = pickle.loads(checkpoint)
|
|
return {
|
|
app: ServeApplicationSchema.model_validate(config)
|
|
for app, config in config_checkpoints_dict.items()
|
|
}
|
|
|
|
def get_external_scaler_enabled(self, app_name: str) -> bool:
|
|
"""Get the external_scaler_enabled flag value for an application.
|
|
|
|
This is a helper method specifically for Java tests to verify the flag
|
|
is correctly set, since Java cannot deserialize Python Pydantic objects.
|
|
|
|
Args:
|
|
app_name: Name of the application.
|
|
|
|
Returns:
|
|
True if external_scaler_enabled is set for the application, False otherwise.
|
|
"""
|
|
return self.application_state_manager.get_external_scaler_enabled(app_name)
|
|
|
|
def get_all_deployment_statuses(self) -> List[bytes]:
|
|
"""Gets deployment status bytes for all live deployments."""
|
|
statuses = self.deployment_state_manager.get_deployment_statuses()
|
|
return [status.to_proto().SerializeToString() for status in statuses]
|
|
|
|
def get_deployment_status(
|
|
self, name: str, app_name: str = ""
|
|
) -> Union[None, bytes]:
|
|
"""Get deployment status by deployment name.
|
|
|
|
Args:
|
|
name: Deployment name.
|
|
app_name: Application name. Default is "" because 1.x
|
|
deployments go through this API.
|
|
|
|
Returns:
|
|
Protobuf-serialized bytes of the deployment's status, or ``None``
|
|
if no deployment exists for ``(name, app_name)``.
|
|
"""
|
|
|
|
id = DeploymentID(name=name, app_name=app_name)
|
|
status = self.deployment_state_manager.get_deployment_statuses([id])
|
|
if not status:
|
|
return None
|
|
return status[0].to_proto().SerializeToString()
|
|
|
|
def get_docs_path(self, name: str):
|
|
"""Docs path for application.
|
|
|
|
Currently, this is the OpenAPI docs path for FastAPI-integrated applications."""
|
|
return self.application_state_manager.get_docs_path(name)
|
|
|
|
def get_ingress_deployment_name(self, app_name: str) -> Optional[str]:
|
|
"""Name of the ingress deployment in an application.
|
|
|
|
Args:
|
|
app_name: the application to look up.
|
|
|
|
Returns:
|
|
Ingress deployment name (str): if the application exists.
|
|
None: if the application does not exist.
|
|
"""
|
|
return self.application_state_manager.get_ingress_deployment_name(app_name)
|
|
|
|
def delete_apps(self, names: Iterable[str]):
|
|
"""Delete applications based on names
|
|
|
|
During deletion, the application status is DELETING
|
|
"""
|
|
for name in names:
|
|
self.application_state_manager.delete_app(name)
|
|
|
|
self.application_state_manager.save_checkpoint()
|
|
|
|
def record_request_routing_info(self, info: RequestRoutingInfo):
|
|
"""Record replica routing information for a replica.
|
|
|
|
Args:
|
|
info: RequestRoutingInfo including deployment name, replica tag,
|
|
multiplex model ids, and routing stats.
|
|
"""
|
|
self.deployment_state_manager.record_request_routing_info(info)
|
|
|
|
def _get_replica_ranks_mapping(
|
|
self, deployment_id: DeploymentID
|
|
) -> Dict[str, ReplicaRank]:
|
|
"""Get the current rank mapping for all replicas in a deployment.
|
|
Args:
|
|
deployment_id: The deployment ID to get ranks for.
|
|
Returns:
|
|
Dictionary mapping replica_id to ReplicaRank object (with rank, node_rank, local_rank).
|
|
"""
|
|
return self.deployment_state_manager._get_replica_ranks_mapping(deployment_id)
|
|
|
|
async def graceful_shutdown(self, wait: bool = True) -> None:
|
|
"""Set the shutting down flag on controller to signal shutdown in
|
|
run_control_loop().
|
|
|
|
This is used to signal to the controller that it should proceed with shutdown
|
|
process, so it can shut down gracefully. It also waits until the shutdown
|
|
event is triggered if wait is true.
|
|
|
|
Args:
|
|
wait: if True, block until the controller's shutdown event fires
|
|
(the caller is expected to handle the resulting
|
|
``RayActorError`` raised when the controller actor exits).
|
|
|
|
Raises:
|
|
RayActorError: if wait is True, the caller waits until the controller
|
|
is killed, which raises a RayActorError.
|
|
"""
|
|
self._shutting_down = True
|
|
try:
|
|
self.kv_store.put(SHUTDOWN_IN_PROGRESS_KEY, b"1")
|
|
self._shutdown_flag_persisted = True
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to persist shutdown flag; will retry in control loop.",
|
|
extra={"log_to_stderr": False},
|
|
)
|
|
if not wait:
|
|
return
|
|
|
|
# This event never gets set. The caller waits indefinitely on this event
|
|
# until the controller is killed, which raises a RayActorError.
|
|
await self._shutdown_event.wait()
|
|
|
|
def _get_logging_config(self) -> Tuple:
|
|
"""Get the logging configuration (for testing purposes)."""
|
|
log_file_path = None
|
|
for handler in logger.handlers:
|
|
if isinstance(handler, logging.handlers.MemoryHandler):
|
|
log_file_path = handler.target.baseFilename
|
|
return self.global_logging_config, log_file_path
|
|
|
|
def _get_target_capacity_direction(self) -> Optional[TargetCapacityDirection]:
|
|
"""Gets the controller's scale direction (for testing purposes)."""
|
|
|
|
return self._target_capacity_direction
|
|
|
|
|
|
def calculate_target_capacity_direction(
|
|
curr_config: Optional[ServeDeploySchema],
|
|
new_config: ServeDeploySchema,
|
|
curr_target_capacity_direction: Optional[float],
|
|
) -> Optional[TargetCapacityDirection]:
|
|
"""Compares two Serve configs to calculate the next scaling direction."""
|
|
|
|
curr_target_capacity = None
|
|
next_target_capacity_direction = None
|
|
|
|
if curr_config is not None and applications_match(curr_config, new_config):
|
|
curr_target_capacity = curr_config.target_capacity
|
|
next_target_capacity = new_config.target_capacity
|
|
|
|
if curr_target_capacity == next_target_capacity:
|
|
next_target_capacity_direction = curr_target_capacity_direction
|
|
elif curr_target_capacity is None and next_target_capacity is not None:
|
|
# target_capacity is scaling down from None to a number.
|
|
next_target_capacity_direction = TargetCapacityDirection.DOWN
|
|
elif next_target_capacity is None:
|
|
next_target_capacity_direction = None
|
|
elif curr_target_capacity < next_target_capacity:
|
|
next_target_capacity_direction = TargetCapacityDirection.UP
|
|
else:
|
|
next_target_capacity_direction = TargetCapacityDirection.DOWN
|
|
elif new_config.target_capacity is not None:
|
|
# A config with different apps has been applied, and it contains a
|
|
# target_capacity. Serve must start scaling this config up.
|
|
next_target_capacity_direction = TargetCapacityDirection.UP
|
|
else:
|
|
next_target_capacity_direction = None
|
|
|
|
return next_target_capacity_direction
|
|
|
|
|
|
def applications_match(config1: ServeDeploySchema, config2: ServeDeploySchema) -> bool:
|
|
"""Checks whether the applications in config1 and config2 match.
|
|
|
|
Two applications match if they have the same name.
|
|
"""
|
|
|
|
config1_app_names = {app.name for app in config1.applications}
|
|
config2_app_names = {app.name for app in config2.applications}
|
|
|
|
return config1_app_names == config2_app_names
|
|
|
|
|
|
def log_target_capacity_change(
|
|
curr_target_capacity: Optional[float],
|
|
next_target_capacity: Optional[float],
|
|
next_target_capacity_direction: Optional[TargetCapacityDirection],
|
|
):
|
|
"""Logs changes in the target_capacity."""
|
|
|
|
if curr_target_capacity != next_target_capacity:
|
|
if isinstance(next_target_capacity_direction, TargetCapacityDirection):
|
|
logger.info(
|
|
"Target capacity scaling "
|
|
f"{next_target_capacity_direction.value.lower()} "
|
|
f"from {curr_target_capacity} to {next_target_capacity}."
|
|
)
|
|
else:
|
|
logger.info("Target capacity entering 100% at steady state.")
|