Files
2026-07-13 13:17:40 +08:00

1776 lines
72 KiB
Python

import logging
import math
import time
import uuid
from collections import defaultdict
from typing import Dict, List, Optional, Set, Tuple
from ray._common.utils import binary_to_hex
from ray.autoscaler.v2.instance_manager.cloud_providers.kuberay.cloud_provider import (
KubeRayProvider,
)
from ray.autoscaler.v2.instance_manager.common import InstanceUtil
from ray.autoscaler.v2.instance_manager.config import (
AutoscalingConfig,
InstanceReconcileConfig,
Provider,
)
from ray.autoscaler.v2.instance_manager.instance_manager import InstanceManager
from ray.autoscaler.v2.instance_manager.node_provider import (
CloudInstance,
CloudInstanceId,
CloudInstanceProviderError,
ICloudInstanceProvider,
LaunchNodeError,
TerminateNodeError,
)
from ray.autoscaler.v2.instance_manager.subscribers.cloud_resource_monitor import (
CloudResourceMonitor,
)
from ray.autoscaler.v2.instance_manager.subscribers.ray_stopper import RayStopError
from ray.autoscaler.v2.instance_manager.subscribers.threaded_ray_installer import (
RayInstallError,
)
from ray.autoscaler.v2.metrics_reporter import AutoscalerMetricsReporter
from ray.autoscaler.v2.scheduler import IResourceScheduler, SchedulingRequest
from ray.autoscaler.v2.schema import AutoscalerInstance, NodeType
from ray.autoscaler.v2.utils import is_head_node
from ray.core.generated.autoscaler_pb2 import (
AutoscalingState,
ClusterResourceState,
FailedInstanceRequest,
NodeState,
NodeStatus,
PendingInstance,
PendingInstanceRequest,
)
from ray.core.generated.instance_manager_pb2 import (
GetInstanceManagerStateRequest,
Instance as IMInstance,
InstanceUpdateEvent as IMInstanceUpdateEvent,
NodeKind,
StatusCode,
UpdateInstanceManagerStateRequest,
)
logger = logging.getLogger(__name__)
class Reconciler:
"""
A singleton class that reconciles the instance states of the instance manager
for autoscaler.
"""
@staticmethod
def reconcile(
instance_manager: InstanceManager,
scheduler: IResourceScheduler,
cloud_provider: ICloudInstanceProvider,
cloud_resource_monitor: CloudResourceMonitor,
ray_cluster_resource_state: ClusterResourceState,
non_terminated_cloud_instances: Dict[CloudInstanceId, CloudInstance],
autoscaling_config: AutoscalingConfig,
cloud_provider_errors: Optional[List[CloudInstanceProviderError]] = None,
ray_install_errors: Optional[List[RayInstallError]] = None,
ray_stop_errors: Optional[List[RayStopError]] = None,
metrics_reporter: Optional[AutoscalerMetricsReporter] = None,
_logger: Optional[logging.Logger] = None,
) -> AutoscalingState:
"""
The reconcile method computes InstanceUpdateEvents for the instance manager
by:
1. Reconciling the instance manager's instances with external states like
the cloud provider's, the ray cluster's states, the ray installer's results.
It performs "passive" status transitions for the instances (where the status
transition should only be reflecting the external states of the cloud provider
and the ray cluster, and should not be actively changing them)
2. Stepping the instances to the active states by computing instance status
transitions that are needed and updating the instance manager's state.
These transitions should be "active" where the transitions have side effects
(through InstanceStatusSubscriber) to the cloud provider and the ray cluster.
Args:
instance_manager: The instance manager to reconcile.
scheduler: The resource scheduler to make scaling decisions.
cloud_provider: The cloud instance provider used to launch and
terminate nodes.
cloud_resource_monitor: The cloud resource monitor for monitoring
resource availability of all node types.
ray_cluster_resource_state: The ray cluster's resource state.
non_terminated_cloud_instances: The non-terminated cloud instances
from the cloud provider.
autoscaling_config: The autoscaling config.
cloud_provider_errors: The errors from the cloud provider.
ray_install_errors: The errors from RayInstaller.
ray_stop_errors: The errors from RayStopper.
metrics_reporter: The metric reporter to report the autoscaler
metrics.
_logger: The logger (for testing).
Returns:
The updated autoscaling state after reconciling.
"""
cloud_provider_errors = cloud_provider_errors or []
ray_install_errors = ray_install_errors or []
ray_stop_errors = ray_stop_errors or []
autoscaling_state = AutoscalingState()
autoscaling_state.last_seen_cluster_resource_state_version = (
ray_cluster_resource_state.cluster_resource_state_version
)
Reconciler._sync_from(
instance_manager=instance_manager,
ray_nodes=ray_cluster_resource_state.node_states,
non_terminated_cloud_instances=non_terminated_cloud_instances,
cloud_provider_errors=cloud_provider_errors,
ray_install_errors=ray_install_errors,
ray_stop_errors=ray_stop_errors,
autoscaling_config=autoscaling_config,
)
Reconciler._step_next(
autoscaling_state=autoscaling_state,
instance_manager=instance_manager,
scheduler=scheduler,
cloud_provider=cloud_provider,
cloud_resource_monitor=cloud_resource_monitor,
ray_cluster_resource_state=ray_cluster_resource_state,
non_terminated_cloud_instances=non_terminated_cloud_instances,
autoscaling_config=autoscaling_config,
_logger=_logger,
)
Reconciler._report_metrics(
instance_manager=instance_manager,
autoscaling_config=autoscaling_config,
metrics_reporter=metrics_reporter,
)
return autoscaling_state
@staticmethod
def _sync_from(
instance_manager: InstanceManager,
ray_nodes: List[NodeState],
non_terminated_cloud_instances: Dict[CloudInstanceId, CloudInstance],
cloud_provider_errors: List[CloudInstanceProviderError],
ray_install_errors: List[RayInstallError],
ray_stop_errors: List[RayStopError],
autoscaling_config: AutoscalingConfig,
):
"""
Reconcile the instance states of the instance manager from external states like
the cloud provider's, the ray cluster's states, the ray installer's results,
etc.
For each instance, we try to figure out if we need to transition the instance
status to a new status, and if so, what the new status should be.
These transitions should be purely "passive", meaning they should only be
reflecting the external states of the cloud provider and the ray cluster,
and should not be actively changing the states of the cloud provider or the ray
cluster.
More specifically, we will reconcile status transitions for:
1. QUEUED/REQUESTED -> ALLOCATED:
When an instance with launch request id (indicating a previous launch
request was made) could be assigned to an unassigned cloud instance
of the same instance type.
2. REQUESTED -> ALLOCATION_FAILED:
When there's an error from the cloud provider for launch failure so
that the instance becomes ALLOCATION_FAILED.
3. ALLOCATED -> ALLOCATION_TIMEOUT:
When an instance has been allocated to a cloud instance, but is stuck in
this state. For example, a kubernetes pod remains pending due to
insufficient resources.
4. * -> RAY_RUNNING:
When a ray node on a cloud instance joins the ray cluster, we will
transition the instance to RAY_RUNNING.
5. * -> TERMINATED:
When the cloud instance is already terminated, we will transition the
instance to TERMINATED.
6. TERMINATING -> TERMINATION_FAILED:
When there's an error from the cloud provider for termination failure.
7. * -> RAY_STOPPED:
When ray was stopped on the cloud instance, we will transition the
instance to RAY_STOPPED.
8. * -> RAY_INSTALL_FAILED:
When there's an error from RayInstaller.
9. RAY_STOP_REQUESTED -> RAY_RUNNING:
When requested to stop ray, but failed to stop/drain the ray node
(e.g. idle termination drain rejected by the node).
Args:
instance_manager: The instance manager to reconcile.
ray_nodes: The ray cluster's states of ray nodes.
non_terminated_cloud_instances: The non-terminated cloud instances from
the cloud provider.
cloud_provider_errors: The errors from the cloud provider.
ray_install_errors: The errors from RayInstaller.
ray_stop_errors: The errors from RayStopper.
autoscaling_config: The autoscaling config.
"""
# Handle 1 & 2 for cloud instance allocation.
Reconciler._handle_cloud_instance_allocation(
instance_manager,
non_terminated_cloud_instances,
cloud_provider_errors,
)
Reconciler._handle_cloud_instance_terminated(
instance_manager, non_terminated_cloud_instances
)
Reconciler._handle_cloud_instance_termination_errors(
instance_manager, cloud_provider_errors
)
Reconciler._handle_extra_cloud_instances(
instance_manager, non_terminated_cloud_instances, ray_nodes
)
Reconciler._handle_ray_status_transition(
instance_manager, ray_nodes, autoscaling_config
)
Reconciler._handle_ray_install_failed(instance_manager, ray_install_errors)
Reconciler._handle_ray_stop_failed(instance_manager, ray_stop_errors, ray_nodes)
@staticmethod
def _step_next(
autoscaling_state: AutoscalingState,
instance_manager: InstanceManager,
scheduler: IResourceScheduler,
cloud_provider: ICloudInstanceProvider,
cloud_resource_monitor: CloudResourceMonitor,
ray_cluster_resource_state: ClusterResourceState,
non_terminated_cloud_instances: Dict[CloudInstanceId, CloudInstance],
autoscaling_config: AutoscalingConfig,
_logger: Optional[logging.Logger] = None,
):
"""
Step the reconciler to the next state by computing instance status transitions
that are needed and updating the instance manager's state.
Specifically, we will:
1. Shut down leak cloud instances
Leaked cloud instances that are not managed by the instance manager.
2. Terminating instances with ray stopped or ray install failure.
3. Scale down the cluster:
(* -> RAY_STOP_REQUESTED/TERMINATING)
b. Extra cloud due to max nodes config.
c. Cloud instances with outdated configs.
4. Scale up the cluster:
(new QUEUED)
Create new instances based on the IResourceScheduler's decision for
scaling up.
5. Request cloud provider to launch new instances.
(QUEUED -> REQUESTED)
6. Install ray
(ALLOCATED -> RAY_INSTALLING)
When ray could be installed and launched.
7. Reconcile IM instances whose ray nodes are missing from GCS.
8. Handle any stuck instances with timeouts.
Args:
autoscaling_state: The autoscaling state populated by this reconcile loop.
instance_manager: The instance manager to reconcile.
scheduler: The resource scheduler to make scaling decisions.
cloud_provider: The cloud instance provider used to launch and
terminate nodes.
cloud_resource_monitor: The cloud resource monitor for monitoring
resource availability of all node types.
ray_cluster_resource_state: The ray cluster's resource state.
non_terminated_cloud_instances: The non-terminated cloud instances
from the cloud provider.
autoscaling_config: The autoscaling config.
_logger: The logger (for testing).
"""
Reconciler._handle_missing_ray_nodes(
instance_manager=instance_manager,
ray_nodes=ray_cluster_resource_state.node_states,
)
Reconciler._handle_stuck_instances(
instance_manager=instance_manager,
reconcile_config=autoscaling_config.get_instance_reconcile_config(),
_logger=_logger or logger,
)
Reconciler._scale_cluster(
autoscaling_state=autoscaling_state,
instance_manager=instance_manager,
cloud_resource_monitor=cloud_resource_monitor,
ray_state=ray_cluster_resource_state,
scheduler=scheduler,
autoscaling_config=autoscaling_config,
cloud_provider=cloud_provider,
)
# Fix: Terminate instances before launching new ones
# This ensures that when launching new instances, the replica count
# does not include instances that are about to be terminated,
# preventing the maxReplicas limit from being incorrectly triggered.
Reconciler._terminate_instances(instance_manager=instance_manager)
Reconciler._handle_instances_launch(
instance_manager=instance_manager, autoscaling_config=autoscaling_config
)
if not autoscaling_config.disable_node_updaters():
Reconciler._install_ray(
instance_manager=instance_manager,
non_terminated_cloud_instances=non_terminated_cloud_instances,
)
Reconciler._fill_autoscaling_state(
instance_manager=instance_manager,
autoscaling_state=autoscaling_state,
autoscaling_config=autoscaling_config,
)
#######################################################
# Utility methods for reconciling instance states.
#######################################################
@staticmethod
def _handle_cloud_instance_allocation(
instance_manager: InstanceManager,
non_terminated_cloud_instances: Dict[CloudInstanceId, CloudInstance],
cloud_provider_errors: List[CloudInstanceProviderError],
):
im_instances, version = Reconciler._get_im_instances(instance_manager)
updates = {}
# Compute intermediate states.
instances_with_launch_requests: List[IMInstance] = []
for instance in im_instances:
if instance.status != IMInstance.REQUESTED:
continue
assert (
instance.launch_request_id
), "Instance in REQUESTED status should have launch_request_id set."
instances_with_launch_requests.append(instance)
assigned_cloud_instance_ids: Set[CloudInstanceId] = {
instance.cloud_instance_id
for instance in im_instances
if instance.cloud_instance_id
and instance.status
not in [IMInstance.TERMINATED, IMInstance.ALLOCATION_FAILED]
}
launch_errors: Dict[str, LaunchNodeError] = {
error.request_id: error
for error in cloud_provider_errors
if isinstance(error, LaunchNodeError)
}
unassigned_cloud_instances_by_type: Dict[
str, List[CloudInstance]
] = defaultdict(list)
for cloud_instance_id, cloud_instance in non_terminated_cloud_instances.items():
if cloud_instance_id not in assigned_cloud_instance_ids:
unassigned_cloud_instances_by_type[cloud_instance.node_type].append(
cloud_instance
)
# Sort the request instance by the increasing request time.
instances_with_launch_requests.sort(
key=lambda instance: InstanceUtil.get_status_transition_times_ns(
instance, IMInstance.REQUESTED
)
)
# For each instance, try to allocate or fail the allocation.
for instance in instances_with_launch_requests:
# Try allocate or fail with errors.
update_event = Reconciler._try_resolve_pending_allocation(
instance, unassigned_cloud_instances_by_type, launch_errors
)
if not update_event:
continue
updates[instance.instance_id] = update_event
# Update the instance manager for the events.
Reconciler._update_instance_manager(instance_manager, version, updates)
@staticmethod
def _try_resolve_pending_allocation(
im_instance: IMInstance,
unassigned_cloud_instances_by_type: Dict[str, List[CloudInstance]],
launch_errors: Dict[str, LaunchNodeError],
) -> Optional[IMInstanceUpdateEvent]:
"""
Allocate, or fail the cloud instance allocation for the instance.
Args:
im_instance: The instance to allocate or fail.
unassigned_cloud_instances_by_type: The unassigned cloud instances by type.
launch_errors: The launch errors from the cloud provider.
Returns:
Instance update to ALLOCATED: if there's a matching unassigned cloud
instance with the same type.
Instance update to ALLOCATION_FAILED: if the instance allocation failed
with errors.
None: if there's no update.
"""
unassigned_cloud_instance = None
# Try to allocate an unassigned cloud instance.
# TODO(rickyx): We could also look at the launch request id
# on the cloud node and the im instance later once all node providers
# support request id. For now, we only look at the instance type.
if len(unassigned_cloud_instances_by_type.get(im_instance.instance_type, [])):
unassigned_cloud_instance = unassigned_cloud_instances_by_type[
im_instance.instance_type
].pop()
if unassigned_cloud_instance:
return IMInstanceUpdateEvent(
instance_id=im_instance.instance_id,
new_instance_status=IMInstance.ALLOCATED,
cloud_instance_id=unassigned_cloud_instance.cloud_instance_id,
node_kind=unassigned_cloud_instance.node_kind,
instance_type=unassigned_cloud_instance.node_type,
details=(
"allocated unassigned cloud instance "
f"{unassigned_cloud_instance.cloud_instance_id}"
),
)
# If there's a launch error, transition to ALLOCATION_FAILED.
launch_error = launch_errors.get(im_instance.launch_request_id)
if launch_error and launch_error.node_type == im_instance.instance_type:
return IMInstanceUpdateEvent(
instance_id=im_instance.instance_id,
new_instance_status=IMInstance.ALLOCATION_FAILED,
details=f"launch failed with {str(launch_error)}",
)
# No update.
return None
@staticmethod
def _handle_ray_stop_failed(
instance_manager: InstanceManager,
ray_stop_errors: List[RayStopError],
ray_nodes: List[NodeState],
):
"""
The instance requested to stop ray, but failed to stop/drain the ray node.
E.g. connection errors, idle termination drain rejected by the node.
We will transition the instance back to RAY_RUNNING.
Args:
instance_manager: The instance manager to reconcile.
ray_stop_errors: The errors from RayStopper.
ray_nodes: The ray cluster's states of ray nodes.
"""
instances, version = Reconciler._get_im_instances(instance_manager)
updates = {}
ray_stop_errors_by_instance_id = {
error.im_instance_id: error for error in ray_stop_errors
}
ray_nodes_by_ray_node_id = {binary_to_hex(n.node_id): n for n in ray_nodes}
ray_stop_requested_instances = {
instance.instance_id: instance
for instance in instances
if instance.status == IMInstance.RAY_STOP_REQUESTED
}
for instance_id, instance in ray_stop_requested_instances.items():
stop_error = ray_stop_errors_by_instance_id.get(instance_id)
if not stop_error:
continue
assert instance.node_id
ray_node = ray_nodes_by_ray_node_id.get(instance.node_id)
assert ray_node is not None and ray_node.status in [
NodeStatus.RUNNING,
NodeStatus.IDLE,
], (
"There should be a running ray node for instance with ray stop "
"requested failed."
)
updates[instance_id] = IMInstanceUpdateEvent(
instance_id=instance_id,
new_instance_status=IMInstance.RAY_RUNNING,
details="failed to stop/drain ray",
ray_node_id=instance.node_id,
)
Reconciler._update_instance_manager(instance_manager, version, updates)
@staticmethod
def _handle_ray_install_failed(
instance_manager: InstanceManager, ray_install_errors: List[RayInstallError]
):
instances, version = Reconciler._get_im_instances(instance_manager)
updates = {}
# Get all instances with RAY_INSTALLING status.
instances_with_ray_installing = {
instance.instance_id: instance
for instance in instances
if instance.status == IMInstance.RAY_INSTALLING
}
install_errors = {error.im_instance_id: error for error in ray_install_errors}
# For each instance with RAY_INSTALLING status, check if there's any
# install error.
for instance_id, instance in instances_with_ray_installing.items():
install_error = install_errors.get(instance_id)
if install_error:
updates[instance_id] = IMInstanceUpdateEvent(
instance_id=instance_id,
new_instance_status=IMInstance.RAY_INSTALL_FAILED,
details=(
f"failed to install ray with errors: {install_error.details}"
),
)
# Update the instance manager for the events.
Reconciler._update_instance_manager(instance_manager, version, updates)
@staticmethod
def _handle_cloud_instance_terminated(
instance_manager: InstanceManager,
non_terminated_cloud_instances: Dict[CloudInstanceId, CloudInstance],
):
"""
For any IM (instance manager) instance with a cloud node id, if the mapped
cloud instance is no longer running, transition the instance to TERMINATED.
Args:
instance_manager: The instance manager to reconcile.
non_terminated_cloud_instances: The non-terminated cloud instances from
the cloud provider.
"""
updates = {}
instances, version = Reconciler._get_im_instances(instance_manager)
non_terminated_instances_with_cloud_instance_assigned = {
instance.cloud_instance_id: instance
for instance in instances
if instance.cloud_instance_id and instance.status != IMInstance.TERMINATED
}
for (
cloud_instance_id,
instance,
) in non_terminated_instances_with_cloud_instance_assigned.items():
if cloud_instance_id in non_terminated_cloud_instances.keys():
# The cloud instance is still running.
continue
# The cloud instance is terminated.
updates[instance.instance_id] = IMInstanceUpdateEvent(
instance_id=instance.instance_id,
new_instance_status=IMInstance.TERMINATED,
cloud_instance_id=cloud_instance_id,
details=f"cloud instance {cloud_instance_id} no longer found",
)
Reconciler._update_instance_manager(instance_manager, version, updates)
@staticmethod
def _handle_cloud_instance_termination_errors(
instance_manager: InstanceManager,
cloud_provider_errors: List[CloudInstanceProviderError],
):
"""
If any TERMINATING instances have termination errors, transition the instance to
TERMINATION_FAILED.
We will retry the termination for the TERMINATION_FAILED instances in the next
reconciler step.
Args:
instance_manager: The instance manager to reconcile.
cloud_provider_errors: The errors from the cloud provider.
"""
instances, version = Reconciler._get_im_instances(instance_manager)
updates = {}
termination_errors = {
error.cloud_instance_id: error
for error in cloud_provider_errors
if isinstance(error, TerminateNodeError)
}
terminating_instances_by_cloud_instance_id = {
instance.cloud_instance_id: instance
for instance in instances
if instance.status == IMInstance.TERMINATING
}
for cloud_instance_id, failure in termination_errors.items():
instance = terminating_instances_by_cloud_instance_id.get(cloud_instance_id)
if not instance:
# The instance is no longer in TERMINATING status.
continue
updates[instance.instance_id] = IMInstanceUpdateEvent(
instance_id=instance.instance_id,
new_instance_status=IMInstance.TERMINATION_FAILED,
details=f"termination failed: {str(failure)}",
)
Reconciler._update_instance_manager(instance_manager, version, updates)
@staticmethod
def _get_im_instances(
instance_manager: InstanceManager,
) -> Tuple[List[IMInstance], int]:
reply = instance_manager.get_instance_manager_state(
request=GetInstanceManagerStateRequest()
)
assert reply.status.code == StatusCode.OK
im_state = reply.state
return im_state.instances, im_state.version
@staticmethod
def _update_instance_manager(
instance_manager: InstanceManager,
version: int,
updates: Dict[str, IMInstanceUpdateEvent],
) -> None:
if not updates:
return
updates = list(updates.values()) or []
reply = instance_manager.update_instance_manager_state(
request=UpdateInstanceManagerStateRequest(
expected_version=version,
updates=updates,
)
)
# TODO: While it's possible that a version mismatch
# happens, or some other failures could happen. But given
# the current implementation:
# 1. There's only 1 writer (the reconciler) for updating the instance
# manager states, so there shouldn't be version mismatch.
# 2. Any failures in one reconciler step should be caught at a higher
# level and be retried in the next reconciler step. If the IM
# fails to be updated, we don't have sufficient info to handle it
# here.
assert (
reply.status.code == StatusCode.OK
), f"Failed to update instance manager: {reply}"
@staticmethod
def _handle_ray_status_transition(
instance_manager: InstanceManager,
ray_nodes: List[NodeState],
autoscaling_config: AutoscalingConfig,
):
"""
Handle the ray status transition for the instance manager.
If a new ray node running on the instance, transition it to RAY_RUNNING.
If a ray node stopped, transition it to RAY_STOPPED.
If a ray node is draining, transition it to RAY_STOPPING.
Args:
instance_manager: The instance manager to reconcile.
ray_nodes: The ray cluster's states of ray nodes.
autoscaling_config: The autoscaling config.
"""
instances, version = Reconciler._get_im_instances(instance_manager)
updates = {}
im_instances_by_cloud_instance_id = {
instance.cloud_instance_id: instance
for instance in instances
if instance.cloud_instance_id
and instance.status
not in [IMInstance.TERMINATED, IMInstance.ALLOCATION_FAILED]
}
im_instances_by_ray_node_id = {
instance.node_id: instance for instance in instances if instance.node_id
}
for ray_node in ray_nodes:
im_instance = None
ray_node_id = binary_to_hex(ray_node.node_id)
if ray_node_id in im_instances_by_ray_node_id:
im_instance = im_instances_by_ray_node_id[ray_node_id]
else:
if autoscaling_config.provider == Provider.READ_ONLY:
# We will use the node id as the cloud instance id for read-only
# provider.
im_instance = im_instances_by_cloud_instance_id[ray_node_id]
elif ray_node.instance_id:
im_instance = im_instances_by_cloud_instance_id[
ray_node.instance_id
]
else:
# This should only happen to a ray node that's not managed by us.
logger.warning(
f"Ray node {ray_node_id} has no instance id. "
"This only happens to a ray node not managed by autoscaler. "
"If not, please file a bug at "
"https://github.com/ray-project/ray"
)
continue
assert im_instance is not None, (
f"Ray node {ray_node_id} has no matching "
f"instance with cloud instance id={ray_node.instance_id}. We should "
"not see a ray node with cloud instance id not found in IM since "
"we have reconciled all cloud instances, and ray nodes by now."
)
reconciled_im_status = Reconciler._reconciled_im_status_from_ray_status(
ray_node.status, im_instance.status
)
if reconciled_im_status != im_instance.status:
updates[ray_node_id] = IMInstanceUpdateEvent(
instance_id=im_instance.instance_id,
new_instance_status=reconciled_im_status,
details=(
f"ray node {ray_node_id} is "
f"{NodeStatus.Name(ray_node.status)}"
),
ray_node_id=ray_node_id,
instance_type=im_instance.instance_type,
)
Reconciler._update_instance_manager(instance_manager, version, updates)
@staticmethod
def _reconciled_im_status_from_ray_status(
ray_status: NodeStatus, cur_im_status: IMInstance.InstanceStatus
) -> "IMInstance.InstanceStatus":
"""
Reconcile the instance status from the ray node status.
Args:
ray_status: the current ray node status.
cur_im_status: the current IM instance status.
Returns:
The reconciled IM instance status
Raises:
ValueError: If the ray status is unknown.
"""
reconciled_im_status = None
if ray_status in [NodeStatus.RUNNING, NodeStatus.IDLE]:
reconciled_im_status = IMInstance.RAY_RUNNING
elif ray_status == NodeStatus.DEAD:
reconciled_im_status = IMInstance.RAY_STOPPED
elif ray_status == NodeStatus.DRAINING:
reconciled_im_status = IMInstance.RAY_STOPPING
else:
raise ValueError(f"Unknown ray status: {ray_status}")
if (
cur_im_status == reconciled_im_status
or cur_im_status
in InstanceUtil.get_reachable_statuses(reconciled_im_status)
):
# No need to reconcile if the instance is already in the reconciled status
# or has already transitioned beyond it.
return cur_im_status
return reconciled_im_status
@staticmethod
def _handle_instances_launch(
instance_manager: InstanceManager, autoscaling_config: AutoscalingConfig
):
instances, version = Reconciler._get_im_instances(instance_manager)
queued_instances = []
requested_instances = []
running_instances = []
for instance in instances:
if instance.status == IMInstance.QUEUED:
queued_instances.append(instance)
elif instance.status == IMInstance.REQUESTED:
requested_instances.append(instance)
elif instance.status == IMInstance.RAY_RUNNING:
running_instances.append(instance)
if not queued_instances:
# No QUEUED instances
return
to_launch = Reconciler._compute_to_launch(
queued_instances,
requested_instances,
running_instances,
autoscaling_config.get_upscaling_speed(),
autoscaling_config.get_max_concurrent_launches(),
)
# Transition the instances to REQUESTED for instance launcher to
# launch them.
updates = {}
new_launch_request_id = str(uuid.uuid4())
for instance_type, instances in to_launch.items():
for instance in instances:
# Reuse launch request id for any QUEUED instances that have been
# requested before due to retry.
launch_request_id = (
new_launch_request_id
if len(instance.launch_request_id) == 0
else instance.launch_request_id
)
updates[instance.instance_id] = IMInstanceUpdateEvent(
instance_id=instance.instance_id,
new_instance_status=IMInstance.REQUESTED,
launch_request_id=launch_request_id,
instance_type=instance_type,
details=(
f"requested to launch {instance_type} with request id "
f"{launch_request_id}"
),
)
Reconciler._update_instance_manager(instance_manager, version, updates)
@staticmethod
def _compute_to_launch(
queued_instances: List[IMInstance],
requested_instances: List[IMInstance],
running_instances: List[IMInstance],
upscaling_speed: float,
max_concurrent_launches: int,
) -> Dict[NodeType, List[IMInstance]]:
def _group_by_type(instances):
instances_by_type = defaultdict(list)
for instance in instances:
instances_by_type[instance.instance_type].append(instance)
return instances_by_type
# Sort the instances by the time they were queued.
def _sort_by_earliest_queued(instance: IMInstance) -> List[int]:
queue_times = InstanceUtil.get_status_transition_times_ns(
instance, IMInstance.QUEUED
)
return sorted(queue_times)
queued_instances_by_type = _group_by_type(queued_instances)
running_instances_by_type = _group_by_type(running_instances)
total_num_requested_to_launch = len(requested_instances)
all_to_launch: Dict[NodeType : List[IMInstance]] = defaultdict(list)
for (
instance_type,
queued_instances_for_type,
) in queued_instances_by_type.items():
running_instances_for_type = running_instances_by_type.get(
instance_type, []
)
# Enforce the max allowed pending nodes based on current running nodes
num_desired_to_upscale = max(
1,
math.ceil(upscaling_speed * max(len(running_instances_for_type), 1)),
)
# Enforce global limit, at most we can launch `max_concurrent_launches`
num_to_launch = min(
max_concurrent_launches - total_num_requested_to_launch,
num_desired_to_upscale,
)
# Cap both ends 0 <= num_to_launch <= num_queued
num_to_launch = max(0, num_to_launch)
num_to_launch = min(len(queued_instances_for_type), num_to_launch)
to_launch = sorted(queued_instances_for_type, key=_sort_by_earliest_queued)[
:num_to_launch
]
all_to_launch[instance_type].extend(to_launch)
total_num_requested_to_launch += num_to_launch
return all_to_launch
@staticmethod
def _handle_missing_ray_nodes(
instance_manager: InstanceManager,
ray_nodes: List[NodeState],
) -> None:
"""
Reconcile IM instances that still point at ray nodes missing from GCS.
GCS is the source of truth for Ray node liveness. A worker instance in
RAY_RUNNING / RAY_STOP_REQUESTED / RAY_STOPPING with a node_id should
have a corresponding entry in the GCS node snapshot. If the node is
absent entirely, GCS has either GC'd its DEAD entry or otherwise no
longer considers the ray node alive. Treat this the same as a stopped ray
node so the instance can continue through the existing RAY_STOPPED ->
TERMINATING path instead of lingering as RAY_RUNNING with a stale
node_id.
We intentionally skip the head node here. During head startup/restart,
GCS can be reachable while the head raylet is not present in
ClusterResourceState yet; head-node recovery is outside this worker
cleanup path.
Args:
instance_manager: The instance manager to reconcile.
ray_nodes: The ray cluster's view of ray nodes from GCS.
"""
statuses_requiring_ray_node = {
IMInstance.RAY_RUNNING,
IMInstance.RAY_STOP_REQUESTED,
IMInstance.RAY_STOPPING,
}
im_instances, version = Reconciler._get_im_instances(instance_manager)
candidates = [
instance
for instance in im_instances
if instance.status in statuses_requiring_ray_node
and instance.node_kind != NodeKind.HEAD
and instance.node_id
]
if not candidates:
return
ray_node_ids = {binary_to_hex(n.node_id) for n in ray_nodes}
updates = {}
for instance in candidates:
if instance.node_id in ray_node_ids:
continue
updates[instance.instance_id] = IMInstanceUpdateEvent(
instance_id=instance.instance_id,
new_instance_status=IMInstance.RAY_STOPPED,
details=f"ray node {instance.node_id} no longer found in GCS",
ray_node_id=instance.node_id,
instance_type=instance.instance_type,
)
Reconciler._update_instance_manager(instance_manager, version, updates)
@staticmethod
def _handle_stuck_instances(
instance_manager: InstanceManager,
reconcile_config: InstanceReconcileConfig,
_logger: logging.Logger,
):
"""
Handle stuck instances with timeouts.
Instances could be stuck in the following status and needs to be updated:
- REQUESTED: cloud provider is slow/fails to launch instances.
- ALLOCATED: ray fails to be started on the instance.
- RAY_INSTALLING: ray fails to be installed on the instance.
- TERMINATING: cloud provider is slow/fails to terminate instances.
Instances could be in the following status which could be unbounded or
transient, and we don't have a timeout mechanism to handle them. We would
warn if they are stuck for too long:
- RAY_STOPPING: ray taking time to drain.
- QUEUED: cloud provider is slow to launch instances, resulting in long
queue.
Reconciler should handle below statuses, if not, could be slow
reconcilation loop or a bug:
- RAY_INSTALL_FAILED
- RAY_STOPPED
- TERMINATION_FAILED
Args:
instance_manager: The instance manager to reconcile.
reconcile_config: The instance reconcile config.
_logger: The logger to log the warning messages. It's used for testing.
"""
instances, version = Reconciler._get_im_instances(instance_manager)
instances_by_status = defaultdict(list)
for instance in instances:
instances_by_status[instance.status].append(instance)
im_updates = {}
# Fail or retry the cloud instance allocation if it's stuck
# in the REQUESTED state.
for instance in instances_by_status[IMInstance.REQUESTED]:
update = Reconciler._handle_stuck_requested_instance(
instance,
reconcile_config.request_status_timeout_s,
reconcile_config.max_num_retry_request_to_allocate,
)
if update:
im_updates[instance.instance_id] = update
# Leaked ALLOCATED instances should be terminated.
# This usually happens when ray fails to be started on the instance, so
# it's unable to be RAY_RUNNING after a long time.
for instance in instances_by_status[IMInstance.ALLOCATED]:
assert (
instance.cloud_instance_id
), "cloud instance id should be set on ALLOCATED instance"
update = Reconciler._handle_stuck_instance(
instance,
reconcile_config.allocate_status_timeout_s,
new_status=IMInstance.ALLOCATION_TIMEOUT,
cloud_instance_id=instance.cloud_instance_id,
instance_type=instance.instance_type,
)
if update:
im_updates[instance.instance_id] = update
# Fail the installation if it's stuck in RAY_INSTALLING for too long.
# If RAY_INSTALLING is stuck for too long, it's likely that the instance
# is not able to install ray, so we should also fail the installation.
for instance in instances_by_status[IMInstance.RAY_INSTALLING]:
update = Reconciler._handle_stuck_instance(
instance,
reconcile_config.ray_install_status_timeout_s,
new_status=IMInstance.RAY_INSTALL_FAILED,
)
if update:
im_updates[instance.instance_id] = update
# If we tried to terminate the instance, but it doesn't terminate (disappear
# from the cloud provider) after a long time, we fail the termination.
# This will trigger another attempt to terminate the instance.
for instance in instances_by_status[IMInstance.TERMINATING]:
update = Reconciler._handle_stuck_instance(
instance,
reconcile_config.terminating_status_timeout_s,
new_status=IMInstance.TERMINATION_FAILED,
)
if update:
im_updates[instance.instance_id] = update
# If we tried to stop ray on the instance, but it doesn't stop after a long
# time, we will transition it back to RAY_RUNNING as the stop/drain somehow
# failed. Instances whose ray node has already disappeared from GCS are moved
# to RAY_STOPPED earlier by _handle_missing_ray_nodes, so anything still in
# RAY_STOP_REQUESTED here genuinely failed to drain.
for instance in instances_by_status[IMInstance.RAY_STOP_REQUESTED]:
update = Reconciler._handle_stuck_instance(
instance,
reconcile_config.ray_stop_requested_status_timeout_s,
new_status=IMInstance.RAY_RUNNING,
ray_node_id=instance.node_id,
)
if update:
im_updates[instance.instance_id] = update
# These statues could be unbounded or transient, and we don't have a timeout
# mechanism to handle them. We only warn if they are stuck for too long.
for status in [
# Ray taking time to drain. We could also have a timeout when Drain protocol
# supports timeout.
IMInstance.RAY_STOPPING,
# These should just be transient, we will terminate instances with this
# status in the next reconciler step.
IMInstance.RAY_INSTALL_FAILED,
IMInstance.RAY_STOPPED,
IMInstance.TERMINATION_FAILED,
# Instances could be in the QUEUED status for a long time if the cloud
# provider is slow to launch instances.
IMInstance.QUEUED,
]:
Reconciler._warn_stuck_instances(
instances_by_status[status],
status=status,
warn_interval_s=reconcile_config.transient_status_warn_interval_s,
logger=_logger,
)
Reconciler._update_instance_manager(instance_manager, version, im_updates)
@staticmethod
def _warn_stuck_instances(
instances: List[IMInstance],
status: IMInstance.InstanceStatus,
warn_interval_s: int,
logger: logging.Logger,
):
"""Warn if any instance is stuck in a transient/unbounded status for too
long.
"""
for instance in instances:
status_times_ns = InstanceUtil.get_status_transition_times_ns(
instance, select_instance_status=status
)
assert len(status_times_ns) >= 1
status_time_ns = sorted(status_times_ns)[-1]
if time.time_ns() - status_time_ns > warn_interval_s * 1e9:
logger.warning(
"Instance {}({}) is stuck in {} for {} seconds.".format(
instance.instance_id,
IMInstance.InstanceStatus.Name(instance.status),
IMInstance.InstanceStatus.Name(status),
(time.time_ns() - status_time_ns) // 1e9,
)
)
@staticmethod
def _is_head_node_running(instance_manager: InstanceManager) -> bool:
"""
Check if the head node is running and ready.
If we scale up the cluster before head node is running,
it would cause issues when launching the worker nodes.
There are corner cases when the GCS is up (so the ray cluster resource
state is retrievable from the GCS), but the head node's raylet is not
running so the head node is missing from the reported nodes. This happens
when the head node is still starting up, or the raylet is not running
due to some issues, and this would yield false.
Args:
instance_manager: The instance manager to reconcile.
Returns:
True if the head node is running and ready, False otherwise.
"""
im_instances, _ = Reconciler._get_im_instances(instance_manager)
for instance in im_instances:
if instance.node_kind == NodeKind.HEAD:
if instance.status == IMInstance.RAY_RUNNING:
return True
return False
@staticmethod
def _scale_cluster(
autoscaling_state: AutoscalingState,
instance_manager: InstanceManager,
cloud_resource_monitor: CloudResourceMonitor,
ray_state: ClusterResourceState,
scheduler: IResourceScheduler,
autoscaling_config: AutoscalingConfig,
cloud_provider: ICloudInstanceProvider,
) -> None:
"""
Scale the cluster based on the resource state and the resource scheduler's
decision:
- It launches new instances if needed.
- It terminates extra ray nodes if they should be shut down (preemption
or idle termination)
Args:
autoscaling_state: The autoscaling state to reconcile.
instance_manager: The instance manager to reconcile.
cloud_resource_monitor: The cloud resource monitor for monitoring resource
availability of all node types.
ray_state: The ray cluster's resource state.
scheduler: The resource scheduler to make scaling decisions.
autoscaling_config: The autoscaling config.
cloud_provider: The cloud provider interface.
"""
# Get the current instance states.
im_instances, version = Reconciler._get_im_instances(instance_manager)
im_instances_by_instance_id = {
i.instance_id: i for i in im_instances if i.instance_id
}
autoscaler_instances = []
ray_nodes_by_id = {
binary_to_hex(node.node_id): node for node in ray_state.node_states
}
for im_instance in im_instances:
ray_node = ray_nodes_by_id.get(im_instance.node_id)
autoscaler_instances.append(
AutoscalerInstance(
ray_node=ray_node,
im_instance=im_instance,
cloud_instance_id=(
im_instance.cloud_instance_id
if im_instance.cloud_instance_id
else None
),
)
)
# TODO(rickyx): We should probably name it as "Planner" or "Scaler"
# or "ClusterScaler"
sched_request = SchedulingRequest(
node_type_configs=autoscaling_config.get_node_type_configs(),
max_num_nodes=autoscaling_config.get_max_num_nodes(),
resource_requests=ray_state.pending_resource_requests,
gang_resource_requests=ray_state.pending_gang_resource_requests,
cluster_resource_constraints=ray_state.cluster_resource_constraints,
current_instances=autoscaler_instances,
idle_timeout_s=autoscaling_config.get_idle_timeout_s(),
disable_launch_config_check=(
autoscaling_config.disable_launch_config_check()
),
cloud_resource_availabilities=(
cloud_resource_monitor.get_resource_availabilities()
),
recoverable_resource_availabilities=(
cloud_resource_monitor.get_recoverable_resource_availabilities()
),
)
if isinstance(cloud_provider, KubeRayProvider):
sched_request.ippr_specs = cloud_provider.get_ippr_specs()
sched_request.ippr_statuses = cloud_provider.get_ippr_statuses()
# Ask scheduler for updates to the cluster shape.
reply = scheduler.schedule(sched_request)
# Populate the autoscaling state.
autoscaling_state.infeasible_resource_requests.extend(
reply.infeasible_resource_requests
)
autoscaling_state.infeasible_gang_resource_requests.extend(
reply.infeasible_gang_resource_requests
)
autoscaling_state.infeasible_cluster_resource_constraints.extend(
reply.infeasible_cluster_resource_constraints
)
if not Reconciler._is_head_node_running(instance_manager):
# We shouldn't be scaling the cluster until the head node is ready.
# This could happen when the head node (i.e. the raylet) is still
# pending registration even though GCS is up.
# We will wait until the head node is running and ready to avoid
# scaling the cluster from min worker nodes constraint.
return
if autoscaling_config.provider == Provider.READ_ONLY:
# We shouldn't be scaling the cluster if the provider is read-only.
return
# Scale the clusters if needed.
to_launch = reply.to_launch
to_terminate = reply.to_terminate
updates = {}
# Add terminating instances.
for terminate_request in to_terminate:
instance_id = terminate_request.instance_id
if terminate_request.instance_status == IMInstance.QUEUED:
# QUEUED instances have no cloud resources allocated yet.
# Cancel the allocation request by transitioning directly to TERMINATED.
updates[terminate_request.instance_id] = IMInstanceUpdateEvent(
instance_id=instance_id,
new_instance_status=IMInstance.TERMINATED,
termination_request=terminate_request,
details=f"allocation canceled: {terminate_request.details}",
)
elif terminate_request.instance_status in (
IMInstance.ALLOCATED,
IMInstance.RAY_INSTALLING,
):
# The instance is not yet running, so we can't request to stop/drain Ray.
# Therefore, we can skip the RAY_STOP_REQUESTED state and directly terminate the node.
im_instance_to_terminate = im_instances_by_instance_id[instance_id]
updates[terminate_request.instance_id] = IMInstanceUpdateEvent(
instance_id=instance_id,
new_instance_status=IMInstance.TERMINATING,
cloud_instance_id=im_instance_to_terminate.cloud_instance_id,
termination_request=terminate_request,
details=f"terminating ray: {terminate_request.details}",
)
else:
updates[terminate_request.instance_id] = IMInstanceUpdateEvent(
instance_id=instance_id,
new_instance_status=IMInstance.RAY_STOP_REQUESTED,
termination_request=terminate_request,
details=f"draining ray: {terminate_request.details}",
)
# Add new instances.
for launch_request in to_launch:
for _ in range(launch_request.count):
instance_id = InstanceUtil.random_instance_id()
updates[instance_id] = IMInstanceUpdateEvent(
instance_id=instance_id,
new_instance_status=IMInstance.QUEUED,
instance_type=launch_request.instance_type,
upsert=True,
details=(
f"queuing new instance of {launch_request.instance_type} "
"from scheduler"
),
)
if isinstance(cloud_provider, KubeRayProvider):
cloud_provider.do_ippr_requests(reply.to_ippr)
Reconciler._update_instance_manager(instance_manager, version, updates)
@staticmethod
def _terminate_instances(instance_manager: InstanceManager):
"""
Terminate instances with the below statuses:
- RAY_STOPPED: ray was stopped on the cloud instance.
- ALLOCATION_TIMEOUT: cloud provider timed out to allocate a running cloud instance.
- RAY_INSTALL_FAILED: ray installation failed on the cloud instance,
we will not retry.
- TERMINATION_FAILED: cloud provider failed to terminate the instance
or timeout for termination happened, we will retry again.
Args:
instance_manager: The instance manager to reconcile.
"""
im_instances, version = Reconciler._get_im_instances(instance_manager)
updates = {}
statuses_to_terminate = {
IMInstance.RAY_STOPPED,
IMInstance.ALLOCATION_TIMEOUT,
IMInstance.RAY_INSTALL_FAILED,
IMInstance.TERMINATION_FAILED,
}
inactive_statuses = statuses_to_terminate | {
IMInstance.TERMINATED,
IMInstance.ALLOCATION_FAILED,
}
# A RAY_STOPPED row can be stale after a raylet restart on the same
# cloud instance. If another active IM row still owns that cloud
# instance, clean up only the stale row without terminating the cloud.
active_cloud_instance_ids = {
instance.cloud_instance_id
for instance in im_instances
if instance.cloud_instance_id and instance.status not in inactive_statuses
}
for instance in im_instances:
if instance.status not in statuses_to_terminate:
continue
if (
instance.status == IMInstance.RAY_STOPPED
and instance.cloud_instance_id in active_cloud_instance_ids
):
updates[instance.instance_id] = IMInstanceUpdateEvent(
instance_id=instance.instance_id,
new_instance_status=IMInstance.TERMINATED,
details=(
"marking stale instance record as terminated from "
f"{IMInstance.InstanceStatus.Name(instance.status)} "
"without terminating its cloud instance"
),
)
continue
# Terminate the instance.
updates[instance.instance_id] = IMInstanceUpdateEvent(
instance_id=instance.instance_id,
new_instance_status=IMInstance.TERMINATING,
cloud_instance_id=instance.cloud_instance_id,
details="terminating instance from "
f"{IMInstance.InstanceStatus.Name(instance.status)}",
)
Reconciler._update_instance_manager(instance_manager, version, updates)
@staticmethod
def _install_ray(
instance_manager: InstanceManager,
non_terminated_cloud_instances: Dict[CloudInstanceId, CloudInstance],
) -> None:
"""
Install ray on the allocated instances when it's ready (cloud instance
should be running)
This is needed if ray installation needs to be performed by
the instance manager.
Args:
instance_manager: The instance manager to reconcile.
non_terminated_cloud_instances: The non-terminated cloud instances
from the cloud provider.
"""
im_instances, version = Reconciler._get_im_instances(instance_manager)
updates = {}
for instance in im_instances:
if instance.status != IMInstance.ALLOCATED:
continue
if instance.node_kind == NodeKind.HEAD:
# Skip head node.
continue
cloud_instance = non_terminated_cloud_instances.get(
instance.cloud_instance_id
)
assert cloud_instance, (
f"Cloud instance {instance.cloud_instance_id} is not found "
"in non_terminated_cloud_instances."
)
if not cloud_instance.is_running:
# It might still be pending (e.g. setting up ssh)
continue
# Install ray on the running cloud instance
updates[instance.instance_id] = IMInstanceUpdateEvent(
instance_id=instance.instance_id,
new_instance_status=IMInstance.RAY_INSTALLING,
details="installing ray",
)
Reconciler._update_instance_manager(instance_manager, version, updates)
@staticmethod
def _fill_autoscaling_state(
instance_manager: InstanceManager,
autoscaling_state: AutoscalingState,
autoscaling_config: AutoscalingConfig,
) -> None:
def get_provider_instance_type(instance_type: str) -> str:
return autoscaling_config.get_provider_instance_type(instance_type)
# Use the IM instance version for the autoscaler_state_version
instances, version = Reconciler._get_im_instances(instance_manager)
autoscaling_state.autoscaler_state_version = version
# Group instances by status
instances_by_status = defaultdict(list)
for instance in instances:
instances_by_status[instance.status].append(instance)
# Pending instance requests
instances_by_launch_request = defaultdict(list)
queued_instances = []
for instance in (
instances_by_status[IMInstance.REQUESTED]
+ instances_by_status[IMInstance.QUEUED]
):
if instance.launch_request_id:
instances_by_launch_request[instance.launch_request_id].append(instance)
else:
queued_instances.append(instance)
for _, instances in instances_by_launch_request.items():
num_instances_by_type = defaultdict(int)
for instance in instances:
num_instances_by_type[instance.instance_type] += 1
# All instances with same request id should have the same
# request time.
request_update = InstanceUtil.get_last_status_transition(
instances[0], IMInstance.REQUESTED
)
request_time_ns = request_update.timestamp_ns if request_update else 0
for instance_type, count in num_instances_by_type.items():
autoscaling_state.pending_instance_requests.append(
PendingInstanceRequest(
instance_type_name=get_provider_instance_type(instance_type),
ray_node_type_name=instance_type,
count=int(count),
request_ts=int(request_time_ns // 1e9),
)
)
# Pending instances
for instance in (
instances_by_status[IMInstance.ALLOCATED]
+ instances_by_status[IMInstance.RAY_INSTALLING]
):
status_history = sorted(
instance.status_history, key=lambda x: x.timestamp_ns, reverse=True
)
autoscaling_state.pending_instances.append(
PendingInstance(
instance_id=instance.instance_id,
instance_type_name=get_provider_instance_type(
instance.instance_type
),
ray_node_type_name=instance.instance_type,
details=status_history[0].details,
)
)
# Failed instance requests
for instance in instances_by_status[IMInstance.ALLOCATION_FAILED]:
request_status_update = InstanceUtil.get_last_status_transition(
instance, IMInstance.REQUESTED
)
failed_status_update = InstanceUtil.get_last_status_transition(
instance, IMInstance.ALLOCATION_FAILED
)
failed_time = (
failed_status_update.timestamp_ns if failed_status_update else 0
)
request_time = (
request_status_update.timestamp_ns if request_status_update else 0
)
autoscaling_state.failed_instance_requests.append(
FailedInstanceRequest(
instance_type_name=get_provider_instance_type(
instance.instance_type
),
ray_node_type_name=instance.instance_type,
start_ts=int(request_time // 1e9),
failed_ts=int(
failed_time // 1e9,
),
reason=failed_status_update.details,
count=1,
)
)
@staticmethod
def _handle_stuck_requested_instance(
instance: IMInstance, timeout_s: int, max_num_retry_request_to_allocate: int
) -> Optional[IMInstanceUpdateEvent]:
"""
Fail the cloud instance allocation if it's stuck in the REQUESTED state.
Args:
instance: The instance to handle.
timeout_s: The timeout in seconds.
max_num_retry_request_to_allocate: The maximum number of times an instance
could be requested to allocate.
Returns:
Instance update to ALLOCATION_FAILED: if the instance allocation failed
with errors.
None: if there's no update.
"""
if not InstanceUtil.has_timeout(instance, timeout_s):
# Not timeout yet, be patient.
return None
all_request_times_ns = sorted(
InstanceUtil.get_status_transition_times_ns(
instance, select_instance_status=IMInstance.REQUESTED
)
)
# Fail the allocation if we have tried too many times.
if len(all_request_times_ns) > max_num_retry_request_to_allocate:
return IMInstanceUpdateEvent(
instance_id=instance.instance_id,
new_instance_status=IMInstance.ALLOCATION_FAILED,
details=(
"failed to allocate cloud instance after "
f"{len(all_request_times_ns)} attempts > "
f"max_num_retry_request_to_allocate={max_num_retry_request_to_allocate}" # noqa
),
)
# Retry the allocation if we could by transitioning to QUEUED again.
return IMInstanceUpdateEvent(
instance_id=instance.instance_id,
new_instance_status=IMInstance.QUEUED,
details=f"queue again to launch after timeout={timeout_s}s",
)
@staticmethod
def _handle_stuck_instance(
instance: IMInstance,
timeout_s: int,
new_status: IMInstance.InstanceStatus,
**update_kwargs: Dict,
) -> Optional[IMInstanceUpdateEvent]:
"""
Fail the instance if it's stuck in the status for too long.
Args:
instance: The instance to handle.
timeout_s: The timeout in seconds.
new_status: The new status to transition to.
**update_kwargs: Keyword arguments for InstanceUpdateEvent.
Returns:
Instance update to the new status: if the instance is stuck in the status
for too long.
None: if there's no update.
"""
if not InstanceUtil.has_timeout(instance, timeout_s):
# Not timeout yet, be patient.
return None
return IMInstanceUpdateEvent(
instance_id=instance.instance_id,
new_instance_status=new_status,
details=f"timeout={timeout_s}s at status "
f"{IMInstance.InstanceStatus.Name(instance.status)}",
**update_kwargs,
)
@staticmethod
def _handle_extra_cloud_instances(
instance_manager: InstanceManager,
non_terminated_cloud_instances: Dict[CloudInstanceId, CloudInstance],
ray_nodes: List[NodeState],
):
"""
For extra cloud instances (i.e. cloud instances that are non terminated as
returned by cloud provider, but not managed by the instance manager), we
will create new IM instances with ALLOCATED status.
Such instances could either be:
1. Leaked instances that are incorrectly started by the cloud instance
provider, and they would be terminated eventually if they fail to
transition to RAY_RUNNING by stuck instances reconciliation, or they
would join the ray cluster and be terminated when the cluster scales down.
2. Instances that are started by the cloud instance provider intentionally
but not yet discovered by the instance manager. This could happen for
a. Head node that's started before the autoscaler.
b. Worker nodes that's started by the cloud provider upon users'
actions: i.e. KubeRay scaling up the cluster with ray cluster config
change.
3. Ray nodes with cloud instance id not in the cloud provider. This could
happen if there's delay in the Ray's state (i.e. cloud instance already
terminated, but the ray node is still not dead yet).
Args:
instance_manager: The instance manager to reconcile.
non_terminated_cloud_instances: The non-terminated cloud instances from
the cloud provider.
ray_nodes: The ray cluster's states of ray nodes.
"""
Reconciler._handle_extra_cloud_instances_from_ray_nodes(
instance_manager, ray_nodes
)
Reconciler._handle_extra_cloud_instances_from_cloud_provider(
instance_manager, non_terminated_cloud_instances
)
@staticmethod
def _handle_extra_cloud_instances_from_cloud_provider(
instance_manager: InstanceManager,
non_terminated_cloud_instances: Dict[CloudInstanceId, CloudInstance],
):
"""
For extra cloud instances that are not managed by the instance manager but
are running in the cloud provider, we will create new IM instances with
ALLOCATED status.
Args:
instance_manager: The instance manager to reconcile.
non_terminated_cloud_instances: The non-terminated cloud instances from
the cloud provider.
"""
updates = {}
instances, version = Reconciler._get_im_instances(instance_manager)
cloud_instance_ids_managed_by_im = {
instance.cloud_instance_id
for instance in instances
if instance.cloud_instance_id
and instance.status
not in [IMInstance.TERMINATED, IMInstance.ALLOCATION_FAILED]
}
# Find the extra cloud instances that are not managed by the instance manager.
for cloud_instance_id, cloud_instance in non_terminated_cloud_instances.items():
if cloud_instance_id in cloud_instance_ids_managed_by_im:
continue
updates[cloud_instance_id] = IMInstanceUpdateEvent(
instance_id=InstanceUtil.random_instance_id(), # Assign a new id.
cloud_instance_id=cloud_instance_id,
new_instance_status=IMInstance.ALLOCATED,
node_kind=cloud_instance.node_kind,
instance_type=cloud_instance.node_type,
details=(
"allocated unmanaged cloud instance :"
f"{cloud_instance.cloud_instance_id} "
f"({NodeKind.Name(cloud_instance.node_kind)}) from cloud provider"
),
upsert=True,
)
Reconciler._update_instance_manager(instance_manager, version, updates)
@staticmethod
def _handle_extra_cloud_instances_from_ray_nodes(
instance_manager: InstanceManager, ray_nodes: List[NodeState]
):
"""
For extra cloud instances reported by Ray but not managed by the instance
manager, we will create new IM instances with ALLOCATED status.
Args:
instance_manager: The instance manager to reconcile.
ray_nodes: The ray cluster's states of ray nodes.
"""
updates = {}
instances, version = Reconciler._get_im_instances(instance_manager)
cloud_instance_ids_managed_by_im = {
instance.cloud_instance_id
for instance in instances
if instance.cloud_instance_id
and not instance.node_id
and instance.status
not in [IMInstance.TERMINATED, IMInstance.ALLOCATION_FAILED]
}
ray_node_ids_managed_by_im = {
instance.node_id for instance in instances if instance.node_id
}
for ray_node in ray_nodes:
if not ray_node.instance_id:
continue
ray_node_id = binary_to_hex(ray_node.node_id)
if ray_node_id in ray_node_ids_managed_by_im:
continue
cloud_instance_id = ray_node.instance_id
if cloud_instance_id in cloud_instance_ids_managed_by_im:
continue
is_head = is_head_node(ray_node)
updates[ray_node_id] = IMInstanceUpdateEvent(
instance_id=InstanceUtil.random_instance_id(), # Assign a new id.
cloud_instance_id=cloud_instance_id,
new_instance_status=IMInstance.ALLOCATED,
node_kind=NodeKind.HEAD if is_head else NodeKind.WORKER,
ray_node_id=ray_node_id,
instance_type=ray_node.ray_node_type_name,
details=(
"allocated unmanaged worker cloud instance from ray node: "
f"{ray_node_id}"
),
upsert=True,
)
Reconciler._update_instance_manager(instance_manager, version, updates)
@staticmethod
def _report_metrics(
instance_manager: InstanceManager,
autoscaling_config: AutoscalingConfig,
metrics_reporter: Optional[AutoscalerMetricsReporter] = None,
):
if not metrics_reporter:
return
instances, _ = Reconciler._get_im_instances(instance_manager)
node_type_configs = autoscaling_config.get_node_type_configs()
metrics_reporter.report_instances(instances, node_type_configs)
metrics_reporter.report_resources(instances, node_type_configs)