chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,768 @@
import copy
import logging
import time
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Set, Tuple
import requests
from ray._raylet import RAY_INTERNAL_NAMESPACE_PREFIX, GcsClient
# TODO(rickyx): We should eventually remove these imports
# when we deprecate the v1 kuberay node provider.
from ray.autoscaler._private.kuberay.node_provider import (
KUBERAY_KIND_HEAD,
KUBERAY_KIND_WORKER,
KUBERAY_LABEL_KEY_KIND,
KUBERAY_LABEL_KEY_TYPE,
RAY_HEAD_POD_NAME,
IKubernetesHttpApiClient,
KubernetesHttpApiClient,
_worker_group_index,
_worker_group_max_replicas,
_worker_group_num_of_hosts,
_worker_group_replicas,
worker_delete_patch,
worker_replica_patch,
)
from ray.autoscaler.v2.instance_manager.cloud_providers.kuberay.ippr_provider import (
KubeRayIPPRProvider,
)
from ray.autoscaler.v2.instance_manager.node_provider import (
CloudInstance,
CloudInstanceId,
CloudInstanceProviderError,
ICloudInstanceProvider,
LaunchNodeError,
NodeKind,
TerminateNodeError,
)
from ray.autoscaler.v2.schema import IPPRSpecs, IPPRStatus, NodeType
logger = logging.getLogger(__name__)
# Annotation the KubeRay operator acts on to terminate the cluster.
NO_DRIVER_TTL_EXPIRED_ANNOTATION = "ray.io/no-driver-ttl-expired"
AUTOSCALER_OPTIONS_KEY = "autoscalerOptions"
NO_DRIVER_TIMEOUT_SECONDS_KEY = "noDriverTimeoutSeconds"
class KubeRayProvider(ICloudInstanceProvider):
"""
This class is a thin wrapper around the Kubernetes API client. It modifies
the RayCluster resource spec on the Kubernetes API server to scale the cluster:
It launches new instances/nodes by submitting patches to the Kubernetes API
to update the RayCluster CRD.
"""
def __init__(
self,
cluster_name: str,
provider_config: Dict[str, Any],
gcs_client: GcsClient,
k8s_api_client: Optional[IKubernetesHttpApiClient] = None,
):
"""
Initializes a new KubeRayProvider.
Args:
cluster_name: The name of the RayCluster resource.
provider_config: The configuration for the RayCluster.
gcs_client: The client to the GCS server. Will be used for resizing raylets.
k8s_api_client: The client to the Kubernetes
API server. This can be used to mock the Kubernetes API server for testing.
"""
self._cluster_name = cluster_name
self._namespace = provider_config["namespace"]
self._k8s_api_client = k8s_api_client or KubernetesHttpApiClient(
namespace=self._namespace
)
self._gcs_client = gcs_client
# Below are states that are cached locally.
self._requests = set()
self._launch_errors_queue = []
self._terminate_errors_queue = []
# Below are states for idle-cluster termination tracking.
# Monotonic timestamp when no driver was first observed; None resets it.
self._no_driver_observed_since: Optional[float] = None
# Latest GCS job end time seen; a newer one means a driver came and went.
self._last_seen_job_end_time = 0
# No-driver timeout (seconds) from the CR; None disables the feature.
self._no_driver_timeout_seconds: Optional[float] = None
# Below are states that are fetched from the Kubernetes API server.
self._ray_cluster = None
self._cached_instances: Dict[CloudInstanceId, CloudInstance]
self._ippr_provider = KubeRayIPPRProvider(
gcs_client=gcs_client, k8s_api_client=self._k8s_api_client
)
@dataclass
class ScaleRequest:
"""Represents a scale request that contains the current states and go-to states
for the ray cluster.
This class will be converted to patches to be submitted to the Kubernetes API
server:
- For launching new instances, it will adjust the `replicas` field in the
workerGroupSpecs.
- For terminating instances, it will adjust the `workersToDelete` field in the
workerGroupSpecs.
"""
# The desired number of workers for each node type.
desired_num_workers: Dict[NodeType, int] = field(default_factory=dict)
# The workers to delete for each node type.
workers_to_delete: Dict[NodeType, List[CloudInstanceId]] = field(
default_factory=dict
)
# The worker groups with empty workersToDelete field.
# This is needed since we will also need to clear the workersToDelete field
# for the worker groups that have finished deletes.
worker_groups_without_pending_deletes: Set[NodeType] = field(
default_factory=set
)
# The worker groups that still have workers to be deleted.
worker_groups_with_pending_deletes: Set[NodeType] = field(default_factory=set)
################################
# Interface for ICloudInstanceProvider
################################
def get_non_terminated(self) -> Dict[CloudInstanceId, CloudInstance]:
self._sync_with_api_server()
self._evaluate_no_driver_termination()
return copy.deepcopy(dict(self._cached_instances))
def terminate(self, ids: List[CloudInstanceId], request_id: str) -> None:
if request_id in self._requests:
# This request is already processed.
logger.warning(f"Request {request_id} is already processed for: {ids}")
return
logger.info("Terminating worker pods: {}".format(ids))
scale_request = None
try:
scale_request = self._initialize_scale_request(
to_launch={}, to_delete_instances=ids
)
if scale_request.worker_groups_with_pending_deletes:
errors_msg = (
"There are workers to be deleted from: "
f"{scale_request.worker_groups_with_pending_deletes}. "
"Waiting for them to be deleted before adding new workers "
" to be deleted"
)
logger.warning(errors_msg)
self._add_terminate_errors(
ids,
request_id,
details=errors_msg,
)
return
self._submit_scale_request(scale_request)
# Only add to processed requests if successful
self._requests.add(request_id)
except Exception as e:
logger.exception(f"Error terminating nodes: {scale_request or 'N/A'}")
self._add_terminate_errors(ids, request_id, details=str(e), e=e)
def launch(self, shape: Dict[NodeType, int], request_id: str) -> None:
if request_id in self._requests:
# This request is already processed.
return
scale_request = None
try:
scale_request = self._initialize_scale_request(
to_launch=shape, to_delete_instances=[]
)
if scale_request.worker_groups_with_pending_deletes:
error_msg = (
"There are workers to be deleted from: "
f"{scale_request.worker_groups_with_pending_deletes}. "
"Waiting for them to be deleted before creating new workers."
)
logger.warning(error_msg)
self._add_launch_errors(
shape,
request_id,
details=error_msg,
)
return
self._submit_scale_request(scale_request)
# Only add to processed requests if successful
self._requests.add(request_id)
except Exception as e:
logger.exception(f"Error launching nodes: {scale_request or 'N/A'}")
self._add_launch_errors(shape, request_id, details=str(e), e=e)
def poll_errors(self) -> List[CloudInstanceProviderError]:
errors = []
errors += self._launch_errors_queue
errors += self._terminate_errors_queue
self._launch_errors_queue = []
self._terminate_errors_queue = []
return errors
def get_ippr_specs(self) -> IPPRSpecs:
"""Return the cached, validated IPPR specs for the cluster.
The IPPR specs are refreshed during the provider's periodic sync with the
API server by reading the RayCluster annotation and validating it against
the IPPR schema.
"""
return self._ippr_provider.get_ippr_specs()
def get_ippr_statuses(self) -> Dict[str, IPPRStatus]:
"""Return the latest per-pod IPPR statuses keyed by pod name.
These statuses are refreshed from the current pod list during the provider's
periodic sync with the API server.
"""
return self._ippr_provider.get_ippr_statuses()
def do_ippr_requests(self, resizes: List[IPPRStatus]) -> None:
"""Execute IPPR resize requests via the underlying IPPR provider.
Args:
resizes: The list of per-pod IPPR actions produced by the scheduler.
"""
self._ippr_provider.do_ippr_requests(resizes)
############################
# Private
############################
def _initialize_scale_request(
self, to_launch: Dict[NodeType, int], to_delete_instances: List[CloudInstanceId]
) -> "KubeRayProvider.ScaleRequest":
"""
Initialize the scale request based on the current state of the cluster and
the desired state (to launch, to delete).
Args:
to_launch: The desired number of workers to launch for each node type.
to_delete_instances: The instances to delete.
Returns:
The scale request.
"""
# Update the cached states.
self._sync_with_api_server()
ray_cluster = self.ray_cluster
cur_instances = self.instances
# Get the worker groups that have pending deletes and the worker groups that
# have finished deletes, and the set of workers included in the workersToDelete
# field of any worker group.
(
worker_groups_with_pending_deletes,
worker_groups_without_pending_deletes,
worker_to_delete_set,
) = self._get_workers_delete_info(ray_cluster, set(cur_instances.keys()))
observed_workers_dict = defaultdict(int)
for instance in cur_instances.values():
if instance.node_kind != NodeKind.WORKER:
continue
if instance.cloud_instance_id in worker_to_delete_set:
continue
observed_workers_dict[instance.node_type] += 1
# Calculate the desired number of workers by type.
num_workers_dict = defaultdict(int)
worker_groups = ray_cluster["spec"].get("workerGroupSpecs", [])
for worker_group in worker_groups:
node_type = worker_group["groupName"]
# Handle the case where users manually increase `minReplicas`
# to scale up the number of worker Pods. In this scenario,
# `replicas` will be smaller than `minReplicas`.
# num_workers_dict should account for multi-host replicas when
# `numOfHosts`` is set.
num_of_hosts = worker_group.get("numOfHosts", 1)
replicas = (
max(worker_group["replicas"], worker_group["minReplicas"])
* num_of_hosts
)
# The `replicas` field in worker group specs can be updated by users at any time.
# However, users should only increase the field (manually upscaling the worker group), not decrease it,
# because downscaling the worker group requires specifying which workers to delete explicitly in the `workersToDelete` field.
# Since we don't have a way to enforce this, we need to fix unexpected decreases on the `replicas` field by using actual observations.
# For example, if the user manually decreases the `replicas` field to 0 without specifying which workers to delete,
# we should fix the `replicas` field back to the number of observed workers excluding the workers to be deleted,
# otherwise, we won't have a correct `replicas` matches the actual number of workers eventually.
num_workers_dict[node_type] = max(
replicas, observed_workers_dict[node_type]
)
# Add to launch nodes.
for node_type, count in to_launch.items():
num_workers_dict[node_type] += count
to_delete_instances_by_type = defaultdict(list)
# Update the number of workers with to_delete_instances
# and group them by type.
for to_delete_id in to_delete_instances:
to_delete_instance = cur_instances.get(to_delete_id, None)
if to_delete_instance is None:
# This instance has already been deleted.
continue
if to_delete_instance.node_kind == NodeKind.HEAD:
# Not possible to delete head node.
continue
if to_delete_instance.cloud_instance_id in worker_to_delete_set:
# If the instance is already in the workersToDelete field of
# any worker group, skip it.
continue
num_workers_dict[to_delete_instance.node_type] -= 1
assert num_workers_dict[to_delete_instance.node_type] >= 0
to_delete_instances_by_type[to_delete_instance.node_type].append(
to_delete_instance
)
scale_request = KubeRayProvider.ScaleRequest(
desired_num_workers=num_workers_dict,
workers_to_delete=to_delete_instances_by_type,
worker_groups_without_pending_deletes=worker_groups_without_pending_deletes,
worker_groups_with_pending_deletes=worker_groups_with_pending_deletes,
)
return scale_request
def _submit_scale_request(
self, scale_request: "KubeRayProvider.ScaleRequest"
) -> None:
"""Submits a scale request to the Kubernetes API server.
This method will convert the scale request to patches and submit the patches
to the Kubernetes API server.
Args:
scale_request: The scale request.
Raises:
Exception: An exception is raised if the Kubernetes API server returns an
error.
"""
# Get the current ray cluster spec.
patch_payload = []
raycluster = self.ray_cluster
# Collect patches for replica counts.
for node_type, num_workers in scale_request.desired_num_workers.items():
group_index = _worker_group_index(raycluster, node_type)
group_max_replicas = _worker_group_max_replicas(raycluster, group_index)
group_num_of_hosts = _worker_group_num_of_hosts(raycluster, group_index)
# the num_workers from the scale request is multiplied by numOfHosts, so we need to divide it back.
target_replicas = num_workers // group_num_of_hosts
# Cap the replica count to maxReplicas.
if group_max_replicas is not None and group_max_replicas < target_replicas:
logger.warning(
"Autoscaler attempted to create "
+ "more than maxReplicas pods of type {}.".format(node_type)
)
target_replicas = group_max_replicas
# Check if we need to change the target count.
if target_replicas == _worker_group_replicas(raycluster, group_index):
# No patch required.
continue
# Need to patch replica count. Format the patch and add it to the payload.
patch = worker_replica_patch(group_index, target_replicas)
patch_payload.append(patch)
# Maps node_type to nodes to delete for that group.
for (
node_type,
workers_to_delete_of_type,
) in scale_request.workers_to_delete.items():
group_index = _worker_group_index(raycluster, node_type)
worker_ids_to_delete = [
worker.cloud_instance_id for worker in workers_to_delete_of_type
]
patch = worker_delete_patch(group_index, worker_ids_to_delete)
patch_payload.append(patch)
# Clear the workersToDelete field for the worker groups that have been deleted.
for node_type in scale_request.worker_groups_without_pending_deletes:
if node_type in scale_request.workers_to_delete:
# This node type is still being deleted.
continue
group_index = _worker_group_index(raycluster, node_type)
patch = worker_delete_patch(group_index, [])
patch_payload.append(patch)
if len(patch_payload) == 0:
# No patch required.
return
logger.info(f"Submitting a scale request: {scale_request}")
self._patch(f"rayclusters/{self._cluster_name}", patch_payload)
def _add_launch_errors(
self,
shape: Dict[NodeType, int],
request_id: str,
details: str,
e: Optional[Exception] = None,
) -> None:
"""
Adds launch errors to the error queue.
Args:
shape: The shape of the nodes that failed to launch.
request_id: The request id of the launch request.
details: The details of the error.
e: The exception that caused the error.
"""
for node_type, count in shape.items():
self._launch_errors_queue.append(
LaunchNodeError(
node_type=node_type,
timestamp_ns=time.time_ns(),
count=count,
request_id=request_id,
details=details,
cause=e,
)
)
def _add_terminate_errors(
self,
ids: List[CloudInstanceId],
request_id: str,
details: str,
e: Optional[Exception] = None,
) -> None:
"""
Adds terminate errors to the error queue.
Args:
ids: The ids of the nodes that failed to terminate.
request_id: The request id of the terminate request.
details: The details of the error.
e: The exception that caused the error.
"""
for id in ids:
self._terminate_errors_queue.append(
TerminateNodeError(
cloud_instance_id=id,
timestamp_ns=time.time_ns(),
request_id=request_id,
details=details,
cause=e,
)
)
def _sync_with_api_server(self) -> None:
"""Fetches the RayCluster resource from the Kubernetes API server."""
self._ray_cluster = self._get(f"rayclusters/{self._cluster_name}")
self._refresh_no_driver_timeout_seconds()
self._ippr_provider.validate_and_set_ippr_specs(self._ray_cluster)
self._cached_instances = self._fetch_instances()
self._ippr_provider.sync_with_raylets()
def _refresh_no_driver_timeout_seconds(self) -> None:
"""Reads noDriverTimeoutSeconds from the RayCluster CR."""
opts = self._ray_cluster["spec"].get(AUTOSCALER_OPTIONS_KEY, {})
secs = opts.get(NO_DRIVER_TIMEOUT_SECONDS_KEY)
self._no_driver_timeout_seconds = float(secs) if secs is not None else None
@property
def ray_cluster(self) -> Dict[str, Any]:
return copy.deepcopy(self._ray_cluster)
@property
def instances(self) -> Dict[CloudInstanceId, CloudInstance]:
return copy.deepcopy(self._cached_instances)
@staticmethod
def _get_workers_delete_info(
ray_cluster_spec: Dict[str, Any], node_set: Set[CloudInstanceId]
) -> Tuple[Set[NodeType], Set[NodeType], Set[CloudInstanceId]]:
"""
Gets the worker groups that have pending deletes and the worker groups that
have finished deletes.
Args:
ray_cluster_spec: The RayCluster CR spec dict.
node_set: The set of currently known cloud instance IDs.
Returns:
A tuple of:
- worker_groups_with_pending_deletes: The worker groups that have pending
deletes.
- worker_groups_with_finished_deletes: The worker groups that have finished
deletes.
- worker_to_delete_set: A set of Pods that are included in the
workersToDelete field of any worker group.
"""
worker_groups_with_pending_deletes = set()
worker_groups_with_deletes = set()
worker_to_delete_set = set()
worker_groups = ray_cluster_spec["spec"].get("workerGroupSpecs", [])
for worker_group in worker_groups:
workersToDelete = worker_group.get("scaleStrategy", {}).get(
"workersToDelete", []
)
if not workersToDelete:
# No workers to delete in this group.
continue
node_type = worker_group["groupName"]
worker_groups_with_deletes.add(node_type)
for worker in workersToDelete:
worker_to_delete_set.add(worker)
if worker in node_set:
worker_groups_with_pending_deletes.add(node_type)
worker_groups_with_finished_deletes = (
worker_groups_with_deletes - worker_groups_with_pending_deletes
)
return (
worker_groups_with_pending_deletes,
worker_groups_with_finished_deletes,
worker_to_delete_set,
)
def _fetch_instances(self) -> Dict[CloudInstanceId, CloudInstance]:
"""
Fetches the pods from the Kubernetes API server and convert them to Ray
CloudInstance.
Returns:
A dict of CloudInstanceId to CloudInstance.
"""
# Get the pods resource version.
# Specifying a resource version in list requests is important for scalability:
# https://kubernetes.io/docs/reference/using-api/api-concepts/#semantics-for-get-and-list
resource_version = self._get_head_pod_resource_version()
if resource_version:
logger.info(
f"Listing pods for RayCluster {self._cluster_name}"
f" in namespace {self._namespace}"
f" at pods resource version >= {resource_version}."
)
# Filter pods by cluster_name.
label_selector = requests.utils.quote(f"ray.io/cluster={self._cluster_name}")
resource_path = f"pods?labelSelector={label_selector}"
if resource_version:
resource_path += (
f"&resourceVersion={resource_version}"
+ "&resourceVersionMatch=NotOlderThan"
)
pod_list = self._get(resource_path)
fetched_resource_version = pod_list["metadata"]["resourceVersion"]
logger.info(
f"Fetched pod data at resource version" f" {fetched_resource_version}."
)
# Extract node data from the pod list.
cloud_instances = {}
for pod in pod_list["items"]:
# Kubernetes sets metadata.deletionTimestamp immediately after admitting a
# request to delete an object. Full removal of the object may take some time
# after the deletion timestamp is set. See link for details:
# https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-deletion
if "deletionTimestamp" in pod["metadata"]:
# Ignore pods marked for termination.
continue
pod_name = pod["metadata"]["name"]
cloud_instance = self._cloud_instance_from_pod(pod)
if cloud_instance:
cloud_instances[pod_name] = cloud_instance
self._ippr_provider.sync_ippr_status_from_pods(pod_list["items"])
return cloud_instances
@staticmethod
def _cloud_instance_from_pod(pod: Dict[str, Any]) -> Optional[CloudInstance]:
"""
Convert a pod to a Ray CloudInstance.
Args:
pod: The pod resource dict.
Returns:
The CloudInstance representing the pod, or None if the pod is not a
tracked Ray node (e.g. a redis-cleanup pod).
"""
labels = pod["metadata"]["labels"]
if labels[KUBERAY_LABEL_KEY_KIND] == KUBERAY_KIND_HEAD:
kind = NodeKind.HEAD
type = labels[KUBERAY_LABEL_KEY_TYPE]
elif labels[KUBERAY_LABEL_KEY_KIND] == KUBERAY_KIND_WORKER:
kind = NodeKind.WORKER
type = labels[KUBERAY_LABEL_KEY_TYPE]
else:
# Other ray nodes types defined by KubeRay.
# e.g. this could also be `redis-cleanup`
# We will not track these nodes.
return None
# TODO: we should prob get from the pod's env var (RAY_CLOUD_INSTANCE_ID)
# directly.
cloud_instance_id = pod["metadata"]["name"]
return CloudInstance(
cloud_instance_id=cloud_instance_id,
node_type=type,
node_kind=kind,
is_running=KubeRayProvider._is_running(pod),
)
@staticmethod
def _is_running(pod) -> bool:
"""Convert pod state to Ray NodeStatus
A cloud instance is considered running if the pod is in the running state,
else it could be pending/containers-terminated.
When it disappears from the list, it is considered terminated.
"""
if (
"containerStatuses" not in pod["status"]
or not pod["status"]["containerStatuses"]
):
return False
state = pod["status"]["containerStatuses"][0]["state"]
if "running" in state:
return True
return False
def _get(self, remote_path: str) -> Dict[str, Any]:
"""Get a resource from the Kubernetes API server."""
return self._k8s_api_client.get(remote_path)
def _patch(self, remote_path: str, payload: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Patch a resource on the Kubernetes API server."""
return self._k8s_api_client.patch(remote_path, payload)
def _evaluate_no_driver_termination(self) -> None:
"""Patches the no-driver-TTL annotation once no driver held for the timeout.
Detached actors do not count as a driver.
"""
# Feature disabled or a driver is attached: reset the anchor.
if self._no_driver_timeout_seconds is None:
self._no_driver_observed_since = None
return
has_active_driver, latest_job_end_time = self._driver_status()
if has_active_driver:
self._no_driver_observed_since = None
return
# A driver finished since the last check: it was attached during the
# no-driver window, so restart the timer.
if latest_job_end_time > self._last_seen_job_end_time:
self._last_seen_job_end_time = latest_job_end_time
self._no_driver_observed_since = None
# Anchor on the first loop with no driver, then dispatch once the
# no-driver window reaches the timeout.
now = time.monotonic()
if self._no_driver_observed_since is None:
self._no_driver_observed_since = now
if now - self._no_driver_observed_since < self._no_driver_timeout_seconds:
return
self._set_no_driver_annotation()
def _driver_status(self) -> Tuple[bool, int]:
"""Returns whether a non-internal driver is alive and the latest job end time.
Fails closed: a failed GCS query reports a driver as present.
"""
try:
jobs = self._gcs_client.get_all_job_info(
skip_submission_job_info_field=True,
skip_is_running_tasks_field=True,
)
except Exception:
logger.exception(
"Failed to query GCS job table; treating as drivers attached."
)
return True, self._last_seen_job_end_time
has_active_driver = False
latest_job_end_time = 0
for job in jobs.values():
# Ray-internal drivers (e.g. the dashboard) are not user activity.
if job.config.ray_namespace.startswith(RAY_INTERNAL_NAMESPACE_PREFIX):
continue
if job.is_dead:
latest_job_end_time = max(latest_job_end_time, job.end_time)
else:
has_active_driver = True
return has_active_driver, latest_job_end_time
def _set_no_driver_annotation(self) -> None:
"""Sets `ray.io/no-driver-ttl-expired=true` on the RayCluster CR.
Idempotent via the CR cached this reconcile loop; PATCH errors are swallowed.
"""
annotations = self._ray_cluster.get("metadata", {}).get("annotations", {})
if annotations.get(NO_DRIVER_TTL_EXPIRED_ANNOTATION) == "true":
return
path = f"rayclusters/{self._cluster_name}"
# Merge patch covers missing and present annotations in one call.
payload = {
"metadata": {"annotations": {NO_DRIVER_TTL_EXPIRED_ANNOTATION: "true"}}
}
try:
self._k8s_api_client.patch(
path,
payload,
content_type="application/merge-patch+json",
)
except Exception:
logger.exception(
"Failed to PATCH %s=true on RayCluster %s",
NO_DRIVER_TTL_EXPIRED_ANNOTATION,
self._cluster_name,
)
return
logger.info(
"Set %s=true on RayCluster %s.",
NO_DRIVER_TTL_EXPIRED_ANNOTATION,
self._cluster_name,
)
def _get_head_pod_resource_version(self) -> str:
"""
Extract a recent pods resource version by reading the head pod's
metadata.resourceVersion of the response.
"""
if not RAY_HEAD_POD_NAME:
return None
pod_resp = self._get(f"pods/{RAY_HEAD_POD_NAME}")
return pod_resp["metadata"]["resourceVersion"]
@@ -0,0 +1,736 @@
import json
import logging
import re
import time
from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple, Union
import jsonschema
from ray._raylet import GcsClient
from ray.autoscaler._private.kuberay.node_provider import (
KUBERAY_KIND_HEAD,
KUBERAY_KIND_WORKER,
KUBERAY_LABEL_KEY_KIND,
KUBERAY_LABEL_KEY_TYPE,
IKubernetesHttpApiClient,
replace_patch,
)
from ray.autoscaler._private.kuberay.utils import parse_quantity
from ray.autoscaler.v2.schema import (
IPPRGroupSpec,
IPPRSpecs,
IPPRSpecsSchema,
IPPRStatus,
)
logger = logging.getLogger(__name__)
class KubeRayIPPRProvider:
"""Implements in-place pod resize (IPPR) operations for KubeRay pods.
This provider is responsible for:
- Validating and materializing IPPR specs from the RayCluster annotation
(``ray.io/ippr``) into typed structures (``IPPRSpecs``/``IPPRGroupSpec``).
- Tracking per-pod resize status (``IPPRStatus``) from Kubernetes pods and
computing the desired resize actions.
- Issuing Kubernetes Pod Resize API requests and keeping a shadow annotation
(``ray.io/ippr-status``) to track progress and temporary caps.
- Synchronizing successful resource changes with the Raylet so Ray's local
resource view matches Kubernetes.
Attributes:
_gcs_client: Ray GCS client used to fetch Raylet node information.
_k8s_api_client: Kubernetes HTTP client for patching pods.
_ippr_specs: Validated per-group IPPR specs (limits and timeouts).
_ippr_statuses: Latest per-pod IPPR statuses indexed by pod name.
_container_resources: Snapshot of container resource requests/limits
from both pod spec and pod status, per pod name, used to compute
patch diffs.
"""
def __init__(
self,
gcs_client: GcsClient,
k8s_api_client: IKubernetesHttpApiClient,
):
"""Create a new IPPR provider.
Args:
gcs_client: Ray GCS client for resolving Raylet addresses.
k8s_api_client: Kubernetes HTTP client to issue patch requests.
"""
self._gcs_client = gcs_client
self._k8s_api_client = k8s_api_client
self._ippr_specs: IPPRSpecs = IPPRSpecs(groups={})
self._ippr_statuses: Dict[str, IPPRStatus] = {}
self._container_resources: Dict[str, Any] = {}
def validate_and_set_ippr_specs(
self, ray_cluster: Optional[Dict[str, Any]]
) -> None:
"""Validate and load IPPR specs from a RayCluster CR.
Reads the ``ray.io/ippr`` annotation, validates it against
``IPPRSpecsSchema``, and converts it to typed ``IPPRSpecs`` with per-group
``IPPRGroupSpec`` entries. Minimal resources are derived from the group's
pod template; maximums and timeout come from the annotation. If the
annotation is removed, clear any previously loaded IPPR specs.
Args:
ray_cluster: The RayCluster custom resource as a dict. If missing or
lacking the annotation, this method is a no-op.
Raises:
ValueError: If the Ray pod template is incompatible with IPPR (e.g.,
missing required requests, using unsupported resizePolicy restarts,
or conflicting ``rayStartParams``).
Example:
import json
ray_cluster = {
"metadata": {
"name": "example-raycluster",
"annotations": {
"ray.io/ippr": json.dumps(
{
"groups": {
"headgroup": {
"max-cpu": "4",
"max-memory": "8Gi",
"resize-timeout": 300,
},
"small-workers": {
"max-cpu": 2,
"max-memory": "4Gi",
"resize-timeout": 120,
},
}
}
),
},
},
"spec": {
"headGroupSpec": {
"rayStartParams": {},
"template": {
"spec": {
"containers": [
{
"name": "ray-head",
"resources": {
"requests": {
"cpu": "1",
"memory": "2Gi",
},
"limits": {
"cpu": "2",
"memory": "4Gi",
},
},
"resizePolicy": [
{
"resourceName": "cpu",
"restartPolicy": "NotRequired",
},
{
"resourceName": "memory",
"restartPolicy": "NotRequired",
},
],
}
],
}
},
},
"workerGroupSpecs": [
{
"groupName": "small-workers",
"rayStartParams": {},
"template": {
"spec": {
"containers": [
{
"name": "ray-worker",
"resources": {
"requests": {
"cpu": "500m",
"memory": "1Gi",
},
},
}
],
}
},
}
],
},
}
provider.validate_and_set_ippr_specs(ray_cluster)
"""
if not ray_cluster:
return
specs_str = ray_cluster["metadata"].get("annotations", {}).get("ray.io/ippr")
if not specs_str:
self._ippr_specs = IPPRSpecs(groups={})
return
ippr_specs_raw = json.loads(specs_str)
jsonschema.validate(instance=ippr_specs_raw, schema=IPPRSpecsSchema)
# Validate and build typed spec per group
worker_groups = {
worker_group_spec["groupName"]: worker_group_spec
for worker_group_spec in ray_cluster["spec"].get("workerGroupSpecs", [])
}
worker_groups["headgroup"] = ray_cluster["spec"]["headGroupSpec"]
groups = {
group_name: _build_ippr_group_spec(group_spec, worker_groups[group_name])
for group_name, group_spec in ippr_specs_raw.get("groups", {}).items()
if group_name in worker_groups
}
self._ippr_specs = IPPRSpecs(groups=groups)
def sync_with_raylets(self) -> None:
"""Propagate completed K8s resizes to Raylets via GCS.
For any pod whose K8s resize has completed, update the corresponding Raylet's local resource
instances via GCS gRPC and clear the pending timestamp on the pod's
``ray.io/ippr-status`` annotation.
Three situations we can have exceptions are:
1. K8s API is not available.
2. GCS is not available.
3. Raylet is not available.
If a raylet is truly dead, its pod will also be deleted eventually.
All of the above exceptions can only be resolved in the future reconcile loops.
"""
for ippr_status in self._ippr_statuses.values():
if not ippr_status.need_sync_with_raylet():
continue
try:
self._gcs_client.resize_raylet_resource_instances(
ippr_status.raylet_id,
{
"CPU": ippr_status.current_cpu,
"memory": ippr_status.current_memory,
},
)
self._patch_ippr_status(ippr_status, resizing_at=None)
logger.info(f"Pod {ippr_status.cloud_instance_id} resized successfully")
except Exception as e:
logger.error(
f"Failed to resize pod {ippr_status.cloud_instance_id}: {e}. "
"If this persists, check GCS (e.g. Head/GCS logs and Raylet reachability) "
"and Kubernetes (e.g. API errors, pod events, ray.io/ippr-status) for "
"related request failures."
)
def sync_ippr_status_from_pods(self, pods: List[Dict[str, Any]]) -> None:
"""Refresh internal IPPR statuses and container resources from pods.
Parses pods to produce up-to-date ``IPPRStatus`` objects and stores
relevant request/limit snapshots for later patch computations.
Args:
pods: List of Kubernetes Pod resources for the Ray cluster.
"""
self._ippr_statuses = {}
self._container_resources = {}
if not self._ippr_specs.groups:
return
for pod in pods:
if "deletionTimestamp" in pod["metadata"]:
continue
labels = pod["metadata"].get("labels", {})
kind = labels.get(KUBERAY_LABEL_KEY_KIND)
if kind not in (KUBERAY_KIND_HEAD, KUBERAY_KIND_WORKER):
continue
ippr_group_spec = self._ippr_specs.groups.get(
labels.get(KUBERAY_LABEL_KEY_TYPE)
)
ippr_status, container_resource = _get_ippr_status_from_pod(
ippr_group_spec, pod
)
if ippr_status:
self._ippr_statuses[ippr_status.cloud_instance_id] = ippr_status
if ippr_status and container_resource:
self._container_resources[
ippr_status.cloud_instance_id
] = container_resource
def do_ippr_requests(self, resizes: List[IPPRStatus]) -> None:
"""Issue Kubernetes Pod Resize requests for the given targets.
If any dimension downsizes, attempt to first adjust the Raylet's local
resources via gRPC to avoid temporary overcommit in Ray's scheduler.
The raylet request uses ``min(desired, current)`` per resource so mixed
resizes (e.g. CPU up and memory down) do not advertise increases before
Kubernetes applies them; the reply is merged so upsizing targets are kept
for the pod patch.
Args:
resizes: List of IPPRStatus with desired resources and metadata.
"""
for resize in resizes:
logger.info(
f"Resizing pod {resize.cloud_instance_id} to cpu={resize.desired_cpu} memory={resize.desired_memory} from cpu={resize.current_cpu} memory={resize.current_memory}"
)
if resize.raylet_id and (
resize.desired_cpu < resize.current_cpu
or resize.desired_memory < resize.current_memory
):
# For any downsize, update Raylet first. Cap each resource at
# current so we never tell the scheduler about an upsize until
# sync_with_raylets runs after K8s applies the pod resize.
try:
updated_total_resources = (
self._gcs_client.resize_raylet_resource_instances(
resize.raylet_id,
{
"CPU": min(resize.desired_cpu, resize.current_cpu),
"memory": min(
resize.desired_memory, resize.current_memory
),
},
)
)
if (
"CPU" not in updated_total_resources
or "memory" not in updated_total_resources
):
raise RuntimeError(
f"CPU or memory not found in the response of resizing raylet resources: {updated_total_resources}"
)
if resize.desired_cpu < resize.current_cpu:
resize.desired_cpu = float(updated_total_resources["CPU"])
if resize.desired_memory < resize.current_memory:
resize.desired_memory = int(updated_total_resources["memory"])
except Exception as e:
logger.error(
f"Skip failed downsizing on pod {resize.cloud_instance_id}: {e}"
)
continue
self._patch_ippr_resize(resize)
def get_ippr_specs(self) -> IPPRSpecs:
"""Return the current validated IPPR specs."""
return self._ippr_specs
def get_ippr_statuses(self) -> Dict[str, IPPRStatus]:
"""Return the latest per-pod IPPR statuses keyed by pod name."""
return self._ippr_statuses
def _patch_ippr_resize(self, resize: IPPRStatus) -> None:
patch = self._make_ippr_patch(resize)
self._k8s_api_client.patch(
"pods/{}/resize".format(resize.cloud_instance_id), patch
)
self._patch_ippr_status(resize, resizing_at=int(time.time()))
def _patch_ippr_status(
self, resize: IPPRStatus, resizing_at: Optional[int]
) -> None:
"""Save the IPPR status to the pod annotation ``ray.io/ippr-status``.
The annotation is used to track the IPPR status of the pod across reconcile loops.
Args:
resize: The IPPR status to save.
resizing_at: Timestamp while a resize is in progress; pass ``None``
to clear after the resize completes (e.g. from ``sync_with_raylets``).
"""
self._k8s_api_client.patch(
"pods/{}".format(resize.cloud_instance_id),
{
"metadata": {
"annotations": {
"ray.io/ippr-status": json.dumps(
{
"raylet-id": resize.raylet_id,
"resizing-at": resizing_at,
"suggested-max-cpu": resize.suggested_max_cpu,
"suggested-max-memory": resize.suggested_max_memory,
"last-failed-at": resize.last_failed_at,
"last-failed-reason": resize.last_failed_reason,
}
)
}
}
},
content_type="application/strategic-merge-patch+json",
)
def _make_ippr_patch(self, resize: IPPRStatus) -> List[Dict[str, Any]]:
patch = []
path_prefix = "/spec/containers/0/resources"
container_resource = self._container_resources[resize.cloud_instance_id]
# When limits are present, preserve the existing gap (limits - requests)
# by adjusting requests proportionally so QoS doesn't change.
for resource_name, desired in (
("cpu", resize.desired_cpu),
("memory", resize.desired_memory),
):
if container_resource["status"]["limits"].get(resource_name):
# Gap between status limits and requests for each resource.
diff = _resource_gap(
container_resource["status"]["limits"],
container_resource["status"]["requests"],
resource_name,
)
patch.append(
replace_patch(f"{path_prefix}/limits/{resource_name}", desired)
)
patch.append(
replace_patch(
f"{path_prefix}/requests/{resource_name}",
_request_from_desired(desired, diff),
)
)
else:
# No limits configured: adjust requests only.
patch.append(
replace_patch(f"{path_prefix}/requests/{resource_name}", desired)
)
return patch
def _build_ippr_group_spec(
group_spec: Dict[str, Any], worker_group_spec: Dict[str, Any]
) -> IPPRGroupSpec:
# Disallow per-pod overrides that conflict with IPPR's dynamic sizing.
ray_start_params = worker_group_spec.get("rayStartParams", {})
if "num-cpus" in ray_start_params or "memory" in ray_start_params:
raise ValueError(
"should not have 'num-cpus' or 'memory' in rayStartParams if IPPR is used"
)
container_spec = worker_group_spec["template"]["spec"]["containers"][0]
pod_spec_requests = container_spec.get("resources", {}).get("requests", {})
# Pod template must declare baseline CPU/memory requests for IPPR.
if "cpu" not in pod_spec_requests or "memory" not in pod_spec_requests:
raise ValueError(
"should have 'cpu' and 'memory' in resource requests as the resources lower bounds if IPPR is used"
)
for policy in container_spec.get("resizePolicy", []):
resource_name = policy.get("resourceName")
if resource_name != "cpu" and resource_name != "memory":
continue
restart = policy.get("restartPolicy")
# IPPR requires NotRequired so that K8s won't restart the container
# during in-place resource updates.
if restart is not None and restart != "NotRequired":
raise ValueError("IPPR only supports restartPolicy=NotRequired")
# pod_spec_limits are the initial resource limits specified for the pod.
# we use it together with pod_spec_requests to derive the lower bounds for IPPR.
pod_spec_limits = container_spec.get("resources", {}).get("limits", {})
return IPPRGroupSpec(
min_cpu=_resource_value(pod_spec_requests, pod_spec_limits, "cpu", float),
min_memory=_resource_value(pod_spec_requests, pod_spec_limits, "memory", int),
max_cpu=float(parse_quantity(group_spec.get("max-cpu"))),
max_memory=int(parse_quantity(group_spec.get("max-memory"))),
resize_timeout=int(group_spec.get("resize-timeout")),
)
def _get_ippr_status_from_pod(
ippr_group_spec: Optional[IPPRGroupSpec],
pod: Dict[str, Any],
) -> Tuple[Optional[IPPRStatus], Optional[Dict[str, Any]]]:
"""Build IPPRStatus and container resource snapshots from a Pod.
Returns a tuple of (ippr_status, container_resource_snapshot). The snapshot
contains both spec and status requests/limits used to construct resize
patches that preserve the current QoS gap.
IPPRStatus includes the updated desired resources based on the failure messages of the previous resize request carried on the pod.
This function doesn't have any external side effects.
"""
if not ippr_group_spec:
return (None, None)
container_status = {}
other_container_resources = []
for status in pod.get("status", {}).get("containerStatuses", []):
if status["name"] == pod["spec"]["containers"][0]["name"]:
container_status = status
else:
# We need to substract other containers' resources when adjusting the
# the new resource requests based on the capactity report from the Kubelet.
requests = status.get("resources", {}).get("requests")
if requests:
other_container_resources.append(requests)
pod_spec_requests = (
pod["spec"]["containers"][0].get("resources", {}).get("requests", {})
)
pod_spec_limits = (
pod["spec"]["containers"][0].get("resources", {}).get("limits", {})
)
pod_status_requests = container_status.get("resources", {}).get(
"requests", pod_spec_requests
)
pod_status_limits = container_status.get("resources", {}).get(
"limits", pod_spec_limits
)
ippr_status = IPPRStatus(
cloud_instance_id=pod["metadata"]["name"],
spec=ippr_group_spec,
desired_cpu=_resource_value(pod_spec_requests, pod_spec_limits, "cpu", float),
desired_memory=_resource_value(
pod_spec_requests, pod_spec_limits, "memory", int
),
current_cpu=_resource_value(
pod_status_requests, pod_status_limits, "cpu", float
),
current_memory=_resource_value(
pod_status_requests, pod_status_limits, "memory", int
),
)
ippr_status = _restore_ippr_status_from_annotation(ippr_status, pod)
ippr_status = _apply_resize_conditions(
ippr_status=ippr_status,
pod=pod,
pod_status_requests=pod_status_requests,
pod_status_limits=pod_status_limits,
other_container_resources=other_container_resources,
)
ippr_status = _handle_failed_or_timed_out_ippr(ippr_status)
return (
ippr_status,
{
"spec": {
"requests": pod_spec_requests,
"limits": pod_spec_limits,
},
"status": {
"requests": pod_status_requests,
"limits": pod_status_limits,
},
},
)
def _restore_ippr_status_from_annotation(
ippr_status: IPPRStatus, pod: Dict[str, Any]
) -> IPPRStatus:
"""Restore previously persisted IPPR status fields from pod annotations."""
pod_ippr_status_json = (
pod["metadata"].get("annotations", {}).get("ray.io/ippr-status")
)
if not pod_ippr_status_json:
return ippr_status
pod_ippr_status = json.loads(pod_ippr_status_json)
ippr_status.raylet_id = pod_ippr_status.get("raylet-id")
ippr_status.resizing_at = pod_ippr_status.get("resizing-at")
ippr_status.suggested_max_cpu = pod_ippr_status.get("suggested-max-cpu")
ippr_status.suggested_max_memory = pod_ippr_status.get("suggested-max-memory")
ippr_status.last_failed_at = pod_ippr_status.get("last-failed-at")
ippr_status.last_failed_reason = pod_ippr_status.get("last-failed-reason")
return ippr_status
def _apply_resize_conditions(
ippr_status: IPPRStatus,
pod: Dict[str, Any],
pod_status_requests: Dict[str, Any],
pod_status_limits: Dict[str, Any],
other_container_resources: List[Dict[str, Any]],
) -> IPPRStatus:
"""Parse pod resize conditions and queue any follow-up suggestions."""
for condition in pod.get("status", {}).get("conditions", []):
if condition["type"] == "PodResizePending" and condition["status"] == "True":
ippr_status.k8s_resize_message = condition.get("message")
ippr_status.k8s_resize_status = condition.get("reason", "").lower()
ippr_status = _apply_resize_suggestion(
ippr_status=ippr_status,
pod_status_requests=pod_status_requests,
pod_status_limits=pod_status_limits,
other_container_resources=other_container_resources,
)
break
if condition["type"] == "PodResizeInProgress" and condition["status"] == "True":
ippr_status.k8s_resize_message = condition.get("message")
ippr_status.k8s_resize_status = "inprogress"
if condition.get("reason") == "Error":
ippr_status.k8s_resize_status = "error"
break
return ippr_status
def _apply_resize_suggestion(
ippr_status: IPPRStatus,
pod_status_requests: Dict[str, Any],
pod_status_limits: Dict[str, Any],
other_container_resources: List[Dict[str, Any]],
) -> IPPRStatus:
"""Parse Kubelet capacity reports and queue a suggested follow-up resize.
A suggested follow-up resize is taken from the failure message of the previous resize request on the pod.
For example, a message, "Node didn't have enough resource: cpu, requested: 8000, used: 5000, capacity: 9000",
means the pod can only have CPU request up to 4. For our suggested follow-up resize, we will set the suggested_max_cpu to
4 + (orignal cpu limit - original cpu request) to keep the gap.
"""
report = None
if ippr_status.k8s_resize_message and ippr_status.k8s_resize_status == "deferred":
report = re.search(
r"Node didn't have enough resource: (cpu|memory), requested: (\d+), used: (\d+), capacity: (\d+)",
ippr_status.k8s_resize_message,
)
elif (
ippr_status.k8s_resize_message and ippr_status.k8s_resize_status == "infeasible"
):
report = re.search(
r"Node didn't have enough capacity: (cpu|memory), requested: (\d+), ()capacity: (\d+)",
ippr_status.k8s_resize_message,
)
if not report:
return ippr_status
# Example (resize to max-cpu = 8, max-memory = 20Gi):
# Initial pod status:
# - CPU limit is 4 cores
# - CPU request is 1 core (gap = 3 cores)
# - Mem limit is 8Gi
# - Mem request is 2Gi (gap = 6Gi)
# Initial resize request will be:
# - desired_cpu=8 cores
# - desired_memory=20Gi
# The actual resize patch will be:
# - CPU limit is 8 cores (upsize from 4)
# - CPU request is 8 - 3 = 5 cores (upsize from 1, keep the gap 3 cores)
# - Mem limit is 20Gi (upsize from 8Gi)
# - Mem request is 20 6 = 14Gi (upsize from 2Gi, keep the gap 6Gi)
# If Kubelet reports (the deferred case):
# - CPU: used=5, capacity=9 → remaining_cpu = 4 cores
# - Mem: used=6Gi, capacity=10Gi → remaining_mem = 4Gi
# The suggestions used in the next patch will be:
# - suggested_max_cpu = remaining_cpu + cpu_gap = 4 + 3 = 7 cores
# - suggested_max_memory = remaining_mem + mem_gap = 4Gi + 6Gi = 10Gi
# The actual resize patch will be:
# - CPU limit is 7 cores
# - CPU request is 7 - 3 = 4 cores (aligned with the kubelet's report, and keep the gap 3 cores)
# - Mem limit is 10Gi
# - Mem request is 10Gi - 6Gi = 4Gi (aligned with the kubelet's report, and keep the gap 6Gi)
used = int(
report.group(3) or "0"
) # this field is the used resource request capacity of the k8s node excluding the current pod.
capacity = int(
report.group(4)
) # this field is the total resource request capacity of the k8s node.
max_request = (
capacity - used
) # so this max_request is the remaining resource request capacity that this pod can still request.
resource_name = report.group(1)
suggested = _suggested_resize_limit(
resource_name,
max_request,
pod_status_requests,
pod_status_limits,
other_container_resources,
)
if resource_name == "cpu":
ippr_status.suggested_max_cpu = float(suggested)
if ippr_status.queue_resize_request(desired_cpu=ippr_status.suggested_max_cpu):
logger.info(
f"Apply resize suggestions for {ippr_status.cloud_instance_id} to cpu={ippr_status.suggested_max_cpu}"
)
else:
ippr_status.suggested_max_memory = int(suggested)
if ippr_status.queue_resize_request(
desired_memory=ippr_status.suggested_max_memory,
):
logger.info(
f"Apply resize suggestions for {ippr_status.cloud_instance_id} to memory={ippr_status.suggested_max_memory}"
)
return ippr_status
def _suggested_resize_limit(
resource_name: str,
max_request: int,
pod_status_requests: Dict[str, Any],
pod_status_limits: Dict[str, Any],
other_container_resources: List[Dict[str, Any]],
) -> Union[float, int]:
gap = _resource_gap(pod_status_limits, pod_status_requests, resource_name)
other_requests = sum(
parse_quantity(requests.get(resource_name, "0"))
for requests in other_container_resources
)
if resource_name == "cpu":
available = Decimal(str(max_request)) / 1000
return float(available + gap - other_requests)
else:
available = Decimal(str(max_request))
return int(available + gap - other_requests)
def _resource_value(
requests: Dict[str, Any],
limits: Dict[str, Any],
resource_name: str,
value_type: Union[type[float], type[int]],
) -> Union[float, int]:
return value_type(
parse_quantity(limits.get(resource_name) or requests.get(resource_name))
)
def _resource_gap(
limits: Dict[str, Any],
requests: Dict[str, Any],
resource_name: str,
) -> Decimal:
return parse_quantity(
limits.get(resource_name) or requests.get(resource_name)
) - parse_quantity(requests.get(resource_name))
def _request_from_desired(
desired: Union[float, int], gap: Decimal
) -> Union[float, int]:
requested = Decimal(str(desired)) - gap
return type(desired)(requested)
def _handle_failed_or_timed_out_ippr(ippr_status: IPPRStatus) -> IPPRStatus:
"""Record terminal IPPR failures and queue a revert to current resources."""
if not (ippr_status.is_errored() or ippr_status.is_timeout()):
return ippr_status
if ippr_status.last_failed_at is None:
if ippr_status.is_errored():
ippr_status.record_failure(
reason=ippr_status.k8s_resize_message or "Pod resize failed"
)
else:
ippr_status.record_failure(reason="Pod resize timed out")
if ippr_status.queue_resize_request(
desired_cpu=ippr_status.current_cpu,
desired_memory=ippr_status.current_memory,
):
logger.info(
f"Revert failed or stuck IPPR for {ippr_status.cloud_instance_id} to cpu={ippr_status.current_cpu} memory={ippr_status.current_memory}"
)
return ippr_status
@@ -0,0 +1,73 @@
from typing import Dict, List
from ray._common.utils import binary_to_hex
from ray._raylet import GcsClient
from ray.autoscaler._private.util import format_readonly_node_type
from ray.autoscaler.v2.instance_manager.node_provider import (
CloudInstance,
CloudInstanceId,
CloudInstanceProviderError,
ICloudInstanceProvider,
NodeKind,
)
from ray.autoscaler.v2.sdk import get_cluster_resource_state
from ray.autoscaler.v2.utils import is_head_node
from ray.core.generated.autoscaler_pb2 import NodeStatus
class ReadOnlyProvider(ICloudInstanceProvider):
"""
A read only provider that use the ray node states from the GCS as the
cloud instances.
This is used for laptop mode / manual cluster setup modes, in order to
provide status reporting in the same way for users.
"""
def __init__(self, provider_config: dict):
self._provider_config = provider_config
self._gcs_address = provider_config["gcs_address"]
self._gcs_client = GcsClient(address=self._gcs_address)
def get_non_terminated(self) -> Dict[str, CloudInstance]:
cluster_resource_state = get_cluster_resource_state(self._gcs_client)
cloud_instances = {}
for gcs_node_state in cluster_resource_state.node_states:
if gcs_node_state.status == NodeStatus.DEAD:
# Skip dead nodes.
continue
# Use node's node id if instance id is not available
cloud_instance_id = (
gcs_node_state.instance_id
if gcs_node_state.instance_id
else binary_to_hex(gcs_node_state.node_id)
)
# TODO: we should add a field to the proto to indicate if the node is head
# or not.
is_head = is_head_node(gcs_node_state)
cloud_instances[cloud_instance_id] = CloudInstance(
cloud_instance_id=cloud_instance_id,
node_kind=NodeKind.HEAD if is_head else NodeKind.WORKER,
node_type=format_readonly_node_type(
binary_to_hex(gcs_node_state.node_id) # Legacy behavior.
),
is_running=True,
request_id="",
)
return cloud_instances
def terminate(self, ids: List[CloudInstanceId], request_id: str) -> None:
raise NotImplementedError("Cannot terminate instances in read-only mode.")
def launch(
self, shape: Dict[CloudInstanceId, int], request_id: CloudInstanceId
) -> None:
raise NotImplementedError("Cannot launch instances in read-only mode.")
def poll_errors(self) -> List[CloudInstanceProviderError]:
return []