6269 lines
255 KiB
Python
6269 lines
255 KiB
Python
import itertools
|
|
import json
|
|
import logging
|
|
import math
|
|
import os
|
|
import random
|
|
import time
|
|
import traceback
|
|
from collections import defaultdict, deque
|
|
from copy import copy
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
from typing import Any, Callable, Deque, Dict, List, Optional, Set, Tuple
|
|
|
|
import ray
|
|
from ray import ObjectRef, cloudpickle
|
|
from ray._common import ray_constants
|
|
from ray.actor import ActorHandle
|
|
from ray.exceptions import (
|
|
RayActorError,
|
|
RayError,
|
|
RayTaskError,
|
|
RuntimeEnvSetupError,
|
|
)
|
|
from ray.serve import metrics
|
|
from ray.serve._private import default_impl
|
|
from ray.serve._private.autoscaling_state import AutoscalingStateManager
|
|
from ray.serve._private.cluster_node_info_cache import ClusterNodeInfoCache
|
|
from ray.serve._private.common import (
|
|
GANG_PG_NAME_PREFIX,
|
|
DeploymentID,
|
|
DeploymentStatus,
|
|
DeploymentStatusInfo,
|
|
DeploymentStatusInternalTrigger,
|
|
DeploymentStatusTrigger,
|
|
DeploymentTargetInfo,
|
|
Duration,
|
|
GangPlacementGroupRequest,
|
|
GangReservationResult,
|
|
ReplicaID,
|
|
ReplicaState,
|
|
RequestRoutingInfo,
|
|
RunningReplicaInfo,
|
|
)
|
|
from ray.serve._private.config import DeploymentConfig, GangSchedulingConfig
|
|
from ray.serve._private.constants import (
|
|
DEFAULT_LATENCY_BUCKET_MS,
|
|
DEPLOYMENT_ACTOR_HEALTH_CHECK_PERIOD_S,
|
|
DEPLOYMENT_ACTOR_HEALTH_CHECK_TIMEOUT_S,
|
|
DEPLOYMENT_ACTOR_HEALTH_CHECK_UNHEALTHY_THRESHOLD,
|
|
MAX_PER_REPLICA_RETRY_COUNT,
|
|
RAY_SERVE_CONTROLLER_METRICS_INCLUDE_HIGH_CARDINALITY_TAGS,
|
|
RAY_SERVE_DIRECT_INGRESS_MIN_DRAINING_PERIOD_S,
|
|
RAY_SERVE_ENABLE_DIRECT_INGRESS,
|
|
RAY_SERVE_ENABLE_TASK_EVENTS,
|
|
RAY_SERVE_FAIL_ON_RANK_ERROR,
|
|
RAY_SERVE_FORCE_STOP_UNHEALTHY_REPLICAS,
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_ACTOR_NAME_ENV_VAR,
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_APP_NAME_ENV_VAR,
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_CODE_VERSION_ENV_VAR,
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_NAME_ENV_VAR,
|
|
RAY_SERVE_RETAINED_DEAD_REPLICAS,
|
|
RAY_SERVE_STATUS_GAUGE_REPORT_INTERVAL_S,
|
|
RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY,
|
|
REPLICA_HEALTH_CHECK_UNHEALTHY_THRESHOLD,
|
|
REPLICA_STARTUP_SHUTDOWN_LATENCY_BUCKETS_MS,
|
|
REQUEST_LATENCY_BUCKETS_MS,
|
|
SERVE_LOGGER_NAME,
|
|
SERVE_NAMESPACE,
|
|
)
|
|
from ray.serve._private.deployment_info import DeploymentInfo
|
|
from ray.serve._private.deployment_scheduler import (
|
|
DeploymentDownscaleRequest,
|
|
DeploymentScheduler,
|
|
ReplicaSchedulingRequest,
|
|
ReplicaSchedulingRequestStatus,
|
|
SpreadDeploymentSchedulingPolicy,
|
|
)
|
|
from ray.serve._private.exceptions import DeploymentIsBeingDeletedError
|
|
from ray.serve._private.long_poll import LongPollHost, LongPollNamespace
|
|
from ray.serve._private.storage.kv_store import KVStoreBase
|
|
from ray.serve._private.usage import ServeUsageTag
|
|
from ray.serve._private.utils import (
|
|
JavaActorHandleProxy,
|
|
check_obj_ref_ready_nowait,
|
|
get_active_placement_group_ids,
|
|
get_capacity_adjusted_num_replicas,
|
|
get_deployment_actor_name,
|
|
get_random_string,
|
|
msgpack_deserialize,
|
|
msgpack_serialize,
|
|
override_runtime_envs_except_env_vars,
|
|
)
|
|
from ray.serve._private.version import DeploymentVersion
|
|
from ray.serve.config import DeploymentActorConfig, GangRuntimeFailurePolicy
|
|
from ray.serve.gang import GangContext
|
|
from ray.serve.generated.serve_pb2 import DeploymentLanguage
|
|
from ray.serve.schema import (
|
|
DeploymentDetails,
|
|
ReplicaDetails,
|
|
ReplicaRank,
|
|
_deployment_info_to_schema,
|
|
)
|
|
from ray.util import metrics as ray_metrics
|
|
from ray.util.placement_group import PlacementGroup
|
|
|
|
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
|
|
|
_RESERVED_INTERNAL_DEPLOYMENT_CONTEXT_ENV_VARS = {
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_APP_NAME_ENV_VAR,
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_NAME_ENV_VAR,
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_ACTOR_NAME_ENV_VAR,
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_CODE_VERSION_ENV_VAR,
|
|
}
|
|
|
|
|
|
def _validate_no_reserved_internal_deployment_context_env_vars(
|
|
env_vars: Dict[str, Any],
|
|
) -> None:
|
|
conflicting_keys = sorted(
|
|
_RESERVED_INTERNAL_DEPLOYMENT_CONTEXT_ENV_VARS.intersection(env_vars)
|
|
)
|
|
if conflicting_keys:
|
|
raise ValueError(
|
|
"Users may not set reserved Ray Serve deployment actor context env vars: "
|
|
+ ", ".join(conflicting_keys)
|
|
)
|
|
|
|
|
|
def _inject_internal_deployment_context_env_vars(
|
|
runtime_env: Optional[Dict[str, Any]],
|
|
deployment_id: DeploymentID,
|
|
actor_name: str,
|
|
code_version: str,
|
|
) -> Dict[str, Any]:
|
|
runtime_env = copy(runtime_env) if runtime_env else {}
|
|
env_vars = dict(runtime_env.get("env_vars", {}))
|
|
_validate_no_reserved_internal_deployment_context_env_vars(env_vars)
|
|
env_vars.update(
|
|
{
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_APP_NAME_ENV_VAR: deployment_id.app_name,
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_NAME_ENV_VAR: deployment_id.name,
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_ACTOR_NAME_ENV_VAR: actor_name,
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_CODE_VERSION_ENV_VAR: code_version,
|
|
}
|
|
)
|
|
runtime_env["env_vars"] = env_vars
|
|
return runtime_env
|
|
|
|
|
|
class DeploymentActorState(Enum):
|
|
STARTING = 1
|
|
RECOVERING = 2
|
|
RUNNING = 3
|
|
|
|
|
|
ALL_DEPLOYMENT_ACTOR_STATES = list(DeploymentActorState)
|
|
|
|
|
|
class DeploymentActorWrapper:
|
|
"""Lifecycle wrapper for a single deployment-scoped actor.
|
|
|
|
The controller reconciles failed actors (no Ray auto-restart): it polls
|
|
``__ray_ready__`` on a schedule and recreates the actor after repeated
|
|
failures, same pattern as replica health checks.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
deployment_id: DeploymentID,
|
|
config: DeploymentActorConfig,
|
|
code_version: str,
|
|
recovered_handle: Optional[ActorHandle] = None,
|
|
):
|
|
self._deployment_id = deployment_id
|
|
self._config = config
|
|
self._code_version = code_version
|
|
self._actor_name = get_deployment_actor_name(
|
|
self._deployment_id, self._config.name, code_version=self._code_version
|
|
)
|
|
self._handle: Optional[ActorHandle] = recovered_handle
|
|
self._ready_ref: Optional[ObjectRef] = None
|
|
if recovered_handle is not None and hasattr(recovered_handle, "__ray_ready__"):
|
|
self._ready_ref = recovered_handle.__ray_ready__.remote()
|
|
self._health_check_ref: Optional[ObjectRef] = None
|
|
self._last_health_check_time: float = 0.0
|
|
self._consecutive_health_check_failures: int = 0
|
|
self._healthy: bool = True
|
|
|
|
@property
|
|
def actor_logical_name(self) -> str:
|
|
return self._config.name
|
|
|
|
@property
|
|
def code_version(self) -> str:
|
|
return self._code_version
|
|
|
|
def start(
|
|
self, deployment_runtime_env: Optional[Dict[str, Any]] = None
|
|
) -> Tuple[bool, Optional[str]]:
|
|
"""Start this deployment actor.
|
|
|
|
Args:
|
|
deployment_runtime_env: Runtime env inherited from the deployment.
|
|
|
|
Returns:
|
|
(started_successfully, error_msg). `started_successfully` indicates
|
|
whether actor start/setup succeeded. Callers should check wrapper
|
|
state to decide if readiness polling is needed.
|
|
"""
|
|
deployment_runtime_env = deployment_runtime_env or {}
|
|
try:
|
|
actor_cls = self._config.get_actor_class()
|
|
logger.info(
|
|
f"Creating deployment actor '{self._config.name}' with "
|
|
f"name={self._actor_name}"
|
|
)
|
|
actor_options = {
|
|
k: v
|
|
for k, v in (self._config.actor_options or {}).items()
|
|
if k not in ("name", "max_restarts")
|
|
}
|
|
# Inherit runtime_env from deployment; actor_options override.
|
|
actor_runtime_env = actor_options.pop("runtime_env", {})
|
|
merged_runtime_env = override_runtime_envs_except_env_vars(
|
|
deployment_runtime_env, actor_runtime_env
|
|
)
|
|
merged_runtime_env = _inject_internal_deployment_context_env_vars(
|
|
merged_runtime_env,
|
|
deployment_id=self._deployment_id,
|
|
actor_name=self._config.name,
|
|
code_version=self._code_version,
|
|
)
|
|
actor_options["runtime_env"] = merged_runtime_env
|
|
# Serve recreates deployment actors after failed health checks instead
|
|
# of relying on Ray actor restarts.
|
|
actor_options["max_restarts"] = 0
|
|
self._handle = actor_cls.options(
|
|
name=self._actor_name,
|
|
namespace=SERVE_NAMESPACE,
|
|
lifetime="detached",
|
|
get_if_exists=True,
|
|
**actor_options,
|
|
).remote(
|
|
*(self._config.init_args or ()),
|
|
**(self._config.init_kwargs or {}),
|
|
)
|
|
# Keep both handle and __ray_ready__ ref so pending creation can be
|
|
# cancelled on delete while still waiting for resources.
|
|
self._ready_ref = self._handle.__ray_ready__.remote()
|
|
return True, None
|
|
except Exception as e:
|
|
logger.exception(
|
|
f"Failed to create deployment actor '{self._config.name}' "
|
|
f"for {self._deployment_id}: {e}"
|
|
)
|
|
return False, str(e)
|
|
|
|
def check_ready(self) -> Tuple[bool, Optional[str]]:
|
|
"""Check readiness for this actor without blocking."""
|
|
# Already ready: _ready_ref cleared after successful wait
|
|
if self._ready_ref is None and self._handle is not None:
|
|
return True, None
|
|
if self._ready_ref is None:
|
|
return False, None
|
|
if not check_obj_ref_ready_nowait(self._ready_ref):
|
|
return False, None
|
|
|
|
try:
|
|
ray.get(self._ready_ref)
|
|
self._ready_ref = None
|
|
return True, None
|
|
except Exception as e:
|
|
return False, f"Deployment actor '{self._config.name}' failed: {e}"
|
|
|
|
def reset_health_state_after_running(self) -> None:
|
|
"""Reset health-check bookkeeping when the actor reaches RUNNING."""
|
|
self._health_check_ref = None
|
|
self._last_health_check_time = 0.0
|
|
self._consecutive_health_check_failures = 0
|
|
self._healthy = True
|
|
|
|
def _check_active_deployment_actor_health_check(
|
|
self,
|
|
) -> "ReplicaHealthCheckResponse":
|
|
"""Resolve the outstanding ``__ray_ready__`` health check ref, if any."""
|
|
if self._health_check_ref is None:
|
|
return ReplicaHealthCheckResponse.NONE
|
|
if check_obj_ref_ready_nowait(self._health_check_ref):
|
|
try:
|
|
ray.get(self._health_check_ref)
|
|
return ReplicaHealthCheckResponse.SUCCEEDED
|
|
except RayActorError:
|
|
return ReplicaHealthCheckResponse.ACTOR_CRASHED
|
|
except RayError as e:
|
|
logger.warning(
|
|
f"Health check for deployment actor "
|
|
f"'{self._config.name}' ({self._deployment_id}) failed: {e}"
|
|
)
|
|
return ReplicaHealthCheckResponse.APP_FAILURE
|
|
elif (
|
|
time.time() - self._last_health_check_time
|
|
> DEPLOYMENT_ACTOR_HEALTH_CHECK_TIMEOUT_S
|
|
):
|
|
logger.warning(
|
|
"Didn't receive health check response for deployment actor "
|
|
f"'{self._config.name}' ({self._deployment_id}) after "
|
|
f"{DEPLOYMENT_ACTOR_HEALTH_CHECK_TIMEOUT_S}s, treating as failed."
|
|
)
|
|
return ReplicaHealthCheckResponse.APP_FAILURE
|
|
return ReplicaHealthCheckResponse.NONE
|
|
|
|
def _should_start_new_deployment_actor_health_check(self) -> bool:
|
|
# Do not poll a handle we already marked unhealthy: ``check_health`` clears
|
|
# the ref before returning False, and the controller will ``kill`` this
|
|
# wrapper—starting another ``__ray_ready__`` would target a dead actor and
|
|
# orphan the new ObjectRef when ``kill`` clears ``_health_check_ref``.
|
|
if not self._healthy:
|
|
return False
|
|
if self._health_check_ref is not None:
|
|
return False
|
|
if self._handle is None:
|
|
return False
|
|
time_since_last = time.time() - self._last_health_check_time
|
|
randomized_period = DEPLOYMENT_ACTOR_HEALTH_CHECK_PERIOD_S * random.uniform(
|
|
0.9, 1.1
|
|
)
|
|
return time_since_last > randomized_period
|
|
|
|
def check_health(self) -> bool:
|
|
"""Poll ``__ray_ready__`` like replica health checks; update ``_healthy``."""
|
|
response = self._check_active_deployment_actor_health_check()
|
|
if response is ReplicaHealthCheckResponse.NONE:
|
|
pass
|
|
elif response is ReplicaHealthCheckResponse.SUCCEEDED:
|
|
if self._consecutive_health_check_failures > 0:
|
|
logger.info(
|
|
f"Deployment actor '{self._config.name}' ({self._deployment_id}) "
|
|
"passed the health check after "
|
|
f"{self._consecutive_health_check_failures} consecutive failures."
|
|
)
|
|
self._consecutive_health_check_failures = 0
|
|
self._healthy = True
|
|
elif response is ReplicaHealthCheckResponse.APP_FAILURE:
|
|
self._consecutive_health_check_failures += 1
|
|
if (
|
|
self._consecutive_health_check_failures
|
|
>= DEPLOYMENT_ACTOR_HEALTH_CHECK_UNHEALTHY_THRESHOLD
|
|
):
|
|
logger.warning(
|
|
f"Deployment actor '{self._config.name}' ({self._deployment_id}) "
|
|
"failed the health check "
|
|
f"{self._consecutive_health_check_failures} times in a row, "
|
|
"marking unhealthy."
|
|
)
|
|
self._healthy = False
|
|
elif response is ReplicaHealthCheckResponse.ACTOR_CRASHED:
|
|
logger.warning(
|
|
f"Deployment actor '{self._config.name}' ({self._deployment_id}) "
|
|
"actor crashed during health check, marking unhealthy immediately."
|
|
)
|
|
self._healthy = False
|
|
else:
|
|
assert False, f"Unknown response type: {response}."
|
|
|
|
if response is not ReplicaHealthCheckResponse.NONE:
|
|
self._health_check_ref = None
|
|
|
|
if self._should_start_new_deployment_actor_health_check():
|
|
self._last_health_check_time = time.time()
|
|
self._health_check_ref = self._handle.__ray_ready__.remote()
|
|
|
|
return self._healthy
|
|
|
|
def kill(self) -> None:
|
|
"""Kill this deployment actor by deterministic actor name."""
|
|
self._health_check_ref = None
|
|
try:
|
|
if not self._handle:
|
|
self._handle = ray.get_actor(
|
|
self._actor_name, namespace=SERVE_NAMESPACE
|
|
)
|
|
ray.kill(self._handle, no_restart=True)
|
|
except (ValueError, RayActorError):
|
|
logger.warning(
|
|
f"Deployment actor '{self._config.name}' for {self._deployment_id} "
|
|
f"was already stopped"
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class DeploymentActorEntry:
|
|
"""Encapsulates a deployment actor with its version, name, and wrapper.
|
|
|
|
Similar to how DeploymentReplica is stored in ReplicaStateContainer.
|
|
State is tracked by the container via which bucket the entry lives in.
|
|
"""
|
|
|
|
code_version: str
|
|
name: str
|
|
wrapper: DeploymentActorWrapper
|
|
|
|
|
|
class DeploymentActorContainer:
|
|
"""Manages deployment-scoped actor wrappers for a single deployment.
|
|
|
|
Entries are keyed by state (like ReplicaStateContainer). Each entry
|
|
encapsulates version, name, wrapper, and state.
|
|
"""
|
|
|
|
def __init__(self, deployment_id: DeploymentID):
|
|
self._deployment_id = deployment_id
|
|
self._actors_by_state: Dict[
|
|
DeploymentActorState, List[DeploymentActorEntry]
|
|
] = defaultdict(list)
|
|
self._actors_index: Dict[
|
|
Tuple[str, str], Tuple[DeploymentActorState, DeploymentActorEntry]
|
|
] = {} # (code_version, name) -> (state, entry)
|
|
|
|
def add(
|
|
self,
|
|
state: DeploymentActorState,
|
|
wrapper: DeploymentActorWrapper,
|
|
) -> None:
|
|
"""Add or move a wrapper under the given state."""
|
|
code_version = wrapper.code_version
|
|
name = wrapper.actor_logical_name
|
|
key = (code_version, name)
|
|
|
|
# Remove from old state bucket if present (e.g. STARTING -> READY)
|
|
existing = self._actors_index.get(key)
|
|
if existing is not None:
|
|
old_state, old_entry = existing
|
|
bucket = self._actors_by_state[old_state]
|
|
bucket.remove(old_entry)
|
|
if not bucket:
|
|
del self._actors_by_state[old_state]
|
|
|
|
entry = DeploymentActorEntry(
|
|
code_version=code_version,
|
|
name=name,
|
|
wrapper=wrapper,
|
|
)
|
|
self._actors_by_state[state].append(entry)
|
|
self._actors_index[key] = (state, entry)
|
|
|
|
def get(
|
|
self,
|
|
code_version: Optional[str] = None,
|
|
states: Optional[List[DeploymentActorState]] = None,
|
|
) -> List[DeploymentActorWrapper]:
|
|
if states is None:
|
|
states = ALL_DEPLOYMENT_ACTOR_STATES
|
|
entries = list(
|
|
itertools.chain.from_iterable(self._actors_by_state[s] for s in states)
|
|
)
|
|
if code_version is not None:
|
|
entries = [e for e in entries if e.code_version == code_version]
|
|
return [e.wrapper for e in entries]
|
|
|
|
def pop(
|
|
self,
|
|
code_version: Optional[str] = None,
|
|
states: Optional[List[DeploymentActorState]] = None,
|
|
) -> List[Tuple[DeploymentActorState, DeploymentActorEntry]]:
|
|
"""Remove and return (state, entry) pairs."""
|
|
if code_version is None:
|
|
removed: List[Tuple[DeploymentActorState, DeploymentActorEntry]] = []
|
|
for version in list(
|
|
{t[1].code_version for t in self._actors_index.values()}
|
|
):
|
|
removed.extend(self.pop(version, states=states))
|
|
return removed
|
|
|
|
if states is None:
|
|
states = ALL_DEPLOYMENT_ACTOR_STATES
|
|
|
|
to_remove: List[Tuple[DeploymentActorState, DeploymentActorEntry]] = []
|
|
for state in states:
|
|
bucket = self._actors_by_state.get(state, [])
|
|
for entry in bucket[:]:
|
|
if entry.code_version == code_version:
|
|
bucket.remove(entry)
|
|
to_remove.append((state, entry))
|
|
self._actors_index.pop((entry.code_version, entry.name), None)
|
|
if not bucket:
|
|
self._actors_by_state.pop(state, None)
|
|
|
|
return to_remove
|
|
|
|
def count(
|
|
self,
|
|
code_version: Optional[str] = None,
|
|
states: Optional[List[DeploymentActorState]] = None,
|
|
) -> int:
|
|
return len(self.get(code_version, states=states))
|
|
|
|
def get_wrapper(
|
|
self, code_version: str, name: str
|
|
) -> Optional[DeploymentActorWrapper]:
|
|
"""Get wrapper by (code_version, name), or None if not found."""
|
|
existing = self._actors_index.get((code_version, name))
|
|
return existing[1].wrapper if existing is not None else None
|
|
|
|
def get_code_versions(self) -> Set[str]:
|
|
"""Return the set of code versions currently in the container."""
|
|
return {entry.code_version for _, entry in self._actors_index.values()}
|
|
|
|
def is_empty(self) -> bool:
|
|
"""O(1): True if no actor wrappers are tracked in any state."""
|
|
return not self._actors_index
|
|
|
|
|
|
class ReplicaStartupStatus(Enum):
|
|
PENDING_ALLOCATION = 1
|
|
PENDING_INITIALIZATION = 2
|
|
SUCCEEDED = 3
|
|
FAILED = 4
|
|
|
|
|
|
class ReplicaHealthCheckResponse(Enum):
|
|
NONE = 1
|
|
SUCCEEDED = 2
|
|
APP_FAILURE = 3
|
|
ACTOR_CRASHED = 4
|
|
|
|
|
|
@dataclass
|
|
class DeploymentTargetState:
|
|
"""The current goal state for a deployment.
|
|
|
|
info: contains the information needed to initialize a replica.
|
|
target_num_replicas: the number of replicas to run. This should already
|
|
be adjusted by the target_capacity.
|
|
version: the goal version of the deployment.
|
|
deleting: whether the deployment is being deleted.
|
|
"""
|
|
|
|
info: Optional[DeploymentInfo]
|
|
target_num_replicas: int
|
|
version: Optional[DeploymentVersion]
|
|
deleting: bool
|
|
|
|
@classmethod
|
|
def default(cls) -> "DeploymentTargetState":
|
|
return cls(None, -1, None, False)
|
|
|
|
@classmethod
|
|
def create(
|
|
cls,
|
|
info: DeploymentInfo,
|
|
target_num_replicas: int,
|
|
*,
|
|
deleting: bool = False,
|
|
) -> "DeploymentTargetState":
|
|
if deleting:
|
|
if target_num_replicas != 0:
|
|
raise ValueError(
|
|
"target_num_replicas must be 0 when setting target state "
|
|
f"to deleting. Got {target_num_replicas} instead."
|
|
)
|
|
|
|
version = DeploymentVersion(
|
|
info.version,
|
|
deployment_config=info.deployment_config,
|
|
ray_actor_options=info.replica_config.ray_actor_options,
|
|
placement_group_bundles=info.replica_config.placement_group_bundles,
|
|
placement_group_strategy=info.replica_config.placement_group_strategy,
|
|
max_replicas_per_node=info.replica_config.max_replicas_per_node,
|
|
route_prefix=info.route_prefix,
|
|
placement_group_bundle_label_selector=(
|
|
info.replica_config.placement_group_bundle_label_selector
|
|
),
|
|
placement_group_fallback_strategy=(
|
|
info.replica_config.placement_group_fallback_strategy
|
|
),
|
|
)
|
|
|
|
return cls(info, target_num_replicas, version, deleting)
|
|
|
|
def is_scaled_copy_of(self, other_target_state: "DeploymentTargetState") -> bool:
|
|
"""Checks if this target state is a scaled copy of another target state.
|
|
|
|
A target state is a scaled copy of another target state if all
|
|
configurable info is identical, other than target_num_replicas.
|
|
|
|
Returns: True if this target state contains a non-None DeploymentInfo
|
|
and is a scaled copy of the other target state.
|
|
"""
|
|
|
|
if other_target_state.info is None:
|
|
return False
|
|
|
|
if self.info is None:
|
|
return False
|
|
|
|
actor_options_match = (
|
|
self.info.replica_config.ray_actor_options
|
|
== other_target_state.info.replica_config.ray_actor_options
|
|
)
|
|
bundles_match = (
|
|
self.info.replica_config.placement_group_bundles
|
|
== other_target_state.info.replica_config.placement_group_bundles
|
|
)
|
|
strategy_match = (
|
|
self.info.replica_config.placement_group_strategy
|
|
== other_target_state.info.replica_config.placement_group_strategy
|
|
)
|
|
max_replicas_match = (
|
|
self.info.replica_config.max_replicas_per_node
|
|
== other_target_state.info.replica_config.max_replicas_per_node
|
|
)
|
|
deployment_config_match = self.info.deployment_config.model_dump(
|
|
exclude={"num_replicas"}
|
|
) == other_target_state.info.deployment_config.model_dump(
|
|
exclude={"num_replicas"}
|
|
)
|
|
|
|
# Backward compatibility check for older versions of Ray without these fields.
|
|
current_bundle_label_selector = getattr(
|
|
self.info.replica_config, "placement_group_bundle_label_selector", None
|
|
)
|
|
other_bundle_label_selector = getattr(
|
|
other_target_state.info.replica_config,
|
|
"placement_group_bundle_label_selector",
|
|
None,
|
|
)
|
|
bundle_label_selector_match = (
|
|
current_bundle_label_selector == other_bundle_label_selector
|
|
)
|
|
|
|
current_fallback = getattr(
|
|
self.info.replica_config, "placement_group_fallback_strategy", None
|
|
)
|
|
other_fallback = getattr(
|
|
other_target_state.info.replica_config,
|
|
"placement_group_fallback_strategy",
|
|
None,
|
|
)
|
|
fallback_match = current_fallback == other_fallback
|
|
|
|
# TODO(zcin): version can be None, this is from an outdated codepath.
|
|
# We should remove outdated code, so version can never be None.
|
|
version_match = (
|
|
self.version is not None and self.version == other_target_state.version
|
|
)
|
|
|
|
return all(
|
|
[
|
|
actor_options_match,
|
|
bundles_match,
|
|
strategy_match,
|
|
bundle_label_selector_match,
|
|
fallback_match,
|
|
max_replicas_match,
|
|
deployment_config_match,
|
|
version_match,
|
|
]
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class DeploymentStateUpdateResult:
|
|
deleted: bool
|
|
any_replicas_recovering: bool
|
|
upscale: List[ReplicaSchedulingRequest]
|
|
downscale: Optional[DeploymentDownscaleRequest]
|
|
|
|
|
|
CHECKPOINT_KEY = "serve-deployment-state-checkpoint"
|
|
SLOW_STARTUP_WARNING_S = int(
|
|
os.environ.get(
|
|
"RAY_SERVE_SLOW_STARTUP_WARNING_S",
|
|
os.environ.get("SERVE_SLOW_STARTUP_WARNING_S", 30),
|
|
)
|
|
)
|
|
SLOW_STARTUP_WARNING_PERIOD_S = int(
|
|
os.environ.get(
|
|
"RAY_SERVE_SLOW_STARTUP_WARNING_PERIOD_S",
|
|
os.environ.get("SERVE_SLOW_STARTUP_WARNING_PERIOD_S", 30),
|
|
)
|
|
)
|
|
|
|
ALL_REPLICA_STATES = list(ReplicaState)
|
|
_SCALING_LOG_ENABLED = os.environ.get("SERVE_ENABLE_SCALING_LOG", "0") != "0"
|
|
|
|
|
|
def print_verbose_scaling_log():
|
|
assert _SCALING_LOG_ENABLED
|
|
|
|
log_path = "/tmp/ray/session_latest/logs/monitor.log"
|
|
last_n_lines = 50
|
|
autoscaler_log_last_n_lines = []
|
|
if os.path.exists(log_path):
|
|
with open(log_path) as f:
|
|
autoscaler_log_last_n_lines = f.readlines()[-last_n_lines:]
|
|
|
|
debug_info = {
|
|
"nodes": ray.nodes(),
|
|
"available_resources": ray.available_resources(),
|
|
"total_resources": ray.cluster_resources(),
|
|
"autoscaler_logs": autoscaler_log_last_n_lines,
|
|
}
|
|
logger.error(f"Scaling information\n{json.dumps(debug_info, indent=2)}")
|
|
|
|
|
|
class ActorReplicaWrapper:
|
|
"""Wraps a Ray actor for a deployment replica.
|
|
|
|
This is primarily defined so that we can mock out actual Ray operations
|
|
for unit testing.
|
|
|
|
*All Ray API calls should be made here, not in DeploymentState.*
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
replica_id: ReplicaID,
|
|
version: DeploymentVersion,
|
|
):
|
|
self._replica_id = replica_id
|
|
self._deployment_id = replica_id.deployment_id
|
|
self._actor_name = replica_id.to_full_id_str()
|
|
|
|
# Populated in either self.start() or self.recover()
|
|
self._allocated_obj_ref: ObjectRef = None
|
|
self._ready_obj_ref: ObjectRef = None
|
|
# Populated in self.recover() for non-cross-language replicas to
|
|
# asynchronously verify the actor finished its initial setup before
|
|
# we trigger `initialize_and_get_metadata`.
|
|
self._was_initialized_obj_ref: Optional[ObjectRef] = None
|
|
# Set to True when `check_ready()` determines the actor cannot be
|
|
# recovered (e.g., the previous controller crashed before the actor
|
|
# finished its initial setup). The reconciler treats this case as a
|
|
# silent drop / replace rather than a deploy failure, since the
|
|
# underlying cause is a controller-side crash, not user code.
|
|
self._unrecoverable: bool = False
|
|
|
|
self._actor_resources: Dict[str, float] = None
|
|
# If the replica is being started, this will be the true version
|
|
# If the replica is being recovered, this will be the target
|
|
# version, which may be inconsistent with the actual replica
|
|
# version. If so, the actual version will be updated later after
|
|
# recover() and check_ready()
|
|
self._version: DeploymentVersion = version
|
|
self._healthy: bool = True
|
|
self._health_check_ref: Optional[ObjectRef] = None
|
|
self._last_health_check_time: float = 0.0
|
|
self._consecutive_health_check_failures = 0
|
|
self._last_health_check_latency_ms: Optional[float] = None
|
|
self._last_health_check_failed: Optional[bool] = None
|
|
self._initialization_latency_s: Optional[float] = None
|
|
self._reconfigure_start_time: Optional[float] = None
|
|
self._internal_grpc_port: Optional[int] = None
|
|
self._docs_path: Optional[str] = None
|
|
self._route_patterns: Optional[List[str]] = None
|
|
# Rank assigned to the replica.
|
|
self._assign_rank_callback: Optional[Callable[[ReplicaID], ReplicaRank]] = None
|
|
self._rank: Optional[ReplicaRank] = None
|
|
# Gang context for the replica.
|
|
self._gang_context: Optional[GangContext] = None
|
|
# Populated in `on_scheduled` or `recover`.
|
|
self._actor_handle: ActorHandle = None
|
|
self._placement_group: PlacementGroup = None
|
|
|
|
# Populated after replica is allocated.
|
|
self._pid: int = None
|
|
self._actor_id: str = None
|
|
self._worker_id: str = None
|
|
self._node_id: str = None
|
|
self._node_ip: str = None
|
|
self._node_instance_id: str = None
|
|
self._log_file_path: str = None
|
|
self._http_port: int = None
|
|
self._grpc_port: int = None
|
|
|
|
# Populated in self.stop().
|
|
self._graceful_shutdown_ref: ObjectRef = None
|
|
|
|
# todo: will be confused with deployment_config.is_cross_language
|
|
self._is_cross_language = False
|
|
self._deployment_is_cross_language = False
|
|
self._routing_stats: Dict[str, Any] = {}
|
|
self._record_routing_stats_ref: Optional[ObjectRef] = None
|
|
self._last_record_routing_stats_time: float = 0.0
|
|
self._has_user_routing_stats_method: bool = False
|
|
# Static per-replica metadata captured once when the replica became
|
|
# ready (via the user's `record_replica_metadata` hook).
|
|
self._replica_metadata: Dict[str, Any] = {}
|
|
self._ingress: bool = False
|
|
|
|
# Outbound deployments polling state
|
|
self._outbound_deployments: Optional[List[DeploymentID]] = None
|
|
|
|
# Histogram to track routing stats delay from replica to controller
|
|
self._routing_stats_delay_histogram = metrics.Histogram(
|
|
"serve_routing_stats_delay_ms",
|
|
description=(
|
|
"The delay in milliseconds for routing stats to propagate "
|
|
"from replica to controller."
|
|
),
|
|
boundaries=DEFAULT_LATENCY_BUCKET_MS,
|
|
tag_keys=("deployment", "application"),
|
|
)
|
|
self._routing_stats_delay_histogram.set_default_tags(
|
|
{
|
|
"deployment": self._deployment_id.name,
|
|
"application": self._deployment_id.app_name,
|
|
}
|
|
)
|
|
|
|
# Counter to track exceptions/timeouts when getting routing stats
|
|
self._routing_stats_error_counter = metrics.Counter(
|
|
"serve_routing_stats_error",
|
|
description=(
|
|
"The number of errors (exceptions or timeouts) when getting "
|
|
"routing stats from replica."
|
|
),
|
|
tag_keys=("deployment", "replica", "application", "error_type"),
|
|
)
|
|
self._routing_stats_error_counter.set_default_tags(
|
|
{
|
|
"deployment": self._deployment_id.name,
|
|
"replica": self._replica_id.unique_id,
|
|
"application": self._deployment_id.app_name,
|
|
}
|
|
)
|
|
|
|
@property
|
|
def replica_id(self) -> str:
|
|
return self._replica_id
|
|
|
|
@property
|
|
def deployment_name(self) -> str:
|
|
return self._deployment_id.name
|
|
|
|
@property
|
|
def rank(self) -> Optional[ReplicaRank]:
|
|
return self._rank
|
|
|
|
@property
|
|
def gang_context(self) -> Optional[GangContext]:
|
|
return self._gang_context
|
|
|
|
@property
|
|
def replica_metadata(self) -> Dict[str, Any]:
|
|
return self._replica_metadata
|
|
|
|
@property
|
|
def unrecoverable(self) -> bool:
|
|
return self._unrecoverable
|
|
|
|
@property
|
|
def app_name(self) -> str:
|
|
return self._deployment_id.app_name
|
|
|
|
@property
|
|
def is_cross_language(self) -> bool:
|
|
return self._is_cross_language
|
|
|
|
@property
|
|
def actor_handle(self) -> Optional[ActorHandle]:
|
|
if not self._actor_handle:
|
|
try:
|
|
self._actor_handle = ray.get_actor(
|
|
self._actor_name, namespace=SERVE_NAMESPACE
|
|
)
|
|
except ValueError:
|
|
self._actor_handle = None
|
|
|
|
if self._is_cross_language:
|
|
assert isinstance(self._actor_handle, JavaActorHandleProxy)
|
|
return self._actor_handle.handle
|
|
|
|
return self._actor_handle
|
|
|
|
@property
|
|
def placement_group_bundles(self) -> Optional[List[Dict[str, float]]]:
|
|
if not self._placement_group:
|
|
return None
|
|
|
|
return self._placement_group.bundle_specs
|
|
|
|
@property
|
|
def version(self) -> DeploymentVersion:
|
|
"""Replica version. This can be incorrect during state recovery.
|
|
|
|
If the controller crashes and the deployment state is being
|
|
recovered, this will temporarily be the deployment-wide target
|
|
version, which may be inconsistent with the actual version
|
|
running on the replica actor. If so, the actual version will be
|
|
updated when the replica transitions from RECOVERING -> RUNNING
|
|
"""
|
|
return self._version
|
|
|
|
@property
|
|
def deployment_config(self) -> DeploymentConfig:
|
|
"""Deployment config. This can return an incorrect config during state recovery.
|
|
|
|
If the controller hasn't yet recovered the up-to-date version
|
|
from the running replica actor, this property will return the
|
|
current target config for the deployment.
|
|
"""
|
|
return self._version.deployment_config
|
|
|
|
@property
|
|
def docs_path(self) -> Optional[str]:
|
|
return self._docs_path
|
|
|
|
@property
|
|
def route_patterns(self) -> Optional[List[str]]:
|
|
return self._route_patterns
|
|
|
|
@property
|
|
def max_ongoing_requests(self) -> int:
|
|
return self.deployment_config.max_ongoing_requests
|
|
|
|
@property
|
|
def max_queued_requests(self) -> int:
|
|
return self.deployment_config.max_queued_requests
|
|
|
|
@property
|
|
def graceful_shutdown_timeout_s(self) -> float:
|
|
return self.deployment_config.graceful_shutdown_timeout_s
|
|
|
|
@property
|
|
def health_check_period_s(self) -> float:
|
|
return self.deployment_config.health_check_period_s
|
|
|
|
@property
|
|
def health_check_timeout_s(self) -> float:
|
|
return self.deployment_config.health_check_timeout_s
|
|
|
|
@property
|
|
def http_port(self) -> Optional[int]:
|
|
return self._http_port
|
|
|
|
@property
|
|
def grpc_port(self) -> Optional[int]:
|
|
return self._grpc_port
|
|
|
|
@property
|
|
def request_routing_stats_period_s(self) -> float:
|
|
return (
|
|
self.deployment_config.request_router_config.request_routing_stats_period_s
|
|
)
|
|
|
|
@property
|
|
def request_routing_stats_timeout_s(self) -> float:
|
|
return (
|
|
self.deployment_config.request_router_config.request_routing_stats_timeout_s
|
|
)
|
|
|
|
@property
|
|
def pid(self) -> Optional[int]:
|
|
"""Returns the pid of the actor, None if not started."""
|
|
return self._pid
|
|
|
|
@property
|
|
def actor_id(self) -> Optional[str]:
|
|
"""Returns the actor id, None if not started."""
|
|
return self._actor_id
|
|
|
|
@property
|
|
def worker_id(self) -> Optional[str]:
|
|
"""Returns the worker id, None if not started."""
|
|
return self._worker_id
|
|
|
|
@property
|
|
def node_id(self) -> Optional[str]:
|
|
"""Returns the node id of the actor, None if not placed."""
|
|
return self._node_id
|
|
|
|
@property
|
|
def node_ip(self) -> Optional[str]:
|
|
"""Returns the node ip of the actor, None if not placed."""
|
|
return self._node_ip
|
|
|
|
@property
|
|
def node_instance_id(self) -> Optional[str]:
|
|
"""Returns the node instance id of the actor, None if not placed."""
|
|
return self._node_instance_id
|
|
|
|
@property
|
|
def log_file_path(self) -> Optional[str]:
|
|
"""Returns the relative log file path of the actor, None if not placed."""
|
|
return self._log_file_path
|
|
|
|
@property
|
|
def initialization_latency_s(self) -> Optional[float]:
|
|
"""Returns the initialization latency for the replica actor.
|
|
|
|
Returns None if the replica hasn't started yet.
|
|
|
|
Note: this value isn't checkpointed, so if the controller restarts,
|
|
this value goes back to None.
|
|
"""
|
|
|
|
return self._initialization_latency_s
|
|
|
|
@property
|
|
def reconfigure_start_time(self) -> Optional[float]:
|
|
"""Returns the start time of the last reconfigure operation.
|
|
|
|
Returns None if no reconfigure operation has started.
|
|
"""
|
|
return self._reconfigure_start_time
|
|
|
|
@property
|
|
def last_health_check_latency_ms(self) -> Optional[float]:
|
|
"""Returns the latency of the last completed health check in milliseconds.
|
|
|
|
Returns None if no health check has completed in the current check cycle.
|
|
"""
|
|
return self._last_health_check_latency_ms
|
|
|
|
@property
|
|
def last_health_check_failed(self) -> Optional[bool]:
|
|
"""Returns whether the last completed health check failed.
|
|
|
|
Returns False if no health check has completed in the current check cycle.
|
|
"""
|
|
return self._last_health_check_failed
|
|
|
|
def start(
|
|
self,
|
|
deployment_info: DeploymentInfo,
|
|
assign_rank_callback: Callable[[ReplicaID], ReplicaRank],
|
|
gang_placement_group: Optional[PlacementGroup] = None,
|
|
gang_pg_index: Optional[int] = None,
|
|
gang_context: Optional[GangContext] = None,
|
|
) -> ReplicaSchedulingRequest:
|
|
"""Start the current DeploymentReplica instance.
|
|
|
|
The replica will be in the STARTING and PENDING_ALLOCATION states
|
|
until the deployment scheduler schedules the underlying actor.
|
|
|
|
Args:
|
|
deployment_info: Configuration info for the deployment.
|
|
assign_rank_callback: Callback to assign rank to the replica.
|
|
gang_placement_group: Pre-created gang PG to schedule this replica on.
|
|
gang_pg_index: Bundle index within the gang PG for this replica.
|
|
gang_context: Gang context for this replica.
|
|
|
|
Returns:
|
|
ReplicaSchedulingRequest: The scheduling request for the replica.
|
|
"""
|
|
self._assign_rank_callback = assign_rank_callback
|
|
self._actor_resources = deployment_info.replica_config.resource_dict
|
|
self._ingress = deployment_info.ingress
|
|
self._gang_placement_group = gang_placement_group
|
|
self._gang_pg_index = gang_pg_index
|
|
self._gang_context = gang_context
|
|
# it is currently not possible to create a placement group
|
|
# with no resources (https://github.com/ray-project/ray/issues/20401)
|
|
self._deployment_is_cross_language = (
|
|
deployment_info.deployment_config.is_cross_language
|
|
)
|
|
|
|
logger.info(
|
|
f"Starting {self.replica_id}.",
|
|
extra={"log_to_stderr": False},
|
|
)
|
|
|
|
actor_def = deployment_info.actor_def
|
|
if (
|
|
deployment_info.deployment_config.deployment_language
|
|
== DeploymentLanguage.PYTHON
|
|
):
|
|
if deployment_info.replica_config.serialized_init_args is None:
|
|
serialized_init_args = cloudpickle.dumps(())
|
|
else:
|
|
serialized_init_args = (
|
|
cloudpickle.dumps(
|
|
msgpack_deserialize(
|
|
deployment_info.replica_config.serialized_init_args
|
|
)
|
|
)
|
|
if self._deployment_is_cross_language
|
|
else deployment_info.replica_config.serialized_init_args
|
|
)
|
|
init_args = (
|
|
self.replica_id,
|
|
cloudpickle.dumps(deployment_info.replica_config.deployment_def)
|
|
if self._deployment_is_cross_language
|
|
else deployment_info.replica_config.serialized_deployment_def,
|
|
serialized_init_args,
|
|
deployment_info.replica_config.serialized_init_kwargs
|
|
if deployment_info.replica_config.serialized_init_kwargs
|
|
else cloudpickle.dumps({}),
|
|
deployment_info.deployment_config.to_proto_bytes(),
|
|
self._version,
|
|
deployment_info.ingress,
|
|
deployment_info.route_prefix,
|
|
deployment_info.ingress_request_router,
|
|
)
|
|
# TODO(simon): unify the constructor arguments across language
|
|
elif (
|
|
deployment_info.deployment_config.deployment_language
|
|
== DeploymentLanguage.JAVA
|
|
):
|
|
self._is_cross_language = True
|
|
actor_def = ray.cross_language.java_actor_class(
|
|
"io.ray.serve.replica.RayServeWrappedReplica"
|
|
)
|
|
init_args = (
|
|
# String deploymentName,
|
|
self.deployment_name,
|
|
# String replicaID,
|
|
self.replica_id.to_full_id_str(),
|
|
# String deploymentDef
|
|
deployment_info.replica_config.deployment_def_name,
|
|
# byte[] initArgsbytes
|
|
msgpack_serialize(
|
|
cloudpickle.loads(
|
|
deployment_info.replica_config.serialized_init_args
|
|
)
|
|
)
|
|
if self._deployment_is_cross_language
|
|
else deployment_info.replica_config.serialized_init_args,
|
|
# byte[] deploymentConfigBytes,
|
|
deployment_info.deployment_config.to_proto_bytes(),
|
|
# byte[] deploymentVersionBytes,
|
|
self._version.to_proto().SerializeToString(),
|
|
# String controllerName
|
|
# String appName
|
|
self.app_name,
|
|
)
|
|
|
|
actor_options = {
|
|
"name": self._actor_name,
|
|
"namespace": SERVE_NAMESPACE,
|
|
"lifetime": "detached",
|
|
"enable_task_events": RAY_SERVE_ENABLE_TASK_EVENTS,
|
|
}
|
|
actor_options.update(deployment_info.replica_config.ray_actor_options)
|
|
|
|
# A replica's default `max_concurrency` value can prevent it from
|
|
# respecting the configured `max_ongoing_requests`. To avoid this
|
|
# unintentional behavior, use `max_ongoing_requests` to override
|
|
# the Actor's `max_concurrency` if it is larger.
|
|
if (
|
|
deployment_info.deployment_config.max_ongoing_requests
|
|
> ray_constants.DEFAULT_MAX_CONCURRENCY_ASYNC
|
|
):
|
|
actor_options[
|
|
"max_concurrency"
|
|
] = deployment_info.deployment_config.max_ongoing_requests
|
|
|
|
return ReplicaSchedulingRequest(
|
|
replica_id=self.replica_id,
|
|
actor_def=actor_def,
|
|
actor_resources=self._actor_resources,
|
|
actor_options=actor_options,
|
|
actor_init_args=init_args,
|
|
placement_group_bundles=(
|
|
deployment_info.replica_config.placement_group_bundles
|
|
),
|
|
placement_group_strategy=(
|
|
deployment_info.replica_config.placement_group_strategy
|
|
),
|
|
placement_group_bundle_label_selector=(
|
|
deployment_info.replica_config.placement_group_bundle_label_selector
|
|
),
|
|
placement_group_fallback_strategy=(
|
|
deployment_info.replica_config.placement_group_fallback_strategy
|
|
),
|
|
max_replicas_per_node=(
|
|
deployment_info.replica_config.max_replicas_per_node
|
|
),
|
|
on_scheduled=self.on_scheduled,
|
|
gang_placement_group=self._gang_placement_group,
|
|
gang_pg_index=self._gang_pg_index,
|
|
)
|
|
|
|
def on_scheduled(
|
|
self,
|
|
actor_handle: ActorHandle,
|
|
placement_group: Optional[PlacementGroup] = None,
|
|
):
|
|
self._actor_handle = actor_handle
|
|
self._placement_group = placement_group
|
|
|
|
if self._is_cross_language:
|
|
self._actor_handle = JavaActorHandleProxy(self._actor_handle)
|
|
self._allocated_obj_ref = self._actor_handle.is_allocated.remote()
|
|
else:
|
|
self._allocated_obj_ref = self._actor_handle.is_allocated.remote()
|
|
|
|
def _format_user_config(self, user_config: Any):
|
|
temp = copy(user_config)
|
|
if user_config is not None and self._deployment_is_cross_language:
|
|
if self._is_cross_language:
|
|
temp = msgpack_serialize(temp)
|
|
else:
|
|
temp = msgpack_deserialize(temp)
|
|
return temp
|
|
|
|
def reconfigure(self, version: DeploymentVersion, rank: ReplicaRank) -> bool:
|
|
"""
|
|
Update replica version. Also, updates the deployment config on the actor
|
|
behind this DeploymentReplica instance if necessary.
|
|
|
|
Returns: whether the actor is being updated.
|
|
"""
|
|
updating = False
|
|
|
|
# Determine if we need heavyweight reconfiguration
|
|
# vs lightweight updates
|
|
needs_actor_reconfigure = self._version.requires_actor_reconfigure(version)
|
|
has_rank_changes = self._rank != rank
|
|
|
|
if needs_actor_reconfigure or has_rank_changes:
|
|
# Call into replica actor reconfigure() with updated user config and
|
|
# graceful_shutdown_wait_loop_s
|
|
# Setting updating=True because we want to transition to UPDATING state
|
|
# when rank is updated or deployment config changes.
|
|
updating = True
|
|
self._reconfigure_start_time = time.time()
|
|
deployment_config = copy(version.deployment_config)
|
|
deployment_config.user_config = self._format_user_config(
|
|
deployment_config.user_config
|
|
)
|
|
self._ready_obj_ref = self._actor_handle.reconfigure.remote(
|
|
deployment_config,
|
|
rank,
|
|
version.route_prefix,
|
|
)
|
|
|
|
self._version = version
|
|
self._rank = rank
|
|
return updating
|
|
|
|
def recover(self, ingress: bool = False) -> bool:
|
|
"""Recover replica version from a live replica actor.
|
|
|
|
When controller dies, the deployment state loses the info on the version that's
|
|
running on each individual replica actor, so as part of the recovery process, we
|
|
need to recover the version that is running on the replica actor.
|
|
|
|
Also confirm that actor is allocated and initialized before marking as running.
|
|
|
|
This call is non-blocking: any RPCs needed to query the actor are
|
|
fired here as ObjectRefs and observed in `check_ready()` from the
|
|
reconcile loop.
|
|
|
|
Args:
|
|
ingress: Whether this replica is an ingress replica.
|
|
|
|
Returns:
|
|
False if the replica actor is no longer alive; the caller drops
|
|
the replica from tracking. Otherwise True. Replicas that are
|
|
alive but never finished their initial setup are detected
|
|
asynchronously in `check_ready()` rather than here so that
|
|
controller recovery does not block.
|
|
"""
|
|
logger.info(f"Recovering {self.replica_id}.")
|
|
self._ingress = ingress
|
|
try:
|
|
self._actor_handle = ray.get_actor(
|
|
self._actor_name, namespace=SERVE_NAMESPACE
|
|
)
|
|
except ValueError:
|
|
logger.warning(
|
|
f"Failed to get handle to replica {self._actor_name} "
|
|
"during controller recovery. Marking as dead."
|
|
)
|
|
return False
|
|
|
|
try:
|
|
self._placement_group = ray.util.get_placement_group(
|
|
self._actor_name,
|
|
)
|
|
except ValueError:
|
|
# ValueError is raised if the placement group does not exist.
|
|
self._placement_group = None
|
|
|
|
# Re-fetch initialization proof
|
|
self._allocated_obj_ref = self._actor_handle.is_allocated.remote()
|
|
|
|
# Running actor handle already has all info needed, thus successful
|
|
# starting simply means retrieving replica version hash from actor
|
|
if self._is_cross_language:
|
|
self._ready_obj_ref = self._actor_handle.check_health.remote()
|
|
else:
|
|
# For non-cross-language replicas, asynchronously probe whether
|
|
# the actor finished its initial setup before triggering
|
|
# `initialize_and_get_metadata`. If the previous controller
|
|
# crashed between actor creation and the first
|
|
# `initialize_and_get_metadata(rank=...)` call, the actor has
|
|
# neither a rank nor an initialized user callable. Recovering it
|
|
# would silently complete its initialization with `rank=None`,
|
|
# leaving rank tracking permanently broken for that replica.
|
|
# `check_ready()` waits for this probe and replaces the replica
|
|
# if it reports `False`. We defer firing
|
|
# `initialize_and_get_metadata` until the probe passes so we
|
|
# don't accidentally drive the bad actor through initialization
|
|
# only to kill it afterwards.
|
|
self._was_initialized_obj_ref = self._actor_handle.was_initialized.remote()
|
|
|
|
return True
|
|
|
|
def _kill_unrecoverable_actor(self) -> None:
|
|
"""Force-kill an actor that cannot be recovered.
|
|
|
|
Best-effort: any failure to kill the actor is
|
|
logged but ignored.
|
|
"""
|
|
try:
|
|
ray.kill(self._actor_handle, no_restart=True)
|
|
except Exception:
|
|
logger.exception(
|
|
f"Failed to kill unrecoverable replica actor "
|
|
f"{self._replica_id} during controller recovery."
|
|
)
|
|
|
|
def check_ready(self) -> Tuple[ReplicaStartupStatus, Optional[str]]:
|
|
"""
|
|
Check if current replica has started by making ray API calls on
|
|
relevant actor / object ref.
|
|
|
|
Replica initialization calls __init__(), reconfigure(), and check_health().
|
|
|
|
Returns:
|
|
state (ReplicaStartupStatus):
|
|
PENDING_ALLOCATION: replica is waiting for a worker to start
|
|
PENDING_INITIALIZATION: replica initialization hasn't finished.
|
|
FAILED: replica initialization failed.
|
|
SUCCEEDED: replica initialization succeeded.
|
|
error_msg:
|
|
None: for PENDING_ALLOCATION, PENDING_INITIALIZATION or SUCCEEDED states
|
|
str: for FAILED state
|
|
"""
|
|
|
|
# Check whether the replica has been allocated.
|
|
if self._allocated_obj_ref is None or not check_obj_ref_ready_nowait(
|
|
self._allocated_obj_ref
|
|
):
|
|
return ReplicaStartupStatus.PENDING_ALLOCATION, None
|
|
|
|
if not self._is_cross_language:
|
|
try:
|
|
(
|
|
self._pid,
|
|
self._actor_id,
|
|
self._worker_id,
|
|
self._node_id,
|
|
self._node_ip,
|
|
self._node_instance_id,
|
|
self._log_file_path,
|
|
) = ray.get(self._allocated_obj_ref)
|
|
except RayTaskError as e:
|
|
logger.exception(
|
|
f"Exception in {self._replica_id}, the replica will be stopped."
|
|
)
|
|
return ReplicaStartupStatus.FAILED, str(e.as_instanceof_cause())
|
|
except RuntimeEnvSetupError as e:
|
|
msg = f"Exception when allocating {self._replica_id}: {str(e)}"
|
|
logger.exception(msg)
|
|
return ReplicaStartupStatus.FAILED, msg
|
|
except Exception:
|
|
msg = (
|
|
f"Exception when allocating {self._replica_id}:\n"
|
|
+ traceback.format_exc()
|
|
)
|
|
logger.exception(msg)
|
|
return ReplicaStartupStatus.FAILED, msg
|
|
|
|
# If we issued a `was_initialized` probe in `recover()`, wait for it
|
|
# before triggering `initialize_and_get_metadata`. If the actor was
|
|
# never initialized previously (the previous controller crashed
|
|
# mid-startup), kill it and signal an unrecoverable drop so the
|
|
# reconciler replaces it without counting a deploy failure.
|
|
if self._was_initialized_obj_ref is not None:
|
|
if not check_obj_ref_ready_nowait(self._was_initialized_obj_ref):
|
|
return ReplicaStartupStatus.PENDING_INITIALIZATION, None
|
|
|
|
probe_ref = self._was_initialized_obj_ref
|
|
self._was_initialized_obj_ref = None
|
|
try:
|
|
was_initialized = ray.get(probe_ref)
|
|
except Exception as e:
|
|
msg = (
|
|
f"Failed to probe initialization state of "
|
|
f"{self._replica_id} during controller recovery ({e!r}). "
|
|
"Replacing with a fresh replica."
|
|
)
|
|
logger.warning(msg)
|
|
self._kill_unrecoverable_actor()
|
|
self._unrecoverable = True
|
|
return ReplicaStartupStatus.FAILED, msg
|
|
|
|
if not was_initialized:
|
|
msg = (
|
|
f"{self._replica_id} was found alive but never finished "
|
|
"its initial setup; the previous controller likely "
|
|
"crashed during replica startup. Replacing with a fresh "
|
|
"replica."
|
|
)
|
|
logger.warning(msg)
|
|
self._kill_unrecoverable_actor()
|
|
self._unrecoverable = True
|
|
return ReplicaStartupStatus.FAILED, msg
|
|
|
|
# Probe succeeded; safe to drive the actor through recovery.
|
|
self._ready_obj_ref = (
|
|
self._actor_handle.initialize_and_get_metadata.remote()
|
|
)
|
|
|
|
if self._ready_obj_ref is None:
|
|
# Perform auto method name translation for java handles.
|
|
# See https://github.com/ray-project/ray/issues/21474
|
|
deployment_config = copy(self._version.deployment_config)
|
|
deployment_config.user_config = self._format_user_config(
|
|
deployment_config.user_config
|
|
)
|
|
if self._is_cross_language:
|
|
self._ready_obj_ref = self._actor_handle.is_initialized.remote(
|
|
deployment_config.to_proto_bytes()
|
|
)
|
|
else:
|
|
replica_ready_check_func = (
|
|
self._actor_handle.initialize_and_get_metadata
|
|
)
|
|
# this guarantees that node_id is set before rank is assigned
|
|
self._rank = self._assign_rank_callback(
|
|
self._replica_id.unique_id, self._node_id
|
|
)
|
|
self._ready_obj_ref = replica_ready_check_func.remote(
|
|
deployment_config, self._rank, self._gang_context
|
|
)
|
|
|
|
return ReplicaStartupStatus.PENDING_INITIALIZATION, None
|
|
|
|
# Check whether replica initialization has completed.
|
|
replica_ready = check_obj_ref_ready_nowait(self._ready_obj_ref)
|
|
# In case of deployment constructor failure, ray.get will help to
|
|
# surface exception to each update() cycle.
|
|
if not replica_ready:
|
|
return ReplicaStartupStatus.PENDING_INITIALIZATION, None
|
|
else:
|
|
try:
|
|
# TODO(simon): fully implement reconfigure for Java replicas.
|
|
if self._is_cross_language:
|
|
return ReplicaStartupStatus.SUCCEEDED, None
|
|
|
|
# todo: The replica's userconfig whitch java client created
|
|
# is different from the controller's userconfig
|
|
if not self._deployment_is_cross_language:
|
|
# This should only update version if the replica is being recovered.
|
|
# If this is checking on a replica that is newly started, this
|
|
# should return a version that is identical to what's already stored
|
|
(
|
|
_,
|
|
self._version,
|
|
self._initialization_latency_s,
|
|
self._internal_grpc_port,
|
|
self._docs_path,
|
|
self._http_port,
|
|
self._grpc_port,
|
|
self._rank,
|
|
self._route_patterns,
|
|
self._outbound_deployments,
|
|
self._has_user_routing_stats_method,
|
|
self._gang_context,
|
|
self._replica_metadata,
|
|
) = ray.get(self._ready_obj_ref)
|
|
except RayTaskError as e:
|
|
logger.exception(
|
|
f"Exception in {self._replica_id}, the replica will be stopped."
|
|
)
|
|
# NOTE(zcin): we should use str(e) instead of traceback.format_exc()
|
|
# here because the full details of the error is not displayed properly
|
|
# with traceback.format_exc().
|
|
return ReplicaStartupStatus.FAILED, str(e.as_instanceof_cause())
|
|
except Exception as e:
|
|
logger.exception(
|
|
f"Exception in {self._replica_id}, the replica will be stopped."
|
|
)
|
|
return ReplicaStartupStatus.FAILED, repr(e)
|
|
|
|
return ReplicaStartupStatus.SUCCEEDED, None
|
|
|
|
@property
|
|
def actor_resources(self) -> Optional[Dict[str, float]]:
|
|
return self._actor_resources
|
|
|
|
@property
|
|
def available_resources(self) -> Dict[str, float]:
|
|
return ray.available_resources()
|
|
|
|
def graceful_stop(self) -> Duration:
|
|
"""Request the actor to exit gracefully.
|
|
|
|
Returns the timeout after which to kill the actor.
|
|
"""
|
|
try:
|
|
handle = ray.get_actor(self._actor_name, namespace=SERVE_NAMESPACE)
|
|
if self._is_cross_language:
|
|
handle = JavaActorHandleProxy(handle)
|
|
self._graceful_shutdown_ref = handle.perform_graceful_shutdown.remote()
|
|
except ValueError:
|
|
# ValueError thrown from ray.get_actor means actor has already been deleted.
|
|
pass
|
|
|
|
return self.graceful_shutdown_timeout_s
|
|
|
|
def check_stopped(self) -> bool:
|
|
"""Check if the actor has exited."""
|
|
stopped = False
|
|
try:
|
|
handle = ray.get_actor(self._actor_name, namespace=SERVE_NAMESPACE)
|
|
if self._graceful_shutdown_ref is None:
|
|
# graceful_stop() failed to set the shutdown ref (e.g., the
|
|
# actor was not found at that time). Treat as not yet stopped;
|
|
# the next reconcile iteration will retry.
|
|
stopped = False
|
|
else:
|
|
stopped = check_obj_ref_ready_nowait(self._graceful_shutdown_ref)
|
|
if stopped:
|
|
try:
|
|
ray.get(self._graceful_shutdown_ref)
|
|
except Exception:
|
|
logger.exception(
|
|
"Exception when trying to gracefully shutdown replica:\n"
|
|
+ traceback.format_exc()
|
|
)
|
|
|
|
ray.kill(handle, no_restart=True)
|
|
except ValueError:
|
|
# ValueError thrown from ray.get_actor means actor has already been deleted.
|
|
stopped = True
|
|
finally:
|
|
# Remove the placement group both if the actor has already been deleted or
|
|
# it was just killed above.
|
|
if stopped and self._placement_group is not None:
|
|
try:
|
|
ray.util.remove_placement_group(self._placement_group)
|
|
except ValueError:
|
|
# ValueError thrown from ray.util.remove_placement_group means the
|
|
# placement group has already been removed.
|
|
logger.debug(
|
|
f"Placement group for {self._replica_id} was already removed."
|
|
)
|
|
|
|
return stopped
|
|
|
|
def _check_active_health_check(self) -> ReplicaHealthCheckResponse:
|
|
"""Check the active health check (if any).
|
|
|
|
self._health_check_ref will be reset to `None` when the active health
|
|
check is deemed to have succeeded or failed. This method *does not*
|
|
start a new health check, that's up to the caller.
|
|
|
|
Returns:
|
|
- NONE if there's no active health check, or it hasn't returned
|
|
yet and the timeout is not up.
|
|
- SUCCEEDED if the active health check succeeded.
|
|
- APP_FAILURE if the active health check failed (or didn't return
|
|
before the timeout).
|
|
- ACTOR_CRASHED if the underlying actor crashed.
|
|
"""
|
|
# Reset the last health check status for this check cycle.
|
|
# We do this because _check_active_health_check is being called in a loop,
|
|
# and we want to avoid accumulating latency and failure metrics over multiple
|
|
# check cycles.
|
|
self._last_health_check_latency_ms = None
|
|
self._last_health_check_failed = None
|
|
|
|
if self._health_check_ref is None:
|
|
# There is no outstanding health check.
|
|
response = ReplicaHealthCheckResponse.NONE
|
|
elif check_obj_ref_ready_nowait(self._health_check_ref):
|
|
# Object ref is ready, ray.get it to check for exceptions.
|
|
try:
|
|
ray.get(self._health_check_ref)
|
|
# Calculate health check latency.
|
|
self._last_health_check_latency_ms = (
|
|
time.time() - self._last_health_check_time
|
|
) * 1000
|
|
self._last_health_check_failed = False
|
|
# Health check succeeded without exception.
|
|
response = ReplicaHealthCheckResponse.SUCCEEDED
|
|
except RayActorError:
|
|
# Health check failed due to actor crashing.
|
|
response = ReplicaHealthCheckResponse.ACTOR_CRASHED
|
|
self._last_health_check_failed = True
|
|
except RayError as e:
|
|
# Health check failed due to application-level exception.
|
|
logger.warning(f"Health check for {self._replica_id} failed: {e}")
|
|
response = ReplicaHealthCheckResponse.APP_FAILURE
|
|
self._last_health_check_failed = True
|
|
elif time.time() - self._last_health_check_time > self.health_check_timeout_s:
|
|
# Health check hasn't returned and the timeout is up, consider it failed.
|
|
logger.warning(
|
|
"Didn't receive health check response for replica "
|
|
f"{self._replica_id} after "
|
|
f"{self.health_check_timeout_s}s, marking it unhealthy."
|
|
)
|
|
response = ReplicaHealthCheckResponse.APP_FAILURE
|
|
# Calculate latency for timeout case.
|
|
self._last_health_check_latency_ms = (
|
|
time.time() - self._last_health_check_time
|
|
) * 1000
|
|
self._last_health_check_failed = True
|
|
else:
|
|
# Health check hasn't returned and the timeout isn't up yet.
|
|
response = ReplicaHealthCheckResponse.NONE
|
|
|
|
if response is not ReplicaHealthCheckResponse.NONE:
|
|
self._health_check_ref = None
|
|
|
|
return response
|
|
|
|
def _should_start_new_health_check(self) -> bool:
|
|
"""Determines if a new health check should be kicked off.
|
|
|
|
A health check will be started if:
|
|
1) There is not already an active health check.
|
|
2) It has been more than health_check_period_s since the
|
|
previous health check was *started*.
|
|
|
|
This assumes that self._health_check_ref is reset to `None` when an
|
|
active health check succeeds or fails (due to returning or timeout).
|
|
"""
|
|
if self._health_check_ref is not None:
|
|
# There's already an active health check.
|
|
return False
|
|
|
|
# If there's no active health check, kick off another and reset
|
|
# the timer if it's been long enough since the last health
|
|
# check. Add some randomness to avoid synchronizing across all
|
|
# replicas.
|
|
time_since_last = time.time() - self._last_health_check_time
|
|
randomized_period = self.health_check_period_s * random.uniform(0.9, 1.1)
|
|
return time_since_last > randomized_period
|
|
|
|
def _should_record_routing_stats(self) -> bool:
|
|
"""Determines if a new record routing stats should be kicked off.
|
|
|
|
A record routing stats will be started if:
|
|
1) The user has defined a record_routing_stats method on their
|
|
deployment class. If not, skip entirely to avoid unnecessary
|
|
remote calls that would just return empty dicts.
|
|
2) There is not already an active record routing stats.
|
|
3) It has been more than request_routing_stats_period_s since
|
|
the previous record routing stats was *started*.
|
|
|
|
This assumes that self._record_routing_stats_ref is reset to `None`
|
|
when an active record routing stats succeeds or fails (due to
|
|
returning or timeout).
|
|
"""
|
|
if not self._has_user_routing_stats_method:
|
|
# The user hasn't defined a record_routing_stats method, so
|
|
# there's no point in making remote calls to collect stats.
|
|
return False
|
|
|
|
if self._record_routing_stats_ref is not None:
|
|
# There's already an active record routing stats.
|
|
return False
|
|
|
|
# If there's no active record routing stats, kick off another and
|
|
# reset the timer if it's been long enough since the last record
|
|
# routing stats. Add some randomness to avoid synchronizing across
|
|
# all replicas.
|
|
time_since_last = time.time() - self._last_record_routing_stats_time
|
|
randomized_period = self.request_routing_stats_period_s * random.uniform(
|
|
0.9, 1.1
|
|
)
|
|
return time_since_last > randomized_period
|
|
|
|
def check_health(self) -> bool:
|
|
"""Check if the actor is healthy.
|
|
|
|
self._healthy should *only* be modified in this method.
|
|
|
|
This is responsible for:
|
|
1) Checking the outstanding health check (if any).
|
|
2) Determining the replica health based on the health check results.
|
|
3) Kicking off a new health check if needed.
|
|
"""
|
|
response: ReplicaHealthCheckResponse = self._check_active_health_check()
|
|
if response is ReplicaHealthCheckResponse.NONE:
|
|
# No info; don't update replica health.
|
|
pass
|
|
elif response is ReplicaHealthCheckResponse.SUCCEEDED:
|
|
# Health check succeeded. Reset the consecutive failure counter
|
|
# and mark the replica healthy.
|
|
if self._consecutive_health_check_failures > 0:
|
|
logger.info(
|
|
f"{self._replica_id} passed the health check after "
|
|
f"{self._consecutive_health_check_failures} consecutive failures."
|
|
)
|
|
self._consecutive_health_check_failures = 0
|
|
self._healthy = True
|
|
elif response is ReplicaHealthCheckResponse.APP_FAILURE:
|
|
# Health check failed. If it has failed more than N times in a row,
|
|
# mark the replica unhealthy.
|
|
self._consecutive_health_check_failures += 1
|
|
if (
|
|
self._consecutive_health_check_failures
|
|
>= REPLICA_HEALTH_CHECK_UNHEALTHY_THRESHOLD
|
|
):
|
|
logger.warning(
|
|
f"Replica {self._replica_id} failed the health "
|
|
f"check {self._consecutive_health_check_failures} "
|
|
"times in a row, marking it unhealthy."
|
|
)
|
|
self._healthy = False
|
|
elif response is ReplicaHealthCheckResponse.ACTOR_CRASHED:
|
|
# Actor crashed, mark the replica unhealthy immediately.
|
|
logger.warning(
|
|
f"Actor for {self._replica_id} crashed, marking "
|
|
"it unhealthy immediately."
|
|
)
|
|
self._healthy = False
|
|
else:
|
|
assert False, f"Unknown response type: {response}."
|
|
|
|
if self._should_start_new_health_check():
|
|
self._last_health_check_time = time.time()
|
|
self._health_check_ref = self._actor_handle.check_health.remote()
|
|
|
|
return self._healthy
|
|
|
|
def get_routing_stats(self) -> Dict[str, Any]:
|
|
"""Get the routing stats for the replica."""
|
|
if self._record_routing_stats_ref is None:
|
|
# There's no active record routing stats.
|
|
pass
|
|
elif check_obj_ref_ready_nowait(self._record_routing_stats_ref):
|
|
# Object ref is ready, ray.get it to check for exceptions.
|
|
try:
|
|
self._routing_stats = ray.get(self._record_routing_stats_ref)
|
|
# Record the round-trip delay for routing stats
|
|
delay_ms = (time.time() - self._last_record_routing_stats_time) * 1000
|
|
self._routing_stats_delay_histogram.observe(delay_ms)
|
|
except Exception:
|
|
logger.exception(
|
|
"Exception when trying to get routing stats:\n"
|
|
+ traceback.format_exc()
|
|
)
|
|
self._routing_stats_error_counter.inc(tags={"error_type": "exception"})
|
|
self._record_routing_stats_ref = None
|
|
elif (
|
|
time.time() - self._last_record_routing_stats_time
|
|
> self.request_routing_stats_timeout_s
|
|
):
|
|
# Record routing stats hasn't returned and the timeout is up, retrying.
|
|
logger.warning(
|
|
"Didn't receive routing stats response for replica "
|
|
f"{self._replica_id} after "
|
|
f"{self.request_routing_stats_timeout_s}s, retrying."
|
|
)
|
|
self._routing_stats_error_counter.inc(tags={"error_type": "timeout"})
|
|
self._record_routing_stats_ref = None
|
|
|
|
if self._should_record_routing_stats():
|
|
self._last_record_routing_stats_time = time.time()
|
|
self._record_routing_stats_ref = (
|
|
self._actor_handle.record_routing_stats.remote()
|
|
)
|
|
|
|
return self._routing_stats
|
|
|
|
def force_stop(self):
|
|
"""Force the actor to exit without shutting down gracefully."""
|
|
try:
|
|
ray.kill(ray.get_actor(self._actor_name, namespace=SERVE_NAMESPACE))
|
|
except ValueError:
|
|
pass
|
|
|
|
def get_outbound_deployments(self) -> Optional[List[DeploymentID]]:
|
|
return self._outbound_deployments
|
|
|
|
|
|
class DeploymentReplica:
|
|
"""Manages state transitions for deployment replicas.
|
|
|
|
This is basically a checkpointable lightweight state machine.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
replica_id: ReplicaID,
|
|
version: DeploymentVersion,
|
|
):
|
|
self._replica_id = replica_id
|
|
self._actor = ActorReplicaWrapper(replica_id, version)
|
|
self._start_time = None
|
|
self._shutdown_start_time: Optional[float] = None
|
|
self._actor_details = ReplicaDetails(
|
|
actor_name=replica_id.to_full_id_str(),
|
|
replica_id=self._replica_id.unique_id,
|
|
state=ReplicaState.STARTING,
|
|
start_time_s=0,
|
|
)
|
|
self._multiplexed_model_ids: List[str] = []
|
|
self._routing_stats: Dict[str, Any] = {}
|
|
|
|
def get_running_replica_info(
|
|
self, cluster_node_info_cache: ClusterNodeInfoCache
|
|
) -> RunningReplicaInfo:
|
|
return RunningReplicaInfo(
|
|
replica_id=self._replica_id,
|
|
node_id=self.actor_node_id,
|
|
node_ip=self._actor.node_ip,
|
|
availability_zone=cluster_node_info_cache.get_node_az(self.actor_node_id),
|
|
actor_name=self._actor._actor_name,
|
|
max_ongoing_requests=self._actor.max_ongoing_requests,
|
|
is_cross_language=self._actor.is_cross_language,
|
|
multiplexed_model_ids=self.multiplexed_model_ids,
|
|
routing_stats=self.routing_stats,
|
|
replica_metadata=self.replica_metadata,
|
|
port=self._actor._internal_grpc_port,
|
|
backend_http_port=self._actor._http_port or None,
|
|
)
|
|
|
|
def record_multiplexed_model_ids(self, multiplexed_model_ids: List[str]):
|
|
"""Record the multiplexed model ids for this replica."""
|
|
self._multiplexed_model_ids = multiplexed_model_ids
|
|
|
|
def record_routing_stats(self, routing_stats: Optional[Dict[str, Any]]):
|
|
"""Record the routing stats for this replica.
|
|
|
|
Recording routing_stats as an empty dictionary is valid. But skip
|
|
update if the routing_stats is None.
|
|
"""
|
|
if routing_stats is not None:
|
|
self._routing_stats = routing_stats
|
|
|
|
@property
|
|
def multiplexed_model_ids(self) -> List[str]:
|
|
return self._multiplexed_model_ids
|
|
|
|
@property
|
|
def routing_stats(self) -> Dict[str, Any]:
|
|
return self._routing_stats
|
|
|
|
@property
|
|
def replica_metadata(self) -> Dict[str, Any]:
|
|
# Captured by the actor wrapper from the ready handshake (and re-captured
|
|
# on a new replica incarnation), so no separate restore path is needed.
|
|
return getattr(self._actor, "replica_metadata", {})
|
|
|
|
@property
|
|
def actor_details(self) -> ReplicaDetails:
|
|
return self._actor_details
|
|
|
|
@property
|
|
def replica_id(self) -> ReplicaID:
|
|
return self._replica_id
|
|
|
|
@property
|
|
def deployment_name(self) -> str:
|
|
return self._replica_id.deployment_id.name
|
|
|
|
@property
|
|
def app_name(self) -> str:
|
|
return self._replica_id.deployment_id.app_name
|
|
|
|
@property
|
|
def version(self):
|
|
return self._actor.version
|
|
|
|
@property
|
|
def docs_path(self) -> Optional[str]:
|
|
return self._actor.docs_path
|
|
|
|
@property
|
|
def route_patterns(self) -> Optional[List[str]]:
|
|
return self._actor.route_patterns
|
|
|
|
@property
|
|
def actor_id(self) -> str:
|
|
return self._actor.actor_id
|
|
|
|
@property
|
|
def actor_handle(self) -> ActorHandle:
|
|
return self._actor.actor_handle
|
|
|
|
@property
|
|
def actor_node_id(self) -> Optional[str]:
|
|
"""Returns the node id of the actor, None if not placed."""
|
|
return self._actor.node_id
|
|
|
|
@property
|
|
def actor_http_port(self) -> Optional[int]:
|
|
return self._actor.http_port
|
|
|
|
@property
|
|
def actor_grpc_port(self) -> Optional[int]:
|
|
return self._actor.grpc_port
|
|
|
|
@property
|
|
def actor_pid(self) -> Optional[int]:
|
|
"""Returns the node id of the actor, None if not placed."""
|
|
return self._actor.pid
|
|
|
|
@property
|
|
def initialization_latency_s(self) -> Optional[float]:
|
|
"""Returns how long the replica took to initialize."""
|
|
|
|
return self._actor.initialization_latency_s
|
|
|
|
@property
|
|
def reconfigure_start_time(self) -> Optional[float]:
|
|
"""Returns the start time of the last reconfigure operation."""
|
|
return self._actor.reconfigure_start_time
|
|
|
|
@property
|
|
def last_health_check_latency_ms(self) -> Optional[float]:
|
|
"""Returns the latency of the last completed health check in milliseconds."""
|
|
return self._actor.last_health_check_latency_ms
|
|
|
|
@property
|
|
def last_health_check_failed(self) -> Optional[bool]:
|
|
"""Returns whether the last completed health check failed."""
|
|
return self._actor.last_health_check_failed
|
|
|
|
@property
|
|
def shutdown_start_time(self) -> Optional[float]:
|
|
"""Returns the start time of the shutdown operation."""
|
|
return self._shutdown_start_time
|
|
|
|
def start(
|
|
self,
|
|
deployment_info: DeploymentInfo,
|
|
assign_rank_callback: Callable[[ReplicaID], ReplicaRank],
|
|
gang_placement_group: Optional[PlacementGroup] = None,
|
|
gang_pg_index: Optional[int] = None,
|
|
gang_context: Optional[GangContext] = None,
|
|
) -> ReplicaSchedulingRequest:
|
|
"""
|
|
Start a new actor for current DeploymentReplica instance.
|
|
|
|
Args:
|
|
deployment_info: Configuration info for the deployment.
|
|
assign_rank_callback: Callback to assign rank to the replica.
|
|
gang_placement_group: Pre-created gang PG to schedule this replica on.
|
|
gang_pg_index: Bundle index within the gang PG for this replica.
|
|
gang_context: Gang context for this replica.
|
|
|
|
Returns:
|
|
ReplicaSchedulingRequest: The scheduling request for the replica.
|
|
"""
|
|
replica_scheduling_request = self._actor.start(
|
|
deployment_info,
|
|
assign_rank_callback=assign_rank_callback,
|
|
gang_placement_group=gang_placement_group,
|
|
gang_pg_index=gang_pg_index,
|
|
gang_context=gang_context,
|
|
)
|
|
self._start_time = time.time()
|
|
self.update_actor_details(start_time_s=self._start_time)
|
|
return replica_scheduling_request
|
|
|
|
def reconfigure(
|
|
self,
|
|
version: DeploymentVersion,
|
|
rank: ReplicaRank,
|
|
) -> bool:
|
|
"""
|
|
Update replica version. Also, updates the deployment config on the actor
|
|
behind this DeploymentReplica instance if necessary.
|
|
|
|
Returns: whether the actor is being updated.
|
|
"""
|
|
return self._actor.reconfigure(version, rank=rank)
|
|
|
|
def recover(self, deployment_info: DeploymentInfo) -> bool:
|
|
"""
|
|
Recover states in DeploymentReplica instance by fetching running actor
|
|
status
|
|
|
|
Args:
|
|
deployment_info: The deployment info for this replica.
|
|
|
|
Returns:
|
|
True if the replica actor is alive and recovered successfully.
|
|
False if the replica actor is no longer alive.
|
|
"""
|
|
# If replica is no longer alive
|
|
if not self._actor.recover(ingress=deployment_info.ingress):
|
|
return False
|
|
|
|
self._start_time = time.time()
|
|
self.update_actor_details(start_time_s=self._start_time)
|
|
return True
|
|
|
|
@property
|
|
def rank(self) -> Optional[ReplicaRank]:
|
|
"""Get the rank assigned to the replica."""
|
|
return self._actor.rank
|
|
|
|
@property
|
|
def gang_context(self) -> Optional[GangContext]:
|
|
"""Get the gang context for this replica."""
|
|
return self._actor.gang_context
|
|
|
|
@property
|
|
def unrecoverable(self) -> bool:
|
|
"""Whether `check_ready()` determined the actor cannot be recovered.
|
|
|
|
When True, the reconciler should drop and replace the replica
|
|
without counting it as a deploy failure (the underlying cause is a
|
|
previous controller crash, not user code).
|
|
"""
|
|
return self._actor.unrecoverable
|
|
|
|
def check_started(
|
|
self,
|
|
) -> Tuple[ReplicaStartupStatus, Optional[str], Optional[float]]:
|
|
"""Check if the replica has started. If so, transition to RUNNING.
|
|
|
|
Should handle the case where the replica has already stopped.
|
|
|
|
Returns:
|
|
status: Most recent state of replica by
|
|
querying actor obj ref
|
|
"""
|
|
is_ready = self._actor.check_ready()
|
|
self.update_actor_details(
|
|
pid=self._actor.pid,
|
|
node_id=self._actor.node_id,
|
|
node_ip=self._actor.node_ip,
|
|
node_instance_id=self._actor.node_instance_id,
|
|
actor_id=self._actor.actor_id,
|
|
worker_id=self._actor.worker_id,
|
|
log_file_path=self._actor.log_file_path,
|
|
)
|
|
|
|
return is_ready
|
|
|
|
def stop(self, graceful: bool = True) -> None:
|
|
"""Stop the replica.
|
|
|
|
Should handle the case where the replica is already stopped.
|
|
"""
|
|
state = self._actor_details.state
|
|
logger.info(
|
|
f"Stopping {self.replica_id} (currently {state}).",
|
|
extra={"log_to_stderr": False},
|
|
)
|
|
self._shutdown_start_time = time.time()
|
|
timeout_s = self._actor.graceful_stop()
|
|
if not graceful:
|
|
timeout_s = 0
|
|
elif self._actor._ingress and RAY_SERVE_ENABLE_DIRECT_INGRESS:
|
|
# In direct ingress mode, ensure we wait at least
|
|
# RAY_SERVE_DIRECT_INGRESS_MIN_DRAINING_PERIOD_S to give external
|
|
# load balancers (e.g., ALB) time to deregister the replica.
|
|
timeout_s = max(timeout_s, RAY_SERVE_DIRECT_INGRESS_MIN_DRAINING_PERIOD_S)
|
|
self._shutdown_deadline = time.time() + timeout_s
|
|
|
|
def check_stopped(self) -> bool:
|
|
"""Check if the replica has finished stopping."""
|
|
if self._actor.check_stopped():
|
|
return True
|
|
|
|
timeout_passed = time.time() >= self._shutdown_deadline
|
|
if timeout_passed:
|
|
logger.info(
|
|
f"{self.replica_id} did not shut down after grace "
|
|
"period, force-killing it."
|
|
)
|
|
self._actor.force_stop()
|
|
return False
|
|
|
|
def check_health(self) -> bool:
|
|
"""Check if the replica is healthy.
|
|
|
|
Returns `True` if the replica is healthy, else `False`.
|
|
"""
|
|
return self._actor.check_health()
|
|
|
|
def pull_routing_stats(self) -> Optional[Dict[str, Any]]:
|
|
"""Get the latest response from the routing stats on the replica.
|
|
|
|
Returns None if the replica is still calculating the stats.
|
|
"""
|
|
return self._actor.get_routing_stats()
|
|
|
|
def update_state(self, state: ReplicaState) -> None:
|
|
"""Updates state in actor details."""
|
|
self.update_actor_details(state=state)
|
|
|
|
_SENTINEL = object()
|
|
|
|
def update_actor_details(self, **kwargs) -> None:
|
|
# Fast path: skip if all provided values are already current.
|
|
# This avoids unnecessary object creation on every tick when the
|
|
# pop-iterate-readd pattern re-adds replicas without state changes.
|
|
# We use _SENTINEL (not None) as the getattr default so that an
|
|
# invalid field name always fails the check and falls through to
|
|
# .copy(), which will raise an appropriate error.
|
|
if all(
|
|
getattr(self._actor_details, k, self._SENTINEL) == v
|
|
for k, v in kwargs.items()
|
|
):
|
|
return
|
|
# Use .model_copy(update=...) instead of .model_dump() + reconstruction
|
|
# to avoid full Pydantic serialization and validation on every update.
|
|
self._actor_details = self._actor_details.model_copy(update=kwargs)
|
|
|
|
def resource_requirements(self) -> Tuple[str, str]:
|
|
"""Returns required and currently available resources.
|
|
|
|
Only resources with nonzero requirements will be included in the
|
|
required dict and only resources in the required dict will be
|
|
included in the available dict (filtered for relevance).
|
|
"""
|
|
if self._actor.actor_resources is None:
|
|
return "UNKNOWN", "UNKNOWN"
|
|
|
|
if self._actor.placement_group_bundles is not None:
|
|
required = self._actor.placement_group_bundles
|
|
else:
|
|
required = {
|
|
k: v
|
|
for k, v in self._actor.actor_resources.items()
|
|
if v is not None and v > 0
|
|
}
|
|
|
|
available = {
|
|
k: v for k, v in self._actor.available_resources.items() if k in required
|
|
}
|
|
|
|
# Use json.dumps() instead of str() here to avoid double-quoting keys
|
|
# when dumping these objects. See
|
|
# https://github.com/ray-project/ray/issues/26210 for the issue.
|
|
return json.dumps(required), json.dumps(available)
|
|
|
|
def get_outbound_deployments(self) -> Optional[List[DeploymentID]]:
|
|
return self._actor.get_outbound_deployments()
|
|
|
|
|
|
class ReplicaStateContainer:
|
|
"""Container for mapping ReplicaStates to lists of DeploymentReplicas."""
|
|
|
|
def __init__(self, on_replica_state_change=None):
|
|
self._replicas: Dict[ReplicaState, List[DeploymentReplica]] = defaultdict(list)
|
|
self._replica_id_index: Dict[ReplicaID, DeploymentReplica] = {}
|
|
self._on_replica_state_change = on_replica_state_change
|
|
|
|
def __getstate__(self):
|
|
# Exclude the callback to keep the container picklable (the callback
|
|
# is a bound method on DeploymentState which holds unpicklable objects
|
|
# like asyncio futures). Used by _dump_replica_states_for_testing.
|
|
state = self.__dict__.copy()
|
|
state["_on_replica_state_change"] = None
|
|
return state
|
|
|
|
def add(self, state: ReplicaState, replica: DeploymentReplica):
|
|
"""Add the provided replica under the provided state.
|
|
|
|
Args:
|
|
state: state to add the replica under.
|
|
replica: replica to add.
|
|
"""
|
|
assert isinstance(state, ReplicaState), f"Type: {type(state)}"
|
|
actor_details = getattr(replica, "actor_details", None)
|
|
old_state = actor_details.state if actor_details is not None else None
|
|
replica.update_state(state)
|
|
self._replicas[state].append(replica)
|
|
self._replica_id_index[replica.replica_id] = replica
|
|
if self._on_replica_state_change and state != old_state:
|
|
self._on_replica_state_change(old_state, state)
|
|
|
|
def get(
|
|
self, states: Optional[List[ReplicaState]] = None
|
|
) -> List[DeploymentReplica]:
|
|
"""Get all replicas of the given states.
|
|
|
|
This does not remove them from the container. Replicas are returned
|
|
in order of state as passed in.
|
|
|
|
Args:
|
|
states: states to consider. If not specified, all replicas
|
|
are considered.
|
|
|
|
Returns:
|
|
The matching replicas, in the same order as ``states``.
|
|
"""
|
|
if states is None:
|
|
return list(self._replica_id_index.values())
|
|
|
|
assert isinstance(states, list)
|
|
|
|
return list(
|
|
itertools.chain.from_iterable(self._replicas[state] for state in states)
|
|
)
|
|
|
|
def get_by_id(self, replica_id: ReplicaID) -> Optional[DeploymentReplica]:
|
|
"""Get a replica by its ID in O(1) time.
|
|
|
|
Args:
|
|
replica_id: the ID of the replica to look up.
|
|
|
|
Returns:
|
|
The DeploymentReplica if found, else None.
|
|
"""
|
|
return self._replica_id_index.get(replica_id)
|
|
|
|
def pop(
|
|
self,
|
|
exclude_version: Optional[DeploymentVersion] = None,
|
|
states: Optional[List[ReplicaState]] = None,
|
|
max_replicas: Optional[int] = math.inf,
|
|
) -> List[DeploymentReplica]:
|
|
"""Get and remove all replicas of the given states.
|
|
|
|
This removes the replicas from the container. Replicas are returned
|
|
in order of state as passed in.
|
|
|
|
Args:
|
|
exclude_version: if specified, replicas of the
|
|
provided version will *not* be removed.
|
|
states: states to consider. If not specified, all replicas
|
|
are considered.
|
|
max_replicas: max number of replicas to return. If not
|
|
specified, will pop all replicas matching the criteria.
|
|
|
|
Returns:
|
|
The removed replicas, in the same order as ``states``.
|
|
"""
|
|
if states is None:
|
|
states = ALL_REPLICA_STATES
|
|
|
|
assert exclude_version is None or isinstance(exclude_version, DeploymentVersion)
|
|
assert isinstance(states, list)
|
|
|
|
replicas = []
|
|
for state in states:
|
|
popped = []
|
|
remaining = []
|
|
|
|
for replica in self._replicas[state]:
|
|
if len(replicas) + len(popped) == max_replicas:
|
|
remaining.append(replica)
|
|
elif exclude_version is not None and replica.version == exclude_version:
|
|
remaining.append(replica)
|
|
else:
|
|
popped.append(replica)
|
|
|
|
self._replicas[state] = remaining
|
|
replicas.extend(popped)
|
|
|
|
for replica in replicas:
|
|
self._replica_id_index.pop(replica.replica_id, None)
|
|
|
|
return replicas
|
|
|
|
def count(
|
|
self,
|
|
exclude_version: Optional[DeploymentVersion] = None,
|
|
version: Optional[DeploymentVersion] = None,
|
|
states: Optional[List[ReplicaState]] = None,
|
|
):
|
|
"""Get the total count of replicas of the given states.
|
|
|
|
Args:
|
|
exclude_version: version to exclude. If not
|
|
specified, all versions are considered.
|
|
version: version to filter to. If not specified,
|
|
all versions are considered.
|
|
states: states to consider. If not specified, all replicas
|
|
are considered.
|
|
|
|
Returns:
|
|
The number of replicas matching the criteria.
|
|
"""
|
|
if states is None:
|
|
states = ALL_REPLICA_STATES
|
|
assert isinstance(states, list)
|
|
assert exclude_version is None or isinstance(exclude_version, DeploymentVersion)
|
|
assert version is None or isinstance(version, DeploymentVersion)
|
|
if exclude_version is None and version is None:
|
|
return sum(len(self._replicas[state]) for state in states)
|
|
elif exclude_version is None and version is not None:
|
|
return sum(
|
|
sum(1 for r in self._replicas[state] if r.version == version)
|
|
for state in states
|
|
)
|
|
elif exclude_version is not None and version is None:
|
|
return sum(
|
|
sum(1 for r in self._replicas[state] if r.version != exclude_version)
|
|
for state in states
|
|
)
|
|
else:
|
|
raise ValueError(
|
|
"Only one of `version` or `exclude_version` may be provided."
|
|
)
|
|
|
|
def remove(self, replica_ids: Set[ReplicaID]) -> List[DeploymentReplica]:
|
|
"""Remove and return all replicas whose IDs are in the given set.
|
|
|
|
Performs a single pass over the container. Non-matching replicas
|
|
stay in place without being re-added (so no spurious
|
|
``update_state`` / ``update_actor_details`` calls).
|
|
|
|
Args:
|
|
replica_ids: collection of ReplicaIDs to remove.
|
|
|
|
Returns:
|
|
The list of removed DeploymentReplicas.
|
|
"""
|
|
replica_ids = set(replica_ids)
|
|
removed = []
|
|
remaining_to_find = len(replica_ids)
|
|
for state in ALL_REPLICA_STATES:
|
|
if remaining_to_find == 0:
|
|
break
|
|
found_any = False
|
|
remaining = []
|
|
for replica in self._replicas[state]:
|
|
if remaining_to_find > 0 and replica.replica_id in replica_ids:
|
|
removed.append(replica)
|
|
remaining_to_find -= 1
|
|
found_any = True
|
|
else:
|
|
remaining.append(replica)
|
|
if found_any:
|
|
self._replicas[state] = remaining
|
|
return removed
|
|
|
|
def __str__(self):
|
|
return str(self._replicas)
|
|
|
|
def __repr__(self):
|
|
return repr(self._replicas)
|
|
|
|
|
|
class RankManager:
|
|
"""Manages ranks for a single node."""
|
|
|
|
def __init__(self):
|
|
self._ranks: Dict[str, int] = {}
|
|
self._released_ranks: Set[int] = set()
|
|
self._next_rank: int = 0
|
|
|
|
def assign_rank(self, key: str) -> int:
|
|
if key in self._ranks:
|
|
raise RuntimeError(f"Rank for {key} already assigned: {self._ranks[key]}")
|
|
|
|
if self._released_ranks:
|
|
# Reuse the smallest released rank
|
|
rank = min(self._released_ranks)
|
|
self._released_ranks.remove(rank)
|
|
else:
|
|
# Assign the next available rank
|
|
# This is the first time we're assigning a rank to this replica
|
|
rank = self._next_rank
|
|
self._next_rank += 1
|
|
|
|
self._ranks[key] = rank
|
|
return rank
|
|
|
|
def release_rank(self, key: str) -> None:
|
|
if key not in self._ranks:
|
|
raise RuntimeError(f"Rank for {key} not assigned")
|
|
rank = self._ranks.pop(key)
|
|
# Add the released rank to the set of released ranks
|
|
# This rank can be reused for a new replica
|
|
self._released_ranks.add(rank)
|
|
|
|
def recover_rank(self, key: str, rank: int) -> None:
|
|
if key in self._ranks:
|
|
raise RuntimeError(f"Rank for {key} already assigned: {self._ranks[key]}")
|
|
self._ranks[key] = rank
|
|
self._released_ranks.discard(rank)
|
|
if rank >= self._next_rank:
|
|
self._next_rank = rank + 1
|
|
|
|
def get_rank(self, key: str) -> int:
|
|
if key not in self._ranks:
|
|
raise RuntimeError(f"Rank for {key} not assigned")
|
|
return self._ranks[key]
|
|
|
|
def has_rank(self, key: str) -> bool:
|
|
return key in self._ranks
|
|
|
|
def get_ranks_mapping(self) -> Dict[str, int]:
|
|
return self._ranks.copy()
|
|
|
|
def clear(self) -> None:
|
|
self._ranks.clear()
|
|
self._released_ranks.clear()
|
|
self._next_rank = 0
|
|
|
|
def check_rank_consistency_and_reassign_minimally(
|
|
self,
|
|
active_keys: List[str],
|
|
) -> List[str]:
|
|
"""Verify rank system invariants and reassign ranks when needed.
|
|
|
|
This method ensures:
|
|
1. All active keys have ranks
|
|
2. No duplicate ranks exist
|
|
3. Ranks are contiguous when at target count
|
|
|
|
Args:
|
|
active_keys: List of currently active keys
|
|
|
|
Returns:
|
|
List of keys that need to be reconfigured with new ranks
|
|
|
|
Raises:
|
|
RuntimeError: If rank system invariants are violated and fail_on_error=True
|
|
"""
|
|
if not active_keys:
|
|
return []
|
|
|
|
active_keys_set = set(active_keys)
|
|
|
|
# Check for stale ranks - this should never happen
|
|
stale_keys = set(self._ranks.keys()) - active_keys_set
|
|
if stale_keys:
|
|
logger.error(
|
|
f"Found stale ranks for keys: {stale_keys}. "
|
|
"This should never happen. Please report this as a bug."
|
|
)
|
|
raise RuntimeError("Rank system is in an invalid state.")
|
|
|
|
# Verify system invariants - all active keys must have ranks
|
|
unranked_keys = active_keys_set - set(self._ranks.keys())
|
|
if unranked_keys:
|
|
logger.error(
|
|
f"Found active keys without ranks: {unranked_keys}. "
|
|
"This should never happen. Please report this as a bug."
|
|
)
|
|
raise RuntimeError("Rank system is in an invalid state.")
|
|
|
|
# Check for duplicate ranks - this should never happen
|
|
rank_counts = {}
|
|
for key, rank in self._ranks.copy().items():
|
|
if key in active_keys_set: # Only check active keys
|
|
rank_counts[rank] = rank_counts.get(rank, 0) + 1
|
|
if rank_counts[rank] > 1:
|
|
logger.error(
|
|
f"Found duplicate rank {rank} assigned to multiple keys. "
|
|
"This should never happen. Please report this as a bug."
|
|
)
|
|
raise RuntimeError("Rank system is in an invalid state.")
|
|
|
|
# Check if we need to reassign ranks for contiguity
|
|
# Only force contiguity when at target count (e.g., after autoscaling down)
|
|
current_ranks = sorted(self._ranks.values())
|
|
expected_ranks = list(range(len(active_keys)))
|
|
|
|
keys_needing_reconfiguration_from_reassignment = []
|
|
|
|
if current_ranks != expected_ranks:
|
|
logger.debug(
|
|
f"At target count but ranks are not contiguous. "
|
|
f"Current: {current_ranks}, Expected: {expected_ranks}. "
|
|
"Performing minimal reassignment."
|
|
)
|
|
keys_needing_reconfiguration_from_reassignment = (
|
|
self._perform_minimal_rank_reassignment(active_keys)
|
|
)
|
|
|
|
return keys_needing_reconfiguration_from_reassignment
|
|
|
|
def _perform_minimal_rank_reassignment(self, active_keys: List[str]) -> List[str]:
|
|
"""Perform minimal rank reassignment to achieve contiguity.
|
|
|
|
This method reassigns ranks while minimizing the number of keys that need
|
|
to be reconfigured. It prioritizes keeping existing ranks when possible.
|
|
|
|
Args:
|
|
active_keys: List of currently active keys
|
|
|
|
Returns:
|
|
List of keys that need to be reconfigured with new ranks
|
|
"""
|
|
target_ranks_set = set(range(len(active_keys)))
|
|
|
|
# Find which keys need new ranks
|
|
keys_needing_ranks = []
|
|
keys_keeping_ranks = []
|
|
|
|
for key in active_keys:
|
|
current_rank = self.get_rank(key)
|
|
|
|
if current_rank in target_ranks_set:
|
|
# This key can keep its rank
|
|
target_ranks_set.remove(current_rank) # O(1) operation
|
|
keys_keeping_ranks.append(key)
|
|
else:
|
|
# This key needs a new rank
|
|
keys_needing_ranks.append(key)
|
|
|
|
# Convert remaining target ranks to sorted list for deterministic assignment
|
|
available_ranks = sorted(target_ranks_set)
|
|
|
|
# Assign new ranks to keys that need them
|
|
for i, key in enumerate(keys_needing_ranks):
|
|
new_rank = available_ranks[i] # O(1) operation
|
|
|
|
# Store the old rank before updating
|
|
old_rank = self._ranks[key]
|
|
|
|
logger.debug(f"Reassigning key {key}: rank {old_rank} -> {new_rank}")
|
|
|
|
# Update the rank mapping
|
|
self._ranks[key] = new_rank
|
|
# Remove the newly assigned rank from available ranks
|
|
self._released_ranks.discard(new_rank)
|
|
# Add the old rank back to available ranks for reuse
|
|
self._released_ranks.add(old_rank)
|
|
|
|
# Log the reassignment summary
|
|
logger.debug(
|
|
f"Minimal reassignment complete: {len(keys_keeping_ranks)} keys kept ranks, "
|
|
f"{len(keys_needing_ranks)} keys reassigned"
|
|
)
|
|
|
|
return keys_needing_ranks
|
|
|
|
|
|
class DeploymentRankManager:
|
|
"""Manages replica ranks for a deployment.
|
|
This class handles rank assignment, release, consistency checking, and reassignment.
|
|
It maintains the rank system invariants and provides a clean interface for rank operations.
|
|
|
|
Maintains three levels of rank tracking:
|
|
- Global rank: Replica-level rank across all nodes (0, 1, 2, ...)
|
|
- Local rank: Replica's rank within its node (0, 1, 2, ... per node)
|
|
- Node rank ID: Index assigned to each node (0, 1, 2, ...)
|
|
"""
|
|
|
|
def __init__(self, fail_on_rank_error: bool = True):
|
|
# Global rank manager (existing replica-level rank)
|
|
self._replica_rank_manager = RankManager()
|
|
self._fail_on_rank_error = fail_on_rank_error
|
|
|
|
# Node rank manager (assigns rank IDs to nodes)
|
|
self._node_rank_manager = RankManager()
|
|
|
|
# Local rank managers (one per node, manages replica ranks within each node)
|
|
self._local_rank_managers: Dict[str, RankManager] = {}
|
|
|
|
# Track which node each replica is on
|
|
self._replica_to_node: Dict[str, str] = {}
|
|
|
|
def _execute_with_error_handling(self, func, safe_default, *args, **kwargs):
|
|
if self._fail_on_rank_error:
|
|
# Let exceptions propagate
|
|
return func(*args, **kwargs)
|
|
else:
|
|
# Catch exceptions and return safe default
|
|
try:
|
|
return func(*args, **kwargs)
|
|
except Exception as e:
|
|
logger.error(f"Error executing function {func.__name__}: {e}")
|
|
return safe_default
|
|
|
|
def assign_rank(self, replica_id: str, node_id: str) -> ReplicaRank:
|
|
"""Assign a rank to a new replica.
|
|
|
|
Args:
|
|
replica_id: The unique ID of the replica
|
|
node_id: The unique ID of the node
|
|
|
|
Returns:
|
|
ReplicaRank object with the assigned rank
|
|
|
|
Raises:
|
|
RuntimeError: If the replica already has a rank assigned
|
|
"""
|
|
|
|
def _assign_rank_impl():
|
|
if self.has_replica_rank(replica_id):
|
|
raise RuntimeError(
|
|
f"Rank for {replica_id} already assigned: {self._replica_rank_manager.get_rank(replica_id)}"
|
|
)
|
|
|
|
# Track the replica-to-node mapping
|
|
self._replica_to_node[replica_id] = node_id
|
|
|
|
# Assign global rank
|
|
rank = self._replica_rank_manager.assign_rank(replica_id)
|
|
|
|
# Assign node rank if this node doesn't have one yet
|
|
if node_id not in self._local_rank_managers:
|
|
self._node_rank_manager.assign_rank(node_id)
|
|
self._local_rank_managers[node_id] = RankManager()
|
|
|
|
node_rank = self._node_rank_manager.get_rank(node_id)
|
|
# Assign local rank within the node
|
|
local_rank = self._local_rank_managers[node_id].assign_rank(replica_id)
|
|
|
|
return ReplicaRank(rank=rank, node_rank=node_rank, local_rank=local_rank)
|
|
|
|
return self._execute_with_error_handling(
|
|
_assign_rank_impl, ReplicaRank(rank=0, node_rank=0, local_rank=0)
|
|
)
|
|
|
|
def release_rank(self, replica_id: str) -> None:
|
|
"""Release rank for a replica.
|
|
|
|
Args:
|
|
replica_id: ID of the replica
|
|
|
|
Raises:
|
|
RuntimeError: If replica doesn't have ranks
|
|
"""
|
|
|
|
def _release_rank_impl():
|
|
if not self.has_replica_rank(replica_id):
|
|
raise RuntimeError(f"Rank for {replica_id} not assigned")
|
|
|
|
# Get the node_id from the replica mapping
|
|
node_id = self._replica_to_node[replica_id]
|
|
|
|
# Release global rank
|
|
self._replica_rank_manager.release_rank(replica_id)
|
|
|
|
# Release local rank
|
|
self._local_rank_managers[node_id].release_rank(replica_id)
|
|
|
|
# Release node rank if this was the last replica on the node
|
|
if len(self._local_rank_managers[node_id].get_ranks_mapping()) == 0:
|
|
self._node_rank_manager.release_rank(node_id)
|
|
del self._local_rank_managers[node_id]
|
|
|
|
# Remove replica from node mapping
|
|
del self._replica_to_node[replica_id]
|
|
|
|
return self._execute_with_error_handling(_release_rank_impl, None)
|
|
|
|
def recover_rank(
|
|
self,
|
|
replica_id: str,
|
|
node_id: str,
|
|
rank: ReplicaRank,
|
|
) -> None:
|
|
"""Recover rank for a replica (e.g., after controller restart).
|
|
|
|
Args:
|
|
replica_id: ID of the replica
|
|
node_id: ID of the node
|
|
rank: The rank to recover
|
|
|
|
Raises:
|
|
RuntimeError: If replica already has ranks assigned
|
|
"""
|
|
|
|
def _recover_rank_impl():
|
|
if self.has_replica_rank(replica_id):
|
|
raise RuntimeError(
|
|
f"Rank for {replica_id} already assigned: {self._replica_rank_manager.get_rank(replica_id)}"
|
|
)
|
|
|
|
# Recover global rank
|
|
self._replica_rank_manager.recover_rank(replica_id, rank.rank)
|
|
|
|
# Recover node rank only if this node doesn't already have one
|
|
if not self._node_rank_manager.has_rank(node_id):
|
|
self._node_rank_manager.recover_rank(node_id, rank.node_rank)
|
|
|
|
# Recover local rank
|
|
if node_id not in self._local_rank_managers:
|
|
self._local_rank_managers[node_id] = RankManager()
|
|
self._local_rank_managers[node_id].recover_rank(replica_id, rank.local_rank)
|
|
|
|
# Track the replica-to-node mapping
|
|
self._replica_to_node[replica_id] = node_id
|
|
|
|
return self._execute_with_error_handling(_recover_rank_impl, None)
|
|
|
|
def has_replica_rank(self, replica_id: str) -> bool:
|
|
"""Check if replica has a rank assigned.
|
|
|
|
Args:
|
|
replica_id: The unique ID of the replica
|
|
|
|
Returns:
|
|
True if the replica has a rank assigned, False otherwise
|
|
|
|
Raises:
|
|
RuntimeError: If the replica doesn't have ranks assigned
|
|
"""
|
|
if replica_id not in self._replica_to_node:
|
|
return False
|
|
|
|
node_id = self._replica_to_node[replica_id]
|
|
return (
|
|
self._replica_rank_manager.has_rank(replica_id)
|
|
and node_id in self._local_rank_managers
|
|
and self._node_rank_manager.has_rank(node_id)
|
|
and self._local_rank_managers[node_id].has_rank(replica_id)
|
|
)
|
|
|
|
def get_replica_rank(self, replica_id: str) -> ReplicaRank:
|
|
"""Get the rank for a replica.
|
|
|
|
Args:
|
|
replica_id: ID of the replica
|
|
|
|
Returns:
|
|
ReplicaRank object
|
|
|
|
Raises:
|
|
RuntimeError: If replica doesn't have ranks assigned
|
|
"""
|
|
|
|
def _get_replica_rank_impl():
|
|
if not self.has_replica_rank(replica_id):
|
|
raise RuntimeError(f"Rank for {replica_id} not assigned")
|
|
|
|
global_rank = self._replica_rank_manager.get_rank(replica_id)
|
|
node_id = self._replica_to_node[replica_id]
|
|
node_rank = self._node_rank_manager.get_rank(node_id)
|
|
local_rank = self._local_rank_managers[node_id].get_rank(replica_id)
|
|
return ReplicaRank(
|
|
rank=global_rank, node_rank=node_rank, local_rank=local_rank
|
|
)
|
|
|
|
return self._execute_with_error_handling(
|
|
_get_replica_rank_impl, ReplicaRank(rank=0, node_rank=0, local_rank=0)
|
|
)
|
|
|
|
def check_rank_consistency_and_reassign_minimally(
|
|
self,
|
|
active_replicas: List["DeploymentReplica"],
|
|
) -> List["DeploymentReplica"]:
|
|
"""Verify rank system invariants and reassign ranks when needed across all three levels.
|
|
|
|
This method ensures:
|
|
1. Global ranks are contiguous [0, N-1] for N replicas
|
|
2. Node ranks are contiguous [0, M-1] for M nodes
|
|
3. Local ranks are contiguous [0, K-1] for K replicas on each node
|
|
|
|
Args:
|
|
active_replicas: List of currently active replicas
|
|
|
|
Returns:
|
|
List of replicas that need to be reconfigured with new ranks
|
|
"""
|
|
|
|
def _check_rank_consistency_impl():
|
|
if not active_replicas:
|
|
return []
|
|
|
|
# Extract replica IDs from replicas
|
|
active_replica_ids = [
|
|
replica.replica_id.unique_id for replica in active_replicas
|
|
]
|
|
|
|
# Create a mapping from replica ID to replica object for quick lookup
|
|
replica_id_to_replica = {
|
|
replica.replica_id.unique_id: replica for replica in active_replicas
|
|
}
|
|
|
|
# Track all replicas needing reconfiguration from any rank system
|
|
all_replica_ids_needing_reconfiguration = set()
|
|
|
|
# STEP 1: Check global rank consistency
|
|
replica_ids_from_global = self._replica_rank_manager.check_rank_consistency_and_reassign_minimally(
|
|
active_replica_ids
|
|
)
|
|
all_replica_ids_needing_reconfiguration.update(replica_ids_from_global)
|
|
|
|
# STEP 2: Group replicas by node and check local rank consistency per node
|
|
replicas_by_node: Dict[str, List[str]] = {}
|
|
for replica_id in active_replica_ids:
|
|
node_id = self._replica_to_node.get(replica_id)
|
|
assert (
|
|
node_id is not None
|
|
), f"Replica {replica_id} not assigned to any node"
|
|
if node_id not in replicas_by_node:
|
|
replicas_by_node[node_id] = []
|
|
replicas_by_node[node_id].append(replica_id)
|
|
|
|
for node_id, replica_ids_on_node in replicas_by_node.items():
|
|
replica_ids_from_local = self._local_rank_managers[
|
|
node_id
|
|
].check_rank_consistency_and_reassign_minimally(replica_ids_on_node)
|
|
all_replica_ids_needing_reconfiguration.update(replica_ids_from_local)
|
|
|
|
# STEP 3: Check node rank consistency
|
|
active_node_ids = list(replicas_by_node.keys())
|
|
if active_node_ids:
|
|
node_ids_needing_reassignment = self._node_rank_manager.check_rank_consistency_and_reassign_minimally(
|
|
active_node_ids,
|
|
)
|
|
# If any nodes were reassigned, all replicas on those nodes need reconfiguration
|
|
for node_id in node_ids_needing_reassignment:
|
|
all_replica_ids_needing_reconfiguration.update(
|
|
replicas_by_node[node_id]
|
|
)
|
|
|
|
# Convert replica IDs back to replica objects
|
|
# Filter out stale replicas that are not in the active set
|
|
replicas_needing_reconfiguration = [
|
|
replica_id_to_replica[replica_id]
|
|
for replica_id in all_replica_ids_needing_reconfiguration
|
|
if replica_id in replica_id_to_replica
|
|
]
|
|
|
|
return replicas_needing_reconfiguration
|
|
|
|
return self._execute_with_error_handling(_check_rank_consistency_impl, [])
|
|
|
|
def clear(self) -> None:
|
|
self._replica_rank_manager.clear()
|
|
self._node_rank_manager.clear()
|
|
self._local_rank_managers.clear()
|
|
self._replica_to_node.clear()
|
|
|
|
def get_replica_ranks_mapping(self) -> Dict[str, ReplicaRank]:
|
|
"""Get the current mapping of replica IDs to ReplicaRank objects.
|
|
|
|
Returns:
|
|
Dict mapping replica_id to ReplicaRank object
|
|
"""
|
|
result = {}
|
|
for replica_id in self._replica_rank_manager.get_ranks_mapping().keys():
|
|
result[replica_id] = self.get_replica_rank(replica_id)
|
|
return result
|
|
|
|
|
|
class DeploymentState:
|
|
"""Manages the target state and replicas for a single deployment."""
|
|
|
|
FORCE_STOP_UNHEALTHY_REPLICAS = RAY_SERVE_FORCE_STOP_UNHEALTHY_REPLICAS
|
|
|
|
def __init__(
|
|
self,
|
|
id: DeploymentID,
|
|
long_poll_host: LongPollHost,
|
|
deployment_scheduler: DeploymentScheduler,
|
|
cluster_node_info_cache: ClusterNodeInfoCache,
|
|
autoscaling_state_manager: AutoscalingStateManager,
|
|
):
|
|
self._id = id
|
|
self._long_poll_host: LongPollHost = long_poll_host
|
|
self._deployment_scheduler = deployment_scheduler
|
|
self._cluster_node_info_cache = cluster_node_info_cache
|
|
self._autoscaling_state_manager = autoscaling_state_manager
|
|
|
|
# Each time we set a new deployment goal, we're trying to save new
|
|
# DeploymentInfo and bring current deployment to meet new status.
|
|
self._target_state: DeploymentTargetState = DeploymentTargetState.default()
|
|
|
|
self._prev_startup_warning: float = time.time()
|
|
self._replica_constructor_error_msg: Optional[str] = None
|
|
# Counter for how many times replicas failed to start. This is reset to 0 when:
|
|
# (1) The deployment is deployed / re-deployed.
|
|
# (2) The deployment reaches the HEALTHY state.
|
|
self._replica_constructor_retry_counter: int = 0
|
|
# Flag for whether any replicas of the target version has successfully started.
|
|
# This is reset to False when the deployment is re-deployed.
|
|
self._replica_has_started: bool = False
|
|
# Set when a deployment-scoped actor fails to start (constructor error).
|
|
# Checked in check_curr_status to transition to DEPLOY_FAILED.
|
|
self._deployment_actor_failed: Optional[str] = None
|
|
# Counter for consecutive deployment actor start failures. Reset on
|
|
# successful actor readiness or re-deploy. After exceeding the
|
|
# threshold, the deployment is considered terminally failed.
|
|
self._deployment_actor_retry_counter: int = 0
|
|
|
|
self._replicas: ReplicaStateContainer = ReplicaStateContainer(
|
|
on_replica_state_change=self._on_replica_state_change
|
|
)
|
|
self._recent_dead_replicas: Deque[ReplicaDetails] = deque(
|
|
maxlen=RAY_SERVE_RETAINED_DEAD_REPLICAS
|
|
)
|
|
self._curr_status_info: DeploymentStatusInfo = DeploymentStatusInfo(
|
|
self._id.name,
|
|
DeploymentStatus.UPDATING,
|
|
DeploymentStatusTrigger.CONFIG_UPDATE_STARTED,
|
|
)
|
|
|
|
self._rank_manager = DeploymentRankManager(
|
|
fail_on_rank_error=RAY_SERVE_FAIL_ON_RANK_ERROR
|
|
)
|
|
|
|
self.replica_average_ongoing_requests: Dict[str, float] = {}
|
|
|
|
# Cache the last-reported health gauge value and timestamp per replica.
|
|
# This avoids redundant Gauge.set() calls on every control loop
|
|
# iteration, which are expensive at scale (O(num_replicas) Cython
|
|
# FFI calls per loop). We only call Gauge.set() when the value
|
|
# changes or the cache entry is older than _HEALTH_GAUGE_REPORT_INTERVAL_S
|
|
# (to ensure the metric is re-exported within each Prometheus scrape window).
|
|
self._health_gauge_cache: Dict[str, Tuple[int, float]] = {}
|
|
self._last_health_check_healthy_replica_ids: Set[str] = set()
|
|
|
|
# Maintain gang membership bookkeeping to avoid O(num_replicas) lookups when stopping gangs.
|
|
# Updated on replica creation during upscaling and permanent removal during downscaling.
|
|
self._gang_id_by_replica: Dict[ReplicaID, str] = {}
|
|
self._replicas_by_gang_id: Dict[str, Set[ReplicaID]] = defaultdict(set)
|
|
|
|
# Deployment-scoped actor lifecycle (per deployment)
|
|
self._deployment_actors = DeploymentActorContainer(self._id)
|
|
|
|
replica_lifecycle_metric_tag_keys = (
|
|
("deployment", "replica", "application")
|
|
if RAY_SERVE_CONTROLLER_METRICS_INCLUDE_HIGH_CARDINALITY_TAGS
|
|
else ("deployment", "application")
|
|
)
|
|
|
|
self.health_check_gauge = metrics.Gauge(
|
|
"serve_deployment_replica_healthy",
|
|
description=(
|
|
"Tracks healthy replicas. When source tags are enabled, each "
|
|
"replica series is 1 for healthy and 0 for unhealthy; otherwise, "
|
|
"the deployment/application series is the healthy replica count."
|
|
),
|
|
tag_keys=replica_lifecycle_metric_tag_keys,
|
|
)
|
|
self.health_check_gauge.set_default_tags(
|
|
{"deployment": self._id.name, "application": self._id.app_name}
|
|
)
|
|
|
|
# Histogram for replica startup latency (time from creation to ready state).
|
|
self.replica_startup_latency_histogram = metrics.Histogram(
|
|
"serve_replica_startup_latency_ms",
|
|
description=("Time from replica creation to ready state in milliseconds."),
|
|
boundaries=REPLICA_STARTUP_SHUTDOWN_LATENCY_BUCKETS_MS,
|
|
tag_keys=("deployment", "application"),
|
|
)
|
|
self.replica_startup_latency_histogram.set_default_tags(
|
|
{"deployment": self._id.name, "application": self._id.app_name}
|
|
)
|
|
|
|
# Histogram for replica initialization latency.
|
|
self.replica_initialization_latency_histogram = metrics.Histogram(
|
|
"serve_replica_initialization_latency_ms",
|
|
description=("Time for replica to initialize in milliseconds."),
|
|
boundaries=REPLICA_STARTUP_SHUTDOWN_LATENCY_BUCKETS_MS,
|
|
tag_keys=("deployment", "application"),
|
|
)
|
|
self.replica_initialization_latency_histogram.set_default_tags(
|
|
{"deployment": self._id.name, "application": self._id.app_name}
|
|
)
|
|
|
|
# Histogram for replica reconfigure latency.
|
|
# NOTE(abrar): value of this metric represents reconfigure + time until next controller loop
|
|
self.replica_reconfigure_latency_histogram = metrics.Histogram(
|
|
"serve_replica_reconfigure_latency_ms",
|
|
description=("Time for replica to complete reconfigure in milliseconds."),
|
|
boundaries=REQUEST_LATENCY_BUCKETS_MS,
|
|
tag_keys=("deployment", "application"),
|
|
)
|
|
self.replica_reconfigure_latency_histogram.set_default_tags(
|
|
{"deployment": self._id.name, "application": self._id.app_name}
|
|
)
|
|
|
|
# Histogram for health check latency.
|
|
self.health_check_latency_histogram = metrics.Histogram(
|
|
"serve_health_check_latency_ms",
|
|
description=("Duration of health check calls in milliseconds."),
|
|
boundaries=REQUEST_LATENCY_BUCKETS_MS,
|
|
tag_keys=("deployment", "application"),
|
|
)
|
|
self.health_check_latency_histogram.set_default_tags(
|
|
{"deployment": self._id.name, "application": self._id.app_name}
|
|
)
|
|
|
|
# Counter for health check failures.
|
|
self.health_check_failures_counter = metrics.Counter(
|
|
"serve_health_check_failures_total",
|
|
description=("Count of failed health checks."),
|
|
tag_keys=replica_lifecycle_metric_tag_keys,
|
|
)
|
|
self.health_check_failures_counter.set_default_tags(
|
|
{"deployment": self._id.name, "application": self._id.app_name}
|
|
)
|
|
|
|
# Histogram for replica shutdown duration.
|
|
self.replica_shutdown_duration_histogram = metrics.Histogram(
|
|
"serve_replica_shutdown_duration_ms",
|
|
description=(
|
|
"Time from shutdown signal to replica fully stopped in milliseconds."
|
|
),
|
|
boundaries=REPLICA_STARTUP_SHUTDOWN_LATENCY_BUCKETS_MS,
|
|
tag_keys=("deployment", "application"),
|
|
)
|
|
self.replica_shutdown_duration_histogram.set_default_tags(
|
|
{"deployment": self._id.name, "application": self._id.app_name}
|
|
)
|
|
|
|
self.target_replicas_gauge = metrics.Gauge(
|
|
"serve_autoscaling_target_replicas",
|
|
description=(
|
|
"The target number of replicas for this deployment. "
|
|
"This is the number the autoscaler is trying to reach."
|
|
),
|
|
tag_keys=("deployment", "application"),
|
|
)
|
|
self.target_replicas_gauge.set_default_tags(
|
|
{"deployment": self._id.name, "application": self._id.app_name}
|
|
)
|
|
|
|
# Whether the request routing info have been updated since the last
|
|
# time we checked.
|
|
self._request_routing_info_updated = False
|
|
|
|
# Dirty flag: set when replicas transition state (start, stop, health
|
|
# check fail, migration) or when availability-related fields change.
|
|
# When False *and* _request_routing_info_updated is False, the
|
|
# broadcast_running_replicas_if_changed() method can skip all work.
|
|
self._broadcasted_replicas_set_changed = True
|
|
|
|
# Reconciliation flag: when False the deployment is in steady state
|
|
# (all replicas RUNNING at target version, status HEALTHY) and
|
|
# expensive per-tick work in check_curr_status(),
|
|
# scale_deployment_replicas(), and the startup/stopping sections of
|
|
# check_and_update_replicas() can be skipped. Health checks on
|
|
# RUNNING/PENDING_MIGRATION replicas always run regardless.
|
|
# Cleared only when check_curr_status() confirms steady state.
|
|
self._in_transition = True
|
|
|
|
self._last_broadcasted_running_replica_infos: List[RunningReplicaInfo] = []
|
|
self._last_broadcasted_availability: Optional[bool] = None
|
|
self._last_broadcasted_deployment_config = None
|
|
|
|
self._docs_path: Optional[str] = None
|
|
self._route_patterns: Optional[List[str]] = None
|
|
|
|
_BROADCAST_STATES = frozenset(
|
|
{ReplicaState.RUNNING, ReplicaState.PENDING_MIGRATION}
|
|
)
|
|
|
|
def _on_replica_state_change(
|
|
self, old_state: ReplicaState, new_state: ReplicaState
|
|
) -> None:
|
|
"""Called by ReplicaStateContainer.add() when a replica transitions."""
|
|
broadcast_set_changed = (old_state in self._BROADCAST_STATES) != (
|
|
new_state in self._BROADCAST_STATES
|
|
)
|
|
if broadcast_set_changed:
|
|
self._broadcasted_replicas_set_changed = True
|
|
self._in_transition = True
|
|
|
|
def should_autoscale(self) -> bool:
|
|
"""
|
|
Check if the deployment is under autoscaling
|
|
"""
|
|
return self._autoscaling_state_manager.should_autoscale_deployment(self._id)
|
|
|
|
def get_checkpoint_data(self) -> DeploymentTargetState:
|
|
"""
|
|
Return deployment's target state submitted by user's deployment call.
|
|
Should be persisted and outlive current ray cluster.
|
|
"""
|
|
return self._target_state
|
|
|
|
def recover_target_state_from_checkpoint(
|
|
self, target_state_checkpoint: DeploymentTargetState
|
|
):
|
|
logger.info(f"Recovering target state for {self._id} from checkpoint.")
|
|
self._target_state = target_state_checkpoint
|
|
self._deployment_scheduler.on_deployment_deployed(
|
|
self._id, self._target_state.info.replica_config
|
|
)
|
|
if self._target_state.info.deployment_config.autoscaling_config:
|
|
self._autoscaling_state_manager.register_deployment(
|
|
self._id,
|
|
self._target_state.info,
|
|
self._target_state.target_num_replicas,
|
|
)
|
|
self._recover_deployment_actors()
|
|
|
|
def recover_current_state_from_replica_actor_names(
|
|
self, replica_actor_names: List[str]
|
|
):
|
|
"""Recover deployment state from live replica actors found in the cluster."""
|
|
|
|
assert self._target_state is not None, (
|
|
"Target state should be recovered successfully first before "
|
|
"recovering current state from replica actor names."
|
|
)
|
|
logger.info(
|
|
f"Recovering current state for {self._id} "
|
|
f"from {len(replica_actor_names)} live actors."
|
|
)
|
|
# All current states use default value, only attach running replicas.
|
|
for replica_actor_name in replica_actor_names:
|
|
replica_id = ReplicaID.from_full_id_str(replica_actor_name)
|
|
new_deployment_replica = DeploymentReplica(
|
|
replica_id,
|
|
self._target_state.version,
|
|
)
|
|
# If replica is no longer alive, simply don't add it to the
|
|
# deployment state manager to track.
|
|
if not new_deployment_replica.recover(self._target_state.info):
|
|
logger.warning(f"{replica_id} died before controller could recover it.")
|
|
continue
|
|
|
|
self._replicas.add(ReplicaState.RECOVERING, new_deployment_replica)
|
|
self._deployment_scheduler.on_replica_recovering(replica_id)
|
|
logger.debug(f"RECOVERING {replica_id}.")
|
|
|
|
def _recover_deployment_actors(self):
|
|
"""Recover deployment-scoped actors that survived a controller restart.
|
|
|
|
Deployment actors are created with ``lifetime="detached"`` and
|
|
``get_if_exists=True``, so they survive across controller restarts.
|
|
After restoring the target state from a checkpoint, probe for each
|
|
expected actor by name. If found, add a wrapper in READY state to
|
|
avoid the unnecessary STARTING delay that would occur if we let
|
|
``start_deployment_actors()`` recreate them via ``get_if_exists``.
|
|
"""
|
|
version = self._target_state.version
|
|
if version is None:
|
|
return
|
|
deployment_actors_configs = self._get_deployment_actors_configs(version)
|
|
if not deployment_actors_configs:
|
|
return
|
|
|
|
code_ver = version.code_version
|
|
recovered = 0
|
|
for cfg in deployment_actors_configs:
|
|
actor_name = get_deployment_actor_name(
|
|
self._id, cfg.name, code_version=code_ver
|
|
)
|
|
try:
|
|
handle = ray.get_actor(actor_name, namespace=SERVE_NAMESPACE)
|
|
except ValueError:
|
|
logger.info(
|
|
f"Deployment actor '{cfg.name}' for {self._id} "
|
|
f"(code_version={code_ver}) not found during recovery; "
|
|
"will be recreated."
|
|
)
|
|
continue
|
|
|
|
wrapper = DeploymentActorWrapper(
|
|
deployment_id=self._id,
|
|
config=cfg,
|
|
code_version=code_ver,
|
|
recovered_handle=handle,
|
|
)
|
|
self._deployment_actors.add(DeploymentActorState.RECOVERING, wrapper)
|
|
recovered += 1
|
|
|
|
if recovered > 0:
|
|
logger.info(
|
|
f"Recovered {recovered}/{len(deployment_actors_configs)} deployment actors "
|
|
f"for {self._id} (code_version={code_ver})."
|
|
)
|
|
|
|
@property
|
|
def target_info(self) -> DeploymentInfo:
|
|
return self._target_state.info
|
|
|
|
@property
|
|
def target_version(self) -> DeploymentVersion:
|
|
return self._target_state.version
|
|
|
|
@property
|
|
def target_num_replicas(self) -> int:
|
|
return self._target_state.target_num_replicas
|
|
|
|
@property
|
|
def curr_status_info(self) -> DeploymentStatusInfo:
|
|
return self._curr_status_info
|
|
|
|
@property
|
|
def deployment_name(self) -> str:
|
|
return self._id.name
|
|
|
|
@property
|
|
def app_name(self) -> str:
|
|
return self._id.app_name
|
|
|
|
@property
|
|
def docs_path(self) -> Optional[str]:
|
|
return self._docs_path
|
|
|
|
@property
|
|
def route_patterns(self) -> Optional[List[str]]:
|
|
return self._route_patterns
|
|
|
|
@property
|
|
def _failed_to_start_threshold(self) -> int:
|
|
return min(
|
|
self._target_state.info.deployment_config.max_constructor_retry_count,
|
|
self._target_state.target_num_replicas * MAX_PER_REPLICA_RETRY_COUNT,
|
|
)
|
|
|
|
@property
|
|
def _deployment_actor_failed_to_start_threshold(self) -> int:
|
|
"""Deployment actor failure threshold. Uses max_constructor_retry_count only.
|
|
|
|
Unlike replica threshold, deployment actors are deployment-scoped so we don't
|
|
scale by target_num_replicas (which would be 0 during scale-to-zero/deletion).
|
|
"""
|
|
return self._target_state.info.deployment_config.max_constructor_retry_count
|
|
|
|
def _get_deployment_actors_configs(
|
|
self, version: Optional[DeploymentVersion] = None
|
|
) -> List[DeploymentActorConfig]:
|
|
"""Return deployment actor configs for the given version, or [] if None."""
|
|
v = version if version is not None else self._target_state.version
|
|
if v is None:
|
|
return []
|
|
return v.deployment_config.deployment_actors or []
|
|
|
|
def _deployment_actors_satisfied_for_target(self) -> bool:
|
|
"""True when every configured deployment-scoped actor is RUNNING for the target.
|
|
|
|
STARTING/RECOVERING do not count: otherwise ``check_curr_status`` could mark
|
|
HEALTHY and clear ``_in_transition`` while ``scale_deployment_replicas`` still
|
|
needs to run ``check_deployment_actors_ready`` to promote pending actors (e.g.
|
|
after a health-check recreation).
|
|
"""
|
|
configs = self._get_deployment_actors_configs()
|
|
if not configs:
|
|
return True
|
|
target_version = self._target_state.version
|
|
if target_version is None:
|
|
return False
|
|
code_ver = target_version.code_version
|
|
expected_names = {cfg.name for cfg in configs}
|
|
running_names = {
|
|
w.actor_logical_name
|
|
for w in self._deployment_actors.get(
|
|
code_ver,
|
|
states=[DeploymentActorState.RUNNING],
|
|
)
|
|
}
|
|
return expected_names == running_names
|
|
|
|
def _replica_startup_failing(self) -> bool:
|
|
"""Check whether replicas are currently failing and the number of
|
|
failures has exceeded a threshold.
|
|
"""
|
|
return (
|
|
self._target_state.target_num_replicas > 0
|
|
and self._replica_constructor_retry_counter
|
|
>= self._failed_to_start_threshold
|
|
)
|
|
|
|
def _terminally_failed(self) -> bool:
|
|
"""Check whether the current version is terminally errored.
|
|
|
|
The version is considered terminally errored if the number of
|
|
replica failures has exceeded a threshold (and there hasn't been
|
|
any replicas of the target version that has successfully started),
|
|
or if deployment-scoped actors have permanently failed to start.
|
|
"""
|
|
replica_failed = (
|
|
not self._replica_has_started and self._replica_startup_failing()
|
|
)
|
|
return replica_failed or self.deployment_actor_terminally_failed()
|
|
|
|
def get_alive_replica_actor_ids(self) -> Set[str]:
|
|
return {replica.actor_id for replica in self._replicas.get()}
|
|
|
|
def get_running_replica_ids(self) -> List[ReplicaID]:
|
|
return [
|
|
replica.replica_id
|
|
for replica in self._replicas.get(
|
|
[ReplicaState.RUNNING, ReplicaState.PENDING_MIGRATION]
|
|
)
|
|
]
|
|
|
|
def get_running_replica_infos(self) -> List[RunningReplicaInfo]:
|
|
return [
|
|
replica.get_running_replica_info(self._cluster_node_info_cache)
|
|
for replica in self._replicas.get(
|
|
[ReplicaState.RUNNING, ReplicaState.PENDING_MIGRATION]
|
|
)
|
|
]
|
|
|
|
def get_num_running_replicas(self, version: DeploymentVersion = None) -> int:
|
|
return self._replicas.count(states=[ReplicaState.RUNNING], version=version)
|
|
|
|
def get_gang_config(self):
|
|
return (
|
|
self._target_state.info.deployment_config.gang_scheduling_config
|
|
if self._target_state is not None
|
|
else None
|
|
)
|
|
|
|
@property
|
|
def _is_gang_deployment(self) -> bool:
|
|
"""Returns True if this deployment uses gang scheduling."""
|
|
return self.get_gang_config() is not None
|
|
|
|
def _get_target_replica_delta(self) -> int:
|
|
"""Calculate delta between target replicas and active replicas."""
|
|
current_replicas = self._replicas.count(
|
|
states=[ReplicaState.STARTING, ReplicaState.UPDATING, ReplicaState.RUNNING]
|
|
)
|
|
recovering_replicas = self._replicas.count(states=[ReplicaState.RECOVERING])
|
|
return (
|
|
self._target_state.target_num_replicas
|
|
- current_replicas
|
|
- recovering_replicas
|
|
)
|
|
|
|
def get_active_node_ids(self) -> Set[str]:
|
|
"""Get the node ids of all running replicas in this deployment.
|
|
|
|
This is used to determine which node has replicas. Only nodes with replicas and
|
|
head node should have active proxies.
|
|
"""
|
|
active_states = [
|
|
ReplicaState.STARTING,
|
|
ReplicaState.UPDATING,
|
|
ReplicaState.RECOVERING,
|
|
ReplicaState.RUNNING,
|
|
# NOTE(zcin): We still want a proxy to run on a draining
|
|
# node before all the replicas are migrated.
|
|
ReplicaState.PENDING_MIGRATION,
|
|
]
|
|
return {
|
|
replica.actor_node_id
|
|
for replica in self._replicas.get(active_states)
|
|
if replica.actor_node_id is not None
|
|
}
|
|
|
|
def list_replica_details(self) -> List[ReplicaDetails]:
|
|
return [replica.actor_details for replica in self._replicas.get()]
|
|
|
|
def list_recent_dead_replicas(self) -> List[ReplicaDetails]:
|
|
return list(self._recent_dead_replicas)
|
|
|
|
def broadcast_running_replicas_if_changed(self) -> None:
|
|
"""Broadcasts the set of running replicas over long poll if it has changed.
|
|
|
|
Keeps an in-memory record of the last set of running replicas that was broadcast
|
|
to determine if it has changed.
|
|
|
|
The set will also be broadcast if any replicas have an updated set of
|
|
multiplexed model IDs.
|
|
|
|
Uses a dirty flag (_broadcasted_replicas_set_changed) to skip all work in steady state
|
|
when no replicas have transitioned and no routing info has been updated.
|
|
RunningReplicaInfo objects are only constructed when a broadcast may
|
|
actually be needed.
|
|
"""
|
|
# Fast path: nothing could have changed, skip entirely.
|
|
if (
|
|
not self._broadcasted_replicas_set_changed
|
|
and not self._request_routing_info_updated
|
|
):
|
|
return
|
|
|
|
# Hold off on broadcasting while replicas are in RECOVERING state to avoid sending
|
|
# partial or empty routable set.
|
|
if self._replicas.count(states=[ReplicaState.RECOVERING]) > 0:
|
|
return
|
|
|
|
running_replica_infos = self.get_running_replica_infos()
|
|
is_available = not self._terminally_failed()
|
|
|
|
running_broadcasted_replicas_set_changed = (
|
|
set(self._last_broadcasted_running_replica_infos)
|
|
!= set(running_replica_infos)
|
|
or self._request_routing_info_updated
|
|
)
|
|
availability_changed = is_available != self._last_broadcasted_availability
|
|
|
|
# Clear the dirty flag now that we've done the comparison.
|
|
self._broadcasted_replicas_set_changed = False
|
|
|
|
if not running_broadcasted_replicas_set_changed and not availability_changed:
|
|
return
|
|
|
|
deployment_metadata = DeploymentTargetInfo(
|
|
is_available=is_available,
|
|
running_replicas=running_replica_infos,
|
|
)
|
|
self._long_poll_host.notify_changed(
|
|
{
|
|
(
|
|
LongPollNamespace.DEPLOYMENT_TARGETS,
|
|
self._id,
|
|
): deployment_metadata,
|
|
# NOTE(zcin): notify changed for Java routers. Since Java only
|
|
# supports 1.x API, there is no concept of applications in Java,
|
|
# so the key should remain a string describing the deployment
|
|
# name. If there are no Java routers, this is a no-op.
|
|
(
|
|
LongPollNamespace.DEPLOYMENT_TARGETS,
|
|
self._id.name,
|
|
): deployment_metadata,
|
|
}
|
|
)
|
|
self._last_broadcasted_running_replica_infos = running_replica_infos
|
|
self._last_broadcasted_availability = is_available
|
|
self._request_routing_info_updated = False
|
|
|
|
def broadcast_deployment_config_if_changed(self) -> None:
|
|
"""Broadcasts the deployment config over long poll if it has changed.
|
|
|
|
Keeps an in-memory record of the last config that was broadcast to determine
|
|
if it has changed.
|
|
"""
|
|
current_deployment_config = self._target_state.info.deployment_config
|
|
if self._last_broadcasted_deployment_config == current_deployment_config:
|
|
return
|
|
|
|
self._long_poll_host.notify_changed(
|
|
{(LongPollNamespace.DEPLOYMENT_CONFIG, self._id): current_deployment_config}
|
|
)
|
|
|
|
self._last_broadcasted_deployment_config = current_deployment_config
|
|
|
|
def _set_target_state_deleting(self) -> None:
|
|
"""Set the target state for the deployment to be deleted."""
|
|
target_state = DeploymentTargetState.create(
|
|
info=self._target_state.info,
|
|
target_num_replicas=0,
|
|
deleting=True,
|
|
)
|
|
|
|
self._target_state = target_state
|
|
self._curr_status_info = self._curr_status_info.handle_transition(
|
|
trigger=DeploymentStatusInternalTrigger.DELETE
|
|
)
|
|
self._broadcasted_replicas_set_changed = True
|
|
self._in_transition = True
|
|
logger.info(
|
|
f"Deleting {self._id}",
|
|
extra={"log_to_stderr": False},
|
|
)
|
|
|
|
def _set_target_state(
|
|
self,
|
|
target_info: DeploymentInfo,
|
|
target_num_replicas: int,
|
|
updated_via_api: bool = False,
|
|
) -> None:
|
|
"""Set the target state for the deployment to the provided info.
|
|
|
|
Args:
|
|
target_info: The info with which to set the target state.
|
|
target_num_replicas: The number of replicas that this deployment
|
|
should attempt to run.
|
|
updated_via_api: Whether the target state update was triggered via API.
|
|
"""
|
|
new_target_state = DeploymentTargetState.create(
|
|
target_info, target_num_replicas, deleting=False
|
|
)
|
|
|
|
if self._target_state.version == new_target_state.version:
|
|
# Record either num replica or autoscaling config lightweight update
|
|
if (
|
|
self._target_state.version.deployment_config.autoscaling_config
|
|
!= new_target_state.version.deployment_config.autoscaling_config
|
|
):
|
|
ServeUsageTag.AUTOSCALING_CONFIG_LIGHTWEIGHT_UPDATED.record("True")
|
|
elif updated_via_api:
|
|
ServeUsageTag.NUM_REPLICAS_VIA_API_CALL_UPDATED.record("True")
|
|
elif (
|
|
self._target_state.version.deployment_config.num_replicas
|
|
!= new_target_state.version.deployment_config.num_replicas
|
|
):
|
|
ServeUsageTag.NUM_REPLICAS_LIGHTWEIGHT_UPDATED.record("True")
|
|
|
|
self._target_state = new_target_state
|
|
self._broadcasted_replicas_set_changed = True
|
|
self._in_transition = True
|
|
|
|
# Emit target replicas metric
|
|
self.target_replicas_gauge.set(target_num_replicas)
|
|
|
|
def deploy(self, deployment_info: DeploymentInfo) -> bool:
|
|
"""Deploy the deployment.
|
|
|
|
If the deployment already exists with the same version, config,
|
|
target_capacity, and target_capacity_direction,
|
|
this method returns False.
|
|
|
|
Args:
|
|
deployment_info: The target deployment info to apply.
|
|
|
|
Returns:
|
|
bool: Whether the target state has changed.
|
|
"""
|
|
curr_deployment_info = self._target_state.info
|
|
if curr_deployment_info is not None:
|
|
# Redeploying should not reset the deployment's start time.
|
|
if not self._target_state.deleting:
|
|
deployment_info.start_time_ms = curr_deployment_info.start_time_ms
|
|
|
|
deployment_settings_changed = (
|
|
self._target_state.deleting
|
|
or curr_deployment_info.deployment_config
|
|
!= deployment_info.deployment_config
|
|
or curr_deployment_info.replica_config.ray_actor_options
|
|
!= deployment_info.replica_config.ray_actor_options
|
|
or curr_deployment_info.route_prefix != deployment_info.route_prefix
|
|
or deployment_info.version is None
|
|
or curr_deployment_info.version != deployment_info.version
|
|
)
|
|
target_capacity_changed = (
|
|
curr_deployment_info.target_capacity != deployment_info.target_capacity
|
|
or curr_deployment_info.target_capacity_direction
|
|
!= deployment_info.target_capacity_direction
|
|
)
|
|
else:
|
|
deployment_settings_changed = True
|
|
target_capacity_changed = True
|
|
|
|
# Exit early if the deployment info hasn't changed. Ensures this method
|
|
# is idempotent.
|
|
if not deployment_settings_changed and not target_capacity_changed:
|
|
# Emit target replicas metric when the deployment info hasn't changed.
|
|
self.target_replicas_gauge.set(self._target_state.target_num_replicas)
|
|
return False
|
|
|
|
logger.debug(f"Deploying '{self._id}': {deployment_info.to_dict()}")
|
|
logger.debug(
|
|
f"Current target state for '{self._id}': "
|
|
f"{self._target_state.info.to_dict() if self._target_state.info is not None else None}"
|
|
)
|
|
|
|
if deployment_info.deployment_config.autoscaling_config:
|
|
target_num_replicas = self._autoscaling_state_manager.register_deployment(
|
|
self._id, deployment_info, self._target_state.target_num_replicas
|
|
)
|
|
else:
|
|
self._autoscaling_state_manager.deregister_deployment(self._id)
|
|
target_num_replicas = get_capacity_adjusted_num_replicas(
|
|
deployment_info.deployment_config.num_replicas,
|
|
deployment_info.target_capacity,
|
|
)
|
|
|
|
old_target_state = self._target_state
|
|
self._set_target_state(deployment_info, target_num_replicas=target_num_replicas)
|
|
self._deployment_scheduler.on_deployment_deployed(
|
|
self._id, deployment_info.replica_config
|
|
)
|
|
|
|
# Determine if the updated target state simply scales the current state.
|
|
# Although the else branch handles the CONFIG_UPDATE, we also take this branch
|
|
# for a config update whose only effect is changing `num_replicas`.
|
|
# Treating it as a scaling event keeps the user-visible deployment status more
|
|
# consistent for observability.
|
|
if self._target_state.is_scaled_copy_of(old_target_state):
|
|
old_num = old_target_state.target_num_replicas
|
|
new_num = self._target_state.target_num_replicas
|
|
|
|
if new_num > old_num:
|
|
self._curr_status_info = self._curr_status_info.handle_transition(
|
|
trigger=DeploymentStatusInternalTrigger.MANUALLY_INCREASE_NUM_REPLICAS, # noqa: E501
|
|
message=f"Upscaling from {old_num} to {new_num} replicas.",
|
|
)
|
|
elif new_num < old_num:
|
|
self._curr_status_info = self._curr_status_info.handle_transition(
|
|
trigger=DeploymentStatusInternalTrigger.MANUALLY_DECREASE_NUM_REPLICAS, # noqa: E501
|
|
message=f"Downscaling from {old_num} to {new_num} replicas.",
|
|
)
|
|
else:
|
|
# Otherwise, the deployment configuration has actually been updated.
|
|
self._curr_status_info = self._curr_status_info.handle_transition(
|
|
trigger=DeploymentStatusInternalTrigger.CONFIG_UPDATE
|
|
)
|
|
|
|
logger.info(
|
|
f"Deploying new version of {self._id} "
|
|
f"(initial target replicas: {target_num_replicas})."
|
|
)
|
|
self._replica_constructor_retry_counter = 0
|
|
self._replica_has_started = False
|
|
self._deployment_actor_failed = None
|
|
self._deployment_actor_retry_counter = 0
|
|
return True
|
|
|
|
def autoscale(self, decision_num_replicas: int) -> bool:
|
|
"""
|
|
Apply the given scaling decision by updating the target replica count.
|
|
|
|
Skips if deleting, if `decision_num_replicas` is None, or matches the
|
|
current target. Otherwise updates the state and logs an up/down scaling.
|
|
|
|
Args:
|
|
decision_num_replicas: target replica count to apply.
|
|
|
|
Returns:
|
|
bool: True if the target state was updated, False if no change occurred.
|
|
"""
|
|
|
|
if self._target_state.deleting:
|
|
return False
|
|
|
|
if decision_num_replicas == self._target_state.target_num_replicas:
|
|
return False
|
|
|
|
new_info = copy(self._target_state.info)
|
|
new_info.version = self._target_state.version.code_version
|
|
|
|
old_num = self._target_state.target_num_replicas
|
|
self._set_target_state(new_info, decision_num_replicas)
|
|
|
|
# The deployment should only transition to UPSCALING/DOWNSCALING
|
|
# if it's within the autoscaling bounds
|
|
if not self._autoscaling_state_manager.is_within_bounds(
|
|
self._id,
|
|
self._replicas.count(
|
|
states=[ReplicaState.RUNNING], version=self._target_state.version
|
|
),
|
|
):
|
|
return True
|
|
|
|
curr_stats_str = (
|
|
f"Current ongoing requests: "
|
|
f"{self._autoscaling_state_manager.get_total_num_requests_for_deployment(self._id):.2f}, "
|
|
f"current running replicas: "
|
|
f"{self._replicas.count(states=[ReplicaState.RUNNING])}."
|
|
)
|
|
new_num = self._target_state.target_num_replicas
|
|
if new_num > old_num:
|
|
logger.info(
|
|
f"Upscaling {self._id} from {old_num} to {new_num} replicas. "
|
|
f"{curr_stats_str}"
|
|
)
|
|
self._curr_status_info = self._curr_status_info.handle_transition(
|
|
trigger=DeploymentStatusInternalTrigger.AUTOSCALE_UP,
|
|
message=f"Upscaling from {old_num} to {new_num} replicas.",
|
|
)
|
|
self._autoscaling_state_manager.record_scale_up(self._id)
|
|
elif new_num < old_num:
|
|
logger.info(
|
|
f"Downscaling {self._id} from {old_num} to {new_num} replicas. "
|
|
f"{curr_stats_str}"
|
|
)
|
|
self._curr_status_info = self._curr_status_info.handle_transition(
|
|
trigger=DeploymentStatusInternalTrigger.AUTOSCALE_DOWN,
|
|
message=f"Downscaling from {old_num} to {new_num} replicas.",
|
|
)
|
|
self._autoscaling_state_manager.record_scale_down(self._id)
|
|
|
|
return True
|
|
|
|
def delete(self) -> bool:
|
|
if not self._target_state.deleting:
|
|
self._set_target_state_deleting()
|
|
return True
|
|
|
|
return False
|
|
|
|
def set_target_num_replicas(
|
|
self,
|
|
target_num_replicas: int,
|
|
) -> None:
|
|
"""Set the target state for the deployment to the provided info."""
|
|
self._set_target_state(
|
|
self._target_state.info, target_num_replicas, updated_via_api=True
|
|
)
|
|
|
|
def _stop_or_update_outdated_version_replicas(
|
|
self, max_to_stop: float = math.inf
|
|
) -> bool:
|
|
"""Stop or update replicas with outdated versions.
|
|
|
|
Stop replicas with versions that require the actor to be restarted, and
|
|
reconfigure replicas that require refreshing deployment config values.
|
|
|
|
For gang-scheduled deployments, replicas that need restarting are
|
|
grouped by gang_id and stopped in complete gangs so that we never
|
|
leave a gang partially torn down.
|
|
|
|
Args:
|
|
max_to_stop: max number of replicas to stop, by default,
|
|
it stops all replicas with an outdated version.
|
|
|
|
Returns:
|
|
Whether any replicas were stopped or reconfigured.
|
|
"""
|
|
replicas_to_update = self._replicas.pop(
|
|
exclude_version=self._target_state.version,
|
|
states=[
|
|
ReplicaState.STARTING,
|
|
ReplicaState.PENDING_MIGRATION,
|
|
ReplicaState.RUNNING,
|
|
],
|
|
)
|
|
replicas_changed = False
|
|
code_version_changes = 0
|
|
reconfigure_changes = 0
|
|
|
|
# Process gang-grouped replicas
|
|
if self._is_gang_deployment:
|
|
need_restart: List[DeploymentReplica] = []
|
|
remaining: List[DeploymentReplica] = []
|
|
for replica in replicas_to_update:
|
|
if replica.version.requires_actor_restart(self._target_state.version):
|
|
need_restart.append(replica)
|
|
else:
|
|
remaining.append(replica)
|
|
|
|
# Group restart-candidates by gang_id.
|
|
gangs: Dict[str, List[DeploymentReplica]] = defaultdict(list)
|
|
for replica in need_restart:
|
|
gangs[replica.gang_context.gang_id].append(replica)
|
|
|
|
# Stop complete gangs atomically within the budget
|
|
for _, gang_replicas in gangs.items():
|
|
expected_size = gang_replicas[0].gang_context.world_size
|
|
if len(gang_replicas) != expected_size:
|
|
# Gang is incomplete (members may be RECOVERING/UPDATING);
|
|
# wait for them to stabilize before tearing down.
|
|
for replica in gang_replicas:
|
|
self._replicas.add(replica.actor_details.state, replica)
|
|
elif code_version_changes + len(gang_replicas) <= max_to_stop:
|
|
for replica in gang_replicas:
|
|
code_version_changes += 1
|
|
# Forcefully stop siblings to avoid partial gangs
|
|
self._stop_replica(replica, graceful_stop=False)
|
|
replicas_changed = True
|
|
else:
|
|
# Not enough budget for this gang; put replicas back
|
|
for replica in gang_replicas:
|
|
self._replicas.add(replica.actor_details.state, replica)
|
|
|
|
# In gang deployments, replicas that only require reconfiguration are processed below.
|
|
# All gang replicas that require actor restart and fit within the max_to_stop budget have already been stopped.
|
|
replicas_to_update = remaining
|
|
|
|
# Per-replica restart/reconfigure handling
|
|
for replica in replicas_to_update:
|
|
if (code_version_changes + reconfigure_changes) >= max_to_stop:
|
|
self._replicas.add(replica.actor_details.state, replica)
|
|
# If the new version requires the actors to be restarted, stop the replica.
|
|
# A new one with the correct version will be started later as part of the
|
|
# normal scale-up process.
|
|
elif replica.version.requires_actor_restart(self._target_state.version):
|
|
code_version_changes += 1
|
|
# If the replica is still `STARTING`, we don't need to go through the
|
|
# graceful stop period.
|
|
graceful_stop = replica.actor_details.state == ReplicaState.RUNNING
|
|
self._stop_replica(replica, graceful_stop=graceful_stop)
|
|
replicas_changed = True
|
|
# Otherwise, only lightweight options in deployment config is a mismatch, so
|
|
# we update it dynamically without restarting the replica.
|
|
elif replica.actor_details.state == ReplicaState.RUNNING:
|
|
reconfigure_changes += 1
|
|
if replica.version.requires_long_poll_broadcast(
|
|
self._target_state.version
|
|
):
|
|
replicas_changed = True
|
|
self._broadcasted_replicas_set_changed = True
|
|
self._in_transition = True
|
|
# Get current rank for the replica
|
|
current_rank = self._rank_manager.get_replica_rank(
|
|
replica.replica_id.unique_id
|
|
)
|
|
actor_updating = replica.reconfigure(
|
|
self._target_state.version, rank=current_rank
|
|
)
|
|
if actor_updating:
|
|
self._replicas.add(ReplicaState.UPDATING, replica)
|
|
else:
|
|
self._replicas.add(ReplicaState.RUNNING, replica)
|
|
# We don't allow going from STARTING, PENDING_MIGRATION to UPDATING.
|
|
else:
|
|
self._replicas.add(replica.actor_details.state, replica)
|
|
|
|
if code_version_changes > 0:
|
|
logger.info(
|
|
f"Stopping {code_version_changes} replicas of {self._id} "
|
|
"with outdated versions."
|
|
)
|
|
|
|
if reconfigure_changes > 0:
|
|
logger.info(
|
|
f"Updating {reconfigure_changes} replicas of {self._id} "
|
|
"with outdated deployment configs."
|
|
)
|
|
# Record user config lightweight update
|
|
ServeUsageTag.USER_CONFIG_LIGHTWEIGHT_UPDATED.record("True")
|
|
|
|
return replicas_changed
|
|
|
|
def _check_and_stop_outdated_version_replicas(self) -> bool:
|
|
"""Stops replicas with outdated versions to implement rolling updates.
|
|
|
|
This includes both explicit code version updates and changes to the
|
|
user_config.
|
|
|
|
Returns whether any replicas were stopped.
|
|
"""
|
|
# Short circuit if target replicas is 0 (the deployment is being
|
|
# deleted) because this will be handled in the main loop.
|
|
if self._target_state.target_num_replicas == 0:
|
|
return False
|
|
|
|
# We include STARTING and UPDATING replicas here
|
|
# because if there are replicas still pending startup, we may as well
|
|
# terminate them and start new version replicas instead.
|
|
old_running_replicas = self._replicas.count(
|
|
exclude_version=self._target_state.version,
|
|
states=[
|
|
ReplicaState.STARTING,
|
|
ReplicaState.UPDATING,
|
|
ReplicaState.RUNNING,
|
|
],
|
|
)
|
|
old_stopping_replicas = self._replicas.count(
|
|
exclude_version=self._target_state.version, states=[ReplicaState.STOPPING]
|
|
)
|
|
new_running_replicas = self._replicas.count(
|
|
version=self._target_state.version, states=[ReplicaState.RUNNING]
|
|
)
|
|
|
|
# If the deployment is currently scaling down, let the scale down
|
|
# complete before doing a rolling update.
|
|
if (
|
|
self._target_state.target_num_replicas
|
|
< old_running_replicas + old_stopping_replicas
|
|
):
|
|
return False
|
|
|
|
# The number of replicas that are currently in transition between
|
|
# an old version and the new version. Note that we cannot directly
|
|
# count the number of stopping replicas because once replicas finish
|
|
# stopping, they are removed from the data structure.
|
|
pending_replicas = (
|
|
self._target_state.target_num_replicas
|
|
- new_running_replicas
|
|
- old_running_replicas
|
|
)
|
|
|
|
# Maximum number of replicas that can be updating at any given time.
|
|
# There should never be more than rollout_size old replicas stopping
|
|
# or rollout_size new replicas starting.
|
|
rolling_update_percentage = (
|
|
self._target_state.info.deployment_config.rolling_update_percentage
|
|
)
|
|
rollout_size = max(
|
|
int(rolling_update_percentage * self._target_state.target_num_replicas), 1
|
|
)
|
|
|
|
# For gang deployments, ensure rollout_size is at least a multiple of
|
|
# gang_size so that we always stop and start complete gangs.
|
|
if self._is_gang_deployment:
|
|
gang_config = self.get_gang_config()
|
|
gs = gang_config.gang_size
|
|
rollout_size = max(rollout_size, gs)
|
|
rollout_size = math.ceil(rollout_size / gs) * gs
|
|
|
|
max_to_stop = max(rollout_size - pending_replicas, 0)
|
|
|
|
return self._stop_or_update_outdated_version_replicas(max_to_stop)
|
|
|
|
def scale_deployment_replicas(
|
|
self,
|
|
gang_placement_groups: Optional[
|
|
Dict[DeploymentID, GangReservationResult]
|
|
] = None,
|
|
) -> Tuple[List[ReplicaSchedulingRequest], DeploymentDownscaleRequest]:
|
|
"""Scale the given deployment to the number of replicas.
|
|
|
|
Args:
|
|
gang_placement_groups: Reserved gang placement groups.
|
|
If this deployment uses gang scheduling and PGs were reserved,
|
|
replicas will be scheduled onto these PGs.
|
|
|
|
Returns:
|
|
Tuple[List[ReplicaSchedulingRequest], DeploymentDownscaleRequest]:
|
|
The scheduling requests for the new replicas and the downscale request.
|
|
"""
|
|
|
|
# Fast path: already at target count with all replicas at target
|
|
# version — no scaling or version updates needed.
|
|
if not self._in_transition:
|
|
return [], None
|
|
|
|
assert (
|
|
self._target_state.target_num_replicas >= 0
|
|
), "Target number of replicas must be greater than or equal to 0."
|
|
|
|
upscale = []
|
|
downscale = None
|
|
|
|
self._check_and_stop_outdated_version_replicas()
|
|
self.stop_deployment_actors_if_needed()
|
|
|
|
# When deployment_actors are configured, start them and wait until ready
|
|
# before creating replicas. Skip during deletion.
|
|
deployment_actors_configs = self._get_deployment_actors_configs()
|
|
if deployment_actors_configs and not self._target_state.deleting:
|
|
self.start_deployment_actors()
|
|
if not self.check_deployment_actors_ready():
|
|
# Deployment actors not ready yet; defer replica creation.
|
|
return (upscale, downscale)
|
|
|
|
delta_replicas = self._get_target_replica_delta()
|
|
if delta_replicas == 0:
|
|
return (upscale, downscale)
|
|
|
|
elif delta_replicas > 0:
|
|
to_add = delta_replicas
|
|
upscale = self._get_upscale_replicas(
|
|
to_add=to_add, gang_placement_groups=gang_placement_groups
|
|
)
|
|
|
|
elif delta_replicas < 0:
|
|
to_remove = -delta_replicas
|
|
gang_id_by_replica = None
|
|
replicas_by_gang_id = None
|
|
|
|
if self._is_gang_deployment:
|
|
gang_id_by_replica = self._gang_id_by_replica
|
|
replicas_by_gang_id = self._replicas_by_gang_id
|
|
|
|
removed_replicas = f"{to_remove} replica{'s' if to_remove > 1 else ''}"
|
|
logger.info(f"Removing {removed_replicas} from {self._id}.")
|
|
downscale = DeploymentDownscaleRequest(
|
|
deployment_id=self._id,
|
|
num_to_stop=to_remove,
|
|
gang_id_by_replica=gang_id_by_replica,
|
|
replicas_by_gang_id=replicas_by_gang_id,
|
|
gang_size=self.get_gang_config().gang_size
|
|
if self._is_gang_deployment
|
|
else None,
|
|
)
|
|
|
|
return upscale, downscale
|
|
|
|
def _get_upscale_replicas(
|
|
self,
|
|
to_add: int,
|
|
gang_placement_groups: Optional[
|
|
Dict[DeploymentID, GangReservationResult]
|
|
] = None,
|
|
) -> List[ReplicaSchedulingRequest]:
|
|
"""Add replicas for this deployment, using gang scheduling when configured."""
|
|
upscale = []
|
|
if to_add <= 0 or self._terminally_failed():
|
|
return upscale
|
|
|
|
if not self._is_gang_deployment:
|
|
return self._add_upscale_replicas(to_add)
|
|
|
|
gang_reservation_result = (
|
|
gang_placement_groups.get(self._id) if gang_placement_groups else None
|
|
)
|
|
return self._add_upscale_gang_replicas(
|
|
self.get_gang_config(), gang_reservation_result
|
|
)
|
|
|
|
def _add_upscale_replicas(self, to_add: int) -> List[ReplicaSchedulingRequest]:
|
|
"""Add replicas for deployments that adopt single-replica (non-gang) scheduling."""
|
|
upscale = []
|
|
logger.info(f"Adding {to_add} replica{'s' * (to_add > 1)} to {self._id}.")
|
|
for _ in range(to_add):
|
|
replica_id = ReplicaID(get_random_string(), deployment_id=self._id)
|
|
|
|
new_deployment_replica = DeploymentReplica(
|
|
replica_id,
|
|
self._target_state.version,
|
|
)
|
|
scheduling_request = new_deployment_replica.start(
|
|
self._target_state.info,
|
|
assign_rank_callback=self._rank_manager.assign_rank,
|
|
)
|
|
upscale.append(scheduling_request)
|
|
|
|
self._replicas.add(ReplicaState.STARTING, new_deployment_replica)
|
|
|
|
return upscale
|
|
|
|
def _add_upscale_gang_replicas(
|
|
self,
|
|
gang_config: GangSchedulingConfig,
|
|
gang_reservation_result: Optional[GangReservationResult],
|
|
) -> List[ReplicaSchedulingRequest]:
|
|
"""Add replicas using gang scheduling with reserved placement groups.
|
|
|
|
Args:
|
|
gang_config: Gang scheduling configuration.
|
|
gang_reservation_result: Gang reservation result with reserved PGs.
|
|
|
|
Returns:
|
|
List of ReplicaSchedulingRequests for the new replicas.
|
|
"""
|
|
upscale = []
|
|
|
|
if gang_reservation_result is None:
|
|
logger.info(
|
|
f"Gang placement group reservation was skipped for {self._id}. "
|
|
"Will retry in the next reconciliation loop."
|
|
)
|
|
return upscale
|
|
|
|
if not gang_reservation_result.success:
|
|
logger.error(
|
|
f"Gang scheduling failed for {self._id}: {gang_reservation_result.error_message}. "
|
|
"Skipping replica creation."
|
|
)
|
|
self.record_replica_startup_failure(
|
|
f"Gang scheduling failed: {gang_reservation_result.error_message} "
|
|
"See Serve controller logs for more details."
|
|
)
|
|
return upscale
|
|
|
|
gang_pgs = gang_reservation_result.gang_pgs
|
|
gang_ids = gang_reservation_result.gang_ids
|
|
gang_pg_names = gang_reservation_result.gang_pg_names
|
|
gang_size = gang_config.gang_size
|
|
num_gangs = len(gang_pgs)
|
|
replicas_to_add = num_gangs * gang_size
|
|
|
|
# When per-replica PG bundles are defined, each replica occupies multiple
|
|
# consecutive bundles in the gang PG. The actor for replica i is placed at
|
|
# bundle i * bundles_per_replica.
|
|
pg_bundles = self._target_state.info.replica_config.placement_group_bundles
|
|
bundles_per_replica = len(pg_bundles) if pg_bundles else 1
|
|
|
|
logger.info(
|
|
f"Adding {replicas_to_add} replica{'s' * (replicas_to_add > 1)} "
|
|
f"to {self._id} using gang scheduling "
|
|
f"(gang_size={gang_size}, {num_gangs} gang(s))."
|
|
)
|
|
|
|
for gang_pg, gang_id, pg_name in zip(gang_pgs, gang_ids, gang_pg_names):
|
|
member_replica_ids = [
|
|
ReplicaID(get_random_string(), deployment_id=self._id)
|
|
for _ in range(gang_size)
|
|
]
|
|
|
|
for bundle_index, replica_id in enumerate(member_replica_ids):
|
|
gang_context = GangContext(
|
|
gang_id=gang_id,
|
|
rank=bundle_index,
|
|
world_size=gang_size,
|
|
member_replica_ids=[r.unique_id for r in member_replica_ids],
|
|
pg_name=pg_name,
|
|
)
|
|
|
|
new_deployment_replica = DeploymentReplica(
|
|
replica_id,
|
|
self._target_state.version,
|
|
)
|
|
|
|
scheduling_request = new_deployment_replica.start(
|
|
self._target_state.info,
|
|
assign_rank_callback=self._rank_manager.assign_rank,
|
|
gang_placement_group=gang_pg,
|
|
gang_pg_index=bundle_index * bundles_per_replica,
|
|
gang_context=gang_context,
|
|
)
|
|
|
|
upscale.append(scheduling_request)
|
|
self._replicas.add(ReplicaState.STARTING, new_deployment_replica)
|
|
self._register_gang_replica(replica_id, gang_id)
|
|
|
|
return upscale
|
|
|
|
def check_curr_status(self) -> Tuple[bool, bool]:
|
|
"""Check the current deployment status.
|
|
|
|
Checks the difference between the target vs. running replica count for
|
|
the target version.
|
|
|
|
This will update the current deployment status depending on the state
|
|
of the replicas.
|
|
|
|
Returns (deleted, any_replicas_recovering).
|
|
"""
|
|
# Fast path: deployment is in steady state — status is already
|
|
# HEALTHY, all replicas are RUNNING at target version. Nothing to do.
|
|
if not self._in_transition:
|
|
return False, False
|
|
|
|
target_version = self._target_state.version
|
|
|
|
any_replicas_recovering = (
|
|
self._replicas.count(states=[ReplicaState.RECOVERING]) > 0
|
|
)
|
|
all_running_replica_cnt = self._replicas.count(states=[ReplicaState.RUNNING])
|
|
all_active_deployment_actors_cnt = self._deployment_actors.count(
|
|
states=[
|
|
DeploymentActorState.RUNNING,
|
|
DeploymentActorState.STARTING,
|
|
DeploymentActorState.RECOVERING,
|
|
]
|
|
)
|
|
running_at_target_version_replica_cnt = self._replicas.count(
|
|
states=[ReplicaState.RUNNING], version=target_version
|
|
)
|
|
|
|
# Got to make a call to complete current deploy() goal after
|
|
# start failure threshold reached, while we might still have
|
|
# pending replicas in current goal.
|
|
if running_at_target_version_replica_cnt > 0:
|
|
# At least one RUNNING replica at target state, partial
|
|
# success; We can stop tracking constructor failures and
|
|
# leave it to the controller to fully scale to target
|
|
# number of replicas and only return as completed once
|
|
# reached target replica count
|
|
self._replica_has_started = True
|
|
# Deployment-scoped actor failed after threshold exceeded (consistent
|
|
# with replica startup: transition only when retries exhausted).
|
|
elif self.deployment_actor_terminally_failed():
|
|
msg = self._deployment_actor_failed
|
|
self._curr_status_info = self._curr_status_info.handle_transition(
|
|
trigger=DeploymentStatusInternalTrigger.DEPLOYMENT_ACTOR_FAILED,
|
|
message=(
|
|
"The deployment failed to start deployment actors "
|
|
f"{self._deployment_actor_retry_counter} times "
|
|
"in a row. See controller logs for details. Error:\n"
|
|
f"{msg}"
|
|
),
|
|
)
|
|
return False, any_replicas_recovering
|
|
elif self._replica_startup_failing():
|
|
self._curr_status_info = self._curr_status_info.handle_transition(
|
|
trigger=DeploymentStatusInternalTrigger.REPLICA_STARTUP_FAILED,
|
|
message=(
|
|
"The deployment failed to start "
|
|
f"{self._replica_constructor_retry_counter} times "
|
|
"in a row. This may be due to a problem with its "
|
|
"constructor or initial health check failing. See "
|
|
"controller logs for details. Error:\n"
|
|
f"{self._replica_constructor_error_msg}"
|
|
),
|
|
)
|
|
return False, any_replicas_recovering
|
|
|
|
# If we have pending ops, the current goal is *not* ready.
|
|
if (
|
|
self._replicas.count(
|
|
states=[
|
|
ReplicaState.STARTING,
|
|
ReplicaState.UPDATING,
|
|
ReplicaState.RECOVERING,
|
|
ReplicaState.STOPPING,
|
|
]
|
|
)
|
|
== 0
|
|
):
|
|
# Deletion can complete when no replicas and no deployment actors
|
|
# (READY or STARTING) remain.
|
|
if (
|
|
self._target_state.deleting
|
|
and all_running_replica_cnt == 0
|
|
and all_active_deployment_actors_cnt == 0
|
|
):
|
|
return True, any_replicas_recovering
|
|
|
|
if (
|
|
# not self._target_state.deleting is important to avoid transitioning to HEALTHY
|
|
# when the deployment is being deleted. This can happen if all replicas are running at the target version,
|
|
# but the deployment is still in the process of being deleted because the deployment actors are not yet
|
|
# deleted.
|
|
not self._target_state.deleting
|
|
and (
|
|
self._target_state.target_num_replicas
|
|
== running_at_target_version_replica_cnt
|
|
and running_at_target_version_replica_cnt == all_running_replica_cnt
|
|
)
|
|
# Stay in transition until deployment actors for obsolete code versions
|
|
# are dropped (see stop_deployment_actors_if_needed); otherwise
|
|
# scale_deployment_replicas would never run cleanup after _in_transition
|
|
# is cleared.
|
|
and not self._orphaned_deployment_actor_code_versions()
|
|
# Require all deployment-scoped actors (target version) to exist so we
|
|
# do not clear _in_transition while actors are missing after a health
|
|
# recycle or similar.
|
|
and self._deployment_actors_satisfied_for_target()
|
|
):
|
|
self._curr_status_info = self._curr_status_info.handle_transition(
|
|
trigger=DeploymentStatusInternalTrigger.HEALTHY
|
|
)
|
|
self._replica_constructor_retry_counter = 0
|
|
# Deployment is in steady state: all replicas RUNNING at
|
|
# target version, no pending operations.
|
|
self._in_transition = False
|
|
return False, any_replicas_recovering
|
|
|
|
return False, any_replicas_recovering
|
|
|
|
def _check_startup_replicas(
|
|
self, original_state: ReplicaState, stop_on_slow: bool = False
|
|
) -> List[Tuple[DeploymentReplica, ReplicaStartupStatus]]:
|
|
"""
|
|
Common helper function for startup actions tracking and status
|
|
transition: STARTING, UPDATING and RECOVERING.
|
|
|
|
Args:
|
|
original_state: The state replicas are transitioning out of.
|
|
stop_on_slow: If we consider a replica failed upon observing it's
|
|
slow to reach running state.
|
|
|
|
Returns:
|
|
The list of replicas considered slow, along with their startup status.
|
|
"""
|
|
slow_replicas = []
|
|
failed_gang_ids: Set[str] = set()
|
|
for replica in self._replicas.pop(states=[original_state]):
|
|
start_status, error_msg = replica.check_started()
|
|
if start_status == ReplicaStartupStatus.SUCCEEDED:
|
|
if original_state == ReplicaState.RECOVERING:
|
|
# If the previous state was RECOVERING, that mean the replica
|
|
# crashed and is now starting up again. We need to recover the rank
|
|
# from the replica actor. The invariant is that the rank is assigned
|
|
# during startup and before the replica is added to the replicas
|
|
# data structure with RUNNING state.
|
|
# Skip if the rank is already assigned (e.g., health-check failure
|
|
# put the replica into RECOVERING without a controller crash, so the
|
|
# rank was never released).
|
|
replica_id = replica.replica_id.unique_id
|
|
if not self._rank_manager.has_replica_rank(replica_id):
|
|
self._rank_manager.recover_rank(
|
|
replica_id, replica.actor_node_id, replica.rank
|
|
)
|
|
# Register recovered gang replicas in the incremental
|
|
# bookkeeping (newly created gang replicas are already
|
|
# registered in _add_upscale_gang_replicas).
|
|
if (
|
|
original_state == ReplicaState.RECOVERING
|
|
and replica.gang_context is not None
|
|
):
|
|
self._register_gang_replica(
|
|
replica.replica_id, replica.gang_context.gang_id
|
|
)
|
|
# This replica should be now be added to handle's replica
|
|
# set.
|
|
self._replicas.add(ReplicaState.RUNNING, replica)
|
|
self._deployment_scheduler.on_replica_running(
|
|
replica.replica_id, replica.actor_node_id
|
|
)
|
|
|
|
# if replica version is the same as the target version,
|
|
# we update the docs path and route patterns
|
|
if replica.version == self._target_state.version:
|
|
self._docs_path = replica.docs_path
|
|
self._route_patterns = replica.route_patterns
|
|
|
|
# Log the startup latency.
|
|
e2e_replica_start_latency = time.time() - replica._start_time
|
|
replica_startup_message = (
|
|
f"{replica.replica_id} started successfully "
|
|
f"on node '{replica.actor_node_id}' after "
|
|
f"{e2e_replica_start_latency:.1f}s (PID: {replica.actor_pid})."
|
|
)
|
|
if replica.initialization_latency_s is not None:
|
|
# This condition should always be True. The initialization
|
|
# latency is only None before the replica has initialized.
|
|
replica_startup_message += (
|
|
" Replica constructor, "
|
|
"reconfigure method, and initial health check took "
|
|
f"{replica.initialization_latency_s:.1f}s."
|
|
)
|
|
logger.info(replica_startup_message, extra={"log_to_stderr": False})
|
|
|
|
# Record startup or reconfigure latency metrics.
|
|
if original_state == ReplicaState.STARTING:
|
|
# Record replica startup latency (end-to-end from creation to ready).
|
|
# This includes the time taken from starting a node, scheduling the replica,
|
|
# and the replica constructor.
|
|
e2e_replica_start_latency_ms = e2e_replica_start_latency * 1000
|
|
self.replica_startup_latency_histogram.observe(
|
|
e2e_replica_start_latency_ms
|
|
)
|
|
# Record replica initialization latency.
|
|
if replica.initialization_latency_s is not None:
|
|
initialization_latency_ms = (
|
|
replica.initialization_latency_s * 1000
|
|
)
|
|
self.replica_initialization_latency_histogram.observe(
|
|
initialization_latency_ms
|
|
)
|
|
elif original_state == ReplicaState.UPDATING:
|
|
# Record replica reconfigure latency.
|
|
if replica.reconfigure_start_time is not None:
|
|
reconfigure_latency_ms = (
|
|
time.time() - replica.reconfigure_start_time
|
|
) * 1000
|
|
self.replica_reconfigure_latency_histogram.observe(
|
|
reconfigure_latency_ms
|
|
)
|
|
|
|
elif start_status == ReplicaStartupStatus.FAILED:
|
|
# Replica reconfigure (deploy / upgrade) failed.
|
|
# When `check_ready()` flagged the replica as unrecoverable
|
|
# (e.g., the previous controller crashed before assigning a
|
|
# rank to it), don't bump the deploy failure counter -- the
|
|
# underlying cause is controller-side, not user code, and a
|
|
# fresh replica will be started in its place. The actor was
|
|
# already killed in `check_ready()`, so force-stop to avoid
|
|
# issuing a graceful shutdown RPC to a dead actor. We still
|
|
# propagate gang failure tracking so siblings get cleaned up.
|
|
if replica.unrecoverable:
|
|
self._stop_replica(replica, graceful_stop=False)
|
|
if replica.gang_context is not None:
|
|
failed_gang_ids.add(replica.gang_context.gang_id)
|
|
continue
|
|
|
|
# For gang replicas, count the failure once per gang and not per replica so the
|
|
# retry counter isn't inflated by gang_size on every cycle.
|
|
if (
|
|
replica.gang_context is None
|
|
or replica.gang_context.gang_id not in failed_gang_ids
|
|
):
|
|
self.record_replica_startup_failure(error_msg)
|
|
|
|
self._stop_replica(replica)
|
|
# Track failed gang IDs for sibling cleanup below.
|
|
if replica.gang_context is not None:
|
|
failed_gang_ids.add(replica.gang_context.gang_id)
|
|
elif start_status in [
|
|
ReplicaStartupStatus.PENDING_ALLOCATION,
|
|
ReplicaStartupStatus.PENDING_INITIALIZATION,
|
|
]:
|
|
is_slow = time.time() - replica._start_time > SLOW_STARTUP_WARNING_S
|
|
if is_slow:
|
|
slow_replicas.append((replica, start_status))
|
|
|
|
# Does it make sense to stop replicas in PENDING_ALLOCATION
|
|
# state?
|
|
if is_slow and stop_on_slow:
|
|
self._stop_replica(replica, graceful_stop=False)
|
|
else:
|
|
self._replicas.add(original_state, replica)
|
|
|
|
# If any gang member failed during startup, stop all other members of
|
|
# that gang so partial gangs never exist.
|
|
if failed_gang_ids:
|
|
for state in {original_state, ReplicaState.RUNNING}:
|
|
for replica in self._replicas.pop(states=[state]):
|
|
if (
|
|
replica.gang_context is not None
|
|
and replica.gang_context.gang_id in failed_gang_ids
|
|
):
|
|
logger.info(
|
|
f"Stopping {replica.replica_id} because a gang "
|
|
f"member failed during startup "
|
|
f"(gang_id={replica.gang_context.gang_id})."
|
|
)
|
|
# Forcefully stop siblings to avoid partial gangs
|
|
self._stop_replica(replica, graceful_stop=False)
|
|
else:
|
|
self._replicas.add(state, replica)
|
|
|
|
return slow_replicas
|
|
|
|
def record_replica_startup_failure(self, error_msg: str):
|
|
"""Record that a replica failed to start."""
|
|
|
|
# There is no need to record replica failures if the target is 0.
|
|
if self._target_state.target_num_replicas == 0:
|
|
return
|
|
|
|
# Increase startup failure counter (may change _terminally_failed()
|
|
# result, which affects broadcasted availability).
|
|
self._replica_constructor_retry_counter += 1
|
|
self._broadcasted_replicas_set_changed = True
|
|
self._in_transition = True
|
|
self._replica_constructor_error_msg = error_msg
|
|
|
|
# Update the deployment message only if replicas are failing during
|
|
# the very first time the controller is trying to start replicas of
|
|
# this version.
|
|
retrying_msg = ""
|
|
if not self._replica_has_started:
|
|
remaining_retries = max(
|
|
self._failed_to_start_threshold
|
|
- self._replica_constructor_retry_counter,
|
|
0,
|
|
)
|
|
retrying_msg = f" {remaining_retries} more time(s)"
|
|
|
|
message = (
|
|
f"A replica failed to start with exception. Retrying{retrying_msg}. "
|
|
f"Error:\n{error_msg}"
|
|
)
|
|
self._curr_status_info = self._curr_status_info.update_message(message)
|
|
|
|
def _set_health_gauge(self, replica_unique_id: str, value: int) -> None:
|
|
"""Set the health-check gauge for *replica_unique_id*, skipping the
|
|
(expensive) Cython ``Gauge.set()`` call when the value hasn't changed
|
|
and was recently reported.
|
|
|
|
In large clusters this avoids O(num_replicas) redundant FFI calls on
|
|
every control-loop iteration while still refreshing the metric often
|
|
enough for Prometheus export.
|
|
"""
|
|
if not RAY_SERVE_CONTROLLER_METRICS_INCLUDE_HIGH_CARDINALITY_TAGS:
|
|
return
|
|
|
|
now = time.time()
|
|
cached = self._health_gauge_cache.get(replica_unique_id)
|
|
if (
|
|
cached is not None
|
|
and cached[0] == value
|
|
and (now - cached[1]) < RAY_SERVE_STATUS_GAUGE_REPORT_INTERVAL_S
|
|
):
|
|
return
|
|
self.health_check_gauge.set(value, tags={"replica": replica_unique_id})
|
|
self._health_gauge_cache[replica_unique_id] = (value, now)
|
|
|
|
def _register_gang_replica(self, replica_id: ReplicaID, gang_id: str) -> None:
|
|
"""Register a replica in the gang membership bookkeeping."""
|
|
self._gang_id_by_replica[replica_id] = gang_id
|
|
self._replicas_by_gang_id[gang_id].add(replica_id)
|
|
|
|
def _unregister_gang_replica(self, replica_id: ReplicaID) -> None:
|
|
"""Remove a replica from the gang membership bookkeeping."""
|
|
gang_id = self._gang_id_by_replica.pop(replica_id, None)
|
|
if gang_id is not None:
|
|
members = self._replicas_by_gang_id.get(gang_id)
|
|
if members is not None:
|
|
members.discard(replica_id)
|
|
if not members:
|
|
self._replicas_by_gang_id.pop(gang_id, None)
|
|
|
|
def _clear_health_gauge_cache(self, replica_unique_id: str) -> None:
|
|
"""Remove a replica from the health-gauge cache (after it has
|
|
fully stopped and been removed from tracking)."""
|
|
self._health_gauge_cache.pop(replica_unique_id, None)
|
|
|
|
def stop_replicas(self, replicas_to_stop: Set[ReplicaID]) -> None:
|
|
for replica in self._replicas.remove(replicas_to_stop):
|
|
self._stop_replica(replica)
|
|
|
|
def _stop_replica(self, replica: DeploymentReplica, graceful_stop=True):
|
|
"""Stop replica
|
|
1. Stop the replica.
|
|
2. Change the replica into stopping state.
|
|
3. Set the health replica stats to 0.
|
|
"""
|
|
logger.debug(f"Adding STOPPING to replica: {replica.replica_id}.")
|
|
replica.stop(graceful=graceful_stop)
|
|
self._replicas.add(ReplicaState.STOPPING, replica)
|
|
self._deployment_scheduler.on_replica_stopping(replica.replica_id)
|
|
if RAY_SERVE_CONTROLLER_METRICS_INCLUDE_HIGH_CARDINALITY_TAGS:
|
|
self._set_health_gauge(replica.replica_id.unique_id, 0)
|
|
else:
|
|
self._last_health_check_healthy_replica_ids.discard(
|
|
replica.replica_id.unique_id
|
|
)
|
|
self.health_check_gauge.set(
|
|
len(self._last_health_check_healthy_replica_ids)
|
|
)
|
|
|
|
def _stop_replica_mark_unhealthy_if_target_version(
|
|
self, replica: DeploymentReplica, graceful_stop: bool
|
|
):
|
|
"""Stop the replica and mark deployment as UNHEALTHY if the replica is the target version."""
|
|
self._stop_replica(replica, graceful_stop=graceful_stop)
|
|
if replica.version == self._target_state.version:
|
|
self._curr_status_info = self._curr_status_info.handle_transition(
|
|
trigger=DeploymentStatusInternalTrigger.HEALTH_CHECK_FAILED,
|
|
message="A replica's health check failed. This "
|
|
"deployment will be UNHEALTHY until the replica "
|
|
"recovers or a new deploy happens.",
|
|
)
|
|
|
|
def _forcefully_stop_gang_replicas(
|
|
self,
|
|
healthy_replicas: List[DeploymentReplica],
|
|
unhealthy_replicas: List[DeploymentReplica],
|
|
) -> Tuple[List[DeploymentReplica], List[DeploymentReplica]]:
|
|
"""Forcefully stop all replicas in gangs that have any unhealthy or missing member.
|
|
|
|
Under the RESTART_GANG policy, when any replica in a gang fails its health check,
|
|
every member of that gang, including healthy ones, must be torn down so the gang
|
|
can be rescheduled atomically.
|
|
|
|
A gang is also restarted when any of its expected members, listed in
|
|
gang_context.member_replica_ids, are missing entirely from the replica manager.
|
|
This handles the case where a gang member dies while the controller is down:
|
|
on recovery, the dead member is never tracked, leaving an incomplete gang that
|
|
would block upscaling (the replica deficit would not be divisible by gang_size).
|
|
|
|
This differs from normal (single-replica scheduling) unhealthy-replica handling
|
|
in two ways:
|
|
|
|
1. Healthy siblings are also force-stopped.
|
|
2. Force-stop is always used (graceful_stop=False), regardless
|
|
of the FORCE_STOP_UNHEALTHY_REPLICAS setting.
|
|
|
|
Args:
|
|
healthy_replicas: A list of healthy replicas.
|
|
unhealthy_replicas: A list of unhealthy replicas.
|
|
|
|
Returns:
|
|
A (remaining_healthy, remaining_unhealthy) tuple containing only
|
|
replicas that follow single-replica scheduling logic.
|
|
"""
|
|
gang_ids_to_restart: Set[str] = {
|
|
replica.gang_context.gang_id
|
|
for replica in unhealthy_replicas
|
|
if replica.gang_context is not None
|
|
}
|
|
|
|
# Detect incomplete gangs: gang members that are missing entirely from
|
|
# the replica manager. The healthy/unhealthy lists were popped from
|
|
# self._replicas in check_and_update_replicas, so we must include them
|
|
# explicitly to get the full set of tracked replica IDs.
|
|
all_tracked_replica_ids: Set[str] = (
|
|
{replica.replica_id.unique_id for replica in self._replicas.get()}
|
|
| {replica.replica_id.unique_id for replica in healthy_replicas}
|
|
| {replica.replica_id.unique_id for replica in unhealthy_replicas}
|
|
)
|
|
|
|
for replica in healthy_replicas:
|
|
gc = replica.gang_context
|
|
if gc is None or gc.gang_id in gang_ids_to_restart:
|
|
continue
|
|
for member_id in gc.member_replica_ids:
|
|
if member_id not in all_tracked_replica_ids:
|
|
gang_ids_to_restart.add(gc.gang_id)
|
|
break
|
|
|
|
if len(gang_ids_to_restart) == 0:
|
|
return healthy_replicas, unhealthy_replicas
|
|
|
|
remaining_healthy: List[DeploymentReplica] = []
|
|
for replica in healthy_replicas:
|
|
if (
|
|
replica.gang_context is not None
|
|
and replica.gang_context.gang_id in gang_ids_to_restart
|
|
):
|
|
logger.warning(
|
|
f"Replica {replica.replica_id} belongs to gang "
|
|
f"(gang_id={replica.gang_context.gang_id}) that has an "
|
|
"unhealthy or missing member. Forcefully stopping it "
|
|
"because RESTART_GANG runtime failure policy is enabled."
|
|
)
|
|
self._stop_replica_mark_unhealthy_if_target_version(replica, False)
|
|
else:
|
|
remaining_healthy.append(replica)
|
|
|
|
remaining_unhealthy: List[DeploymentReplica] = []
|
|
for replica in unhealthy_replicas:
|
|
if (
|
|
replica.gang_context is not None
|
|
and replica.gang_context.gang_id in gang_ids_to_restart
|
|
):
|
|
logger.warning(
|
|
f"Replica {replica.replica_id} failed health check, "
|
|
"forcefully stopping it as part of gang restart "
|
|
f"(gang_id={replica.gang_context.gang_id})."
|
|
)
|
|
self._stop_replica_mark_unhealthy_if_target_version(replica, False)
|
|
else:
|
|
remaining_unhealthy.append(replica)
|
|
|
|
return remaining_healthy, remaining_unhealthy
|
|
|
|
def _record_health_check_metrics(self, replica) -> None:
|
|
"""Record health-check latency + failure metrics for one replica.
|
|
|
|
Shared by the in-place path and the pop/re-add (gang) path
|
|
in ``check_and_update_replicas``.
|
|
"""
|
|
if replica.last_health_check_latency_ms is not None:
|
|
self.health_check_latency_histogram.observe(
|
|
replica.last_health_check_latency_ms
|
|
)
|
|
if replica.last_health_check_failed:
|
|
if RAY_SERVE_CONTROLLER_METRICS_INCLUDE_HIGH_CARDINALITY_TAGS:
|
|
self.health_check_failures_counter.inc(
|
|
tags={"replica": replica.replica_id.unique_id}
|
|
)
|
|
else:
|
|
self.health_check_failures_counter.inc()
|
|
|
|
def _process_healthy_replica(self, replica) -> None:
|
|
"""Set the health gauge and pull/broadcast routing stats for a healthy replica.
|
|
|
|
Container re-bucketing differs between the two reconcile paths, so it is
|
|
left to the caller.
|
|
"""
|
|
self._set_health_gauge(replica.replica_id.unique_id, 1)
|
|
routing_stats = replica.pull_routing_stats()
|
|
if routing_stats is not None and routing_stats != replica.routing_stats:
|
|
self._broadcasted_replicas_set_changed = True
|
|
replica.record_routing_stats(routing_stats)
|
|
|
|
def _stop_unhealthy_replica(self, replica) -> None:
|
|
"""Log and stop a replica that failed its health check.
|
|
|
|
The caller removes it from ``self._replicas`` first -- the pop/re-add path
|
|
already popped it; the in-place path batch-removes the whole unhealthy set.
|
|
"""
|
|
logger.warning(
|
|
f"Replica {replica.replica_id} failed health check, stopping it."
|
|
)
|
|
graceful = not self.FORCE_STOP_UNHEALTHY_REPLICAS
|
|
self._stop_replica_mark_unhealthy_if_target_version(replica, graceful)
|
|
|
|
def check_and_update_replicas(self):
|
|
"""
|
|
Check current state of all DeploymentReplica being tracked, and compare
|
|
with state container from previous update() cycle to see if any state
|
|
transition happened.
|
|
"""
|
|
|
|
healthy_replicas: List[DeploymentReplica] = []
|
|
unhealthy_replicas: List[DeploymentReplica] = []
|
|
|
|
# Profile-guided: for the common non-gang case, iterate
|
|
# RUNNING/PENDING_MIGRATION IN PLACE. Healthy replicas that stay in their state
|
|
# bucket are never popped+re-added -> eliminates the O(num_replicas) container
|
|
# churn on the control loop at scale. Gang deployments fall back to the
|
|
# original pop/re-add path (their force-stop reshuffles the lists).
|
|
if not self._is_gang_deployment:
|
|
origin: List[ReplicaState] = []
|
|
pairs = [
|
|
(replica, st)
|
|
for st in (ReplicaState.RUNNING, ReplicaState.PENDING_MIGRATION)
|
|
for replica in self._replicas.get([st])
|
|
]
|
|
healths = [replica.check_health() for replica, _ in pairs]
|
|
for (replica, st), is_healthy in zip(pairs, healths):
|
|
self._record_health_check_metrics(replica)
|
|
if is_healthy:
|
|
healthy_replicas.append(replica)
|
|
origin.append(st)
|
|
else:
|
|
unhealthy_replicas.append(replica)
|
|
for replica, st in zip(healthy_replicas, origin):
|
|
self._process_healthy_replica(replica)
|
|
# Re-bucket a healthy replica only if its state changed -- avoiding the
|
|
# pop/re-add churn is the whole point of the in-place path.
|
|
# actor_details.state is only set via ReplicaStateContainer.add() and
|
|
# check_health() never transitions state, so for RUNNING/PENDING_MIGRATION
|
|
# this is a no-op today; kept as a defensive guard so the in-place path
|
|
# stays behavior-identical to the pop/re-add path if that ever changes.
|
|
if replica.actor_details.state != st:
|
|
self._replicas.remove({replica.replica_id})
|
|
self._replicas.add(replica.actor_details.state, replica)
|
|
# Batch-remove all unhealthy replicas in a single O(num_replicas) pass;
|
|
# a per-replica remove() would be O(unhealthy * num_replicas) -> O(N^2)
|
|
# during mass health-check failures (e.g. a node/AZ outage).
|
|
self._replicas.remove(
|
|
{replica.replica_id for replica in unhealthy_replicas}
|
|
)
|
|
for replica in unhealthy_replicas:
|
|
self._stop_unhealthy_replica(replica)
|
|
else:
|
|
for replica in self._replicas.pop(
|
|
states=[ReplicaState.RUNNING, ReplicaState.PENDING_MIGRATION]
|
|
):
|
|
is_healthy = replica.check_health()
|
|
self._record_health_check_metrics(replica)
|
|
if is_healthy:
|
|
healthy_replicas.append(replica)
|
|
else:
|
|
unhealthy_replicas.append(replica)
|
|
|
|
# Under the RESTART_GANG policy, force-stop all members of any gang that has at
|
|
# least one unhealthy replica. Replicas handled here are removed from the lists;
|
|
# remaining replicas continue to respect FORCE_STOP_UNHEALTHY_REPLICAS.
|
|
if (
|
|
self._is_gang_deployment
|
|
and self.get_gang_config().runtime_failure_policy
|
|
== GangRuntimeFailurePolicy.RESTART_GANG
|
|
):
|
|
(
|
|
healthy_replicas,
|
|
unhealthy_replicas,
|
|
) = self._forcefully_stop_gang_replicas(
|
|
healthy_replicas, unhealthy_replicas
|
|
)
|
|
|
|
for replica in healthy_replicas:
|
|
self._replicas.add(replica.actor_details.state, replica)
|
|
self._process_healthy_replica(replica)
|
|
|
|
# Only single-replica scheduling replicas remain.
|
|
for replica in unhealthy_replicas:
|
|
self._stop_unhealthy_replica(replica)
|
|
|
|
# In steady state there are no STARTING/UPDATING/RECOVERING/STOPPING
|
|
# replicas, so skip startup/stopping checks. The rank consistency
|
|
# check below still runs (it has its own lightweight guard).
|
|
if self._in_transition:
|
|
self._check_and_update_transitioning_replicas()
|
|
|
|
if not RAY_SERVE_CONTROLLER_METRICS_INCLUDE_HIGH_CARDINALITY_TAGS:
|
|
# When the replica tag is disabled, this is a single
|
|
# deployment/application series. Emit the count of replicas that
|
|
# passed health checks in this iteration so newly promoted replicas
|
|
# are not counted before their first successful health check.
|
|
self._last_health_check_healthy_replica_ids = {
|
|
replica.replica_id.unique_id for replica in healthy_replicas
|
|
}
|
|
self.health_check_gauge.set(
|
|
len(self._last_health_check_healthy_replica_ids)
|
|
)
|
|
|
|
# After replica state updates, check rank consistency and perform minimal reassignment if needed
|
|
# This ensures ranks are continuous after lifecycle events
|
|
# Only do consistency check when deployment is stable (not during active updates)
|
|
# maybe this constraint need to be relaxed in the future. The implication is that
|
|
# if we delay the rank reassignment, the rank system will be in an invalid state
|
|
# for a longer period of time. Abrar made this decision because he is not confident
|
|
# about how rollouts work in the deployment state machine.
|
|
active_replicas = self._replicas.get()
|
|
if (
|
|
active_replicas
|
|
and self._curr_status_info.status == DeploymentStatus.HEALTHY
|
|
# Skip consistency check if there are STARTING replicas. During node
|
|
# migration, new replicas are created in STARTING state (without ranks)
|
|
# after the status is set to HEALTHY. Running the consistency check
|
|
# with STARTING replicas causes "active keys without ranks" error.
|
|
and self._replicas.count(states=[ReplicaState.STARTING]) == 0
|
|
):
|
|
replicas_to_reconfigure = (
|
|
self._rank_manager.check_rank_consistency_and_reassign_minimally(
|
|
active_replicas,
|
|
)
|
|
)
|
|
|
|
# Reconfigure replicas that had their ranks reassigned
|
|
self._reconfigure_replicas_with_new_ranks(replicas_to_reconfigure)
|
|
|
|
def _handle_deployment_actor_failed_health_check(
|
|
self,
|
|
wrapper: DeploymentActorWrapper,
|
|
*,
|
|
actor_name: str,
|
|
) -> None:
|
|
"""Stop an actor after failed health polling; separate from startup retries.
|
|
|
|
Matches replica semantics in ``_stop_replica_mark_unhealthy_if_target_version``:
|
|
target-version failures mark the deployment UNHEALTHY and set
|
|
``_in_transition`` so ``start_deployment_actors`` can recreate; older code
|
|
versions are only stopped (no ``_deployment_actor_retry_counter``).
|
|
"""
|
|
detail = (
|
|
f"Deployment actor '{actor_name}' failed health checks "
|
|
f"({DEPLOYMENT_ACTOR_HEALTH_CHECK_UNHEALTHY_THRESHOLD} consecutive "
|
|
"failures or actor crash); recreating. "
|
|
"Replicas should call serve.get_deployment_actor() again if they "
|
|
"cached a stale ActorHandle."
|
|
)
|
|
logger.warning(f"{detail} deployment_id={self._id}")
|
|
wrapper.kill()
|
|
target_version = self._target_state.version
|
|
if (
|
|
target_version is not None
|
|
and wrapper.code_version == target_version.code_version
|
|
):
|
|
self._in_transition = True
|
|
self._curr_status_info = self._curr_status_info.handle_transition(
|
|
trigger=DeploymentStatusInternalTrigger.HEALTH_CHECK_FAILED,
|
|
message=(
|
|
"A deployment actor's health check failed. This deployment will be "
|
|
"UNHEALTHY until the actor is recreated or a new deploy happens."
|
|
),
|
|
)
|
|
|
|
def check_and_update_deployment_actors(self) -> None:
|
|
"""Poll all RUNNING deployment-scoped actors (every code version), like replicas.
|
|
|
|
Failed health checks kill the actor. Target-version actors are recreated via
|
|
``start_deployment_actors`` without consuming ``_deployment_actor_retry_counter``.
|
|
"""
|
|
if self._target_state.deleting:
|
|
return
|
|
if not self._get_deployment_actors_configs():
|
|
return
|
|
if self.deployment_actor_terminally_failed():
|
|
return
|
|
|
|
removed = self._deployment_actors.pop(
|
|
states=[DeploymentActorState.RUNNING],
|
|
)
|
|
for _, entry in removed:
|
|
wrapper = entry.wrapper
|
|
if wrapper.check_health():
|
|
self._deployment_actors.add(DeploymentActorState.RUNNING, wrapper)
|
|
else:
|
|
self._handle_deployment_actor_failed_health_check(
|
|
wrapper,
|
|
actor_name=wrapper.actor_logical_name,
|
|
)
|
|
|
|
def _check_and_update_transitioning_replicas(self):
|
|
"""Check STARTING/UPDATING/RECOVERING/STOPPING replicas for state transitions."""
|
|
|
|
slow_start_replicas = []
|
|
slow_start = self._check_startup_replicas(ReplicaState.STARTING)
|
|
slow_update = self._check_startup_replicas(ReplicaState.UPDATING)
|
|
slow_recover = self._check_startup_replicas(
|
|
ReplicaState.RECOVERING, stop_on_slow=True
|
|
)
|
|
|
|
slow_start_replicas = slow_start + slow_update + slow_recover
|
|
|
|
if (
|
|
len(slow_start_replicas)
|
|
and time.time() - self._prev_startup_warning > SLOW_STARTUP_WARNING_PERIOD_S
|
|
):
|
|
pending_allocation = []
|
|
pending_initialization = []
|
|
|
|
for replica, startup_status in slow_start_replicas:
|
|
if startup_status == ReplicaStartupStatus.PENDING_ALLOCATION:
|
|
pending_allocation.append(replica)
|
|
if startup_status == ReplicaStartupStatus.PENDING_INITIALIZATION:
|
|
pending_initialization.append(replica)
|
|
|
|
if len(pending_allocation) > 0:
|
|
required, available = pending_allocation[0].resource_requirements()
|
|
message = (
|
|
f"Deployment '{self.deployment_name}' in application "
|
|
f"'{self.app_name}' has {len(pending_allocation)} replicas that "
|
|
f"have taken more than {SLOW_STARTUP_WARNING_S}s to be scheduled. "
|
|
"This may be due to waiting for the cluster to auto-scale or for a "
|
|
"runtime environment to be installed. "
|
|
f"Resources required for each replica: {required}, "
|
|
f"total resources available: {available}. "
|
|
"Use `ray status` for more details."
|
|
)
|
|
logger.warning(message)
|
|
if _SCALING_LOG_ENABLED:
|
|
print_verbose_scaling_log()
|
|
# If status is UNHEALTHY, leave the status and message as is.
|
|
# The issue that caused the deployment to be unhealthy should be
|
|
# prioritized over this resource availability issue.
|
|
if self._curr_status_info.status not in [
|
|
DeploymentStatus.UNHEALTHY,
|
|
DeploymentStatus.DEPLOY_FAILED,
|
|
]:
|
|
self._curr_status_info = self._curr_status_info.update_message(
|
|
message
|
|
)
|
|
|
|
if len(pending_initialization) > 0:
|
|
message = (
|
|
f"Deployment '{self.deployment_name}' in application "
|
|
f"'{self.app_name}' has {len(pending_initialization)} replicas "
|
|
f"that have taken more than {SLOW_STARTUP_WARNING_S}s to "
|
|
"initialize.\n"
|
|
"This may be caused by a slow __init__ or reconfigure method."
|
|
)
|
|
logger.warning(message)
|
|
# If status is UNHEALTHY, leave the status and message as is.
|
|
# The issue that caused the deployment to be unhealthy should be
|
|
# prioritized over this resource availability issue.
|
|
if self._curr_status_info.status not in [
|
|
DeploymentStatus.UNHEALTHY,
|
|
DeploymentStatus.DEPLOY_FAILED,
|
|
]:
|
|
self._curr_status_info = self._curr_status_info.update_message(
|
|
message
|
|
)
|
|
|
|
self._prev_startup_warning = time.time()
|
|
|
|
for replica in self._replicas.pop(states=[ReplicaState.STOPPING]):
|
|
stopped = replica.check_stopped()
|
|
if not stopped:
|
|
self._replicas.add(ReplicaState.STOPPING, replica)
|
|
else:
|
|
logger.info(f"{replica.replica_id} is stopped.")
|
|
|
|
# Retain replicas that allocated a log file so the dashboard can
|
|
# still show their logs after the actor is gone.
|
|
if replica.actor_details.log_file_path is not None:
|
|
self._recent_dead_replicas.append(
|
|
replica.actor_details.model_copy(
|
|
update={"state": ReplicaState.STOPPED}
|
|
)
|
|
)
|
|
|
|
# Record shutdown duration metric.
|
|
if replica.shutdown_start_time is not None:
|
|
shutdown_duration_ms = (
|
|
time.time() - replica.shutdown_start_time
|
|
) * 1000
|
|
self.replica_shutdown_duration_histogram.observe(
|
|
shutdown_duration_ms
|
|
)
|
|
|
|
# Release rank only after replica is successfully stopped
|
|
# This ensures rank is available during draining/graceful shutdown
|
|
replica_id = replica.replica_id.unique_id
|
|
self._clear_health_gauge_cache(replica_id)
|
|
if self._rank_manager.has_replica_rank(replica_id):
|
|
# Only release rank if assigned. Replicas that failed allocation
|
|
# or never reached RUNNING state won't have ranks.
|
|
self._rank_manager.release_rank(replica_id)
|
|
logger.debug(
|
|
f"Released rank from replica {replica_id} in deployment {self._id}"
|
|
)
|
|
self._autoscaling_state_manager.on_replica_stopped(replica.replica_id)
|
|
self._unregister_gang_replica(replica.replica_id)
|
|
|
|
def _reconfigure_replicas_with_new_ranks(
|
|
self, replicas_to_reconfigure: List["DeploymentReplica"]
|
|
):
|
|
"""Reconfigure replicas with their new ranks after reassignment.
|
|
This uses the reconfigure() mechanism to update replicas with their new ranks.
|
|
"""
|
|
if not replicas_to_reconfigure:
|
|
return
|
|
|
|
logger.debug(
|
|
f"Reconfiguring {len(replicas_to_reconfigure)} replicas with rank changes in deployment {self._id}"
|
|
)
|
|
|
|
updated_count = 0
|
|
for replica in replicas_to_reconfigure:
|
|
replica_id = replica.replica_id.unique_id
|
|
new_rank = self._rank_manager.get_replica_rank(replica_id)
|
|
|
|
# Use reconfigure() to update rank
|
|
# World size is calculated automatically from deployment config
|
|
_ = replica.reconfigure(
|
|
self._target_state.version,
|
|
rank=new_rank,
|
|
)
|
|
updated_count += 1
|
|
|
|
logger.debug(
|
|
f"Successfully reconfigured {updated_count} replicas with new ranks in deployment {self._id}"
|
|
)
|
|
|
|
def _get_replica_ranks_mapping(self) -> Dict[str, ReplicaRank]:
|
|
"""Get the current mapping of replica IDs to ReplicaRank objects.
|
|
|
|
Returns:
|
|
Dictionary mapping replica_id to ReplicaRank object (with rank, node_rank, local_rank).
|
|
"""
|
|
return self._rank_manager.get_replica_ranks_mapping()
|
|
|
|
@staticmethod
|
|
def _group_effective_deadline(
|
|
group: List[DeploymentReplica],
|
|
deadlines: Dict[str, int],
|
|
) -> float:
|
|
"""Return the effective deadline for a group of replicas.
|
|
|
|
Uses the earliest deadline among members on draining nodes, and
|
|
falls back to infinity if no member is on a draining node.
|
|
"""
|
|
member_deadlines = [
|
|
deadlines[r.actor_node_id] for r in group if r.actor_node_id in deadlines
|
|
]
|
|
return min(member_deadlines) if member_deadlines else float("inf")
|
|
|
|
@staticmethod
|
|
def _group_shutdown_timeout_ms(
|
|
group: List[DeploymentReplica],
|
|
) -> float:
|
|
"""Return the graceful shutdown timeout (in ms) for the group."""
|
|
return group[0]._actor.graceful_shutdown_timeout_s * 1000
|
|
|
|
def _choose_pending_migration_replicas_to_stop(
|
|
self,
|
|
replicas: List[DeploymentReplica],
|
|
deadlines: Dict[str, int],
|
|
min_replicas_to_stop: int,
|
|
) -> Tuple[List[DeploymentReplica], List[DeploymentReplica]]:
|
|
"""Returns a partition of replicas to stop and to keep.
|
|
|
|
Each replica is treated as an independent unit.
|
|
|
|
Args:
|
|
replicas: The current list of replicas pending migration.
|
|
deadlines: The current draining node deadlines.
|
|
min_replicas_to_stop: The minimum number of replicas to stop.
|
|
|
|
Returns:
|
|
A tuple ``(replicas_to_stop, replicas_to_keep)``.
|
|
"""
|
|
# Treat each replica as a group of one.
|
|
groups = [[r] for r in replicas]
|
|
return self._partition_groups_to_stop(groups, deadlines, min_replicas_to_stop)
|
|
|
|
def _group_replicas_by_gang_id(
|
|
self, replicas: List[DeploymentReplica]
|
|
) -> Dict[str, List[DeploymentReplica]]:
|
|
"""Group replicas by their gang_id."""
|
|
gangs: Dict[str, List[DeploymentReplica]] = defaultdict(list)
|
|
for replica in replicas:
|
|
gangs[replica.gang_context.gang_id].append(replica)
|
|
return gangs
|
|
|
|
def _choose_pending_migration_gangs_to_stop(
|
|
self,
|
|
replicas: List[DeploymentReplica],
|
|
deadlines: Dict[str, int],
|
|
min_replicas_to_stop: int,
|
|
) -> Tuple[List[DeploymentReplica], List[DeploymentReplica]]:
|
|
"""Gang-aware variant: stop complete gangs atomically.
|
|
|
|
A gang is considered deadline-expired if ANY member's deadline is up.
|
|
For excess stopping, gangs are sorted by their earliest member
|
|
deadline.
|
|
"""
|
|
gangs = self._group_replicas_by_gang_id(replicas)
|
|
return self._partition_groups_to_stop(
|
|
list(gangs.values()), deadlines, min_replicas_to_stop
|
|
)
|
|
|
|
def _partition_groups_to_stop(
|
|
self,
|
|
groups: List[List[DeploymentReplica]],
|
|
deadlines: Dict[str, int],
|
|
min_replicas_to_stop: int,
|
|
) -> Tuple[List[DeploymentReplica], List[DeploymentReplica]]:
|
|
"""Partition replica groups into those to stop and those to keep.
|
|
|
|
A group (single replica or full gang) is the atomic unit of stopping.
|
|
|
|
1. Groups whose deadline is up are stopped unconditionally.
|
|
2. Remaining groups are stopped greedily (earliest deadline first)
|
|
until min_replicas_to_stop is satisfied.
|
|
"""
|
|
to_stop: List[DeploymentReplica] = []
|
|
remaining_groups: List[Tuple[float, List[DeploymentReplica]]] = []
|
|
|
|
curr_timestamp_ms = time.time() * 1000
|
|
for group in groups:
|
|
effective_deadline = self._group_effective_deadline(group, deadlines)
|
|
timeout_ms = self._group_shutdown_timeout_ms(group)
|
|
|
|
if curr_timestamp_ms >= effective_deadline - timeout_ms:
|
|
to_stop.extend(group)
|
|
else:
|
|
remaining_groups.append((effective_deadline, group))
|
|
|
|
# Stop excess groups, earliest deadline first.
|
|
# NOTE: num_excess can be negative when deadline-forced stops in the
|
|
# loop above already exceed min_replicas_to_stop. That's fine — no
|
|
# extra groups are stopped because of the guard num_excess >= len(group).
|
|
remaining_groups.sort(key=lambda x: x[0])
|
|
num_excess = min_replicas_to_stop - len(to_stop)
|
|
|
|
remaining: List[DeploymentReplica] = []
|
|
for _, group in remaining_groups:
|
|
if num_excess >= len(group):
|
|
to_stop.extend(group)
|
|
num_excess -= len(group)
|
|
else:
|
|
remaining.extend(group)
|
|
|
|
return to_stop, remaining
|
|
|
|
def migrate_replicas_on_draining_nodes(self, draining_nodes: Dict[str, int]):
|
|
# Fast path: no draining nodes and deployment is in steady state —
|
|
# no PENDING_MIGRATION replicas to move back and no replicas to
|
|
# migrate, so skip the O(N) pop-and-readd.
|
|
if not draining_nodes and not self._in_transition:
|
|
return
|
|
|
|
# Move replicas back to RUNNING if they are no longer on a draining node.
|
|
# If this causes the number of replicas to exceed the target state,
|
|
# they will be scaled down because `scale_deployment_replicas` is called on
|
|
# each deployment after this.
|
|
pending_migration_replicas = self._replicas.pop(
|
|
states=[ReplicaState.PENDING_MIGRATION]
|
|
)
|
|
|
|
if self._is_gang_deployment:
|
|
# For gangs, only move back to RUNNING if ALL members' nodes are no
|
|
# longer draining.
|
|
gangs = self._group_replicas_by_gang_id(pending_migration_replicas)
|
|
|
|
gangs_still_draining: Set[str] = set()
|
|
for gang_id, gang_replicas in gangs.items():
|
|
if any(
|
|
replica.actor_node_id in draining_nodes for replica in gang_replicas
|
|
):
|
|
gangs_still_draining.add(gang_id)
|
|
|
|
def still_draining(r):
|
|
return r.gang_context.gang_id in gangs_still_draining
|
|
|
|
else:
|
|
|
|
def still_draining(r):
|
|
return r.actor_node_id in draining_nodes
|
|
|
|
for replica in pending_migration_replicas:
|
|
if still_draining(replica):
|
|
self._replicas.add(ReplicaState.PENDING_MIGRATION, replica)
|
|
else:
|
|
self._replicas.add(ReplicaState.RUNNING, replica)
|
|
|
|
# Migrate replicas on draining nodes
|
|
all_replicas = self._replicas.pop(
|
|
states=[
|
|
ReplicaState.UPDATING,
|
|
ReplicaState.RUNNING,
|
|
ReplicaState.STARTING,
|
|
]
|
|
)
|
|
|
|
if self._is_gang_deployment:
|
|
# For gangs, if ANY member is on a draining node the entire gang migrates.
|
|
gangs_to_migrate: Set[str] = set()
|
|
for replica in all_replicas:
|
|
if replica.actor_node_id in draining_nodes:
|
|
gangs_to_migrate.add(replica.gang_context.gang_id)
|
|
|
|
def needs_migration(r):
|
|
return r.gang_context.gang_id in gangs_to_migrate
|
|
|
|
else:
|
|
|
|
def needs_migration(r):
|
|
return r.actor_node_id in draining_nodes
|
|
|
|
for replica in all_replicas:
|
|
if not needs_migration(replica):
|
|
self._replicas.add(replica.actor_details.state, replica)
|
|
# For RUNNING replicas, migrate them safely by starting
|
|
# a replacement replica first.
|
|
elif replica.actor_details.state == ReplicaState.RUNNING:
|
|
logger.info(
|
|
f"Migrating {replica.replica_id} from draining node "
|
|
f"'{replica.actor_node_id}'. A new replica will be "
|
|
"created on another node."
|
|
)
|
|
self._replicas.add(ReplicaState.PENDING_MIGRATION, replica)
|
|
# For replicas that are STARTING or UPDATING, might as
|
|
# well terminate them immediately to allow replacement
|
|
# replicas to start. Otherwise we need to wait for them
|
|
# to transition to RUNNING before starting migration.
|
|
else:
|
|
self._stop_replica(
|
|
replica,
|
|
# Always force-stop gang members to avoid leaving partial gangs.
|
|
graceful_stop=not self._is_gang_deployment,
|
|
)
|
|
|
|
num_running = self._replicas.count(states=[ReplicaState.RUNNING])
|
|
num_draining = self._replicas.count(states=[ReplicaState.PENDING_MIGRATION])
|
|
num_pending_migration_replicas_to_stop = (
|
|
num_running + num_draining - self._target_state.target_num_replicas
|
|
)
|
|
|
|
choose_pending_migration_to_stop_fn = (
|
|
self._choose_pending_migration_gangs_to_stop
|
|
if self._is_gang_deployment
|
|
else self._choose_pending_migration_replicas_to_stop
|
|
)
|
|
replicas_to_stop, replicas_to_keep = choose_pending_migration_to_stop_fn(
|
|
self._replicas.pop(states=[ReplicaState.PENDING_MIGRATION]),
|
|
draining_nodes,
|
|
num_pending_migration_replicas_to_stop,
|
|
)
|
|
for replica in replicas_to_stop:
|
|
logger.info(
|
|
f"Stopping {replica.replica_id} "
|
|
f"on draining node {replica.actor_node_id}."
|
|
)
|
|
self._stop_replica(replica, graceful_stop=True)
|
|
|
|
for replica in replicas_to_keep:
|
|
self._replicas.add(ReplicaState.PENDING_MIGRATION, replica)
|
|
|
|
def record_request_routing_info(self, info: RequestRoutingInfo) -> None:
|
|
"""Records the multiplexed model IDs of a replica.
|
|
|
|
Args:
|
|
info: RequestRoutingInfo including deployment name, replica tag,
|
|
multiplex model ids, and routing stats.
|
|
"""
|
|
# O(1) lookup via replica_id index.
|
|
replica = self._replicas.get_by_id(info.replica_id)
|
|
if replica is not None:
|
|
if info.multiplexed_model_ids is not None:
|
|
replica.record_multiplexed_model_ids(info.multiplexed_model_ids)
|
|
if info.routing_stats is not None:
|
|
replica.record_routing_stats(info.routing_stats)
|
|
self._request_routing_info_updated = True
|
|
else:
|
|
logger.warning(f"{info.replica_id} not found.")
|
|
|
|
def _stop_one_running_replica_for_testing(self):
|
|
running_replicas = self._replicas.pop(states=[ReplicaState.RUNNING])
|
|
replica_to_stop = running_replicas.pop()
|
|
replica_to_stop.stop(graceful=False)
|
|
self._replicas.add(ReplicaState.STOPPING, replica_to_stop)
|
|
for replica in running_replicas:
|
|
self._replicas.add(ReplicaState.RUNNING, replica)
|
|
|
|
def is_ingress(self) -> bool:
|
|
return self._target_state.info.ingress
|
|
|
|
def is_ingress_request_router(self) -> bool:
|
|
return self._target_state.info.ingress_request_router
|
|
|
|
def get_outbound_deployments(self) -> Optional[List[DeploymentID]]:
|
|
"""Get the outbound deployments.
|
|
|
|
Returns:
|
|
Sorted list of deployment IDs that this deployment calls. None if
|
|
outbound deployments are not yet polled.
|
|
"""
|
|
result: Set[DeploymentID] = set()
|
|
has_outbound_deployments = False
|
|
for replica in self._replicas.get([ReplicaState.RUNNING]):
|
|
if replica.version != self._target_state.version:
|
|
# Only consider replicas of the target version
|
|
continue
|
|
outbound_deployments = replica.get_outbound_deployments()
|
|
if outbound_deployments is not None:
|
|
result.update(outbound_deployments)
|
|
has_outbound_deployments = True
|
|
if not has_outbound_deployments:
|
|
return None
|
|
return sorted(result, key=lambda d: (d.name))
|
|
|
|
def deployment_actor_terminally_failed(self) -> bool:
|
|
"""True when deployment actors have failed too many times to keep retrying."""
|
|
if self._target_state.deleting:
|
|
return False
|
|
deployment_actors_configs = self._get_deployment_actors_configs()
|
|
if not deployment_actors_configs:
|
|
return False
|
|
return (
|
|
self._deployment_actor_retry_counter
|
|
>= self._deployment_actor_failed_to_start_threshold
|
|
)
|
|
|
|
def record_deployment_actor_startup_failure(self, error_msg: str) -> None:
|
|
"""Record constructor/start failure for a deployment actor (not health checks).
|
|
|
|
Increments ``_deployment_actor_retry_counter`` toward DEPLOY_FAILED, like
|
|
``record_replica_startup_failure``. Health-driven kills use
|
|
``_handle_deployment_actor_failed_health_check`` instead so rolling updates
|
|
are not coupled to startup retries.
|
|
"""
|
|
deployment_actors_configs = self._get_deployment_actors_configs()
|
|
if not deployment_actors_configs:
|
|
return
|
|
|
|
self._deployment_actor_retry_counter += 1
|
|
self._deployment_actor_failed = error_msg
|
|
self._broadcasted_replicas_set_changed = True
|
|
self._in_transition = True
|
|
|
|
remaining_retries = max(
|
|
self._deployment_actor_failed_to_start_threshold
|
|
- self._deployment_actor_retry_counter,
|
|
0,
|
|
)
|
|
retrying_msg = f" {remaining_retries} more time(s)" if remaining_retries else ""
|
|
message = (
|
|
f"A deployment actor failed to start. Retrying{retrying_msg}. "
|
|
f"Error:\n{error_msg}"
|
|
)
|
|
self._curr_status_info = self._curr_status_info.update_message(message)
|
|
|
|
def start_deployment_actors(self) -> None:
|
|
"""Start deployment-scoped actors for the target version.
|
|
|
|
Initiates creation if not already started or ready.
|
|
On failure, records `_deployment_actor_failed` for status transition.
|
|
Stops retrying after `_deployment_actor_failed_to_start_threshold` consecutive failures.
|
|
"""
|
|
if self._target_state.deleting:
|
|
return
|
|
|
|
version = self._target_state.version
|
|
if version is None:
|
|
return
|
|
deployment_actors_configs = self._get_deployment_actors_configs(version)
|
|
if not deployment_actors_configs:
|
|
return
|
|
|
|
code_ver = version.code_version
|
|
if self.deployment_actor_terminally_failed():
|
|
return
|
|
|
|
deployment_runtime_env = (version.ray_actor_options or {}).get(
|
|
"runtime_env", {}
|
|
)
|
|
# Create only missing actors (supports partial recovery after controller restart).
|
|
configs_to_create = [
|
|
cfg
|
|
for cfg in deployment_actors_configs
|
|
if self._deployment_actors.get_wrapper(code_ver, cfg.name) is None
|
|
]
|
|
if not configs_to_create:
|
|
return
|
|
|
|
logger.info(
|
|
f"Creating {len(configs_to_create)} deployment actor(s) for {self._id}"
|
|
)
|
|
for cfg in configs_to_create:
|
|
wrapper = DeploymentActorWrapper(
|
|
deployment_id=self._id,
|
|
config=cfg,
|
|
code_version=code_ver,
|
|
)
|
|
started_successfully, error_msg = wrapper.start(deployment_runtime_env)
|
|
if started_successfully is False:
|
|
logger.warning(
|
|
f"Deployment actor creation failed for {self._id}: {error_msg} "
|
|
f"(attempt {self._deployment_actor_retry_counter + 1}/"
|
|
f"{self._deployment_actor_failed_to_start_threshold})"
|
|
)
|
|
self.record_deployment_actor_startup_failure(error_msg)
|
|
return
|
|
self._deployment_actors.add(DeploymentActorState.STARTING, wrapper)
|
|
return
|
|
|
|
def check_deployment_actors_ready(self) -> bool:
|
|
"""Check if deployment-scoped actors are ready for the target version.
|
|
|
|
Polls pending refs without blocking.
|
|
Returns True when all configured actors are ready.
|
|
Sets _deployment_actor_failed and returns False when an actor constructor fails.
|
|
"""
|
|
version = self._target_state.version
|
|
if version is None:
|
|
return True
|
|
deployment_actors_configs = self._get_deployment_actors_configs(version)
|
|
if not deployment_actors_configs:
|
|
return True
|
|
|
|
code_ver = version.code_version
|
|
ready_count = self._deployment_actors.count(
|
|
code_ver, states=[DeploymentActorState.RUNNING]
|
|
)
|
|
pending_count = self._deployment_actors.count(
|
|
code_ver,
|
|
states=[DeploymentActorState.STARTING, DeploymentActorState.RECOVERING],
|
|
)
|
|
if ready_count == len(deployment_actors_configs) and pending_count == 0:
|
|
# Align with the counter reset when we promote the last pending actor
|
|
# below; otherwise a health-recreate can leave the counter elevated while
|
|
# all actors are already RUNNING on the next tick.
|
|
self._deployment_actor_retry_counter = 0
|
|
return True
|
|
|
|
pending_wrappers = self._deployment_actors.get(
|
|
code_ver,
|
|
states=[DeploymentActorState.STARTING, DeploymentActorState.RECOVERING],
|
|
)
|
|
if not pending_wrappers:
|
|
return False
|
|
|
|
not_ready_wrappers = []
|
|
for wrapper in pending_wrappers:
|
|
ready, error_msg = wrapper.check_ready()
|
|
if error_msg is not None:
|
|
logger.warning(
|
|
f"Deployment actor '{wrapper.actor_logical_name}' for {self._id} "
|
|
f"failed to become ready: {error_msg}"
|
|
)
|
|
self.record_deployment_actor_startup_failure(error_msg)
|
|
self._deployment_actors.pop(
|
|
code_ver,
|
|
states=[
|
|
DeploymentActorState.STARTING,
|
|
DeploymentActorState.RECOVERING,
|
|
],
|
|
)
|
|
return False
|
|
if ready:
|
|
self._deployment_actors.add(DeploymentActorState.RUNNING, wrapper)
|
|
wrapper.reset_health_state_after_running()
|
|
else:
|
|
not_ready_wrappers.append(wrapper)
|
|
|
|
if not_ready_wrappers:
|
|
return False
|
|
|
|
# Verify total RUNNING count equals configured count. When
|
|
# start_deployment_actors fails partway through, some actors are
|
|
# never added; we must not return True or reset retry counter.
|
|
actual_running = self._deployment_actors.count(
|
|
code_ver, states=[DeploymentActorState.RUNNING]
|
|
)
|
|
if actual_running == len(deployment_actors_configs):
|
|
self._deployment_actor_retry_counter = 0
|
|
return True
|
|
return False
|
|
|
|
def _orphaned_deployment_actor_code_versions(self) -> Set[str]:
|
|
"""Code versions still tracked for deployment actors that no replica needs.
|
|
|
|
Matches the retention rule in ``stop_deployment_actors_if_needed``:
|
|
keep actors for every replica's ``code_version``, and (when not deleting)
|
|
for the target deployment version.
|
|
"""
|
|
target_version = self._target_state.version
|
|
if target_version is None:
|
|
return set()
|
|
|
|
# Fast path: with no deployment-scoped actors tracked, nothing can be orphaned
|
|
# (get_code_versions() is empty, and empty - anything == empty). Skips the O(N)
|
|
# self._replicas.get() materialization on every scale_deployment_replicas tick
|
|
# for the common deployment that has no deployment-scoped actors.
|
|
if self._deployment_actors.is_empty():
|
|
return set()
|
|
|
|
versions_to_keep = {r.version.code_version for r in self._replicas.get()}
|
|
if not self._target_state.deleting:
|
|
versions_to_keep.add(target_version.code_version)
|
|
|
|
return self._deployment_actors.get_code_versions() - versions_to_keep
|
|
|
|
def stop_deployment_actors_if_needed(self) -> None:
|
|
"""Stop deployment-scoped actors when no longer needed.
|
|
|
|
During normal operation, keeps actors for versions that have replicas in
|
|
any state (STARTING, UPDATING, RECOVERING, RUNNING, STOPPING,
|
|
PENDING_MIGRATION), since all of these may need deployment actors.
|
|
During deletion, actors are kept only while replicas still exist.
|
|
"""
|
|
if self._target_state.version is None:
|
|
return
|
|
|
|
wrappers_to_stop: List[DeploymentActorWrapper] = []
|
|
for code_version in self._orphaned_deployment_actor_code_versions():
|
|
entries = self._deployment_actors.pop(
|
|
code_version=code_version,
|
|
states=[
|
|
DeploymentActorState.RUNNING,
|
|
DeploymentActorState.STARTING,
|
|
DeploymentActorState.RECOVERING,
|
|
],
|
|
)
|
|
wrappers_to_stop.extend(entry.wrapper for _, entry in entries)
|
|
self._stop_deployment_actor_wrappers(wrappers_to_stop)
|
|
|
|
def _stop_deployment_actor_wrappers(
|
|
self, wrappers: List[DeploymentActorWrapper]
|
|
) -> None:
|
|
"""Best-effort stop for deployment-scoped actor wrappers."""
|
|
for wrapper in wrappers:
|
|
logger.info(
|
|
f"Stopping deployment actor '{wrapper.actor_logical_name}' "
|
|
f"for {self._id} code_version={wrapper.code_version}"
|
|
)
|
|
wrapper.kill()
|
|
|
|
|
|
class DeploymentStateManager:
|
|
"""Manages all state for deployments in the system.
|
|
|
|
This class is *not* thread safe, so any state-modifying methods should be
|
|
called with a lock held.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
kv_store: KVStoreBase,
|
|
long_poll_host: LongPollHost,
|
|
all_current_actor_names: List[str],
|
|
all_current_placement_group_names: List[str],
|
|
cluster_node_info_cache: ClusterNodeInfoCache,
|
|
autoscaling_state_manager: AutoscalingStateManager,
|
|
head_node_id_override: Optional[str] = None,
|
|
create_placement_group_fn_override: Optional[Callable] = None,
|
|
):
|
|
self._kv_store = kv_store
|
|
self._long_poll_host = long_poll_host
|
|
self._cluster_node_info_cache = cluster_node_info_cache
|
|
self._deployment_scheduler = default_impl.create_deployment_scheduler(
|
|
cluster_node_info_cache,
|
|
head_node_id_override,
|
|
create_placement_group_fn_override,
|
|
)
|
|
self._autoscaling_state_manager = autoscaling_state_manager
|
|
|
|
self._shutting_down = False
|
|
|
|
self._deployment_states: Dict[DeploymentID, DeploymentState] = {}
|
|
self._app_deployment_mapping: Dict[str, Set[str]] = defaultdict(set)
|
|
|
|
# Metric for tracking deployment status
|
|
self._deployment_status_gauge = ray_metrics.Gauge(
|
|
"serve_deployment_status",
|
|
description=(
|
|
"Numeric status of deployment. "
|
|
"0=UNKNOWN, 1=DEPLOY_FAILED, 2=UNHEALTHY, 3=UPDATING, "
|
|
"4=UPSCALING, 5=DOWNSCALING, 6=HEALTHY."
|
|
),
|
|
tag_keys=("deployment", "application"),
|
|
)
|
|
|
|
self._recover_from_checkpoint(
|
|
all_current_actor_names, all_current_placement_group_names
|
|
)
|
|
|
|
def _create_deployment_state(self, deployment_id):
|
|
self._deployment_scheduler.on_deployment_created(
|
|
deployment_id, SpreadDeploymentSchedulingPolicy()
|
|
)
|
|
|
|
# Evict any stale long-poll snapshots for this deployment_id from a
|
|
# prior deletion. The delete path tombstones DEPLOYMENT_TARGETS
|
|
# (is_available=False) so existing handles fail fast, but the
|
|
# tombstone persists in LongPollHost. A re-created DeploymentState's
|
|
# _last_broadcasted_* defaults match its own (True, []) state, so
|
|
# broadcast_running_replicas_if_changed short-circuits and never
|
|
# overwrites the tombstone — freshly-subscribed routers would then
|
|
# see is_available=False and reject every request.
|
|
self._long_poll_host.remove_keys(
|
|
[
|
|
(LongPollNamespace.DEPLOYMENT_TARGETS, deployment_id),
|
|
(LongPollNamespace.DEPLOYMENT_TARGETS, deployment_id.name),
|
|
(LongPollNamespace.DEPLOYMENT_CONFIG, deployment_id),
|
|
]
|
|
)
|
|
|
|
return DeploymentState(
|
|
deployment_id,
|
|
self._long_poll_host,
|
|
self._deployment_scheduler,
|
|
self._cluster_node_info_cache,
|
|
self._autoscaling_state_manager,
|
|
)
|
|
|
|
def _map_actor_names_to_deployment(
|
|
self, all_current_actor_names: List[str]
|
|
) -> Dict[str, List[str]]:
|
|
"""
|
|
Given a list of all actor names queried from current ray cluster,
|
|
map them to corresponding deployments.
|
|
|
|
Example:
|
|
Args:
|
|
[A#zxc123, B#xcv234, A#qwe234]
|
|
Returns:
|
|
{
|
|
A: [A#zxc123, A#qwe234]
|
|
B: [B#xcv234]
|
|
}
|
|
|
|
Args:
|
|
all_current_actor_names: Actor names currently registered with Ray.
|
|
|
|
Returns:
|
|
A mapping from deployment ID to the list of replica actor names
|
|
associated with that deployment.
|
|
"""
|
|
all_replica_names = [
|
|
actor_name
|
|
for actor_name in all_current_actor_names
|
|
if ReplicaID.is_full_id_str(actor_name)
|
|
]
|
|
deployment_to_current_replicas = defaultdict(list)
|
|
if len(all_replica_names) > 0:
|
|
for replica_name in all_replica_names:
|
|
replica_id = ReplicaID.from_full_id_str(replica_name)
|
|
deployment_to_current_replicas[replica_id.deployment_id].append(
|
|
replica_name
|
|
)
|
|
|
|
return deployment_to_current_replicas
|
|
|
|
def _detect_and_remove_leaked_placement_groups(
|
|
self,
|
|
all_current_actor_names: List[str],
|
|
all_current_placement_group_names: List[str],
|
|
):
|
|
"""Detect and remove any placement groups not associated with a replica.
|
|
|
|
For per-replica PGs, a PG is leaked if no actor with that name exists. This
|
|
can happen under certain rare circumstances:
|
|
- The controller creates a placement group then crashes before creating
|
|
the associated replica actor.
|
|
- While the controller is down, a replica actor crashes but its placement
|
|
group still exists.
|
|
|
|
In both of these (or any other unknown cases), we simply need to remove the
|
|
leaked placement groups.
|
|
|
|
For gang PGs, a PG is leaked only if no alive actor references its placement
|
|
group ID. Gang PGs that still have live actors are preserved to avoid releasing
|
|
their resource reservations.
|
|
"""
|
|
leaked_pg_names = []
|
|
for pg_name in all_current_placement_group_names:
|
|
if (
|
|
ReplicaID.is_full_id_str(pg_name)
|
|
and pg_name not in all_current_actor_names
|
|
):
|
|
leaked_pg_names.append(pg_name)
|
|
|
|
gang_pg_names_in_cluster = [
|
|
name
|
|
for name in all_current_placement_group_names
|
|
if name.startswith(GANG_PG_NAME_PREFIX)
|
|
]
|
|
if gang_pg_names_in_cluster:
|
|
pg_table = ray.util.placement_group_table()
|
|
gang_pg_name_to_id: Dict[str, str] = {}
|
|
for pg_id_hex, entry in pg_table.items():
|
|
name = entry.get("name", "")
|
|
if name.startswith(GANG_PG_NAME_PREFIX):
|
|
gang_pg_name_to_id[name] = pg_id_hex
|
|
|
|
occupied_pg_ids = get_active_placement_group_ids()
|
|
for gang_pg_name in gang_pg_names_in_cluster:
|
|
pg_id = gang_pg_name_to_id.get(gang_pg_name)
|
|
if pg_id is not None and pg_id not in occupied_pg_ids:
|
|
leaked_pg_names.append(gang_pg_name)
|
|
|
|
if len(leaked_pg_names) > 0:
|
|
logger.warning(
|
|
f"Detected leaked placement groups: {leaked_pg_names}. "
|
|
"The placement groups will be removed. This can happen in rare "
|
|
"circumstances when the controller crashes and should not cause any "
|
|
"issues. If this happens repeatedly, please file an issue on GitHub."
|
|
)
|
|
|
|
for leaked_pg_name in leaked_pg_names:
|
|
try:
|
|
pg = ray.util.get_placement_group(leaked_pg_name)
|
|
ray.util.remove_placement_group(pg)
|
|
except Exception:
|
|
logger.exception(
|
|
f"Failed to remove leaked placement group {leaked_pg_name}."
|
|
)
|
|
|
|
def _recover_from_checkpoint(
|
|
self,
|
|
all_current_actor_names: List[str],
|
|
all_current_placement_group_names: List[str],
|
|
):
|
|
"""
|
|
Recover from checkpoint upon controller failure with all actor names
|
|
found in current cluster.
|
|
|
|
Each deployment resumes target state from checkpoint if available.
|
|
|
|
For current state it will prioritize reconstructing from current
|
|
actor names found that matches deployment tag if applicable.
|
|
"""
|
|
self._detect_and_remove_leaked_placement_groups(
|
|
all_current_actor_names,
|
|
all_current_placement_group_names,
|
|
)
|
|
|
|
deployment_to_current_replicas = self._map_actor_names_to_deployment(
|
|
all_current_actor_names
|
|
)
|
|
checkpoint = self._kv_store.get(CHECKPOINT_KEY)
|
|
if checkpoint is not None:
|
|
deployment_state_info = cloudpickle.loads(checkpoint)
|
|
|
|
for deployment_id, checkpoint_data in deployment_state_info.items():
|
|
deployment_state = self._create_deployment_state(deployment_id)
|
|
deployment_state.recover_target_state_from_checkpoint(checkpoint_data)
|
|
if len(deployment_to_current_replicas[deployment_id]) > 0:
|
|
deployment_state.recover_current_state_from_replica_actor_names( # noqa: E501
|
|
deployment_to_current_replicas[deployment_id]
|
|
)
|
|
self._deployment_states[deployment_id] = deployment_state
|
|
self._app_deployment_mapping[deployment_id.app_name].add(
|
|
deployment_id.name
|
|
)
|
|
|
|
def shutdown(self):
|
|
"""
|
|
Shutdown all running replicas by notifying the controller, and leave
|
|
it to the controller event loop to take actions afterwards.
|
|
|
|
Once shutdown signal is received, it will also prevent any new
|
|
deployments or replicas from being created.
|
|
|
|
One can send multiple shutdown signals but won't effectively make any
|
|
difference compare to calling it once.
|
|
"""
|
|
self._shutting_down = True
|
|
|
|
for deployment_state in self._deployment_states.values():
|
|
deployment_state.delete()
|
|
|
|
def is_ready_for_shutdown(self) -> bool:
|
|
"""Return whether all deployments are shutdown.
|
|
|
|
Check there are no deployment states.
|
|
"""
|
|
return self._shutting_down and len(self._deployment_states) == 0
|
|
|
|
def delete_checkpoint(self) -> None:
|
|
"""Delete the deployment state checkpoint from KV store."""
|
|
self._kv_store.delete(CHECKPOINT_KEY)
|
|
|
|
def save_checkpoint(self) -> None:
|
|
"""Write a checkpoint of all deployment states."""
|
|
if self._shutting_down:
|
|
# Once we're told to shut down, stop writing checkpoints.
|
|
# Calling .shutdown() deletes any existing checkpoint.
|
|
return
|
|
|
|
deployment_state_info = {
|
|
deployment_id: deployment_state.get_checkpoint_data()
|
|
for deployment_id, deployment_state in self._deployment_states.items()
|
|
}
|
|
|
|
self._kv_store.put(
|
|
CHECKPOINT_KEY,
|
|
cloudpickle.dumps(deployment_state_info),
|
|
)
|
|
|
|
def get_running_replica_infos(
|
|
self,
|
|
) -> Dict[DeploymentID, List[RunningReplicaInfo]]:
|
|
return {
|
|
id: deployment_state.get_running_replica_infos()
|
|
for id, deployment_state in self._deployment_states.items()
|
|
}
|
|
|
|
def get_deployment_infos(self) -> Dict[DeploymentID, DeploymentInfo]:
|
|
infos: Dict[DeploymentID, DeploymentInfo] = {}
|
|
for deployment_id, deployment_state in self._deployment_states.items():
|
|
infos[deployment_id] = deployment_state.target_info
|
|
|
|
return infos
|
|
|
|
def get_deployment(self, deployment_id: DeploymentID) -> Optional[DeploymentInfo]:
|
|
if deployment_id in self._deployment_states:
|
|
return self._deployment_states[deployment_id].target_info
|
|
else:
|
|
return None
|
|
|
|
def get_deployment_docs_path(self, deployment_id: DeploymentID) -> Optional[str]:
|
|
if deployment_id in self._deployment_states:
|
|
return self._deployment_states[deployment_id].docs_path
|
|
|
|
def get_deployment_route_patterns(
|
|
self, deployment_id: DeploymentID
|
|
) -> Optional[List[str]]:
|
|
"""Get route patterns for a deployment if available."""
|
|
if deployment_id in self._deployment_states:
|
|
return self._deployment_states[deployment_id].route_patterns
|
|
return None
|
|
|
|
def get_deployment_target_num_replicas(
|
|
self, deployment_id: DeploymentID
|
|
) -> Optional[int]:
|
|
if deployment_id not in self._deployment_states:
|
|
return None
|
|
return self._deployment_states[deployment_id].target_num_replicas
|
|
|
|
def get_deployment_details(self, id: DeploymentID) -> Optional[DeploymentDetails]:
|
|
"""Gets detailed info on a deployment.
|
|
|
|
Args:
|
|
id: The ID of the deployment to look up.
|
|
|
|
Returns:
|
|
DeploymentDetails: if the deployment is live.
|
|
None: if the deployment is deleted.
|
|
"""
|
|
statuses = self.get_deployment_statuses([id])
|
|
if len(statuses) == 0:
|
|
return None
|
|
else:
|
|
status_info = statuses[0]
|
|
deployment_state = self._deployment_states[id]
|
|
return DeploymentDetails(
|
|
name=id.name,
|
|
status=status_info.status,
|
|
status_trigger=status_info.status_trigger,
|
|
message=status_info.message,
|
|
deployment_config=_deployment_info_to_schema(
|
|
id.name, self.get_deployment(id)
|
|
),
|
|
target_num_replicas=deployment_state._target_state.target_num_replicas,
|
|
required_resources=deployment_state.target_info.replica_config.resource_dict,
|
|
replicas=deployment_state.list_replica_details(),
|
|
recent_dead_replicas=deployment_state.list_recent_dead_replicas(),
|
|
)
|
|
|
|
def get_deployment_statuses(
|
|
self, ids: Optional[List[DeploymentID]] = None
|
|
) -> List[DeploymentStatusInfo]:
|
|
"""
|
|
Return the statuses of the deployments with the given `ids`.
|
|
If `ids` is `None`, returns the status of all deployments.
|
|
"""
|
|
if ids is None:
|
|
# fast path for returning all deployments,
|
|
# avoids checking `if ids is None` in a loop
|
|
return [
|
|
state.curr_status_info for state in self._deployment_states.values()
|
|
]
|
|
else:
|
|
statuses = []
|
|
for id in ids:
|
|
state = self._deployment_states.get(id)
|
|
if state is not None:
|
|
statuses.append(state.curr_status_info)
|
|
return statuses
|
|
|
|
def get_alive_replica_actor_ids(self) -> Set[str]:
|
|
alive_replica_actor_ids = set()
|
|
for ds in self._deployment_states.values():
|
|
alive_replica_actor_ids |= ds.get_alive_replica_actor_ids()
|
|
|
|
return alive_replica_actor_ids
|
|
|
|
def get_deployment_ids(self) -> List[DeploymentID]:
|
|
return list(self._deployment_states.keys())
|
|
|
|
def get_node_id_to_alive_replica_ids(self) -> Dict[str, Set[str]]:
|
|
node_id_to_alive_replica_ids = defaultdict(set)
|
|
for deployment_state in self._deployment_states.values():
|
|
# Keep replicas that are alive even if they are not yet fully running so
|
|
# ingress cleanup does not aggressively prune their allocated ports.
|
|
for replica in deployment_state._replicas.get():
|
|
if replica.actor_node_id is not None:
|
|
node_id_to_alive_replica_ids[replica.actor_node_id].add(
|
|
replica.replica_id.unique_id
|
|
)
|
|
|
|
return dict(node_id_to_alive_replica_ids)
|
|
|
|
def _dump_replica_states_for_testing(
|
|
self, deployment_id: DeploymentID
|
|
) -> ReplicaStateContainer:
|
|
return self._deployment_states[deployment_id]._replicas
|
|
|
|
def _stop_one_running_replica_for_testing(self, deployment_id: DeploymentID):
|
|
self._deployment_states[deployment_id]._stop_one_running_replica_for_testing()
|
|
|
|
def deploy(
|
|
self,
|
|
deployment_id: DeploymentID,
|
|
deployment_info: DeploymentInfo,
|
|
) -> bool:
|
|
"""Deploy the deployment.
|
|
|
|
If the deployment already exists with the same version and config,
|
|
this is a no-op and returns False.
|
|
|
|
Args:
|
|
deployment_id: The ID of the deployment to apply.
|
|
deployment_info: The target deployment info to apply.
|
|
|
|
Returns:
|
|
bool: Whether the target state has changed.
|
|
"""
|
|
if self._shutting_down:
|
|
logger.warning(
|
|
f"Ignoring deploy request for {deployment_id} "
|
|
"because deployment state manager is shutting down."
|
|
)
|
|
return False
|
|
if deployment_id not in self._deployment_states:
|
|
self._deployment_states[deployment_id] = self._create_deployment_state(
|
|
deployment_id
|
|
)
|
|
self._app_deployment_mapping[deployment_id.app_name].add(deployment_id.name)
|
|
self._record_deployment_usage()
|
|
|
|
return self._deployment_states[deployment_id].deploy(deployment_info)
|
|
|
|
def get_deployments_in_application(self, app_name: str) -> List[str]:
|
|
"""Return list of deployment names in application."""
|
|
return list(self._app_deployment_mapping[app_name])
|
|
|
|
def delete_deployment(self, id: DeploymentID):
|
|
# This method must be idempotent. We should validate that the
|
|
# specified deployment exists on the client.
|
|
if id in self._deployment_states:
|
|
return self._deployment_states[id].delete()
|
|
|
|
return False
|
|
|
|
def _validate_deployment_state_for_num_replica_update(
|
|
self, deployment_id: DeploymentID
|
|
):
|
|
"""Validate the state of a deployment for num replica update."""
|
|
statuses = self.get_deployment_statuses([deployment_id])
|
|
|
|
if statuses is None or len(statuses) == 0:
|
|
raise ValueError(f"Deployment {deployment_id} not found")
|
|
elif statuses[0].status_trigger == DeploymentStatusTrigger.DELETING:
|
|
raise DeploymentIsBeingDeletedError(
|
|
f"Deployment {deployment_id} is being deleted. Scaling operations are not allowed."
|
|
)
|
|
|
|
def set_target_num_replicas(
|
|
self, deployment_id: DeploymentID, target_num_replicas: int
|
|
):
|
|
"""Set target number of replicas for a deployment."""
|
|
if self._shutting_down:
|
|
logger.warning(
|
|
f"Ignoring set_target_num_replicas request for {deployment_id} "
|
|
"because deployment state manager is shutting down."
|
|
)
|
|
return
|
|
|
|
self._validate_deployment_state_for_num_replica_update(deployment_id)
|
|
|
|
deployment_state = self._deployment_states[deployment_id]
|
|
if target_num_replicas != deployment_state.target_num_replicas:
|
|
logger.info(
|
|
f"Target number of replicas changed from {deployment_state.target_num_replicas} to {target_num_replicas} for deployment {deployment_id}"
|
|
)
|
|
deployment_state.set_target_num_replicas(target_num_replicas)
|
|
self.save_checkpoint()
|
|
else:
|
|
logger.info(
|
|
f"Skipping updating target number of replicas as it did not change for deployment {deployment_id}"
|
|
)
|
|
|
|
def update(self) -> bool:
|
|
"""Updates the state of all deployments to match their goal state.
|
|
|
|
Returns True if any of the deployments have replicas in the RECOVERING state.
|
|
"""
|
|
|
|
deleted_ids = []
|
|
any_recovering = False
|
|
upscales: Dict[DeploymentID, List[ReplicaSchedulingRequest]] = {}
|
|
downscales: Dict[DeploymentID, DeploymentDownscaleRequest] = {}
|
|
target_state_changed = False
|
|
|
|
# STEP 1: Update current state
|
|
for deployment_state in self._deployment_states.values():
|
|
deployment_state.check_and_update_replicas()
|
|
deployment_state.check_and_update_deployment_actors()
|
|
|
|
# STEP 2: Check current status
|
|
for deployment_state in self._deployment_states.values():
|
|
deployment_state.check_curr_status()
|
|
|
|
# STEP 3: Drain nodes
|
|
draining_nodes = self._cluster_node_info_cache.get_draining_nodes()
|
|
allow_new_compaction = len(draining_nodes) == 0 and all(
|
|
ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
# TODO(zcin): Make sure that status should never be healthy if
|
|
# the number of running replicas at target version is not at
|
|
# target number, so we can remove this defensive check.
|
|
and ds.get_num_running_replicas(ds.target_version) == ds.target_num_replicas
|
|
# To be extra conservative, only actively compact if there
|
|
# are no non-running replicas
|
|
and ds._replicas.count() == ds.target_num_replicas
|
|
for ds in self._deployment_states.values()
|
|
)
|
|
if RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY:
|
|
# Tuple of target node to compact, and its draining deadline
|
|
node_info: Optional[
|
|
Tuple[str, float]
|
|
] = self._deployment_scheduler.get_node_to_compact(
|
|
allow_new_compaction=allow_new_compaction
|
|
)
|
|
if node_info:
|
|
target_node_id, deadline = node_info
|
|
draining_nodes = {target_node_id: deadline}
|
|
|
|
for deployment_id, deployment_state in self._deployment_states.items():
|
|
deployment_state.migrate_replicas_on_draining_nodes(draining_nodes)
|
|
|
|
# STEP 3: Reserve gang placement groups
|
|
gang_placement_groups = self._reserve_gang_placement_groups()
|
|
|
|
# STEP 4: Scale replicas
|
|
for deployment_id, deployment_state in self._deployment_states.items():
|
|
upscale, downscale = deployment_state.scale_deployment_replicas(
|
|
gang_placement_groups=gang_placement_groups,
|
|
)
|
|
|
|
if upscale:
|
|
upscales[deployment_id] = upscale
|
|
if downscale:
|
|
downscales[deployment_id] = downscale
|
|
|
|
# STEP 5: Update status
|
|
for deployment_id, deployment_state in self._deployment_states.items():
|
|
deleted, any_replicas_recovering = deployment_state.check_curr_status()
|
|
|
|
if deleted:
|
|
deleted_ids.append(deployment_id)
|
|
any_recovering |= any_replicas_recovering
|
|
|
|
# STEP 6: Schedule all STARTING replicas and stop all STOPPING replicas
|
|
# (Replicas are only added in scale_deployment_replicas when deployment
|
|
# actors are ready, so no additional gate needed here.)
|
|
deployment_to_replicas_to_stop = self._deployment_scheduler.schedule(
|
|
upscales, downscales
|
|
)
|
|
for deployment_id, replicas_to_stop in deployment_to_replicas_to_stop.items():
|
|
self._deployment_states[deployment_id].stop_replicas(replicas_to_stop)
|
|
for deployment_id, scheduling_requests in upscales.items():
|
|
self._handle_scheduling_request_failures(deployment_id, scheduling_requests)
|
|
|
|
# STEP 7: Broadcast long poll information
|
|
for deployment_id, deployment_state in self._deployment_states.items():
|
|
deployment_state.broadcast_running_replicas_if_changed()
|
|
deployment_state.broadcast_deployment_config_if_changed()
|
|
if deployment_state.should_autoscale():
|
|
self._autoscaling_state_manager.update_running_replica_ids(
|
|
deployment_id=deployment_id,
|
|
running_replicas=deployment_state.get_running_replica_ids(),
|
|
)
|
|
|
|
# STEP 8: Record deployment status metrics
|
|
for deployment_id, deployment_state in self._deployment_states.items():
|
|
status = deployment_state.curr_status_info.status
|
|
self._deployment_status_gauge.set(
|
|
status.to_numeric(),
|
|
tags={
|
|
"deployment": deployment_id.name,
|
|
"application": deployment_id.app_name,
|
|
},
|
|
)
|
|
|
|
# STEP 9: Cleanup
|
|
for deployment_id in deleted_ids:
|
|
self._deployment_scheduler.on_deployment_deleted(deployment_id)
|
|
self._autoscaling_state_manager.deregister_deployment(deployment_id)
|
|
del self._deployment_states[deployment_id]
|
|
if (
|
|
deployment_id.app_name in self._app_deployment_mapping
|
|
and deployment_id.name
|
|
in self._app_deployment_mapping[deployment_id.app_name]
|
|
):
|
|
self._app_deployment_mapping[deployment_id.app_name].remove(
|
|
deployment_id.name
|
|
)
|
|
# Clean up the app_name entry if no deployments are left
|
|
if not self._app_deployment_mapping[deployment_id.app_name]:
|
|
del self._app_deployment_mapping[deployment_id.app_name]
|
|
|
|
# Tombstone TARGETS so routers fail fast on a deleted deployment.
|
|
# Don't evict in the same tick: the waiter wakes after update()
|
|
# returns and listen_for_change's guard would drop the payload.
|
|
tombstone = DeploymentTargetInfo(is_available=False, running_replicas=[])
|
|
self._long_poll_host.notify_changed(
|
|
{
|
|
(LongPollNamespace.DEPLOYMENT_TARGETS, deployment_id): tombstone,
|
|
(
|
|
LongPollNamespace.DEPLOYMENT_TARGETS,
|
|
deployment_id.name,
|
|
): tombstone,
|
|
}
|
|
)
|
|
self._long_poll_host.remove_keys(
|
|
[(LongPollNamespace.DEPLOYMENT_CONFIG, deployment_id)]
|
|
)
|
|
|
|
if len(deleted_ids):
|
|
self._record_deployment_usage()
|
|
|
|
if target_state_changed:
|
|
self.save_checkpoint()
|
|
|
|
return any_recovering
|
|
|
|
def autoscale(self, deployment_id: DeploymentID, target_num_replicas: int) -> bool:
|
|
"""Autoscale the deployment to the target number of replicas.
|
|
|
|
Args:
|
|
deployment_id: The deployment ID.
|
|
target_num_replicas: The target number of replicas.
|
|
|
|
Returns:
|
|
True if the deployment was autoscaled, False otherwise.
|
|
"""
|
|
if self._shutting_down:
|
|
logger.warning(
|
|
f"Ignoring autoscale request for {deployment_id} "
|
|
"because deployment state manager is shutting down."
|
|
)
|
|
return False
|
|
|
|
if deployment_id not in self._deployment_states:
|
|
return False
|
|
|
|
return self._deployment_states[deployment_id].autoscale(target_num_replicas)
|
|
|
|
def _handle_scheduling_request_failures(
|
|
self,
|
|
deployment_id: DeploymentID,
|
|
scheduling_requests: List[ReplicaSchedulingRequest],
|
|
):
|
|
"""Updates internal datastructures when replicas fail to be scheduled."""
|
|
failed_replicas: List[ReplicaID] = []
|
|
for scheduling_request in scheduling_requests:
|
|
if (
|
|
scheduling_request.status
|
|
== ReplicaSchedulingRequestStatus.PLACEMENT_GROUP_CREATION_FAILED
|
|
):
|
|
failed_replicas.append(scheduling_request.replica_id)
|
|
self._deployment_states[deployment_id].record_replica_startup_failure(
|
|
"Replica scheduling failed. Failed to create a placement "
|
|
f"group for replica {scheduling_request.replica_id}. "
|
|
"See Serve controller logs for more details."
|
|
)
|
|
elif (
|
|
scheduling_request.status
|
|
== ReplicaSchedulingRequestStatus.ACTOR_CREATION_FAILED
|
|
):
|
|
failed_replicas.append(scheduling_request.replica_id)
|
|
self._deployment_states[deployment_id].record_replica_startup_failure(
|
|
"Replica scheduling failed. Failed to create an actor "
|
|
f"for replica {scheduling_request.replica_id}. "
|
|
"See Serve controller logs for more details."
|
|
)
|
|
if failed_replicas:
|
|
self._deployment_states[deployment_id].stop_replicas(failed_replicas)
|
|
|
|
def _record_deployment_usage(self):
|
|
ServeUsageTag.NUM_DEPLOYMENTS.record(str(len(self._deployment_states)))
|
|
|
|
num_gpu_deployments = 0
|
|
for deployment_state in self._deployment_states.values():
|
|
if (
|
|
deployment_state.target_info is not None
|
|
and deployment_state.target_info.replica_config is not None
|
|
and deployment_state.target_info.replica_config.ray_actor_options
|
|
is not None
|
|
and (
|
|
deployment_state.target_info.replica_config.ray_actor_options.get(
|
|
"num_gpus", 0
|
|
)
|
|
> 0
|
|
)
|
|
):
|
|
num_gpu_deployments += 1
|
|
ServeUsageTag.NUM_GPU_DEPLOYMENTS.record(str(num_gpu_deployments))
|
|
|
|
def _reserve_gang_placement_groups(
|
|
self,
|
|
) -> Dict[DeploymentID, GangReservationResult]:
|
|
"""Reserve gang placement groups for deployments.
|
|
|
|
Returns:
|
|
Map of deployment_id to GangReservationResult containing the
|
|
created placement groups or error information.
|
|
"""
|
|
gang_requests: Dict[DeploymentID, GangPlacementGroupRequest] = {}
|
|
|
|
for deployment_id, deployment_state in self._deployment_states.items():
|
|
if not deployment_state._is_gang_deployment:
|
|
continue
|
|
|
|
gang_config = deployment_state.get_gang_config()
|
|
|
|
num_replicas_to_add = deployment_state._get_target_replica_delta()
|
|
if num_replicas_to_add <= 0:
|
|
# Only reserve PGs if we need to add replicas
|
|
continue
|
|
|
|
# Skip if deployment is terminally failed
|
|
if deployment_state._terminally_failed():
|
|
continue
|
|
|
|
# Skip if deployment has replicas still stopping. Their resources
|
|
# haven't been released yet, so PG creation would likely fail or
|
|
# block waiting for resources. We'll retry next reconciliation loop.
|
|
if deployment_state._replicas.count(states=[ReplicaState.STOPPING]) > 0:
|
|
continue
|
|
|
|
# Skip if deployment actors are configured but not yet ready.
|
|
# scale_deployment_replicas() defers replica creation until actors
|
|
# are ready, so PGs created here would be orphaned. Orphaned PGs
|
|
# accumulate every tick (~100ms) and consume cluster resources.
|
|
if (
|
|
deployment_state._get_deployment_actors_configs()
|
|
and not deployment_state._deployment_actors_satisfied_for_target()
|
|
):
|
|
continue
|
|
|
|
replica_config = deployment_state._target_state.info.replica_config
|
|
gang_requests[deployment_id] = GangPlacementGroupRequest(
|
|
deployment_id=deployment_id,
|
|
gang_size=gang_config.gang_size,
|
|
gang_placement_strategy=gang_config.gang_placement_strategy.value,
|
|
num_replicas_to_add=num_replicas_to_add,
|
|
replica_resource_dict=replica_config.resource_dict.copy(),
|
|
replica_placement_group_bundles=(
|
|
replica_config.placement_group_bundles
|
|
),
|
|
replica_pg_bundle_label_selector=(
|
|
replica_config.placement_group_bundle_label_selector
|
|
),
|
|
replica_pg_fallback_strategy=(
|
|
replica_config.placement_group_fallback_strategy
|
|
),
|
|
)
|
|
|
|
if not gang_requests:
|
|
return {}
|
|
|
|
return self._deployment_scheduler.schedule_gang_placement_groups(gang_requests)
|
|
|
|
def record_request_routing_info(self, info: RequestRoutingInfo) -> None:
|
|
"""
|
|
Record request routing information for a replica.
|
|
|
|
Args:
|
|
info: Request routing info including deployment name, replica tag,
|
|
multiplex model ids, and routing stats.
|
|
"""
|
|
deployment_id = info.replica_id.deployment_id
|
|
if deployment_id not in self._deployment_states:
|
|
app_msg = f" in application '{deployment_id.app_name}'"
|
|
logger.error(
|
|
f"Deployment '{deployment_id.name}'{app_msg} not found in state "
|
|
"manager."
|
|
)
|
|
return
|
|
self._deployment_states[deployment_id].record_request_routing_info(info)
|
|
|
|
def get_active_node_ids(self) -> Set[str]:
|
|
"""Return set of node ids with running replicas of any deployment.
|
|
|
|
This is used to determine which node has replicas. Only nodes with replicas and
|
|
head node should have active proxies.
|
|
"""
|
|
node_ids = set()
|
|
for deployment_state in self._deployment_states.values():
|
|
node_ids.update(deployment_state.get_active_node_ids())
|
|
return node_ids
|
|
|
|
def get_ingress_replicas_info(self) -> List[Tuple[str, str, int, int]]:
|
|
"""Get replicas that own direct-ingress ports.
|
|
|
|
Includes both ingress deployments and ingress request router deployments.
|
|
"""
|
|
ingress_replicas_list = [
|
|
deployment_state._replicas.get()
|
|
for deployment_state in self._deployment_states.values()
|
|
if deployment_state.is_ingress()
|
|
or deployment_state.is_ingress_request_router()
|
|
]
|
|
|
|
ingress_replicas_info = []
|
|
for replicas in ingress_replicas_list:
|
|
for replica in replicas:
|
|
ingress_replicas_info.append(
|
|
(
|
|
replica.actor_node_id,
|
|
replica.replica_id.unique_id,
|
|
replica.actor_http_port,
|
|
replica.actor_grpc_port,
|
|
)
|
|
)
|
|
return ingress_replicas_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).
|
|
"""
|
|
deployment_state = self._deployment_states.get(deployment_id)
|
|
if deployment_state is None:
|
|
return {}
|
|
|
|
return deployment_state._get_replica_ranks_mapping()
|
|
|
|
def get_deployment_outbound_deployments(
|
|
self, deployment_id: DeploymentID
|
|
) -> Optional[List[DeploymentID]]:
|
|
"""Get the cached outbound deployments for a specific deployment.
|
|
|
|
Args:
|
|
deployment_id: The deployment ID to get outbound deployments for.
|
|
|
|
Returns:
|
|
List of deployment IDs that this deployment calls, or None if
|
|
the deployment doesn't exist or hasn't been polled yet.
|
|
"""
|
|
deployment_state = self._deployment_states.get(deployment_id)
|
|
if deployment_state is None:
|
|
return None
|
|
|
|
return deployment_state.get_outbound_deployments()
|