Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- lib/python3.12/site-packages/ray/autoscaler/__pycache__/node_launch_exception.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/__pycache__/tags.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/__init__.py +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/__init__.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/autoscaler.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/event_logger.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/metrics_reporter.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/monitor.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/scheduler.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/schema.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/sdk.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/utils.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/autoscaler.py +216 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/event_logger.py +185 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__init__.py +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/__init__.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/common.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/config.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/instance_manager.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/instance_storage.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/node_provider.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/ray_installer.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/reconciler.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/storage.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/__init__.py +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/__pycache__/__init__.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/kuberay/__init__.py +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/kuberay/__pycache__/__init__.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/kuberay/__pycache__/cloud_provider.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/kuberay/cloud_provider.py +595 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/read_only/__init__.py +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/read_only/__pycache__/__init__.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/read_only/__pycache__/cloud_provider.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/read_only/cloud_provider.py +73 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/common.py +472 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/config.py +552 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/instance_manager.py +271 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/instance_storage.py +151 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/node_provider.py +529 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/ray_installer.py +96 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/reconciler.py +1601 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/storage.py +180 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/__init__.py +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/__pycache__/__init__.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/__pycache__/cloud_instance_updater.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/__pycache__/ray_stopper.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/__pycache__/threaded_ray_installer.cpython-312.pyc +0 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/cloud_instance_updater.py +93 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/ray_stopper.py +154 -0
- lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/threaded_ray_installer.py +95 -0
lib/python3.12/site-packages/ray/autoscaler/__pycache__/node_launch_exception.cpython-312.pyc
ADDED
|
Binary file (1.82 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/__pycache__/tags.cpython-312.pyc
ADDED
|
Binary file (1.32 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/__init__.py
ADDED
|
File without changes
|
lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (196 Bytes). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/autoscaler.cpython-312.pyc
ADDED
|
Binary file (9.01 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/event_logger.cpython-312.pyc
ADDED
|
Binary file (8.41 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/metrics_reporter.cpython-312.pyc
ADDED
|
Binary file (5.7 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/monitor.cpython-312.pyc
ADDED
|
Binary file (12.6 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/scheduler.cpython-312.pyc
ADDED
|
Binary file (65 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/schema.cpython-312.pyc
ADDED
|
Binary file (12.2 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/sdk.cpython-312.pyc
ADDED
|
Binary file (5.39 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/utils.cpython-312.pyc
ADDED
|
Binary file (41.9 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/autoscaler.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from queue import Queue
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
from urllib.parse import urlsplit
|
| 5 |
+
|
| 6 |
+
from ray._raylet import GcsClient
|
| 7 |
+
from ray.autoscaler._private.providers import _get_node_provider
|
| 8 |
+
from ray.autoscaler.v2.event_logger import AutoscalerEventLogger
|
| 9 |
+
from ray.autoscaler.v2.instance_manager.cloud_providers.kuberay.cloud_provider import (
|
| 10 |
+
KubeRayProvider,
|
| 11 |
+
)
|
| 12 |
+
from ray.autoscaler.v2.instance_manager.cloud_providers.read_only.cloud_provider import ( # noqa
|
| 13 |
+
ReadOnlyProvider,
|
| 14 |
+
)
|
| 15 |
+
from ray.autoscaler.v2.instance_manager.config import (
|
| 16 |
+
AutoscalingConfig,
|
| 17 |
+
IConfigReader,
|
| 18 |
+
Provider,
|
| 19 |
+
)
|
| 20 |
+
from ray.autoscaler.v2.instance_manager.instance_manager import (
|
| 21 |
+
InstanceManager,
|
| 22 |
+
InstanceUpdatedSubscriber,
|
| 23 |
+
)
|
| 24 |
+
from ray.autoscaler.v2.instance_manager.instance_storage import InstanceStorage
|
| 25 |
+
from ray.autoscaler.v2.instance_manager.node_provider import (
|
| 26 |
+
ICloudInstanceProvider,
|
| 27 |
+
NodeProviderAdapter,
|
| 28 |
+
)
|
| 29 |
+
from ray.autoscaler.v2.instance_manager.ray_installer import RayInstaller
|
| 30 |
+
from ray.autoscaler.v2.instance_manager.reconciler import Reconciler
|
| 31 |
+
from ray.autoscaler.v2.instance_manager.storage import InMemoryStorage
|
| 32 |
+
from ray.autoscaler.v2.instance_manager.subscribers.cloud_instance_updater import (
|
| 33 |
+
CloudInstanceUpdater,
|
| 34 |
+
)
|
| 35 |
+
from ray.autoscaler.v2.instance_manager.subscribers.ray_stopper import RayStopper
|
| 36 |
+
from ray.autoscaler.v2.instance_manager.subscribers.threaded_ray_installer import (
|
| 37 |
+
ThreadedRayInstaller,
|
| 38 |
+
)
|
| 39 |
+
from ray.autoscaler.v2.metrics_reporter import AutoscalerMetricsReporter
|
| 40 |
+
from ray.autoscaler.v2.scheduler import ResourceDemandScheduler
|
| 41 |
+
from ray.autoscaler.v2.sdk import get_cluster_resource_state
|
| 42 |
+
from ray.core.generated.autoscaler_pb2 import AutoscalingState
|
| 43 |
+
|
| 44 |
+
logger = logging.getLogger(__name__)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class Autoscaler:
|
| 48 |
+
def __init__(
|
| 49 |
+
self,
|
| 50 |
+
session_name: str,
|
| 51 |
+
config_reader: IConfigReader,
|
| 52 |
+
gcs_client: GcsClient,
|
| 53 |
+
event_logger: Optional[AutoscalerEventLogger] = None,
|
| 54 |
+
metrics_reporter: Optional[AutoscalerMetricsReporter] = None,
|
| 55 |
+
) -> None:
|
| 56 |
+
"""
|
| 57 |
+
Args:
|
| 58 |
+
session_name: The current Ray session name.
|
| 59 |
+
config_reader: The config reader.
|
| 60 |
+
gcs_client: The GCS client.
|
| 61 |
+
event_logger: The event logger for emitting cluster events.
|
| 62 |
+
metrics_reporter: The metrics reporter for emitting cluster metrics.
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
self._config_reader = config_reader
|
| 66 |
+
|
| 67 |
+
config = config_reader.get_cached_autoscaling_config()
|
| 68 |
+
logger.info(f"Using Autoscaling Config: \n{config.dump()}")
|
| 69 |
+
|
| 70 |
+
self._gcs_client = gcs_client
|
| 71 |
+
self._cloud_instance_provider = None
|
| 72 |
+
self._instance_manager = None
|
| 73 |
+
self._ray_stop_errors_queue = Queue()
|
| 74 |
+
self._ray_install_errors_queue = Queue()
|
| 75 |
+
self._event_logger = event_logger
|
| 76 |
+
self._metrics_reporter = metrics_reporter
|
| 77 |
+
|
| 78 |
+
self._init_cloud_instance_provider(config, config_reader)
|
| 79 |
+
self._init_instance_manager(
|
| 80 |
+
session_name=session_name,
|
| 81 |
+
config=config,
|
| 82 |
+
cloud_provider=self._cloud_instance_provider,
|
| 83 |
+
gcs_client=self._gcs_client,
|
| 84 |
+
)
|
| 85 |
+
self._scheduler = ResourceDemandScheduler(self._event_logger)
|
| 86 |
+
|
| 87 |
+
def _init_cloud_instance_provider(
|
| 88 |
+
self, config: AutoscalingConfig, config_reader: IConfigReader
|
| 89 |
+
):
|
| 90 |
+
"""
|
| 91 |
+
Initialize the cloud provider, and its dependencies (the v1 node provider)
|
| 92 |
+
|
| 93 |
+
Args:
|
| 94 |
+
config: The autoscaling config.
|
| 95 |
+
config_reader: The config reader.
|
| 96 |
+
|
| 97 |
+
"""
|
| 98 |
+
provider_config = config.get_provider_config()
|
| 99 |
+
if provider_config["type"] == "kuberay":
|
| 100 |
+
provider_config["head_node_type"] = config.get_head_node_type()
|
| 101 |
+
self._cloud_instance_provider = KubeRayProvider(
|
| 102 |
+
config.get_config("cluster_name"),
|
| 103 |
+
provider_config,
|
| 104 |
+
)
|
| 105 |
+
elif config.provider == Provider.READ_ONLY:
|
| 106 |
+
provider_config["gcs_address"] = self._gcs_client.address
|
| 107 |
+
self._cloud_instance_provider = ReadOnlyProvider(
|
| 108 |
+
provider_config=provider_config,
|
| 109 |
+
)
|
| 110 |
+
else:
|
| 111 |
+
node_provider_v1 = _get_node_provider(
|
| 112 |
+
provider_config,
|
| 113 |
+
config.get_config("cluster_name"),
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
self._cloud_instance_provider = NodeProviderAdapter(
|
| 117 |
+
v1_provider=node_provider_v1,
|
| 118 |
+
config_reader=config_reader,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
def _init_instance_manager(
|
| 122 |
+
self,
|
| 123 |
+
session_name: str,
|
| 124 |
+
cloud_provider: ICloudInstanceProvider,
|
| 125 |
+
gcs_client: GcsClient,
|
| 126 |
+
config: AutoscalingConfig,
|
| 127 |
+
):
|
| 128 |
+
"""
|
| 129 |
+
Initialize the instance manager, and its dependencies.
|
| 130 |
+
"""
|
| 131 |
+
|
| 132 |
+
instance_storage = InstanceStorage(
|
| 133 |
+
cluster_id=session_name,
|
| 134 |
+
storage=InMemoryStorage(),
|
| 135 |
+
)
|
| 136 |
+
subscribers: List[InstanceUpdatedSubscriber] = []
|
| 137 |
+
subscribers.append(CloudInstanceUpdater(cloud_provider=cloud_provider))
|
| 138 |
+
subscribers.append(
|
| 139 |
+
RayStopper(gcs_client=gcs_client, error_queue=self._ray_stop_errors_queue)
|
| 140 |
+
)
|
| 141 |
+
if not config.disable_node_updaters() and isinstance(
|
| 142 |
+
cloud_provider, NodeProviderAdapter
|
| 143 |
+
):
|
| 144 |
+
head_node_ip = urlsplit("//" + self._gcs_client.address).hostname
|
| 145 |
+
assert head_node_ip is not None, "Invalid GCS address format"
|
| 146 |
+
subscribers.append(
|
| 147 |
+
ThreadedRayInstaller(
|
| 148 |
+
head_node_ip=head_node_ip,
|
| 149 |
+
instance_storage=instance_storage,
|
| 150 |
+
ray_installer=RayInstaller(
|
| 151 |
+
provider=cloud_provider.v1_provider,
|
| 152 |
+
config=config,
|
| 153 |
+
),
|
| 154 |
+
error_queue=self._ray_install_errors_queue,
|
| 155 |
+
# TODO(rueian): Rewrite the ThreadedRayInstaller and its underlying
|
| 156 |
+
# NodeUpdater and CommandRunner to use the asyncio, so that we don't
|
| 157 |
+
# need to use so many threads. We use so many threads now because
|
| 158 |
+
# they are blocking and letting the new cloud machines to wait for
|
| 159 |
+
# previous machines to finish installing Ray is quite inefficient.
|
| 160 |
+
max_concurrent_installs=config.get_max_num_worker_nodes() or 50,
|
| 161 |
+
)
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
self._instance_manager = InstanceManager(
|
| 165 |
+
instance_storage=instance_storage,
|
| 166 |
+
instance_status_update_subscribers=subscribers,
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
def update_autoscaling_state(
|
| 170 |
+
self,
|
| 171 |
+
) -> Optional[AutoscalingState]:
|
| 172 |
+
"""Update the autoscaling state of the cluster by reconciling the current
|
| 173 |
+
state of the cluster resources, the cloud providers as well as instance
|
| 174 |
+
update subscribers with the desired state.
|
| 175 |
+
|
| 176 |
+
Returns:
|
| 177 |
+
AutoscalingState: The new autoscaling state of the cluster or None if
|
| 178 |
+
the state is not updated.
|
| 179 |
+
|
| 180 |
+
Raises:
|
| 181 |
+
None: No exception.
|
| 182 |
+
"""
|
| 183 |
+
|
| 184 |
+
try:
|
| 185 |
+
ray_stop_errors = []
|
| 186 |
+
while not self._ray_stop_errors_queue.empty():
|
| 187 |
+
ray_stop_errors.append(self._ray_stop_errors_queue.get())
|
| 188 |
+
|
| 189 |
+
ray_install_errors = []
|
| 190 |
+
while not self._ray_install_errors_queue.empty():
|
| 191 |
+
ray_install_errors.append(self._ray_install_errors_queue.get())
|
| 192 |
+
|
| 193 |
+
# Get the current state of the ray cluster resources.
|
| 194 |
+
ray_cluster_resource_state = get_cluster_resource_state(self._gcs_client)
|
| 195 |
+
|
| 196 |
+
# Refresh the config from the source
|
| 197 |
+
self._config_reader.refresh_cached_autoscaling_config()
|
| 198 |
+
autoscaling_config = self._config_reader.get_cached_autoscaling_config()
|
| 199 |
+
|
| 200 |
+
return Reconciler.reconcile(
|
| 201 |
+
instance_manager=self._instance_manager,
|
| 202 |
+
scheduler=self._scheduler,
|
| 203 |
+
cloud_provider=self._cloud_instance_provider,
|
| 204 |
+
ray_cluster_resource_state=ray_cluster_resource_state,
|
| 205 |
+
non_terminated_cloud_instances=(
|
| 206 |
+
self._cloud_instance_provider.get_non_terminated()
|
| 207 |
+
),
|
| 208 |
+
cloud_provider_errors=self._cloud_instance_provider.poll_errors(),
|
| 209 |
+
ray_install_errors=ray_install_errors,
|
| 210 |
+
ray_stop_errors=ray_stop_errors,
|
| 211 |
+
autoscaling_config=autoscaling_config,
|
| 212 |
+
metrics_reporter=self._metrics_reporter,
|
| 213 |
+
)
|
| 214 |
+
except Exception as e:
|
| 215 |
+
logger.exception(e)
|
| 216 |
+
return None
|
lib/python3.12/site-packages/ray/autoscaler/v2/event_logger.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from collections import defaultdict
|
| 3 |
+
from typing import Dict, List, Optional
|
| 4 |
+
|
| 5 |
+
from ray._private.event.event_logger import EventLoggerAdapter
|
| 6 |
+
from ray.autoscaler.v2.utils import ResourceRequestUtil
|
| 7 |
+
from ray.core.generated.autoscaler_pb2 import (
|
| 8 |
+
ClusterResourceConstraint,
|
| 9 |
+
GangResourceRequest,
|
| 10 |
+
ResourceRequest,
|
| 11 |
+
)
|
| 12 |
+
from ray.core.generated.common_pb2 import LabelSelectorOperator
|
| 13 |
+
from ray.core.generated.instance_manager_pb2 import LaunchRequest, TerminationRequest
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class AutoscalerEventLogger:
|
| 19 |
+
"""
|
| 20 |
+
Logs events related to the autoscaler.
|
| 21 |
+
|
| 22 |
+
# TODO:
|
| 23 |
+
- Add more logging for other events.
|
| 24 |
+
- Rate limit the events if too spammy.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def __init__(self, logger: EventLoggerAdapter):
|
| 28 |
+
self._logger = logger
|
| 29 |
+
|
| 30 |
+
def log_cluster_scheduling_update(
|
| 31 |
+
self,
|
| 32 |
+
cluster_resources: Dict[str, float],
|
| 33 |
+
launch_requests: Optional[List[LaunchRequest]] = None,
|
| 34 |
+
terminate_requests: Optional[List[TerminationRequest]] = None,
|
| 35 |
+
infeasible_requests: Optional[List[ResourceRequest]] = None,
|
| 36 |
+
infeasible_gang_requests: Optional[List[GangResourceRequest]] = None,
|
| 37 |
+
infeasible_cluster_resource_constraints: Optional[
|
| 38 |
+
List[ClusterResourceConstraint]
|
| 39 |
+
] = None,
|
| 40 |
+
) -> None:
|
| 41 |
+
"""
|
| 42 |
+
Log updates to the autoscaler scheduling state.
|
| 43 |
+
|
| 44 |
+
Emits:
|
| 45 |
+
- info logs for node launches and terminations (counts grouped by node type).
|
| 46 |
+
- an info log summarizing the cluster size after a resize (CPUs/GPUs/TPUs).
|
| 47 |
+
- warnings describing infeasible single resource requests, infeasible gang
|
| 48 |
+
(placement group) requests, and infeasible cluster resource constraints.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
cluster_resources: Mapping of resource name to total resources for the
|
| 52 |
+
current cluster state.
|
| 53 |
+
launch_requests: Node launch requests issued in this scheduling step.
|
| 54 |
+
terminate_requests: Node termination requests issued in this scheduling
|
| 55 |
+
step.
|
| 56 |
+
infeasible_requests: Resource requests that could not be satisfied by
|
| 57 |
+
any available node type.
|
| 58 |
+
infeasible_gang_requests: Gang/placement group requests that could not
|
| 59 |
+
be scheduled.
|
| 60 |
+
infeasible_cluster_resource_constraints: Cluster-level resource
|
| 61 |
+
constraints that could not be satisfied.
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
None
|
| 65 |
+
"""
|
| 66 |
+
|
| 67 |
+
# Log any launch events.
|
| 68 |
+
if launch_requests:
|
| 69 |
+
launch_type_count = defaultdict(int)
|
| 70 |
+
for req in launch_requests:
|
| 71 |
+
launch_type_count[req.instance_type] += req.count
|
| 72 |
+
|
| 73 |
+
for idx, (instance_type, count) in enumerate(launch_type_count.items()):
|
| 74 |
+
log_str = f"Adding {count} node(s) of type {instance_type}."
|
| 75 |
+
self._logger.info(f"{log_str}")
|
| 76 |
+
logger.info(f"{log_str}")
|
| 77 |
+
|
| 78 |
+
# Log any terminate events.
|
| 79 |
+
if terminate_requests:
|
| 80 |
+
termination_by_causes_and_type = defaultdict(int)
|
| 81 |
+
for req in terminate_requests:
|
| 82 |
+
termination_by_causes_and_type[(req.cause, req.instance_type)] += 1
|
| 83 |
+
|
| 84 |
+
cause_reason_map = {
|
| 85 |
+
TerminationRequest.Cause.OUTDATED: "outdated",
|
| 86 |
+
TerminationRequest.Cause.MAX_NUM_NODES: "max number of worker nodes reached", # noqa
|
| 87 |
+
TerminationRequest.Cause.MAX_NUM_NODE_PER_TYPE: "max number of worker nodes per type reached", # noqa
|
| 88 |
+
TerminationRequest.Cause.IDLE: "idle",
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
for idx, ((cause, instance_type), count) in enumerate(
|
| 92 |
+
termination_by_causes_and_type.items()
|
| 93 |
+
):
|
| 94 |
+
log_str = f"Removing {count} nodes of type {instance_type} ({cause_reason_map[cause]})." # noqa
|
| 95 |
+
self._logger.info(f"{log_str}")
|
| 96 |
+
logger.info(f"{log_str}")
|
| 97 |
+
|
| 98 |
+
# Cluster shape changes.
|
| 99 |
+
if launch_requests or terminate_requests:
|
| 100 |
+
num_cpus = cluster_resources.get("CPU", 0)
|
| 101 |
+
log_str = f"Resized to {int(num_cpus)} CPUs"
|
| 102 |
+
|
| 103 |
+
if "GPU" in cluster_resources:
|
| 104 |
+
log_str += f", {int(cluster_resources['GPU'])} GPUs"
|
| 105 |
+
if "TPU" in cluster_resources:
|
| 106 |
+
log_str += f", {int(cluster_resources['TPU'])} TPUs"
|
| 107 |
+
|
| 108 |
+
self._logger.info(f"{log_str}.")
|
| 109 |
+
self._logger.debug(f"Current cluster resources: {dict(cluster_resources)}.")
|
| 110 |
+
|
| 111 |
+
# Log any infeasible requests.
|
| 112 |
+
if infeasible_requests:
|
| 113 |
+
requests_by_count = ResourceRequestUtil.group_by_count(infeasible_requests)
|
| 114 |
+
log_str = "No available node types can fulfill resource requests "
|
| 115 |
+
for idx, req_count in enumerate(requests_by_count):
|
| 116 |
+
resource_map = ResourceRequestUtil.to_resource_map(req_count.request)
|
| 117 |
+
log_str += f"{resource_map}*{req_count.count}"
|
| 118 |
+
if idx < len(requests_by_count) - 1:
|
| 119 |
+
log_str += ", "
|
| 120 |
+
|
| 121 |
+
# Parse and log label selectors if present
|
| 122 |
+
if req_count.request.label_selectors:
|
| 123 |
+
selector_strs = []
|
| 124 |
+
for selector in req_count.request.label_selectors:
|
| 125 |
+
for constraint in selector.label_constraints:
|
| 126 |
+
op = LabelSelectorOperator.Name(constraint.operator)
|
| 127 |
+
values = ",".join(constraint.label_values)
|
| 128 |
+
selector_strs.append(
|
| 129 |
+
f"{constraint.label_key} {op} [{values}]"
|
| 130 |
+
)
|
| 131 |
+
if selector_strs:
|
| 132 |
+
log_str += (
|
| 133 |
+
" with label selectors: [" + "; ".join(selector_strs) + "]"
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
log_str += (
|
| 137 |
+
". Add suitable node types to this cluster to resolve this issue."
|
| 138 |
+
)
|
| 139 |
+
self._logger.warning(log_str)
|
| 140 |
+
|
| 141 |
+
if infeasible_gang_requests:
|
| 142 |
+
# Log for each placement group requests.
|
| 143 |
+
for gang_request in infeasible_gang_requests:
|
| 144 |
+
log_str = (
|
| 145 |
+
"No available node types can fulfill "
|
| 146 |
+
"placement group requests (detail={details}): ".format(
|
| 147 |
+
details=gang_request.details
|
| 148 |
+
)
|
| 149 |
+
)
|
| 150 |
+
requests_by_count = ResourceRequestUtil.group_by_count(
|
| 151 |
+
gang_request.requests
|
| 152 |
+
)
|
| 153 |
+
for idx, req_count in enumerate(requests_by_count):
|
| 154 |
+
resource_map = ResourceRequestUtil.to_resource_map(
|
| 155 |
+
req_count.request
|
| 156 |
+
)
|
| 157 |
+
log_str += f"{resource_map}*{req_count.count}"
|
| 158 |
+
if idx < len(requests_by_count) - 1:
|
| 159 |
+
log_str += ", "
|
| 160 |
+
|
| 161 |
+
log_str += (
|
| 162 |
+
". Add suitable node types to this cluster to resolve this issue."
|
| 163 |
+
)
|
| 164 |
+
self._logger.warning(log_str)
|
| 165 |
+
|
| 166 |
+
if infeasible_cluster_resource_constraints:
|
| 167 |
+
# We will only have max 1 cluster resource constraint for now since it's
|
| 168 |
+
# from `request_resources()` sdk, where the most recent call would override
|
| 169 |
+
# the previous one.
|
| 170 |
+
for infeasible_constraint in infeasible_cluster_resource_constraints:
|
| 171 |
+
log_str = "No available node types can fulfill cluster constraint: "
|
| 172 |
+
for i, requests_by_count in enumerate(
|
| 173 |
+
infeasible_constraint.resource_requests
|
| 174 |
+
):
|
| 175 |
+
resource_map = ResourceRequestUtil.to_resource_map(
|
| 176 |
+
requests_by_count.request
|
| 177 |
+
)
|
| 178 |
+
log_str += f"{resource_map}*{requests_by_count.count}"
|
| 179 |
+
if i < len(infeasible_constraint.resource_requests) - 1:
|
| 180 |
+
log_str += ", "
|
| 181 |
+
|
| 182 |
+
log_str += (
|
| 183 |
+
". Add suitable node types to this cluster to resolve this issue."
|
| 184 |
+
)
|
| 185 |
+
self._logger.warning(log_str)
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__init__.py
ADDED
|
File without changes
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (213 Bytes). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/common.cpython-312.pyc
ADDED
|
Binary file (15.6 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/config.cpython-312.pyc
ADDED
|
Binary file (26.9 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/instance_manager.cpython-312.pyc
ADDED
|
Binary file (11.4 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/instance_storage.cpython-312.pyc
ADDED
|
Binary file (6.56 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/node_provider.cpython-312.pyc
ADDED
|
Binary file (20.6 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/ray_installer.cpython-312.pyc
ADDED
|
Binary file (5.01 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/reconciler.cpython-312.pyc
ADDED
|
Binary file (58.2 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/storage.cpython-312.pyc
ADDED
|
Binary file (9.48 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/__init__.py
ADDED
|
File without changes
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (229 Bytes). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/kuberay/__init__.py
ADDED
|
File without changes
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/kuberay/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (237 Bytes). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/kuberay/__pycache__/cloud_provider.cpython-312.pyc
ADDED
|
Binary file (21.1 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/kuberay/cloud_provider.py
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import copy
|
| 2 |
+
import logging
|
| 3 |
+
import time
|
| 4 |
+
from collections import defaultdict
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
| 7 |
+
|
| 8 |
+
import requests
|
| 9 |
+
|
| 10 |
+
# TODO(rickyx): We should eventually remove these imports
|
| 11 |
+
# when we deprecate the v1 kuberay node provider.
|
| 12 |
+
from ray.autoscaler._private.kuberay.node_provider import (
|
| 13 |
+
KUBERAY_KIND_HEAD,
|
| 14 |
+
KUBERAY_KIND_WORKER,
|
| 15 |
+
KUBERAY_LABEL_KEY_KIND,
|
| 16 |
+
KUBERAY_LABEL_KEY_TYPE,
|
| 17 |
+
RAY_HEAD_POD_NAME,
|
| 18 |
+
IKubernetesHttpApiClient,
|
| 19 |
+
KubernetesHttpApiClient,
|
| 20 |
+
_worker_group_index,
|
| 21 |
+
_worker_group_max_replicas,
|
| 22 |
+
_worker_group_num_of_hosts,
|
| 23 |
+
_worker_group_replicas,
|
| 24 |
+
worker_delete_patch,
|
| 25 |
+
worker_replica_patch,
|
| 26 |
+
)
|
| 27 |
+
from ray.autoscaler.v2.instance_manager.node_provider import (
|
| 28 |
+
CloudInstance,
|
| 29 |
+
CloudInstanceId,
|
| 30 |
+
CloudInstanceProviderError,
|
| 31 |
+
ICloudInstanceProvider,
|
| 32 |
+
LaunchNodeError,
|
| 33 |
+
NodeKind,
|
| 34 |
+
TerminateNodeError,
|
| 35 |
+
)
|
| 36 |
+
from ray.autoscaler.v2.schema import NodeType
|
| 37 |
+
|
| 38 |
+
logger = logging.getLogger(__name__)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class KubeRayProvider(ICloudInstanceProvider):
|
| 42 |
+
"""
|
| 43 |
+
This class is a thin wrapper around the Kubernetes API client. It modifies
|
| 44 |
+
the RayCluster resource spec on the Kubernetes API server to scale the cluster:
|
| 45 |
+
|
| 46 |
+
It launches new instances/nodes by submitting patches to the Kubernetes API
|
| 47 |
+
to update the RayCluster CRD.
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
def __init__(
|
| 51 |
+
self,
|
| 52 |
+
cluster_name: str,
|
| 53 |
+
provider_config: Dict[str, Any],
|
| 54 |
+
k8s_api_client: Optional[IKubernetesHttpApiClient] = None,
|
| 55 |
+
):
|
| 56 |
+
"""
|
| 57 |
+
Args:
|
| 58 |
+
cluster_name: The name of the RayCluster resource.
|
| 59 |
+
provider_config: The namespace of the RayCluster.
|
| 60 |
+
k8s_api_client: The client to the Kubernetes API server.
|
| 61 |
+
This could be used to mock the Kubernetes API server for testing.
|
| 62 |
+
"""
|
| 63 |
+
self._cluster_name = cluster_name
|
| 64 |
+
self._namespace = provider_config["namespace"]
|
| 65 |
+
|
| 66 |
+
self._k8s_api_client = k8s_api_client or KubernetesHttpApiClient(
|
| 67 |
+
namespace=self._namespace
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# Below are states that are cached locally.
|
| 71 |
+
self._requests = set()
|
| 72 |
+
self._launch_errors_queue = []
|
| 73 |
+
self._terminate_errors_queue = []
|
| 74 |
+
|
| 75 |
+
# Below are states that are fetched from the Kubernetes API server.
|
| 76 |
+
self._ray_cluster = None
|
| 77 |
+
self._cached_instances: Dict[CloudInstanceId, CloudInstance]
|
| 78 |
+
|
| 79 |
+
@dataclass
|
| 80 |
+
class ScaleRequest:
|
| 81 |
+
"""Represents a scale request that contains the current states and go-to states
|
| 82 |
+
for the ray cluster.
|
| 83 |
+
|
| 84 |
+
This class will be converted to patches to be submitted to the Kubernetes API
|
| 85 |
+
server:
|
| 86 |
+
- For launching new instances, it will adjust the `replicas` field in the
|
| 87 |
+
workerGroupSpecs.
|
| 88 |
+
- For terminating instances, it will adjust the `workersToDelete` field in the
|
| 89 |
+
workerGroupSpecs.
|
| 90 |
+
|
| 91 |
+
"""
|
| 92 |
+
|
| 93 |
+
# The desired number of workers for each node type.
|
| 94 |
+
desired_num_workers: Dict[NodeType, int] = field(default_factory=dict)
|
| 95 |
+
# The workers to delete for each node type.
|
| 96 |
+
workers_to_delete: Dict[NodeType, List[CloudInstanceId]] = field(
|
| 97 |
+
default_factory=dict
|
| 98 |
+
)
|
| 99 |
+
# The worker groups with empty workersToDelete field.
|
| 100 |
+
# This is needed since we will also need to clear the workersToDelete field
|
| 101 |
+
# for the worker groups that have finished deletes.
|
| 102 |
+
worker_groups_without_pending_deletes: Set[NodeType] = field(
|
| 103 |
+
default_factory=set
|
| 104 |
+
)
|
| 105 |
+
# The worker groups that still have workers to be deleted.
|
| 106 |
+
worker_groups_with_pending_deletes: Set[NodeType] = field(default_factory=set)
|
| 107 |
+
|
| 108 |
+
################################
|
| 109 |
+
# Interface for ICloudInstanceProvider
|
| 110 |
+
################################
|
| 111 |
+
|
| 112 |
+
def get_non_terminated(self) -> Dict[CloudInstanceId, CloudInstance]:
|
| 113 |
+
self._sync_with_api_server()
|
| 114 |
+
return copy.deepcopy(dict(self._cached_instances))
|
| 115 |
+
|
| 116 |
+
def terminate(self, ids: List[CloudInstanceId], request_id: str) -> None:
|
| 117 |
+
if request_id in self._requests:
|
| 118 |
+
# This request is already processed.
|
| 119 |
+
logger.warning(f"Request {request_id} is already processed for: {ids}")
|
| 120 |
+
return
|
| 121 |
+
self._requests.add(request_id)
|
| 122 |
+
logger.info("Terminating worker pods: {}".format(ids))
|
| 123 |
+
|
| 124 |
+
scale_request = self._initialize_scale_request(
|
| 125 |
+
to_launch={}, to_delete_instances=ids
|
| 126 |
+
)
|
| 127 |
+
if scale_request.worker_groups_with_pending_deletes:
|
| 128 |
+
errors_msg = (
|
| 129 |
+
"There are workers to be deleted from: "
|
| 130 |
+
f"{scale_request.worker_groups_with_pending_deletes}. "
|
| 131 |
+
"Waiting for them to be deleted before adding new workers "
|
| 132 |
+
" to be deleted"
|
| 133 |
+
)
|
| 134 |
+
logger.warning(errors_msg)
|
| 135 |
+
self._add_terminate_errors(
|
| 136 |
+
ids,
|
| 137 |
+
request_id,
|
| 138 |
+
details=errors_msg,
|
| 139 |
+
)
|
| 140 |
+
return
|
| 141 |
+
|
| 142 |
+
try:
|
| 143 |
+
self._submit_scale_request(scale_request)
|
| 144 |
+
except Exception as e:
|
| 145 |
+
logger.exception(f"Error terminating nodes: {scale_request}")
|
| 146 |
+
self._add_terminate_errors(ids, request_id, details=str(e), e=e)
|
| 147 |
+
|
| 148 |
+
def launch(self, shape: Dict[NodeType, int], request_id: str) -> None:
|
| 149 |
+
if request_id in self._requests:
|
| 150 |
+
# This request is already processed.
|
| 151 |
+
return
|
| 152 |
+
self._requests.add(request_id)
|
| 153 |
+
|
| 154 |
+
scale_request = self._initialize_scale_request(
|
| 155 |
+
to_launch=shape, to_delete_instances=[]
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
if scale_request.worker_groups_with_pending_deletes:
|
| 159 |
+
error_msg = (
|
| 160 |
+
"There are workers to be deleted from: "
|
| 161 |
+
f"{scale_request.worker_groups_with_pending_deletes}. "
|
| 162 |
+
"Waiting for them to be deleted before creating new workers."
|
| 163 |
+
)
|
| 164 |
+
logger.warning(error_msg)
|
| 165 |
+
self._add_launch_errors(
|
| 166 |
+
shape,
|
| 167 |
+
request_id,
|
| 168 |
+
details=error_msg,
|
| 169 |
+
)
|
| 170 |
+
return
|
| 171 |
+
|
| 172 |
+
try:
|
| 173 |
+
self._submit_scale_request(scale_request)
|
| 174 |
+
except Exception as e:
|
| 175 |
+
logger.exception(f"Error launching nodes: {scale_request}")
|
| 176 |
+
self._add_launch_errors(shape, request_id, details=str(e), e=e)
|
| 177 |
+
|
| 178 |
+
def poll_errors(self) -> List[CloudInstanceProviderError]:
|
| 179 |
+
errors = []
|
| 180 |
+
errors += self._launch_errors_queue
|
| 181 |
+
errors += self._terminate_errors_queue
|
| 182 |
+
self._launch_errors_queue = []
|
| 183 |
+
self._terminate_errors_queue = []
|
| 184 |
+
return errors
|
| 185 |
+
|
| 186 |
+
############################
|
| 187 |
+
# Private
|
| 188 |
+
############################
|
| 189 |
+
|
| 190 |
+
def _initialize_scale_request(
|
| 191 |
+
self, to_launch: Dict[NodeType, int], to_delete_instances: List[CloudInstanceId]
|
| 192 |
+
) -> "KubeRayProvider.ScaleRequest":
|
| 193 |
+
"""
|
| 194 |
+
Initialize the scale request based on the current state of the cluster and
|
| 195 |
+
the desired state (to launch, to delete).
|
| 196 |
+
|
| 197 |
+
Args:
|
| 198 |
+
to_launch: The desired number of workers to launch for each node type.
|
| 199 |
+
to_delete_instances: The instances to delete.
|
| 200 |
+
|
| 201 |
+
Returns:
|
| 202 |
+
The scale request.
|
| 203 |
+
"""
|
| 204 |
+
|
| 205 |
+
# Update the cached states.
|
| 206 |
+
self._sync_with_api_server()
|
| 207 |
+
ray_cluster = self.ray_cluster
|
| 208 |
+
cur_instances = self.instances
|
| 209 |
+
|
| 210 |
+
# Get the worker groups that have pending deletes and the worker groups that
|
| 211 |
+
# have finished deletes, and the set of workers included in the workersToDelete
|
| 212 |
+
# field of any worker group.
|
| 213 |
+
(
|
| 214 |
+
worker_groups_with_pending_deletes,
|
| 215 |
+
worker_groups_without_pending_deletes,
|
| 216 |
+
worker_to_delete_set,
|
| 217 |
+
) = self._get_workers_delete_info(ray_cluster, set(cur_instances.keys()))
|
| 218 |
+
|
| 219 |
+
observed_workers_dict = defaultdict(int)
|
| 220 |
+
for instance in cur_instances.values():
|
| 221 |
+
if instance.node_kind != NodeKind.WORKER:
|
| 222 |
+
continue
|
| 223 |
+
if instance.cloud_instance_id in worker_to_delete_set:
|
| 224 |
+
continue
|
| 225 |
+
observed_workers_dict[instance.node_type] += 1
|
| 226 |
+
|
| 227 |
+
# Calculate the desired number of workers by type.
|
| 228 |
+
num_workers_dict = defaultdict(int)
|
| 229 |
+
worker_groups = ray_cluster["spec"].get("workerGroupSpecs", [])
|
| 230 |
+
for worker_group in worker_groups:
|
| 231 |
+
node_type = worker_group["groupName"]
|
| 232 |
+
# Handle the case where users manually increase `minReplicas`
|
| 233 |
+
# to scale up the number of worker Pods. In this scenario,
|
| 234 |
+
# `replicas` will be smaller than `minReplicas`.
|
| 235 |
+
# num_workers_dict should account for multi-host replicas when
|
| 236 |
+
# `numOfHosts`` is set.
|
| 237 |
+
num_of_hosts = worker_group.get("numOfHosts", 1)
|
| 238 |
+
replicas = (
|
| 239 |
+
max(worker_group["replicas"], worker_group["minReplicas"])
|
| 240 |
+
* num_of_hosts
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
# The `replicas` field in worker group specs can be updated by users at any time.
|
| 244 |
+
# However, users should only increase the field (manually upscaling the worker group), not decrease it,
|
| 245 |
+
# because downscaling the worker group requires specifying which workers to delete explicitly in the `workersToDelete` field.
|
| 246 |
+
# Since we don't have a way to enforce this, we need to fix unexpected decreases on the `replicas` field by using actual observations.
|
| 247 |
+
# For example, if the user manually decreases the `replicas` field to 0 without specifying which workers to delete,
|
| 248 |
+
# we should fix the `replicas` field back to the number of observed workers excluding the workers to be deleted,
|
| 249 |
+
# otherwise, we won't have a correct `replicas` matches the actual number of workers eventually.
|
| 250 |
+
num_workers_dict[node_type] = max(
|
| 251 |
+
replicas, observed_workers_dict[node_type]
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
# Add to launch nodes.
|
| 255 |
+
for node_type, count in to_launch.items():
|
| 256 |
+
num_workers_dict[node_type] += count
|
| 257 |
+
|
| 258 |
+
to_delete_instances_by_type = defaultdict(list)
|
| 259 |
+
# Update the number of workers with to_delete_instances
|
| 260 |
+
# and group them by type.
|
| 261 |
+
for to_delete_id in to_delete_instances:
|
| 262 |
+
to_delete_instance = cur_instances.get(to_delete_id, None)
|
| 263 |
+
if to_delete_instance is None:
|
| 264 |
+
# This instance has already been deleted.
|
| 265 |
+
continue
|
| 266 |
+
|
| 267 |
+
if to_delete_instance.node_kind == NodeKind.HEAD:
|
| 268 |
+
# Not possible to delete head node.
|
| 269 |
+
continue
|
| 270 |
+
|
| 271 |
+
if to_delete_instance.cloud_instance_id in worker_to_delete_set:
|
| 272 |
+
# If the instance is already in the workersToDelete field of
|
| 273 |
+
# any worker group, skip it.
|
| 274 |
+
continue
|
| 275 |
+
|
| 276 |
+
num_workers_dict[to_delete_instance.node_type] -= 1
|
| 277 |
+
assert num_workers_dict[to_delete_instance.node_type] >= 0
|
| 278 |
+
to_delete_instances_by_type[to_delete_instance.node_type].append(
|
| 279 |
+
to_delete_instance
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
scale_request = KubeRayProvider.ScaleRequest(
|
| 283 |
+
desired_num_workers=num_workers_dict,
|
| 284 |
+
workers_to_delete=to_delete_instances_by_type,
|
| 285 |
+
worker_groups_without_pending_deletes=worker_groups_without_pending_deletes,
|
| 286 |
+
worker_groups_with_pending_deletes=worker_groups_with_pending_deletes,
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
return scale_request
|
| 290 |
+
|
| 291 |
+
def _submit_scale_request(
|
| 292 |
+
self, scale_request: "KubeRayProvider.ScaleRequest"
|
| 293 |
+
) -> None:
|
| 294 |
+
"""Submits a scale request to the Kubernetes API server.
|
| 295 |
+
|
| 296 |
+
This method will convert the scale request to patches and submit the patches
|
| 297 |
+
to the Kubernetes API server.
|
| 298 |
+
|
| 299 |
+
Args:
|
| 300 |
+
scale_request: The scale request.
|
| 301 |
+
|
| 302 |
+
Raises:
|
| 303 |
+
Exception: An exception is raised if the Kubernetes API server returns an
|
| 304 |
+
error.
|
| 305 |
+
"""
|
| 306 |
+
# Get the current ray cluster spec.
|
| 307 |
+
patch_payload = []
|
| 308 |
+
|
| 309 |
+
raycluster = self.ray_cluster
|
| 310 |
+
|
| 311 |
+
# Collect patches for replica counts.
|
| 312 |
+
for node_type, num_workers in scale_request.desired_num_workers.items():
|
| 313 |
+
group_index = _worker_group_index(raycluster, node_type)
|
| 314 |
+
group_max_replicas = _worker_group_max_replicas(raycluster, group_index)
|
| 315 |
+
group_num_of_hosts = _worker_group_num_of_hosts(raycluster, group_index)
|
| 316 |
+
# the num_workers from the scale request is multiplied by numOfHosts, so we need to divide it back.
|
| 317 |
+
target_replicas = num_workers // group_num_of_hosts
|
| 318 |
+
# Cap the replica count to maxReplicas.
|
| 319 |
+
if group_max_replicas is not None and group_max_replicas < target_replicas:
|
| 320 |
+
logger.warning(
|
| 321 |
+
"Autoscaler attempted to create "
|
| 322 |
+
+ "more than maxReplicas pods of type {}.".format(node_type)
|
| 323 |
+
)
|
| 324 |
+
target_replicas = group_max_replicas
|
| 325 |
+
# Check if we need to change the target count.
|
| 326 |
+
if target_replicas == _worker_group_replicas(raycluster, group_index):
|
| 327 |
+
# No patch required.
|
| 328 |
+
continue
|
| 329 |
+
# Need to patch replica count. Format the patch and add it to the payload.
|
| 330 |
+
patch = worker_replica_patch(group_index, target_replicas)
|
| 331 |
+
patch_payload.append(patch)
|
| 332 |
+
|
| 333 |
+
# Maps node_type to nodes to delete for that group.
|
| 334 |
+
for (
|
| 335 |
+
node_type,
|
| 336 |
+
workers_to_delete_of_type,
|
| 337 |
+
) in scale_request.workers_to_delete.items():
|
| 338 |
+
group_index = _worker_group_index(raycluster, node_type)
|
| 339 |
+
worker_ids_to_delete = [
|
| 340 |
+
worker.cloud_instance_id for worker in workers_to_delete_of_type
|
| 341 |
+
]
|
| 342 |
+
patch = worker_delete_patch(group_index, worker_ids_to_delete)
|
| 343 |
+
patch_payload.append(patch)
|
| 344 |
+
|
| 345 |
+
# Clear the workersToDelete field for the worker groups that have been deleted.
|
| 346 |
+
for node_type in scale_request.worker_groups_without_pending_deletes:
|
| 347 |
+
if node_type in scale_request.workers_to_delete:
|
| 348 |
+
# This node type is still being deleted.
|
| 349 |
+
continue
|
| 350 |
+
group_index = _worker_group_index(raycluster, node_type)
|
| 351 |
+
patch = worker_delete_patch(group_index, [])
|
| 352 |
+
patch_payload.append(patch)
|
| 353 |
+
|
| 354 |
+
if len(patch_payload) == 0:
|
| 355 |
+
# No patch required.
|
| 356 |
+
return
|
| 357 |
+
|
| 358 |
+
logger.info(f"Submitting a scale request: {scale_request}")
|
| 359 |
+
self._patch(f"rayclusters/{self._cluster_name}", patch_payload)
|
| 360 |
+
|
| 361 |
+
def _add_launch_errors(
|
| 362 |
+
self,
|
| 363 |
+
shape: Dict[NodeType, int],
|
| 364 |
+
request_id: str,
|
| 365 |
+
details: str,
|
| 366 |
+
e: Optional[Exception] = None,
|
| 367 |
+
) -> None:
|
| 368 |
+
"""
|
| 369 |
+
Adds launch errors to the error queue.
|
| 370 |
+
|
| 371 |
+
Args:
|
| 372 |
+
shape: The shape of the nodes that failed to launch.
|
| 373 |
+
request_id: The request id of the launch request.
|
| 374 |
+
details: The details of the error.
|
| 375 |
+
e: The exception that caused the error.
|
| 376 |
+
"""
|
| 377 |
+
for node_type, count in shape.items():
|
| 378 |
+
self._launch_errors_queue.append(
|
| 379 |
+
LaunchNodeError(
|
| 380 |
+
node_type=node_type,
|
| 381 |
+
timestamp_ns=time.time_ns(),
|
| 382 |
+
count=count,
|
| 383 |
+
request_id=request_id,
|
| 384 |
+
details=details,
|
| 385 |
+
cause=e,
|
| 386 |
+
)
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
def _add_terminate_errors(
|
| 390 |
+
self,
|
| 391 |
+
ids: List[CloudInstanceId],
|
| 392 |
+
request_id: str,
|
| 393 |
+
details: str,
|
| 394 |
+
e: Optional[Exception] = None,
|
| 395 |
+
) -> None:
|
| 396 |
+
"""
|
| 397 |
+
Adds terminate errors to the error queue.
|
| 398 |
+
|
| 399 |
+
Args:
|
| 400 |
+
ids: The ids of the nodes that failed to terminate.
|
| 401 |
+
request_id: The request id of the terminate request.
|
| 402 |
+
details: The details of the error.
|
| 403 |
+
e: The exception that caused the error.
|
| 404 |
+
"""
|
| 405 |
+
for id in ids:
|
| 406 |
+
self._terminate_errors_queue.append(
|
| 407 |
+
TerminateNodeError(
|
| 408 |
+
cloud_instance_id=id,
|
| 409 |
+
timestamp_ns=time.time_ns(),
|
| 410 |
+
request_id=request_id,
|
| 411 |
+
details=details,
|
| 412 |
+
cause=e,
|
| 413 |
+
)
|
| 414 |
+
)
|
| 415 |
+
|
| 416 |
+
def _sync_with_api_server(self) -> None:
|
| 417 |
+
"""Fetches the RayCluster resource from the Kubernetes API server."""
|
| 418 |
+
self._ray_cluster = self._get(f"rayclusters/{self._cluster_name}")
|
| 419 |
+
self._cached_instances = self._fetch_instances()
|
| 420 |
+
|
| 421 |
+
@property
|
| 422 |
+
def ray_cluster(self) -> Dict[str, Any]:
|
| 423 |
+
return copy.deepcopy(self._ray_cluster)
|
| 424 |
+
|
| 425 |
+
@property
|
| 426 |
+
def instances(self) -> Dict[CloudInstanceId, CloudInstance]:
|
| 427 |
+
return copy.deepcopy(self._cached_instances)
|
| 428 |
+
|
| 429 |
+
@staticmethod
|
| 430 |
+
def _get_workers_delete_info(
|
| 431 |
+
ray_cluster_spec: Dict[str, Any], node_set: Set[CloudInstanceId]
|
| 432 |
+
) -> Tuple[Set[NodeType], Set[NodeType], Set[CloudInstanceId]]:
|
| 433 |
+
"""
|
| 434 |
+
Gets the worker groups that have pending deletes and the worker groups that
|
| 435 |
+
have finished deletes.
|
| 436 |
+
|
| 437 |
+
Returns:
|
| 438 |
+
worker_groups_with_pending_deletes: The worker groups that have pending
|
| 439 |
+
deletes.
|
| 440 |
+
worker_groups_with_finished_deletes: The worker groups that have finished
|
| 441 |
+
deletes.
|
| 442 |
+
worker_to_delete_set: A set of Pods that are included in the workersToDelete
|
| 443 |
+
field of any worker group.
|
| 444 |
+
"""
|
| 445 |
+
|
| 446 |
+
worker_groups_with_pending_deletes = set()
|
| 447 |
+
worker_groups_with_deletes = set()
|
| 448 |
+
worker_to_delete_set = set()
|
| 449 |
+
|
| 450 |
+
worker_groups = ray_cluster_spec["spec"].get("workerGroupSpecs", [])
|
| 451 |
+
for worker_group in worker_groups:
|
| 452 |
+
workersToDelete = worker_group.get("scaleStrategy", {}).get(
|
| 453 |
+
"workersToDelete", []
|
| 454 |
+
)
|
| 455 |
+
if not workersToDelete:
|
| 456 |
+
# No workers to delete in this group.
|
| 457 |
+
continue
|
| 458 |
+
|
| 459 |
+
node_type = worker_group["groupName"]
|
| 460 |
+
worker_groups_with_deletes.add(node_type)
|
| 461 |
+
|
| 462 |
+
for worker in workersToDelete:
|
| 463 |
+
worker_to_delete_set.add(worker)
|
| 464 |
+
if worker in node_set:
|
| 465 |
+
worker_groups_with_pending_deletes.add(node_type)
|
| 466 |
+
|
| 467 |
+
worker_groups_with_finished_deletes = (
|
| 468 |
+
worker_groups_with_deletes - worker_groups_with_pending_deletes
|
| 469 |
+
)
|
| 470 |
+
return (
|
| 471 |
+
worker_groups_with_pending_deletes,
|
| 472 |
+
worker_groups_with_finished_deletes,
|
| 473 |
+
worker_to_delete_set,
|
| 474 |
+
)
|
| 475 |
+
|
| 476 |
+
def _fetch_instances(self) -> Dict[CloudInstanceId, CloudInstance]:
|
| 477 |
+
"""
|
| 478 |
+
Fetches the pods from the Kubernetes API server and convert them to Ray
|
| 479 |
+
CloudInstance.
|
| 480 |
+
|
| 481 |
+
Returns:
|
| 482 |
+
A dict of CloudInstanceId to CloudInstance.
|
| 483 |
+
"""
|
| 484 |
+
# Get the pods resource version.
|
| 485 |
+
# Specifying a resource version in list requests is important for scalability:
|
| 486 |
+
# https://kubernetes.io/docs/reference/using-api/api-concepts/#semantics-for-get-and-list
|
| 487 |
+
resource_version = self._get_head_pod_resource_version()
|
| 488 |
+
if resource_version:
|
| 489 |
+
logger.info(
|
| 490 |
+
f"Listing pods for RayCluster {self._cluster_name}"
|
| 491 |
+
f" in namespace {self._namespace}"
|
| 492 |
+
f" at pods resource version >= {resource_version}."
|
| 493 |
+
)
|
| 494 |
+
|
| 495 |
+
# Filter pods by cluster_name.
|
| 496 |
+
label_selector = requests.utils.quote(f"ray.io/cluster={self._cluster_name}")
|
| 497 |
+
|
| 498 |
+
resource_path = f"pods?labelSelector={label_selector}"
|
| 499 |
+
if resource_version:
|
| 500 |
+
resource_path += (
|
| 501 |
+
f"&resourceVersion={resource_version}"
|
| 502 |
+
+ "&resourceVersionMatch=NotOlderThan"
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
pod_list = self._get(resource_path)
|
| 506 |
+
fetched_resource_version = pod_list["metadata"]["resourceVersion"]
|
| 507 |
+
logger.info(
|
| 508 |
+
f"Fetched pod data at resource version" f" {fetched_resource_version}."
|
| 509 |
+
)
|
| 510 |
+
|
| 511 |
+
# Extract node data from the pod list.
|
| 512 |
+
cloud_instances = {}
|
| 513 |
+
for pod in pod_list["items"]:
|
| 514 |
+
# Kubernetes sets metadata.deletionTimestamp immediately after admitting a
|
| 515 |
+
# request to delete an object. Full removal of the object may take some time
|
| 516 |
+
# after the deletion timestamp is set. See link for details:
|
| 517 |
+
# https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-deletion
|
| 518 |
+
if "deletionTimestamp" in pod["metadata"]:
|
| 519 |
+
# Ignore pods marked for termination.
|
| 520 |
+
continue
|
| 521 |
+
pod_name = pod["metadata"]["name"]
|
| 522 |
+
cloud_instance = self._cloud_instance_from_pod(pod)
|
| 523 |
+
if cloud_instance:
|
| 524 |
+
cloud_instances[pod_name] = cloud_instance
|
| 525 |
+
return cloud_instances
|
| 526 |
+
|
| 527 |
+
@staticmethod
|
| 528 |
+
def _cloud_instance_from_pod(pod: Dict[str, Any]) -> Optional[CloudInstance]:
|
| 529 |
+
"""
|
| 530 |
+
Convert a pod to a Ray CloudInstance.
|
| 531 |
+
|
| 532 |
+
Args:
|
| 533 |
+
pod: The pod resource dict.
|
| 534 |
+
"""
|
| 535 |
+
labels = pod["metadata"]["labels"]
|
| 536 |
+
if labels[KUBERAY_LABEL_KEY_KIND] == KUBERAY_KIND_HEAD:
|
| 537 |
+
kind = NodeKind.HEAD
|
| 538 |
+
type = labels[KUBERAY_LABEL_KEY_TYPE]
|
| 539 |
+
elif labels[KUBERAY_LABEL_KEY_KIND] == KUBERAY_KIND_WORKER:
|
| 540 |
+
kind = NodeKind.WORKER
|
| 541 |
+
type = labels[KUBERAY_LABEL_KEY_TYPE]
|
| 542 |
+
else:
|
| 543 |
+
# Other ray nodes types defined by KubeRay.
|
| 544 |
+
# e.g. this could also be `redis-cleanup`
|
| 545 |
+
# We will not track these nodes.
|
| 546 |
+
return None
|
| 547 |
+
|
| 548 |
+
# TODO: we should prob get from the pod's env var (RAY_CLOUD_INSTANCE_ID)
|
| 549 |
+
# directly.
|
| 550 |
+
cloud_instance_id = pod["metadata"]["name"]
|
| 551 |
+
return CloudInstance(
|
| 552 |
+
cloud_instance_id=cloud_instance_id,
|
| 553 |
+
node_type=type,
|
| 554 |
+
node_kind=kind,
|
| 555 |
+
is_running=KubeRayProvider._is_running(pod),
|
| 556 |
+
)
|
| 557 |
+
|
| 558 |
+
@staticmethod
|
| 559 |
+
def _is_running(pod) -> bool:
|
| 560 |
+
"""Convert pod state to Ray NodeStatus
|
| 561 |
+
|
| 562 |
+
A cloud instance is considered running if the pod is in the running state,
|
| 563 |
+
else it could be pending/containers-terminated.
|
| 564 |
+
|
| 565 |
+
When it disappears from the list, it is considered terminated.
|
| 566 |
+
"""
|
| 567 |
+
if (
|
| 568 |
+
"containerStatuses" not in pod["status"]
|
| 569 |
+
or not pod["status"]["containerStatuses"]
|
| 570 |
+
):
|
| 571 |
+
return False
|
| 572 |
+
|
| 573 |
+
state = pod["status"]["containerStatuses"][0]["state"]
|
| 574 |
+
if "running" in state:
|
| 575 |
+
return True
|
| 576 |
+
|
| 577 |
+
return False
|
| 578 |
+
|
| 579 |
+
def _get(self, remote_path: str) -> Dict[str, Any]:
|
| 580 |
+
"""Get a resource from the Kubernetes API server."""
|
| 581 |
+
return self._k8s_api_client.get(remote_path)
|
| 582 |
+
|
| 583 |
+
def _patch(self, remote_path: str, payload: List[Dict[str, Any]]) -> Dict[str, Any]:
|
| 584 |
+
"""Patch a resource on the Kubernetes API server."""
|
| 585 |
+
return self._k8s_api_client.patch(remote_path, payload)
|
| 586 |
+
|
| 587 |
+
def _get_head_pod_resource_version(self) -> str:
|
| 588 |
+
"""
|
| 589 |
+
Extract a recent pods resource version by reading the head pod's
|
| 590 |
+
metadata.resourceVersion of the response.
|
| 591 |
+
"""
|
| 592 |
+
if not RAY_HEAD_POD_NAME:
|
| 593 |
+
return None
|
| 594 |
+
pod_resp = self._get(f"pods/{RAY_HEAD_POD_NAME}")
|
| 595 |
+
return pod_resp["metadata"]["resourceVersion"]
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/read_only/__init__.py
ADDED
|
File without changes
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/read_only/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (239 Bytes). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/read_only/__pycache__/cloud_provider.cpython-312.pyc
ADDED
|
Binary file (3.48 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/read_only/cloud_provider.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, List
|
| 2 |
+
|
| 3 |
+
from ray._common.utils import binary_to_hex
|
| 4 |
+
from ray._raylet import GcsClient
|
| 5 |
+
from ray.autoscaler._private.util import format_readonly_node_type
|
| 6 |
+
from ray.autoscaler.v2.instance_manager.node_provider import (
|
| 7 |
+
CloudInstance,
|
| 8 |
+
CloudInstanceId,
|
| 9 |
+
CloudInstanceProviderError,
|
| 10 |
+
ICloudInstanceProvider,
|
| 11 |
+
NodeKind,
|
| 12 |
+
)
|
| 13 |
+
from ray.autoscaler.v2.sdk import get_cluster_resource_state
|
| 14 |
+
from ray.autoscaler.v2.utils import is_head_node
|
| 15 |
+
from ray.core.generated.autoscaler_pb2 import NodeStatus
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class ReadOnlyProvider(ICloudInstanceProvider):
|
| 19 |
+
"""
|
| 20 |
+
A read only provider that use the ray node states from the GCS as the
|
| 21 |
+
cloud instances.
|
| 22 |
+
|
| 23 |
+
This is used for laptop mode / manual cluster setup modes, in order to
|
| 24 |
+
provide status reporting in the same way for users.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def __init__(self, provider_config: dict):
|
| 28 |
+
self._provider_config = provider_config
|
| 29 |
+
self._gcs_address = provider_config["gcs_address"]
|
| 30 |
+
|
| 31 |
+
self._gcs_client = GcsClient(address=self._gcs_address)
|
| 32 |
+
|
| 33 |
+
def get_non_terminated(self) -> Dict[str, CloudInstance]:
|
| 34 |
+
cluster_resource_state = get_cluster_resource_state(self._gcs_client)
|
| 35 |
+
cloud_instances = {}
|
| 36 |
+
for gcs_node_state in cluster_resource_state.node_states:
|
| 37 |
+
if gcs_node_state.status == NodeStatus.DEAD:
|
| 38 |
+
# Skip dead nodes.
|
| 39 |
+
continue
|
| 40 |
+
|
| 41 |
+
# Use node's node id if instance id is not available
|
| 42 |
+
cloud_instance_id = (
|
| 43 |
+
gcs_node_state.instance_id
|
| 44 |
+
if gcs_node_state.instance_id
|
| 45 |
+
else binary_to_hex(gcs_node_state.node_id)
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# TODO: we should add a field to the proto to indicate if the node is head
|
| 49 |
+
# or not.
|
| 50 |
+
is_head = is_head_node(gcs_node_state)
|
| 51 |
+
|
| 52 |
+
cloud_instances[cloud_instance_id] = CloudInstance(
|
| 53 |
+
cloud_instance_id=cloud_instance_id,
|
| 54 |
+
node_kind=NodeKind.HEAD if is_head else NodeKind.WORKER,
|
| 55 |
+
node_type=format_readonly_node_type(
|
| 56 |
+
binary_to_hex(gcs_node_state.node_id) # Legacy behavior.
|
| 57 |
+
),
|
| 58 |
+
is_running=True,
|
| 59 |
+
request_id="",
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
return cloud_instances
|
| 63 |
+
|
| 64 |
+
def terminate(self, instance_id: CloudInstanceId) -> None:
|
| 65 |
+
raise NotImplementedError("Cannot terminate instances in read-only mode.")
|
| 66 |
+
|
| 67 |
+
def launch(
|
| 68 |
+
self, shape: Dict[CloudInstanceId, int], request_id: CloudInstanceId
|
| 69 |
+
) -> None:
|
| 70 |
+
raise NotImplementedError("Cannot launch instances in read-only mode.")
|
| 71 |
+
|
| 72 |
+
def poll_errors(self) -> List[CloudInstanceProviderError]:
|
| 73 |
+
return []
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/common.py
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import uuid
|
| 3 |
+
from typing import Dict, List, Optional, Set
|
| 4 |
+
|
| 5 |
+
from ray.core.generated.instance_manager_pb2 import Instance, InstanceUpdateEvent
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class InstanceUtil:
|
| 9 |
+
"""
|
| 10 |
+
A helper class to group updates and operations on an Instance object defined
|
| 11 |
+
in instance_manager.proto
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
# Memoized reachable from sets, where the key is the instance status, and
|
| 15 |
+
# the value is the set of instance status that is reachable from the key
|
| 16 |
+
# instance status.
|
| 17 |
+
_reachable_from: Optional[
|
| 18 |
+
Dict["Instance.InstanceStatus", Set["Instance.InstanceStatus"]]
|
| 19 |
+
] = None
|
| 20 |
+
|
| 21 |
+
@staticmethod
|
| 22 |
+
def new_instance(
|
| 23 |
+
instance_id: str,
|
| 24 |
+
instance_type: str,
|
| 25 |
+
status: Instance.InstanceStatus,
|
| 26 |
+
details: str = "",
|
| 27 |
+
) -> Instance:
|
| 28 |
+
"""
|
| 29 |
+
Returns a new instance with the given status.
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
instance_id: The instance id.
|
| 33 |
+
instance_type: The instance type.
|
| 34 |
+
status: The status of the new instance.
|
| 35 |
+
details: The details of the status transition.
|
| 36 |
+
"""
|
| 37 |
+
instance = Instance()
|
| 38 |
+
instance.version = 0 # it will be populated by the underlying storage.
|
| 39 |
+
instance.instance_id = instance_id
|
| 40 |
+
instance.instance_type = instance_type
|
| 41 |
+
instance.status = status
|
| 42 |
+
InstanceUtil._record_status_transition(instance, status, details)
|
| 43 |
+
return instance
|
| 44 |
+
|
| 45 |
+
@staticmethod
|
| 46 |
+
def random_instance_id() -> str:
|
| 47 |
+
"""
|
| 48 |
+
Returns a random instance id.
|
| 49 |
+
"""
|
| 50 |
+
return str(uuid.uuid4())
|
| 51 |
+
|
| 52 |
+
@staticmethod
|
| 53 |
+
def is_cloud_instance_allocated(instance_status: Instance.InstanceStatus) -> bool:
|
| 54 |
+
"""
|
| 55 |
+
Returns True if the instance is in a status where there could exist
|
| 56 |
+
a cloud instance allocated by the cloud provider.
|
| 57 |
+
"""
|
| 58 |
+
assert instance_status != Instance.UNKNOWN
|
| 59 |
+
return instance_status in {
|
| 60 |
+
Instance.ALLOCATED,
|
| 61 |
+
Instance.RAY_INSTALLING,
|
| 62 |
+
Instance.RAY_RUNNING,
|
| 63 |
+
Instance.RAY_STOPPING,
|
| 64 |
+
Instance.RAY_STOP_REQUESTED,
|
| 65 |
+
Instance.RAY_STOPPED,
|
| 66 |
+
Instance.TERMINATING,
|
| 67 |
+
Instance.RAY_INSTALL_FAILED,
|
| 68 |
+
Instance.TERMINATION_FAILED,
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
@staticmethod
|
| 72 |
+
def is_ray_running(instance_status: Instance.InstanceStatus) -> bool:
|
| 73 |
+
"""
|
| 74 |
+
Returns True if the instance is in a status where the ray process is
|
| 75 |
+
running on the cloud instance.
|
| 76 |
+
i.e. RAY_RUNNING, RAY_STOP_REQUESTED, RAY_STOPPING
|
| 77 |
+
"""
|
| 78 |
+
assert instance_status != Instance.UNKNOWN
|
| 79 |
+
|
| 80 |
+
if instance_status in InstanceUtil.get_reachable_statuses(
|
| 81 |
+
Instance.RAY_STOPPING
|
| 82 |
+
):
|
| 83 |
+
return False
|
| 84 |
+
|
| 85 |
+
if instance_status in InstanceUtil.get_reachable_statuses(Instance.RAY_RUNNING):
|
| 86 |
+
return True
|
| 87 |
+
|
| 88 |
+
return False
|
| 89 |
+
|
| 90 |
+
@staticmethod
|
| 91 |
+
def is_ray_pending(instance_status: Instance.InstanceStatus) -> bool:
|
| 92 |
+
"""
|
| 93 |
+
Returns True if the instance is in a status where the ray process is
|
| 94 |
+
pending to be started on the cloud instance.
|
| 95 |
+
|
| 96 |
+
"""
|
| 97 |
+
assert instance_status != Instance.UNKNOWN
|
| 98 |
+
# Not gonna be in a RAY_RUNNING status.
|
| 99 |
+
if Instance.RAY_RUNNING not in InstanceUtil.get_reachable_statuses(
|
| 100 |
+
instance_status
|
| 101 |
+
):
|
| 102 |
+
return False
|
| 103 |
+
|
| 104 |
+
# Already running ray.
|
| 105 |
+
if instance_status in InstanceUtil.get_reachable_statuses(Instance.RAY_RUNNING):
|
| 106 |
+
return False
|
| 107 |
+
|
| 108 |
+
return True
|
| 109 |
+
|
| 110 |
+
def is_ray_running_reachable(instance_status: Instance.InstanceStatus) -> bool:
|
| 111 |
+
"""
|
| 112 |
+
Returns True if the instance is in a status where it may transition
|
| 113 |
+
to RAY_RUNNING status.
|
| 114 |
+
"""
|
| 115 |
+
return Instance.RAY_RUNNING in InstanceUtil.get_reachable_statuses(
|
| 116 |
+
instance_status
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
@staticmethod
|
| 120 |
+
def set_status(
|
| 121 |
+
instance: Instance,
|
| 122 |
+
new_instance_status: Instance.InstanceStatus,
|
| 123 |
+
details: str = "",
|
| 124 |
+
) -> bool:
|
| 125 |
+
"""Transitions the instance to the new state.
|
| 126 |
+
|
| 127 |
+
Args:
|
| 128 |
+
instance: The instance to update.
|
| 129 |
+
new_instance_status: The new status to transition to.
|
| 130 |
+
details: The details of the transition.
|
| 131 |
+
|
| 132 |
+
Returns:
|
| 133 |
+
True if the status transition is successful, False otherwise.
|
| 134 |
+
"""
|
| 135 |
+
if (
|
| 136 |
+
new_instance_status
|
| 137 |
+
not in InstanceUtil.get_valid_transitions()[instance.status]
|
| 138 |
+
):
|
| 139 |
+
return False
|
| 140 |
+
instance.status = new_instance_status
|
| 141 |
+
InstanceUtil._record_status_transition(instance, new_instance_status, details)
|
| 142 |
+
return True
|
| 143 |
+
|
| 144 |
+
@staticmethod
|
| 145 |
+
def _record_status_transition(
|
| 146 |
+
instance: Instance, status: Instance.InstanceStatus, details: str
|
| 147 |
+
):
|
| 148 |
+
"""Records the status transition.
|
| 149 |
+
|
| 150 |
+
Args:
|
| 151 |
+
instance: The instance to update.
|
| 152 |
+
status: The new status to transition to.
|
| 153 |
+
"""
|
| 154 |
+
now_ns = time.time_ns()
|
| 155 |
+
instance.status_history.append(
|
| 156 |
+
Instance.StatusHistory(
|
| 157 |
+
instance_status=status,
|
| 158 |
+
timestamp_ns=now_ns,
|
| 159 |
+
details=details,
|
| 160 |
+
)
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
@staticmethod
|
| 164 |
+
def has_timeout(instance: Instance, timeout_s: int) -> bool:
|
| 165 |
+
"""
|
| 166 |
+
Returns True if the instance has been in the current status for more
|
| 167 |
+
than the timeout_seconds.
|
| 168 |
+
|
| 169 |
+
Args:
|
| 170 |
+
instance: The instance to check.
|
| 171 |
+
timeout_seconds: The timeout in seconds.
|
| 172 |
+
|
| 173 |
+
Returns:
|
| 174 |
+
True if the instance has been in the current status for more than
|
| 175 |
+
the timeout_s seconds.
|
| 176 |
+
"""
|
| 177 |
+
cur_status = instance.status
|
| 178 |
+
|
| 179 |
+
status_times_ns = InstanceUtil.get_status_transition_times_ns(
|
| 180 |
+
instance, select_instance_status=cur_status
|
| 181 |
+
)
|
| 182 |
+
assert len(status_times_ns) >= 1, (
|
| 183 |
+
f"instance {instance.instance_id} has {len(status_times_ns)} "
|
| 184 |
+
f"{Instance.InstanceStatus.Name(cur_status)} status"
|
| 185 |
+
)
|
| 186 |
+
status_time_ns = sorted(status_times_ns)[-1]
|
| 187 |
+
if time.time_ns() - status_time_ns <= (timeout_s * 1e9):
|
| 188 |
+
return False
|
| 189 |
+
|
| 190 |
+
return True
|
| 191 |
+
|
| 192 |
+
@staticmethod
|
| 193 |
+
def get_valid_transitions() -> Dict[
|
| 194 |
+
"Instance.InstanceStatus", Set["Instance.InstanceStatus"]
|
| 195 |
+
]:
|
| 196 |
+
return {
|
| 197 |
+
# This is the initial status of a new instance.
|
| 198 |
+
Instance.QUEUED: {
|
| 199 |
+
# Cloud provider requested to launch a node for the instance.
|
| 200 |
+
# This happens when the a launch request is made to the node provider.
|
| 201 |
+
Instance.REQUESTED,
|
| 202 |
+
},
|
| 203 |
+
# When in this status, a launch request to the node provider is made.
|
| 204 |
+
Instance.REQUESTED: {
|
| 205 |
+
# Cloud provider allocated a cloud instance for the instance.
|
| 206 |
+
# This happens when the cloud instance first appears in the list of
|
| 207 |
+
# running cloud instances from the cloud instance provider.
|
| 208 |
+
Instance.ALLOCATED,
|
| 209 |
+
# Retry the allocation, become queueing again.
|
| 210 |
+
Instance.QUEUED,
|
| 211 |
+
# Cloud provider fails to allocate one. Either as a timeout or
|
| 212 |
+
# the launch request fails immediately.
|
| 213 |
+
Instance.ALLOCATION_FAILED,
|
| 214 |
+
},
|
| 215 |
+
# When in this status, the cloud instance is allocated and running. This
|
| 216 |
+
# happens when the cloud instance is present in node provider's list of
|
| 217 |
+
# running cloud instances.
|
| 218 |
+
Instance.ALLOCATED: {
|
| 219 |
+
# Ray needs to be install and launch on the provisioned cloud instance.
|
| 220 |
+
# This happens when the cloud instance is allocated, and the autoscaler
|
| 221 |
+
# is responsible for installing and launching ray on the cloud instance.
|
| 222 |
+
# For node provider that manages the ray installation and launching,
|
| 223 |
+
# this state is skipped.
|
| 224 |
+
Instance.RAY_INSTALLING,
|
| 225 |
+
# Ray is already installed on the provisioned cloud
|
| 226 |
+
# instance. It could be any valid ray status.
|
| 227 |
+
Instance.RAY_RUNNING,
|
| 228 |
+
Instance.RAY_STOPPING,
|
| 229 |
+
Instance.RAY_STOPPED,
|
| 230 |
+
# Instance is requested to be stopped, e.g. instance leaked: no matching
|
| 231 |
+
# Instance with the same type is found in the autoscaler's state.
|
| 232 |
+
Instance.TERMINATING,
|
| 233 |
+
# cloud instance somehow failed.
|
| 234 |
+
Instance.TERMINATED,
|
| 235 |
+
},
|
| 236 |
+
# Ray process is being installed and started on the cloud instance.
|
| 237 |
+
# This status is skipped for node provider that manages the ray
|
| 238 |
+
# installation and launching. (e.g. Ray-on-Spark)
|
| 239 |
+
Instance.RAY_INSTALLING: {
|
| 240 |
+
# Ray installed and launched successfully, reported by the ray cluster.
|
| 241 |
+
# Similar to the Instance.ALLOCATED -> Instance.RAY_RUNNING transition,
|
| 242 |
+
# where the ray process is managed by the node provider.
|
| 243 |
+
Instance.RAY_RUNNING,
|
| 244 |
+
# Ray installation failed. This happens when the ray process failed to
|
| 245 |
+
# be installed and started on the cloud instance.
|
| 246 |
+
Instance.RAY_INSTALL_FAILED,
|
| 247 |
+
# Wen the ray node is reported as stopped by the ray cluster.
|
| 248 |
+
# This could happen that the ray process was stopped quickly after start
|
| 249 |
+
# such that a ray running node wasn't discovered and the RAY_RUNNING
|
| 250 |
+
# transition was skipped.
|
| 251 |
+
Instance.RAY_STOPPED,
|
| 252 |
+
# A cloud instance is being terminated (when the instance itself is no
|
| 253 |
+
# longer needed, e.g. instance is outdated, autoscaler is scaling down)
|
| 254 |
+
Instance.TERMINATING,
|
| 255 |
+
# cloud instance somehow failed during the installation process.
|
| 256 |
+
Instance.TERMINATED,
|
| 257 |
+
},
|
| 258 |
+
# Ray process is installed and running on the cloud instance. When in this
|
| 259 |
+
# status, a ray node must be present in the ray cluster.
|
| 260 |
+
Instance.RAY_RUNNING: {
|
| 261 |
+
# Ray is requested to be stopped.
|
| 262 |
+
Instance.RAY_STOP_REQUESTED,
|
| 263 |
+
# Ray is stopping (currently draining),
|
| 264 |
+
# e.g. idle termination.
|
| 265 |
+
Instance.RAY_STOPPING,
|
| 266 |
+
# Ray is already stopped, as reported by the ray cluster.
|
| 267 |
+
Instance.RAY_STOPPED,
|
| 268 |
+
# A cloud instance is being terminated (when the instance itself is no
|
| 269 |
+
# longer needed, e.g. instance is outdated, autoscaler is scaling down)
|
| 270 |
+
Instance.TERMINATING,
|
| 271 |
+
# cloud instance somehow failed.
|
| 272 |
+
Instance.TERMINATED,
|
| 273 |
+
},
|
| 274 |
+
# Ray process should be stopped on the cloud instance. The RayStopper
|
| 275 |
+
# subscriber will listen to this status and stop the ray process.
|
| 276 |
+
Instance.RAY_STOP_REQUESTED: {
|
| 277 |
+
# Ray is stopping on the cloud instance.
|
| 278 |
+
Instance.RAY_STOPPING,
|
| 279 |
+
# Ray stopped already.
|
| 280 |
+
Instance.RAY_STOPPED,
|
| 281 |
+
# Ray stop request failed (e.g. idle node no longer idle),
|
| 282 |
+
# ray is still running.
|
| 283 |
+
Instance.RAY_RUNNING,
|
| 284 |
+
# cloud instance somehow failed.
|
| 285 |
+
Instance.TERMINATED,
|
| 286 |
+
},
|
| 287 |
+
# When in this status, the ray process is requested to be stopped to the
|
| 288 |
+
# ray cluster, but not yet present in the dead ray node list reported by
|
| 289 |
+
# the ray cluster.
|
| 290 |
+
Instance.RAY_STOPPING: {
|
| 291 |
+
# Ray is stopped, and the ray node is present in the dead ray node list
|
| 292 |
+
# reported by the ray cluster.
|
| 293 |
+
Instance.RAY_STOPPED,
|
| 294 |
+
# A cloud instance is being terminated (when the instance itself is no
|
| 295 |
+
# longer needed, e.g. instance is outdated, autoscaler is scaling down)
|
| 296 |
+
Instance.TERMINATING,
|
| 297 |
+
# cloud instance somehow failed.
|
| 298 |
+
Instance.TERMINATED,
|
| 299 |
+
},
|
| 300 |
+
# When in this status, the ray process is stopped, and the ray node is
|
| 301 |
+
# present in the dead ray node list reported by the ray cluster.
|
| 302 |
+
Instance.RAY_STOPPED: {
|
| 303 |
+
# A cloud instance is being terminated (when the instance itself is no
|
| 304 |
+
# longer needed, e.g. instance is outdated, autoscaler is scaling down)
|
| 305 |
+
Instance.TERMINATING,
|
| 306 |
+
# cloud instance somehow failed.
|
| 307 |
+
Instance.TERMINATED,
|
| 308 |
+
},
|
| 309 |
+
# When in this status, the cloud instance is requested to be stopped to
|
| 310 |
+
# the node provider.
|
| 311 |
+
Instance.TERMINATING: {
|
| 312 |
+
# When a cloud instance no longer appears in the list of running cloud
|
| 313 |
+
# instances from the node provider.
|
| 314 |
+
Instance.TERMINATED,
|
| 315 |
+
# When the cloud instance failed to be terminated.
|
| 316 |
+
Instance.TERMINATION_FAILED,
|
| 317 |
+
},
|
| 318 |
+
# When in this status, the cloud instance failed to be terminated by the
|
| 319 |
+
# node provider. We will keep retrying.
|
| 320 |
+
Instance.TERMINATION_FAILED: {
|
| 321 |
+
# Retry the termination, become terminating again.
|
| 322 |
+
Instance.TERMINATING,
|
| 323 |
+
},
|
| 324 |
+
# Whenever a cloud instance disappears from the list of running cloud
|
| 325 |
+
# instances from the node provider, the instance is marked as stopped. Since
|
| 326 |
+
# we guarantee 1:1 mapping of a Instance to a cloud instance, this is a
|
| 327 |
+
# terminal state.
|
| 328 |
+
Instance.TERMINATED: set(), # Terminal state.
|
| 329 |
+
# When in this status, the cloud instance failed to be allocated by the
|
| 330 |
+
# node provider.
|
| 331 |
+
Instance.ALLOCATION_FAILED: set(), # Terminal state.
|
| 332 |
+
Instance.RAY_INSTALL_FAILED: {
|
| 333 |
+
# Autoscaler requests to shutdown the instance when ray install failed.
|
| 334 |
+
Instance.TERMINATING,
|
| 335 |
+
# cloud instance somehow failed.
|
| 336 |
+
Instance.TERMINATED,
|
| 337 |
+
},
|
| 338 |
+
# Initial state before the instance is created. Should never be used.
|
| 339 |
+
Instance.UNKNOWN: set(),
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
@staticmethod
|
| 343 |
+
def get_status_transitions(
|
| 344 |
+
instance: Instance,
|
| 345 |
+
select_instance_status: Optional["Instance.InstanceStatus"] = None,
|
| 346 |
+
) -> List["Instance.StatusHistory"]:
|
| 347 |
+
"""
|
| 348 |
+
Returns the status history of the instance.
|
| 349 |
+
|
| 350 |
+
Args:
|
| 351 |
+
instance: The instance.
|
| 352 |
+
select_instance_status: The go-to status to search for, i.e. select
|
| 353 |
+
only status history when the instance transitions into the status.
|
| 354 |
+
If None, returns all status updates.
|
| 355 |
+
"""
|
| 356 |
+
history = []
|
| 357 |
+
for status_update in instance.status_history:
|
| 358 |
+
if (
|
| 359 |
+
select_instance_status
|
| 360 |
+
and status_update.instance_status != select_instance_status
|
| 361 |
+
):
|
| 362 |
+
continue
|
| 363 |
+
history.append(status_update)
|
| 364 |
+
return history
|
| 365 |
+
|
| 366 |
+
@staticmethod
|
| 367 |
+
def get_last_status_transition(
|
| 368 |
+
instance: Instance,
|
| 369 |
+
select_instance_status: Optional["Instance.InstanceStatus"] = None,
|
| 370 |
+
) -> Optional["Instance.StatusHistory"]:
|
| 371 |
+
"""
|
| 372 |
+
Returns the last status transition of the instance.
|
| 373 |
+
|
| 374 |
+
Args:
|
| 375 |
+
instance: The instance.
|
| 376 |
+
instance_status: The status to search for. If None, returns the last
|
| 377 |
+
status update.
|
| 378 |
+
"""
|
| 379 |
+
history = InstanceUtil.get_status_transitions(instance, select_instance_status)
|
| 380 |
+
history.sort(key=lambda x: x.timestamp_ns)
|
| 381 |
+
if history:
|
| 382 |
+
return history[-1]
|
| 383 |
+
return None
|
| 384 |
+
|
| 385 |
+
@staticmethod
|
| 386 |
+
def get_status_transition_times_ns(
|
| 387 |
+
instance: Instance,
|
| 388 |
+
select_instance_status: Optional["Instance.InstanceStatus"] = None,
|
| 389 |
+
) -> List[int]:
|
| 390 |
+
"""
|
| 391 |
+
Returns a list of timestamps of the instance status update.
|
| 392 |
+
|
| 393 |
+
Args:
|
| 394 |
+
instance: The instance.
|
| 395 |
+
instance_status: The status to search for. If None, returns all
|
| 396 |
+
status updates timestamps.
|
| 397 |
+
|
| 398 |
+
Returns:
|
| 399 |
+
The list of timestamps of the instance status updates.
|
| 400 |
+
"""
|
| 401 |
+
return [
|
| 402 |
+
e.timestamp_ns
|
| 403 |
+
for e in InstanceUtil.get_status_transitions(
|
| 404 |
+
instance, select_instance_status
|
| 405 |
+
)
|
| 406 |
+
]
|
| 407 |
+
|
| 408 |
+
@classmethod
|
| 409 |
+
def get_reachable_statuses(
|
| 410 |
+
cls,
|
| 411 |
+
instance_status: Instance.InstanceStatus,
|
| 412 |
+
) -> Set["Instance.InstanceStatus"]:
|
| 413 |
+
"""
|
| 414 |
+
Returns the set of instance status that is reachable from the given
|
| 415 |
+
instance status following the status transitions.
|
| 416 |
+
This method is memoized.
|
| 417 |
+
Args:
|
| 418 |
+
instance_status: The instance status to start from.
|
| 419 |
+
Returns:
|
| 420 |
+
The set of instance status that is reachable from the given instance
|
| 421 |
+
status.
|
| 422 |
+
"""
|
| 423 |
+
if cls._reachable_from is None:
|
| 424 |
+
cls._compute_reachable()
|
| 425 |
+
return cls._reachable_from[instance_status]
|
| 426 |
+
|
| 427 |
+
@staticmethod
|
| 428 |
+
def get_log_str_for_update(instance: Instance, update: InstanceUpdateEvent) -> str:
|
| 429 |
+
"""Returns a log string for the given instance update."""
|
| 430 |
+
if update.upsert:
|
| 431 |
+
return (
|
| 432 |
+
f"New instance "
|
| 433 |
+
f"{Instance.InstanceStatus.Name(update.new_instance_status)} (id="
|
| 434 |
+
f"{instance.instance_id}, type={instance.instance_type}, "
|
| 435 |
+
f"cloud_instance_id={instance.cloud_instance_id}, "
|
| 436 |
+
f"ray_id={instance.node_id}): {update.details}"
|
| 437 |
+
)
|
| 438 |
+
return (
|
| 439 |
+
f"Update instance "
|
| 440 |
+
f"{Instance.InstanceStatus.Name(instance.status)}->"
|
| 441 |
+
f"{Instance.InstanceStatus.Name(update.new_instance_status)} (id="
|
| 442 |
+
f"{instance.instance_id}, type={instance.instance_type}, "
|
| 443 |
+
f"cloud_instance_id={instance.cloud_instance_id}, "
|
| 444 |
+
f"ray_id={instance.node_id}): {update.details}"
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
+
@classmethod
|
| 448 |
+
def _compute_reachable(cls):
|
| 449 |
+
"""
|
| 450 |
+
Computes and memorize the from status sets for each status machine with
|
| 451 |
+
a DFS search.
|
| 452 |
+
"""
|
| 453 |
+
valid_transitions = cls.get_valid_transitions()
|
| 454 |
+
|
| 455 |
+
def dfs(graph, start, visited):
|
| 456 |
+
"""
|
| 457 |
+
Regular DFS algorithm to find all reachable nodes from a given node.
|
| 458 |
+
"""
|
| 459 |
+
for next_node in graph[start]:
|
| 460 |
+
if next_node not in visited:
|
| 461 |
+
# We delay adding the visited set here so we could capture
|
| 462 |
+
# the self loop.
|
| 463 |
+
visited.add(next_node)
|
| 464 |
+
dfs(graph, next_node, visited)
|
| 465 |
+
return visited
|
| 466 |
+
|
| 467 |
+
# Initialize the graphs
|
| 468 |
+
cls._reachable_from = {}
|
| 469 |
+
for status in Instance.InstanceStatus.values():
|
| 470 |
+
# All nodes reachable from 'start'
|
| 471 |
+
visited = set()
|
| 472 |
+
cls._reachable_from[status] = dfs(valid_transitions, status, visited)
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/config.py
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import copy
|
| 2 |
+
import logging
|
| 3 |
+
from abc import ABC, abstractmethod
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
from enum import Enum
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any, Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
import yaml
|
| 10 |
+
|
| 11 |
+
from ray._common.utils import binary_to_hex
|
| 12 |
+
from ray._private.ray_constants import env_integer
|
| 13 |
+
from ray._raylet import GcsClient
|
| 14 |
+
from ray.autoscaler._private.constants import (
|
| 15 |
+
AUTOSCALER_MAX_CONCURRENT_LAUNCHES,
|
| 16 |
+
DEFAULT_UPSCALING_SPEED,
|
| 17 |
+
DISABLE_LAUNCH_CONFIG_CHECK_KEY,
|
| 18 |
+
DISABLE_NODE_UPDATERS_KEY,
|
| 19 |
+
)
|
| 20 |
+
from ray.autoscaler._private.kuberay.autoscaling_config import AutoscalingConfigProducer
|
| 21 |
+
from ray.autoscaler._private.monitor import BASE_READONLY_CONFIG
|
| 22 |
+
from ray.autoscaler._private.util import (
|
| 23 |
+
format_readonly_node_type,
|
| 24 |
+
hash_launch_conf,
|
| 25 |
+
hash_runtime_conf,
|
| 26 |
+
prepare_config,
|
| 27 |
+
validate_config,
|
| 28 |
+
)
|
| 29 |
+
from ray.autoscaler.v2.schema import NodeType
|
| 30 |
+
from ray.autoscaler.v2.sdk import get_cluster_resource_state
|
| 31 |
+
from ray.autoscaler.v2.utils import is_head_node
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger(__name__)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class Provider(Enum):
|
| 37 |
+
UNKNOWN = 0
|
| 38 |
+
ALIYUN = 1
|
| 39 |
+
AWS = 2
|
| 40 |
+
AZURE = 3
|
| 41 |
+
GCP = 4
|
| 42 |
+
KUBERAY = 5
|
| 43 |
+
LOCAL = 6
|
| 44 |
+
READ_ONLY = 7
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class IConfigReader(ABC):
|
| 48 |
+
"""An interface for reading Autoscaling config.
|
| 49 |
+
|
| 50 |
+
A utility class that reads autoscaling configs from various sources:
|
| 51 |
+
- File
|
| 52 |
+
- In-memory dict
|
| 53 |
+
- Remote config service (e.g. KubeRay's config)
|
| 54 |
+
|
| 55 |
+
Example:
|
| 56 |
+
reader = FileConfigReader("path/to/config.yaml")
|
| 57 |
+
# Get the recently cached config.
|
| 58 |
+
config = reader.get_cached_autoscaling_config()
|
| 59 |
+
|
| 60 |
+
...
|
| 61 |
+
# Refresh the cached config.
|
| 62 |
+
reader.refresh_cached_autoscaling_config()
|
| 63 |
+
config = reader.get_cached_autoscaling_config()
|
| 64 |
+
|
| 65 |
+
"""
|
| 66 |
+
|
| 67 |
+
@abstractmethod
|
| 68 |
+
def get_cached_autoscaling_config(self) -> "AutoscalingConfig":
|
| 69 |
+
"""Returns the recently read autoscaling config.
|
| 70 |
+
|
| 71 |
+
Returns:
|
| 72 |
+
AutoscalingConfig: The recently read autoscaling config.
|
| 73 |
+
"""
|
| 74 |
+
pass
|
| 75 |
+
|
| 76 |
+
@abstractmethod
|
| 77 |
+
def refresh_cached_autoscaling_config(self):
|
| 78 |
+
"""Read the config from the source."""
|
| 79 |
+
pass
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@dataclass(frozen=True)
|
| 83 |
+
class InstanceReconcileConfig:
|
| 84 |
+
# The timeout for waiting for a REQUESTED instance to be ALLOCATED.
|
| 85 |
+
request_status_timeout_s: int = env_integer(
|
| 86 |
+
"RAY_AUTOSCALER_RECONCILE_REQUEST_STATUS_TIMEOUT_S", 10 * 60
|
| 87 |
+
)
|
| 88 |
+
# The timeout for waiting for a ALLOCATED instance to be RAY_RUNNING.
|
| 89 |
+
allocate_status_timeout_s: int = env_integer(
|
| 90 |
+
"RAY_AUTOSCALER_RECONCILE_ALLOCATE_STATUS_TIMEOUT_S", 300
|
| 91 |
+
)
|
| 92 |
+
# The timeout for waiting for a RAY_INSTALLING instance to be RAY_RUNNING.
|
| 93 |
+
ray_install_status_timeout_s: int = env_integer(
|
| 94 |
+
"RAY_AUTOSCALER_RECONCILE_RAY_INSTALL_STATUS_TIMEOUT_S", 30 * 60
|
| 95 |
+
)
|
| 96 |
+
# The timeout for waiting for a TERMINATING instance to be TERMINATED.
|
| 97 |
+
terminating_status_timeout_s: int = env_integer(
|
| 98 |
+
"RAY_AUTOSCALER_RECONCILE_TERMINATING_STATUS_TIMEOUT_S", 300
|
| 99 |
+
)
|
| 100 |
+
# The timeout for waiting for a RAY_STOP_REQUESTED instance
|
| 101 |
+
# to be RAY_STOPPING or RAY_STOPPED.
|
| 102 |
+
ray_stop_requested_status_timeout_s: int = env_integer(
|
| 103 |
+
"RAY_AUTOSCALER_RECONCILE_RAY_STOP_REQUESTED_STATUS_TIMEOUT_S", 300
|
| 104 |
+
)
|
| 105 |
+
# The interval for raise a warning when an instance in transient status
|
| 106 |
+
# is not updated for a long time.
|
| 107 |
+
transient_status_warn_interval_s: int = env_integer(
|
| 108 |
+
"RAY_AUTOSCALER_RECONCILE_TRANSIENT_STATUS_WARN_INTERVAL_S", 90
|
| 109 |
+
)
|
| 110 |
+
# The number of times to retry requesting to allocate an instance.
|
| 111 |
+
max_num_retry_request_to_allocate: int = env_integer(
|
| 112 |
+
"RAY_AUTOSCALER_RECONCILE_MAX_NUM_RETRY_REQUEST_TO_ALLOCATE", 3
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@dataclass
|
| 117 |
+
class NodeTypeConfig:
|
| 118 |
+
"""
|
| 119 |
+
NodeTypeConfig is the helper class to provide node type specific configs.
|
| 120 |
+
This maps to subset of the `available_node_types` field in the
|
| 121 |
+
autoscaling config.
|
| 122 |
+
"""
|
| 123 |
+
|
| 124 |
+
# Node type name
|
| 125 |
+
name: NodeType
|
| 126 |
+
# The minimal number of worker nodes to be launched for this node type.
|
| 127 |
+
min_worker_nodes: int
|
| 128 |
+
# The maximal number of worker nodes can be launched for this node type.
|
| 129 |
+
max_worker_nodes: int
|
| 130 |
+
# Idle timeout seconds for worker nodes of this node type.
|
| 131 |
+
idle_timeout_s: Optional[float] = None
|
| 132 |
+
# The total resources on the node.
|
| 133 |
+
resources: Dict[str, float] = field(default_factory=dict)
|
| 134 |
+
# The labels on the node.
|
| 135 |
+
labels: Dict[str, str] = field(default_factory=dict)
|
| 136 |
+
# The node config's launch config hash. It's calculated from the auth
|
| 137 |
+
# config, and the node's config in the `AutoscalingConfig` for the node
|
| 138 |
+
# type when launching the node. It's used to detect config changes.
|
| 139 |
+
launch_config_hash: str = ""
|
| 140 |
+
|
| 141 |
+
def __post_init__(self):
|
| 142 |
+
assert self.min_worker_nodes <= self.max_worker_nodes
|
| 143 |
+
assert self.min_worker_nodes >= 0
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
class AutoscalingConfig:
|
| 147 |
+
"""
|
| 148 |
+
AutoscalingConfig is the helper class to provide autoscaling
|
| 149 |
+
related configs.
|
| 150 |
+
|
| 151 |
+
# TODO(rickyx):
|
| 152 |
+
1. Move the config validation logic here.
|
| 153 |
+
2. Deprecate the ray-schema.json for validation because it's
|
| 154 |
+
static thus not possible to validate the config with interdependency
|
| 155 |
+
of each other.
|
| 156 |
+
"""
|
| 157 |
+
|
| 158 |
+
def __init__(
|
| 159 |
+
self,
|
| 160 |
+
configs: Dict[str, Any],
|
| 161 |
+
skip_content_hash: bool = False,
|
| 162 |
+
) -> None:
|
| 163 |
+
"""
|
| 164 |
+
Args:
|
| 165 |
+
configs : The raw configs dict.
|
| 166 |
+
skip_content_hash :
|
| 167 |
+
Whether to skip file mounts/ray command hash calculation.
|
| 168 |
+
"""
|
| 169 |
+
self._sync_continuously = False
|
| 170 |
+
self.update_configs(configs, skip_content_hash)
|
| 171 |
+
|
| 172 |
+
def update_configs(self, configs: Dict[str, Any], skip_content_hash: bool) -> None:
|
| 173 |
+
self._configs = prepare_config(configs)
|
| 174 |
+
validate_config(self._configs)
|
| 175 |
+
if skip_content_hash:
|
| 176 |
+
return
|
| 177 |
+
self._calculate_hashes()
|
| 178 |
+
self._sync_continuously = self._configs.get(
|
| 179 |
+
"generate_file_mounts_contents_hash", True
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
def _calculate_hashes(self) -> None:
|
| 183 |
+
logger.info("Calculating hashes for file mounts and ray commands.")
|
| 184 |
+
self._runtime_hash, self._file_mounts_contents_hash = hash_runtime_conf(
|
| 185 |
+
self._configs.get("file_mounts", {}),
|
| 186 |
+
self._configs.get("cluster_synced_files", []),
|
| 187 |
+
[
|
| 188 |
+
self._configs.get("worker_setup_commands", []),
|
| 189 |
+
self._configs.get("worker_start_ray_commands", []),
|
| 190 |
+
],
|
| 191 |
+
generate_file_mounts_contents_hash=self._configs.get(
|
| 192 |
+
"generate_file_mounts_contents_hash", True
|
| 193 |
+
),
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
def get_cloud_node_config(self, ray_node_type: NodeType) -> Dict[str, Any]:
|
| 197 |
+
return copy.deepcopy(
|
| 198 |
+
self.get_node_type_specific_config(ray_node_type, "node_config") or {}
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
def get_docker_config(self, ray_node_type: NodeType) -> Dict[str, Any]:
|
| 202 |
+
"""
|
| 203 |
+
Return the docker config for the specified node type.
|
| 204 |
+
If it's a head node, the image will be chosen in the following order:
|
| 205 |
+
1. Node specific docker image.
|
| 206 |
+
2. The 'docker' config's 'head_image' field.
|
| 207 |
+
3. The 'docker' config's 'image' field.
|
| 208 |
+
If it's a worker node, the image will be chosen in the following order:
|
| 209 |
+
1. Node specific docker image.
|
| 210 |
+
2. The 'docker' config's 'worker_image' field.
|
| 211 |
+
3. The 'docker' config's 'image' field.
|
| 212 |
+
"""
|
| 213 |
+
# TODO(rickyx): It's unfortunate we have multiple fields in ray-schema.json
|
| 214 |
+
# that can specify docker images. We should consolidate them.
|
| 215 |
+
docker_config = copy.deepcopy(self._configs.get("docker", {}))
|
| 216 |
+
node_specific_docker_config = self._configs["available_node_types"][
|
| 217 |
+
ray_node_type
|
| 218 |
+
].get("docker", {})
|
| 219 |
+
# Override the global docker config with node specific docker config.
|
| 220 |
+
docker_config.update(node_specific_docker_config)
|
| 221 |
+
|
| 222 |
+
if self._configs.get("head_node_type") == ray_node_type:
|
| 223 |
+
if "head_image" in docker_config:
|
| 224 |
+
logger.info(
|
| 225 |
+
"Overwriting image={} by head_image({}) for head node docker.".format( # noqa: E501
|
| 226 |
+
docker_config["image"], docker_config["head_image"]
|
| 227 |
+
)
|
| 228 |
+
)
|
| 229 |
+
docker_config["image"] = docker_config["head_image"]
|
| 230 |
+
else:
|
| 231 |
+
if "worker_image" in docker_config:
|
| 232 |
+
logger.info(
|
| 233 |
+
"Overwriting image={} by worker_image({}) for worker node docker.".format( # noqa: E501
|
| 234 |
+
docker_config["image"], docker_config["worker_image"]
|
| 235 |
+
)
|
| 236 |
+
)
|
| 237 |
+
docker_config["image"] = docker_config["worker_image"]
|
| 238 |
+
|
| 239 |
+
# These fields should be merged.
|
| 240 |
+
docker_config.pop("head_image", None)
|
| 241 |
+
docker_config.pop("worker_image", None)
|
| 242 |
+
return docker_config
|
| 243 |
+
|
| 244 |
+
def get_worker_start_ray_commands(self) -> List[str]:
|
| 245 |
+
return self._configs.get("worker_start_ray_commands", [])
|
| 246 |
+
|
| 247 |
+
def get_head_setup_commands(self) -> List[str]:
|
| 248 |
+
return self._configs.get("head_setup_commands", [])
|
| 249 |
+
|
| 250 |
+
def get_head_start_ray_commands(self) -> List[str]:
|
| 251 |
+
return self._configs.get("head_start_ray_commands", [])
|
| 252 |
+
|
| 253 |
+
def get_worker_setup_commands(self, ray_node_type: NodeType) -> List[str]:
|
| 254 |
+
"""
|
| 255 |
+
Return the worker setup commands for the specified node type.
|
| 256 |
+
|
| 257 |
+
If the node type specific worker setup commands are not specified,
|
| 258 |
+
return the global worker setup commands.
|
| 259 |
+
"""
|
| 260 |
+
worker_setup_command = self.get_node_type_specific_config(
|
| 261 |
+
ray_node_type, "worker_setup_commands"
|
| 262 |
+
)
|
| 263 |
+
if worker_setup_command is None:
|
| 264 |
+
# Return global worker setup commands if node type specific
|
| 265 |
+
# worker setup commands are not specified.
|
| 266 |
+
logger.info(
|
| 267 |
+
"Using global worker setup commands for {}".format(ray_node_type)
|
| 268 |
+
)
|
| 269 |
+
return self._configs.get("worker_setup_commands", [])
|
| 270 |
+
return worker_setup_command
|
| 271 |
+
|
| 272 |
+
def get_initialization_commands(self, ray_node_type: NodeType) -> List[str]:
|
| 273 |
+
"""
|
| 274 |
+
Return the initialization commands for the specified node type.
|
| 275 |
+
|
| 276 |
+
If the node type specific initialization commands are not specified,
|
| 277 |
+
return the global initialization commands.
|
| 278 |
+
"""
|
| 279 |
+
initialization_command = self.get_node_type_specific_config(
|
| 280 |
+
ray_node_type, "initialization_commands"
|
| 281 |
+
)
|
| 282 |
+
if initialization_command is None:
|
| 283 |
+
logger.info(
|
| 284 |
+
"Using global initialization commands for {}".format(ray_node_type)
|
| 285 |
+
)
|
| 286 |
+
return self._configs.get("initialization_commands", [])
|
| 287 |
+
return initialization_command
|
| 288 |
+
|
| 289 |
+
def get_node_type_specific_config(
|
| 290 |
+
self, ray_node_type: NodeType, config_name: str
|
| 291 |
+
) -> Optional[Any]:
|
| 292 |
+
node_specific_config = self._configs["available_node_types"].get(
|
| 293 |
+
ray_node_type, {}
|
| 294 |
+
)
|
| 295 |
+
return node_specific_config.get(config_name, None)
|
| 296 |
+
|
| 297 |
+
def get_node_resources(self, ray_node_type: NodeType) -> Dict[str, float]:
|
| 298 |
+
return copy.deepcopy(
|
| 299 |
+
self.get_node_type_specific_config(ray_node_type, "resources") or {}
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
def get_node_labels(self, ray_node_type: NodeType) -> Dict[str, str]:
|
| 303 |
+
return copy.deepcopy(
|
| 304 |
+
self.get_node_type_specific_config(ray_node_type, "labels") or {}
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
def get_config(self, config_name, default=None) -> Any:
|
| 308 |
+
return self._configs.get(config_name, default)
|
| 309 |
+
|
| 310 |
+
def get_provider_instance_type(self, ray_node_type: NodeType) -> str:
|
| 311 |
+
provider = self.provider
|
| 312 |
+
node_config = self.get_node_type_specific_config(ray_node_type, "node_config")
|
| 313 |
+
if provider in [Provider.AWS, Provider.ALIYUN]:
|
| 314 |
+
return node_config.get("InstanceType", "")
|
| 315 |
+
elif provider == Provider.AZURE:
|
| 316 |
+
return node_config.get("azure_arm_parameters", {}).get("vmSize", "")
|
| 317 |
+
elif provider == Provider.GCP:
|
| 318 |
+
return node_config.get("machineType", "")
|
| 319 |
+
elif provider in [Provider.KUBERAY, Provider.LOCAL, Provider.UNKNOWN]:
|
| 320 |
+
return ""
|
| 321 |
+
else:
|
| 322 |
+
raise ValueError(f"Unknown provider {provider}")
|
| 323 |
+
|
| 324 |
+
def get_node_type_configs(self) -> Dict[NodeType, NodeTypeConfig]:
|
| 325 |
+
"""
|
| 326 |
+
Returns the node type configs from the `available_node_types` field.
|
| 327 |
+
|
| 328 |
+
Returns:
|
| 329 |
+
Dict[NodeType, NodeTypeConfig]: The node type configs.
|
| 330 |
+
"""
|
| 331 |
+
available_node_types = self._configs.get("available_node_types", {})
|
| 332 |
+
if not available_node_types:
|
| 333 |
+
return None
|
| 334 |
+
node_type_configs = {}
|
| 335 |
+
auth_config = self._configs.get("auth", {})
|
| 336 |
+
head_node_type = self.get_head_node_type()
|
| 337 |
+
assert head_node_type
|
| 338 |
+
for node_type, node_config in available_node_types.items():
|
| 339 |
+
launch_config_hash = hash_launch_conf(
|
| 340 |
+
node_config.get("node_config", {}), auth_config
|
| 341 |
+
)
|
| 342 |
+
max_workers_nodes = node_config.get("max_workers", 0)
|
| 343 |
+
if head_node_type == node_type:
|
| 344 |
+
max_workers_nodes += 1
|
| 345 |
+
|
| 346 |
+
node_type_configs[node_type] = NodeTypeConfig(
|
| 347 |
+
name=node_type,
|
| 348 |
+
min_worker_nodes=node_config.get("min_workers", 0),
|
| 349 |
+
max_worker_nodes=max_workers_nodes,
|
| 350 |
+
idle_timeout_s=node_config.get("idle_timeout_s", None),
|
| 351 |
+
resources=node_config.get("resources", {}),
|
| 352 |
+
labels=node_config.get("labels", {}),
|
| 353 |
+
launch_config_hash=launch_config_hash,
|
| 354 |
+
)
|
| 355 |
+
return node_type_configs
|
| 356 |
+
|
| 357 |
+
def get_head_node_type(self) -> NodeType:
|
| 358 |
+
"""
|
| 359 |
+
Returns the head node type.
|
| 360 |
+
|
| 361 |
+
If there is only one node type, return the only node type as the head
|
| 362 |
+
node type.
|
| 363 |
+
If there are multiple node types, return the head node type specified
|
| 364 |
+
in the config.
|
| 365 |
+
"""
|
| 366 |
+
available_node_types = self._configs.get("available_node_types", {})
|
| 367 |
+
if len(available_node_types) == 1:
|
| 368 |
+
return list(available_node_types.keys())[0]
|
| 369 |
+
return self._configs.get("head_node_type")
|
| 370 |
+
|
| 371 |
+
def get_max_num_worker_nodes(self) -> Optional[int]:
|
| 372 |
+
return self.get_config("max_workers", None)
|
| 373 |
+
|
| 374 |
+
def get_max_num_nodes(self) -> Optional[int]:
|
| 375 |
+
max_num_workers = self.get_max_num_worker_nodes()
|
| 376 |
+
if max_num_workers is not None:
|
| 377 |
+
return max_num_workers + 1 # For head node
|
| 378 |
+
return None
|
| 379 |
+
|
| 380 |
+
def get_raw_config_mutable(self) -> Dict[str, Any]:
|
| 381 |
+
return self._configs
|
| 382 |
+
|
| 383 |
+
def get_upscaling_speed(self) -> float:
|
| 384 |
+
return self.get_config("upscaling_speed", DEFAULT_UPSCALING_SPEED)
|
| 385 |
+
|
| 386 |
+
def get_max_concurrent_launches(self) -> int:
|
| 387 |
+
return AUTOSCALER_MAX_CONCURRENT_LAUNCHES
|
| 388 |
+
|
| 389 |
+
def disable_node_updaters(self) -> bool:
|
| 390 |
+
provider_config = self._configs.get("provider", {})
|
| 391 |
+
return provider_config.get(DISABLE_NODE_UPDATERS_KEY, False)
|
| 392 |
+
|
| 393 |
+
def get_idle_timeout_s(self) -> Optional[float]:
|
| 394 |
+
"""
|
| 395 |
+
Returns the idle timeout in seconds if present in config, otherwise None.
|
| 396 |
+
"""
|
| 397 |
+
idle_timeout_s = self.get_config("idle_timeout_minutes", None)
|
| 398 |
+
return idle_timeout_s * 60 if idle_timeout_s is not None else None
|
| 399 |
+
|
| 400 |
+
def disable_launch_config_check(self) -> bool:
|
| 401 |
+
provider_config = self.get_provider_config()
|
| 402 |
+
return provider_config.get(DISABLE_LAUNCH_CONFIG_CHECK_KEY, True)
|
| 403 |
+
|
| 404 |
+
def get_instance_reconcile_config(self) -> InstanceReconcileConfig:
|
| 405 |
+
# TODO(rickyx): we need a way to customize these configs,
|
| 406 |
+
# either extending the current ray-schema.json, or just use another
|
| 407 |
+
# schema validation paths.
|
| 408 |
+
return InstanceReconcileConfig()
|
| 409 |
+
|
| 410 |
+
def get_provider_config(self) -> Dict[str, Any]:
|
| 411 |
+
return self._configs.get("provider", {})
|
| 412 |
+
|
| 413 |
+
def dump(self) -> str:
|
| 414 |
+
return yaml.safe_dump(self._configs)
|
| 415 |
+
|
| 416 |
+
@property
|
| 417 |
+
def provider(self) -> Provider:
|
| 418 |
+
provider_str = self._configs.get("provider", {}).get("type", "")
|
| 419 |
+
if provider_str == "local":
|
| 420 |
+
return Provider.LOCAL
|
| 421 |
+
elif provider_str == "aws":
|
| 422 |
+
return Provider.AWS
|
| 423 |
+
elif provider_str == "azure":
|
| 424 |
+
return Provider.AZURE
|
| 425 |
+
elif provider_str == "gcp":
|
| 426 |
+
return Provider.GCP
|
| 427 |
+
elif provider_str == "aliyun":
|
| 428 |
+
return Provider.ALIYUN
|
| 429 |
+
elif provider_str == "kuberay":
|
| 430 |
+
return Provider.KUBERAY
|
| 431 |
+
elif provider_str == "readonly":
|
| 432 |
+
return Provider.READ_ONLY
|
| 433 |
+
else:
|
| 434 |
+
return Provider.UNKNOWN
|
| 435 |
+
|
| 436 |
+
@property
|
| 437 |
+
def runtime_hash(self) -> str:
|
| 438 |
+
if not hasattr(self, "_runtime_hash"):
|
| 439 |
+
self._calculate_hashes()
|
| 440 |
+
return self._runtime_hash
|
| 441 |
+
|
| 442 |
+
@property
|
| 443 |
+
def file_mounts_contents_hash(self) -> str:
|
| 444 |
+
if not hasattr(self, "_file_mounts_contents_hash"):
|
| 445 |
+
self._calculate_hashes()
|
| 446 |
+
return self._file_mounts_contents_hash
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
class FileConfigReader(IConfigReader):
|
| 450 |
+
"""A class that reads cluster config from a yaml file."""
|
| 451 |
+
|
| 452 |
+
def __init__(self, config_file: str, skip_content_hash: bool = True) -> None:
|
| 453 |
+
"""
|
| 454 |
+
Args:
|
| 455 |
+
config_file: The path to the config file.
|
| 456 |
+
skip_content_hash: Whether to skip file mounts/ray command
|
| 457 |
+
hash calculation. Default to True.
|
| 458 |
+
"""
|
| 459 |
+
self._config_file_path = Path(config_file).resolve()
|
| 460 |
+
self._skip_content_hash = skip_content_hash
|
| 461 |
+
self._cached_config = self._read()
|
| 462 |
+
|
| 463 |
+
def _read(self) -> AutoscalingConfig:
|
| 464 |
+
with open(self._config_file_path) as f:
|
| 465 |
+
config = yaml.safe_load(f.read())
|
| 466 |
+
return AutoscalingConfig(config, skip_content_hash=self._skip_content_hash)
|
| 467 |
+
|
| 468 |
+
def get_cached_autoscaling_config(self) -> AutoscalingConfig:
|
| 469 |
+
"""
|
| 470 |
+
Returns:
|
| 471 |
+
AutoscalingConfig: The autoscaling config.
|
| 472 |
+
"""
|
| 473 |
+
|
| 474 |
+
return self._cached_config
|
| 475 |
+
|
| 476 |
+
def refresh_cached_autoscaling_config(self):
|
| 477 |
+
self._cached_config = self._read()
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
class KubeRayConfigReader(IConfigReader):
|
| 481 |
+
"""A class that reads cluster config from a K8s RayCluster CR."""
|
| 482 |
+
|
| 483 |
+
def __init__(self, config_producer: AutoscalingConfigProducer):
|
| 484 |
+
self._config_producer = config_producer
|
| 485 |
+
self._cached_config = self._generate_configs_from_k8s()
|
| 486 |
+
|
| 487 |
+
def _generate_configs_from_k8s(self) -> AutoscalingConfig:
|
| 488 |
+
return AutoscalingConfig(self._config_producer())
|
| 489 |
+
|
| 490 |
+
def get_cached_autoscaling_config(self) -> AutoscalingConfig:
|
| 491 |
+
"""
|
| 492 |
+
Returns:
|
| 493 |
+
AutoscalingConfig: The autoscaling config.
|
| 494 |
+
"""
|
| 495 |
+
return self._cached_config
|
| 496 |
+
|
| 497 |
+
def refresh_cached_autoscaling_config(self):
|
| 498 |
+
"""
|
| 499 |
+
Reads the configs from the K8s RayCluster CR.
|
| 500 |
+
|
| 501 |
+
This reads from the K8s API server every time to pick up changes.
|
| 502 |
+
"""
|
| 503 |
+
self._cached_config = self._generate_configs_from_k8s()
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
class ReadOnlyProviderConfigReader(IConfigReader):
|
| 507 |
+
"""A class that reads cluster config for a read-only provider.
|
| 508 |
+
|
| 509 |
+
This is used for laptop mode / manual cluster setup modes, in order to
|
| 510 |
+
provide status reporting in the same way for users."""
|
| 511 |
+
|
| 512 |
+
def __init__(self, gcs_address: str):
|
| 513 |
+
self._configs = BASE_READONLY_CONFIG
|
| 514 |
+
self._gcs_client = GcsClient(address=gcs_address)
|
| 515 |
+
|
| 516 |
+
def refresh_cached_autoscaling_config(self) -> AutoscalingConfig:
|
| 517 |
+
# Update the config with node types from GCS.
|
| 518 |
+
ray_cluster_resource_state = get_cluster_resource_state(self._gcs_client)
|
| 519 |
+
|
| 520 |
+
# Format each node type's config from the running nodes.
|
| 521 |
+
available_node_types = {}
|
| 522 |
+
|
| 523 |
+
head_node_type = None
|
| 524 |
+
for node_state in ray_cluster_resource_state.node_states:
|
| 525 |
+
node_type = node_state.ray_node_type_name
|
| 526 |
+
if not node_type:
|
| 527 |
+
node_type = format_readonly_node_type(binary_to_hex(node_state.node_id))
|
| 528 |
+
|
| 529 |
+
if is_head_node(node_state):
|
| 530 |
+
head_node_type = node_type
|
| 531 |
+
|
| 532 |
+
if node_type not in available_node_types:
|
| 533 |
+
available_node_types[node_type] = {
|
| 534 |
+
"resources": dict(node_state.total_resources),
|
| 535 |
+
"min_workers": 0,
|
| 536 |
+
"max_workers": 0 if is_head_node(node_state) else 1,
|
| 537 |
+
"node_config": {},
|
| 538 |
+
}
|
| 539 |
+
elif not is_head_node(node_state):
|
| 540 |
+
available_node_types[node_type]["max_workers"] += 1
|
| 541 |
+
|
| 542 |
+
if available_node_types:
|
| 543 |
+
self._configs["available_node_types"].update(available_node_types)
|
| 544 |
+
self._configs["max_workers"] = len(available_node_types)
|
| 545 |
+
assert head_node_type, "Head node type should be found."
|
| 546 |
+
self._configs["head_node_type"] = head_node_type
|
| 547 |
+
|
| 548 |
+
# Don't idle terminated nodes in read-only mode.
|
| 549 |
+
self._configs.pop("idle_timeout_minutes", None)
|
| 550 |
+
|
| 551 |
+
def get_cached_autoscaling_config(self) -> AutoscalingConfig:
|
| 552 |
+
return AutoscalingConfig(self._configs, skip_content_hash=True)
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/instance_manager.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from abc import ABC, abstractmethod
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
|
| 5 |
+
from ray.autoscaler.v2.instance_manager.common import InstanceUtil
|
| 6 |
+
from ray.autoscaler.v2.instance_manager.instance_storage import InstanceStorage
|
| 7 |
+
from ray.core.generated.instance_manager_pb2 import (
|
| 8 |
+
GetInstanceManagerStateReply,
|
| 9 |
+
GetInstanceManagerStateRequest,
|
| 10 |
+
Instance,
|
| 11 |
+
InstanceUpdateEvent,
|
| 12 |
+
NodeKind,
|
| 13 |
+
StatusCode,
|
| 14 |
+
UpdateInstanceManagerStateReply,
|
| 15 |
+
UpdateInstanceManagerStateRequest,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class InstanceUpdatedSubscriber(ABC):
|
| 22 |
+
"""Subscribers to instance status changes."""
|
| 23 |
+
|
| 24 |
+
@abstractmethod
|
| 25 |
+
def notify(self, events: List[InstanceUpdateEvent]) -> None:
|
| 26 |
+
pass
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class InstanceManager:
|
| 30 |
+
"""
|
| 31 |
+
See `InstanceManagerService` in instance_manager.proto
|
| 32 |
+
|
| 33 |
+
This handles updates to an instance, or inserts a new instance if
|
| 34 |
+
it's an insert update. We should only be inserting new instances
|
| 35 |
+
of the below statuses:
|
| 36 |
+
1. ALLOCATED: For unmanaged instance not initialized by InstanceManager,
|
| 37 |
+
e.g. head node
|
| 38 |
+
2. QUEUED: For new instance being queued to launch.
|
| 39 |
+
3. TERMINATING: For leaked cloud instance that needs to be terminated.
|
| 40 |
+
|
| 41 |
+
For full status transitions, see:
|
| 42 |
+
https://docs.google.com/document/d/1NzQjA8Mh-oMc-QxXOa529oneWCoA8sDiVoNkBqqDb4U/edit#heading=h.k9a1sp4qpqj4
|
| 43 |
+
|
| 44 |
+
Not thread safe, should be used as a singleton.
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
def __init__(
|
| 48 |
+
self,
|
| 49 |
+
instance_storage: InstanceStorage,
|
| 50 |
+
instance_status_update_subscribers: Optional[List[InstanceUpdatedSubscriber]],
|
| 51 |
+
):
|
| 52 |
+
self._instance_storage = instance_storage
|
| 53 |
+
self._status_update_subscribers = instance_status_update_subscribers or []
|
| 54 |
+
|
| 55 |
+
def update_instance_manager_state(
|
| 56 |
+
self, request: UpdateInstanceManagerStateRequest
|
| 57 |
+
) -> UpdateInstanceManagerStateReply:
|
| 58 |
+
"""
|
| 59 |
+
Updates the instance manager state.
|
| 60 |
+
|
| 61 |
+
If there's any failure, no updates would be made and the reply
|
| 62 |
+
would contain the latest version of the instance manager state,
|
| 63 |
+
and the error info.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
request: The request to update the instance manager state.
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
The reply to the request.
|
| 70 |
+
"""
|
| 71 |
+
|
| 72 |
+
# Handle updates
|
| 73 |
+
ids_to_updates = {update.instance_id: update for update in request.updates}
|
| 74 |
+
to_update_instances, version = self._instance_storage.get_instances(
|
| 75 |
+
instance_ids=ids_to_updates.keys()
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
if request.expected_version >= 0 and request.expected_version != version:
|
| 79 |
+
err_str = (
|
| 80 |
+
f"Version mismatch: expected: {request.expected_version}, "
|
| 81 |
+
f"actual: {version}"
|
| 82 |
+
)
|
| 83 |
+
logger.warning(err_str)
|
| 84 |
+
return self._get_update_im_state_reply(
|
| 85 |
+
StatusCode.VERSION_MISMATCH,
|
| 86 |
+
version,
|
| 87 |
+
err_str,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
# Handle instances states update.
|
| 91 |
+
to_upsert_instances = []
|
| 92 |
+
for instance_id, update in ids_to_updates.items():
|
| 93 |
+
if instance_id in to_update_instances:
|
| 94 |
+
instance = self._update_instance(
|
| 95 |
+
to_update_instances[instance_id], update
|
| 96 |
+
)
|
| 97 |
+
else:
|
| 98 |
+
instance = self._create_instance(update)
|
| 99 |
+
|
| 100 |
+
to_upsert_instances.append(instance)
|
| 101 |
+
|
| 102 |
+
# Updates the instance storage.
|
| 103 |
+
result = self._instance_storage.batch_upsert_instances(
|
| 104 |
+
updates=to_upsert_instances,
|
| 105 |
+
expected_storage_version=version,
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
if not result.success:
|
| 109 |
+
if result.version != version:
|
| 110 |
+
err_str = (
|
| 111 |
+
f"Version mismatch: expected: {version}, actual: {result.version}"
|
| 112 |
+
)
|
| 113 |
+
logger.warning(err_str)
|
| 114 |
+
return self._get_update_im_state_reply(
|
| 115 |
+
StatusCode.VERSION_MISMATCH, result.version, err_str
|
| 116 |
+
)
|
| 117 |
+
else:
|
| 118 |
+
err_str = "Failed to update instance storage."
|
| 119 |
+
logger.error(err_str)
|
| 120 |
+
return self._get_update_im_state_reply(
|
| 121 |
+
StatusCode.UNKNOWN_ERRORS, result.version, err_str
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# Successful updates.
|
| 125 |
+
for subscriber in self._status_update_subscribers:
|
| 126 |
+
subscriber.notify(request.updates)
|
| 127 |
+
|
| 128 |
+
return self._get_update_im_state_reply(StatusCode.OK, result.version)
|
| 129 |
+
|
| 130 |
+
def get_instance_manager_state(
|
| 131 |
+
self, request: GetInstanceManagerStateRequest
|
| 132 |
+
) -> GetInstanceManagerStateReply:
|
| 133 |
+
"""
|
| 134 |
+
Gets the instance manager state.
|
| 135 |
+
|
| 136 |
+
Args:
|
| 137 |
+
request: The request to get the instance manager state.
|
| 138 |
+
|
| 139 |
+
Returns:
|
| 140 |
+
The reply to the request.
|
| 141 |
+
"""
|
| 142 |
+
reply = GetInstanceManagerStateReply()
|
| 143 |
+
instances, version = self._instance_storage.get_instances()
|
| 144 |
+
reply.state.instances.extend(instances.values())
|
| 145 |
+
reply.state.version = version
|
| 146 |
+
reply.status.code = StatusCode.OK
|
| 147 |
+
|
| 148 |
+
return reply
|
| 149 |
+
|
| 150 |
+
#########################################
|
| 151 |
+
# Private methods
|
| 152 |
+
#########################################
|
| 153 |
+
|
| 154 |
+
@staticmethod
|
| 155 |
+
def _get_update_im_state_reply(
|
| 156 |
+
status_code: StatusCode, version: int, error_message: str = ""
|
| 157 |
+
) -> UpdateInstanceManagerStateReply:
|
| 158 |
+
"""
|
| 159 |
+
Returns a UpdateInstanceManagerStateReply with the given status code and
|
| 160 |
+
version.
|
| 161 |
+
|
| 162 |
+
Args:
|
| 163 |
+
status_code: The status code.
|
| 164 |
+
version: The version.
|
| 165 |
+
error_message: The error message if any.
|
| 166 |
+
|
| 167 |
+
Returns:
|
| 168 |
+
The reply.
|
| 169 |
+
"""
|
| 170 |
+
reply = UpdateInstanceManagerStateReply()
|
| 171 |
+
reply.status.code = status_code
|
| 172 |
+
reply.version = version
|
| 173 |
+
if error_message:
|
| 174 |
+
reply.status.message = error_message
|
| 175 |
+
return reply
|
| 176 |
+
|
| 177 |
+
@staticmethod
|
| 178 |
+
def _apply_update(instance: Instance, update: InstanceUpdateEvent):
|
| 179 |
+
"""
|
| 180 |
+
Apply status specific update to the instance.
|
| 181 |
+
|
| 182 |
+
Args:
|
| 183 |
+
instance: The instance to update.
|
| 184 |
+
update: The update to apply.
|
| 185 |
+
"""
|
| 186 |
+
if update.new_instance_status == Instance.ALLOCATED:
|
| 187 |
+
assert (
|
| 188 |
+
update.cloud_instance_id
|
| 189 |
+
), "ALLOCATED update must have cloud_instance_id"
|
| 190 |
+
assert update.node_kind in [
|
| 191 |
+
NodeKind.WORKER,
|
| 192 |
+
NodeKind.HEAD,
|
| 193 |
+
], "ALLOCATED update must have node_kind as WORKER or HEAD"
|
| 194 |
+
assert update.instance_type, "ALLOCATED update must have instance_type"
|
| 195 |
+
assert (
|
| 196 |
+
update.cloud_instance_id
|
| 197 |
+
), "ALLOCATED update must have cloud_instance_id"
|
| 198 |
+
instance.cloud_instance_id = update.cloud_instance_id
|
| 199 |
+
instance.node_kind = update.node_kind
|
| 200 |
+
instance.instance_type = update.instance_type
|
| 201 |
+
instance.node_id = update.ray_node_id
|
| 202 |
+
elif update.new_instance_status == Instance.RAY_RUNNING:
|
| 203 |
+
assert update.ray_node_id, "RAY_RUNNING update must have ray_node_id"
|
| 204 |
+
instance.node_id = update.ray_node_id
|
| 205 |
+
elif update.new_instance_status == Instance.REQUESTED:
|
| 206 |
+
assert (
|
| 207 |
+
update.launch_request_id
|
| 208 |
+
), "REQUESTED update must have launch_request_id"
|
| 209 |
+
assert update.instance_type, "REQUESTED update must have instance_type"
|
| 210 |
+
instance.launch_request_id = update.launch_request_id
|
| 211 |
+
instance.instance_type = update.instance_type
|
| 212 |
+
elif update.new_instance_status == Instance.TERMINATING:
|
| 213 |
+
assert (
|
| 214 |
+
update.cloud_instance_id
|
| 215 |
+
), "TERMINATING update must have cloud instance id"
|
| 216 |
+
|
| 217 |
+
@staticmethod
|
| 218 |
+
def _create_instance(update: InstanceUpdateEvent) -> Instance:
|
| 219 |
+
"""
|
| 220 |
+
Create a new instance from the given update.
|
| 221 |
+
"""
|
| 222 |
+
|
| 223 |
+
assert update.upsert, "upsert must be true for creating new instance."
|
| 224 |
+
|
| 225 |
+
assert update.new_instance_status in [
|
| 226 |
+
# For unmanaged instance not initialized by InstanceManager,
|
| 227 |
+
# e.g. head node
|
| 228 |
+
Instance.ALLOCATED,
|
| 229 |
+
# For new instance being queued to launch.
|
| 230 |
+
Instance.QUEUED,
|
| 231 |
+
# For leaked cloud instance that needs to be terminated.
|
| 232 |
+
Instance.TERMINATING,
|
| 233 |
+
], (
|
| 234 |
+
"Invalid status for new instance, must be one of "
|
| 235 |
+
"[ALLOCATED, QUEUED, TERMINATING]"
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
# Create a new instance first for common fields.
|
| 239 |
+
instance = InstanceUtil.new_instance(
|
| 240 |
+
instance_id=update.instance_id,
|
| 241 |
+
instance_type=update.instance_type,
|
| 242 |
+
status=update.new_instance_status,
|
| 243 |
+
details=update.details,
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
# Apply the status specific updates.
|
| 247 |
+
logger.info(InstanceUtil.get_log_str_for_update(instance, update))
|
| 248 |
+
InstanceManager._apply_update(instance, update)
|
| 249 |
+
return instance
|
| 250 |
+
|
| 251 |
+
@staticmethod
|
| 252 |
+
def _update_instance(instance: Instance, update: InstanceUpdateEvent) -> Instance:
|
| 253 |
+
"""
|
| 254 |
+
Update the instance with the given update.
|
| 255 |
+
|
| 256 |
+
Args:
|
| 257 |
+
instance: The instance to update.
|
| 258 |
+
update: The update to apply.
|
| 259 |
+
|
| 260 |
+
Returns:
|
| 261 |
+
The updated instance.
|
| 262 |
+
"""
|
| 263 |
+
logger.info(InstanceUtil.get_log_str_for_update(instance, update))
|
| 264 |
+
assert InstanceUtil.set_status(instance, update.new_instance_status), (
|
| 265 |
+
"Invalid status transition from "
|
| 266 |
+
f"{Instance.InstanceStatus.Name(instance.status)} to "
|
| 267 |
+
f"{Instance.InstanceStatus.Name(update.new_instance_status)}"
|
| 268 |
+
)
|
| 269 |
+
InstanceManager._apply_update(instance, update)
|
| 270 |
+
|
| 271 |
+
return instance
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/instance_storage.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import copy
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Dict, List, Optional, Set, Tuple
|
| 4 |
+
|
| 5 |
+
from ray.autoscaler.v2.instance_manager.storage import Storage, StoreStatus
|
| 6 |
+
from ray.core.generated.instance_manager_pb2 import Instance
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class InstanceStorage:
|
| 12 |
+
"""Instance storage stores the states of instances in the storage."""
|
| 13 |
+
|
| 14 |
+
def __init__(
|
| 15 |
+
self,
|
| 16 |
+
cluster_id: str,
|
| 17 |
+
storage: Storage,
|
| 18 |
+
) -> None:
|
| 19 |
+
self._storage = storage
|
| 20 |
+
self._cluster_id = cluster_id
|
| 21 |
+
self._table_name = f"instance_table@{cluster_id}"
|
| 22 |
+
|
| 23 |
+
def batch_upsert_instances(
|
| 24 |
+
self,
|
| 25 |
+
updates: List[Instance],
|
| 26 |
+
expected_storage_version: Optional[int] = None,
|
| 27 |
+
) -> StoreStatus:
|
| 28 |
+
"""Upsert instances into the storage. If the instance already exists,
|
| 29 |
+
it will be updated. Otherwise, it will be inserted. If the
|
| 30 |
+
expected_storage_version is specified, the update will fail if the
|
| 31 |
+
current storage version does not match the expected version.
|
| 32 |
+
|
| 33 |
+
Note the version of the upserted instances will be set to the current
|
| 34 |
+
storage version.
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
updates: A list of instances to be upserted.
|
| 38 |
+
expected_storage_version: The expected storage version.
|
| 39 |
+
|
| 40 |
+
Returns:
|
| 41 |
+
StoreStatus: A tuple of (success, storage_version).
|
| 42 |
+
"""
|
| 43 |
+
mutations = {}
|
| 44 |
+
version = self._storage.get_version()
|
| 45 |
+
# handle version mismatch
|
| 46 |
+
if expected_storage_version and expected_storage_version != version:
|
| 47 |
+
return StoreStatus(False, version)
|
| 48 |
+
|
| 49 |
+
for instance in updates:
|
| 50 |
+
instance = copy.deepcopy(instance)
|
| 51 |
+
# the instance version is set to 0, it will be
|
| 52 |
+
# populated by the storage entry's verion on read
|
| 53 |
+
instance.version = 0
|
| 54 |
+
mutations[instance.instance_id] = instance.SerializeToString()
|
| 55 |
+
|
| 56 |
+
result, version = self._storage.batch_update(
|
| 57 |
+
self._table_name, mutations, {}, expected_storage_version
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
return StoreStatus(result, version)
|
| 61 |
+
|
| 62 |
+
def upsert_instance(
|
| 63 |
+
self,
|
| 64 |
+
instance: Instance,
|
| 65 |
+
expected_instance_version: Optional[int] = None,
|
| 66 |
+
expected_storage_verison: Optional[int] = None,
|
| 67 |
+
) -> StoreStatus:
|
| 68 |
+
"""Upsert an instance in the storage.
|
| 69 |
+
If the expected_instance_version is specified, the update will fail
|
| 70 |
+
if the current instance version does not match the expected version.
|
| 71 |
+
Similarly, if the expected_storage_version is
|
| 72 |
+
specified, the update will fail if the current storage version does not
|
| 73 |
+
match the expected version.
|
| 74 |
+
|
| 75 |
+
Note the version of the upserted instances will be set to the current
|
| 76 |
+
storage version.
|
| 77 |
+
|
| 78 |
+
Args:
|
| 79 |
+
instance: The instance to be updated.
|
| 80 |
+
expected_instance_version: The expected instance version.
|
| 81 |
+
expected_storage_version: The expected storage version.
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
StoreStatus: A tuple of (success, storage_version).
|
| 85 |
+
"""
|
| 86 |
+
instance = copy.deepcopy(instance)
|
| 87 |
+
# the instance version is set to 0, it will be
|
| 88 |
+
# populated by the storage entry's verion on read
|
| 89 |
+
instance.version = 0
|
| 90 |
+
result, version = self._storage.update(
|
| 91 |
+
self._table_name,
|
| 92 |
+
key=instance.instance_id,
|
| 93 |
+
value=instance.SerializeToString(),
|
| 94 |
+
expected_entry_version=expected_instance_version,
|
| 95 |
+
expected_storage_version=expected_storage_verison,
|
| 96 |
+
insert_only=False,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
return StoreStatus(result, version)
|
| 100 |
+
|
| 101 |
+
def get_instances(
|
| 102 |
+
self,
|
| 103 |
+
instance_ids: List[str] = None,
|
| 104 |
+
status_filter: Set[int] = None,
|
| 105 |
+
) -> Tuple[Dict[str, Instance], int]:
|
| 106 |
+
"""Get instances from the storage.
|
| 107 |
+
|
| 108 |
+
Args:
|
| 109 |
+
instance_ids: A list of instance ids to be retrieved. If empty, all
|
| 110 |
+
instances will be retrieved.
|
| 111 |
+
status_filter: Only instances with the specified status will be returned.
|
| 112 |
+
|
| 113 |
+
Returns:
|
| 114 |
+
Tuple[Dict[str, Instance], int]: A tuple of (instances, version).
|
| 115 |
+
The instances is a dictionary of (instance_id, instance) pairs.
|
| 116 |
+
"""
|
| 117 |
+
instance_ids = instance_ids or []
|
| 118 |
+
status_filter = status_filter or set()
|
| 119 |
+
pairs, version = self._storage.get(self._table_name, instance_ids)
|
| 120 |
+
instances = {}
|
| 121 |
+
for instance_id, (instance_data, entry_version) in pairs.items():
|
| 122 |
+
instance = Instance()
|
| 123 |
+
instance.ParseFromString(instance_data)
|
| 124 |
+
instance.version = entry_version
|
| 125 |
+
if status_filter and instance.status not in status_filter:
|
| 126 |
+
continue
|
| 127 |
+
instances[instance_id] = instance
|
| 128 |
+
return instances, version
|
| 129 |
+
|
| 130 |
+
def batch_delete_instances(
|
| 131 |
+
self, instance_ids: List[str], expected_storage_version: Optional[int] = None
|
| 132 |
+
) -> StoreStatus:
|
| 133 |
+
"""Delete instances from the storage. If the expected_version is
|
| 134 |
+
specified, the update will fail if the current storage version does not
|
| 135 |
+
match the expected version.
|
| 136 |
+
|
| 137 |
+
Args:
|
| 138 |
+
to_delete: A list of instances to be deleted.
|
| 139 |
+
expected_version: The expected storage version.
|
| 140 |
+
|
| 141 |
+
Returns:
|
| 142 |
+
StoreStatus: A tuple of (success, storage_version).
|
| 143 |
+
"""
|
| 144 |
+
version = self._storage.get_version()
|
| 145 |
+
if expected_storage_version and expected_storage_version != version:
|
| 146 |
+
return StoreStatus(False, version)
|
| 147 |
+
|
| 148 |
+
result = self._storage.batch_update(
|
| 149 |
+
self._table_name, {}, instance_ids, expected_storage_version
|
| 150 |
+
)
|
| 151 |
+
return result
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/node_provider.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import math
|
| 3 |
+
import time
|
| 4 |
+
from abc import ABC, abstractmethod
|
| 5 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from queue import Queue
|
| 8 |
+
from typing import Any, Dict, List, Optional
|
| 9 |
+
|
| 10 |
+
from ray.autoscaler._private.constants import (
|
| 11 |
+
AUTOSCALER_MAX_CONCURRENT_LAUNCHES,
|
| 12 |
+
AUTOSCALER_MAX_LAUNCH_BATCH,
|
| 13 |
+
)
|
| 14 |
+
from ray.autoscaler._private.util import hash_launch_conf
|
| 15 |
+
from ray.autoscaler.node_provider import NodeProvider as NodeProviderV1
|
| 16 |
+
from ray.autoscaler.tags import (
|
| 17 |
+
NODE_KIND_HEAD,
|
| 18 |
+
NODE_KIND_UNMANAGED,
|
| 19 |
+
NODE_KIND_WORKER,
|
| 20 |
+
STATUS_UNINITIALIZED,
|
| 21 |
+
TAG_RAY_LAUNCH_CONFIG,
|
| 22 |
+
TAG_RAY_LAUNCH_REQUEST,
|
| 23 |
+
TAG_RAY_NODE_KIND,
|
| 24 |
+
TAG_RAY_NODE_NAME,
|
| 25 |
+
TAG_RAY_NODE_STATUS,
|
| 26 |
+
TAG_RAY_USER_NODE_TYPE,
|
| 27 |
+
)
|
| 28 |
+
from ray.autoscaler.v2.instance_manager.config import IConfigReader
|
| 29 |
+
from ray.autoscaler.v2.schema import NodeType
|
| 30 |
+
from ray.core.generated.instance_manager_pb2 import NodeKind
|
| 31 |
+
|
| 32 |
+
logger = logging.getLogger(__name__)
|
| 33 |
+
|
| 34 |
+
# Type Alias. This is a **unique identifier** for a cloud instance in the cluster.
|
| 35 |
+
# The provider should guarantee that this id is unique across the cluster,
|
| 36 |
+
# such that:
|
| 37 |
+
# - When a cloud instance is created and running, no other cloud instance in the
|
| 38 |
+
# cluster has the same id.
|
| 39 |
+
# - When a cloud instance is terminated, no other cloud instance in the cluster will
|
| 40 |
+
# be assigned the same id later.
|
| 41 |
+
CloudInstanceId = str
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@dataclass
|
| 45 |
+
class CloudInstance:
|
| 46 |
+
"""
|
| 47 |
+
A class that represents a cloud instance in the cluster, with necessary metadata
|
| 48 |
+
of the cloud instance.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
# The cloud instance id.
|
| 52 |
+
cloud_instance_id: CloudInstanceId
|
| 53 |
+
# The node type of the cloud instance.
|
| 54 |
+
node_type: NodeType
|
| 55 |
+
# The node kind, i.e head or worker.
|
| 56 |
+
node_kind: NodeKind
|
| 57 |
+
# If the cloud instance is already running.
|
| 58 |
+
is_running: bool
|
| 59 |
+
# Update request id from which the cloud instance is launched.
|
| 60 |
+
# This could be None if the cloud instance couldn't be associated with requests
|
| 61 |
+
# by the cloud provider: e.g. cloud provider doesn't support per-instance
|
| 62 |
+
# extra metadata.
|
| 63 |
+
# This is fine for now since the reconciler should be able to know how
|
| 64 |
+
# to handle cloud instances w/o request ids.
|
| 65 |
+
# TODO: make this a required field.
|
| 66 |
+
request_id: Optional[str] = None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class CloudInstanceProviderError(Exception):
|
| 70 |
+
"""
|
| 71 |
+
An base error class that represents an error that happened in the cloud instance
|
| 72 |
+
provider.
|
| 73 |
+
"""
|
| 74 |
+
|
| 75 |
+
# The timestamp of the error occurred in nanoseconds.
|
| 76 |
+
timestamp_ns: int
|
| 77 |
+
|
| 78 |
+
def __init__(self, msg, timestamp_ns) -> None:
|
| 79 |
+
super().__init__(msg)
|
| 80 |
+
self.timestamp_ns = timestamp_ns
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class LaunchNodeError(CloudInstanceProviderError):
|
| 84 |
+
# The node type that failed to launch.
|
| 85 |
+
node_type: NodeType
|
| 86 |
+
# Number of nodes that failed to launch.
|
| 87 |
+
count: int
|
| 88 |
+
# A unique id that identifies from which update request the error originates.
|
| 89 |
+
request_id: str
|
| 90 |
+
|
| 91 |
+
def __init__(
|
| 92 |
+
self,
|
| 93 |
+
node_type: NodeType,
|
| 94 |
+
count: int,
|
| 95 |
+
request_id: str,
|
| 96 |
+
timestamp_ns: int,
|
| 97 |
+
details: str = "",
|
| 98 |
+
cause: Optional[Exception] = None,
|
| 99 |
+
) -> None:
|
| 100 |
+
msg = (
|
| 101 |
+
f"Failed to launch {count} nodes of type {node_type} with "
|
| 102 |
+
f"request id {request_id}: {details}"
|
| 103 |
+
)
|
| 104 |
+
super().__init__(msg, timestamp_ns=timestamp_ns)
|
| 105 |
+
self.node_type = node_type
|
| 106 |
+
self.count = count
|
| 107 |
+
self.request_id = request_id
|
| 108 |
+
if cause:
|
| 109 |
+
self.__cause__ = cause
|
| 110 |
+
|
| 111 |
+
def __repr__(self) -> str:
|
| 112 |
+
return (
|
| 113 |
+
f"LaunchNodeError(node_type={self.node_type}, count={self.count}, "
|
| 114 |
+
f"request_id={self.request_id}): {self.__cause__}"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class TerminateNodeError(CloudInstanceProviderError):
|
| 119 |
+
# The cloud instance id of the node that failed to terminate.
|
| 120 |
+
cloud_instance_id: CloudInstanceId
|
| 121 |
+
# A unique id that identifies from which update request the error originates.
|
| 122 |
+
request_id: str
|
| 123 |
+
|
| 124 |
+
def __init__(
|
| 125 |
+
self,
|
| 126 |
+
cloud_instance_id: CloudInstanceId,
|
| 127 |
+
request_id: str,
|
| 128 |
+
timestamp_ns: int,
|
| 129 |
+
details: str = "",
|
| 130 |
+
cause: Optional[Exception] = None,
|
| 131 |
+
) -> None:
|
| 132 |
+
msg = (
|
| 133 |
+
f"Failed to terminate node {cloud_instance_id} with "
|
| 134 |
+
f"request id {request_id}: {details}"
|
| 135 |
+
)
|
| 136 |
+
super().__init__(msg, timestamp_ns=timestamp_ns)
|
| 137 |
+
self.cloud_instance_id = cloud_instance_id
|
| 138 |
+
self.request_id = request_id
|
| 139 |
+
if cause:
|
| 140 |
+
self.__cause__ = cause
|
| 141 |
+
|
| 142 |
+
def __repr__(self) -> str:
|
| 143 |
+
return (
|
| 144 |
+
f"TerminateNodeError(cloud_instance_id={self.cloud_instance_id}, "
|
| 145 |
+
f"request_id={self.request_id}): {self.__cause__}"
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class ICloudInstanceProvider(ABC):
|
| 150 |
+
"""
|
| 151 |
+
The interface for a cloud instance provider.
|
| 152 |
+
|
| 153 |
+
This interface is a minimal interface that should be implemented by the
|
| 154 |
+
various cloud instance providers (e.g. AWS, and etc).
|
| 155 |
+
|
| 156 |
+
The cloud instance provider is responsible for managing the cloud instances in the
|
| 157 |
+
cluster. It provides the following main functionalities:
|
| 158 |
+
- Launch new cloud instances.
|
| 159 |
+
- Terminate existing running instances.
|
| 160 |
+
- Get the non-terminated cloud instances in the cluster.
|
| 161 |
+
- Poll the errors that happened for the updates to the cloud instance provider.
|
| 162 |
+
|
| 163 |
+
Below properties of the cloud instance provider are assumed with this interface:
|
| 164 |
+
|
| 165 |
+
1. Eventually consistent
|
| 166 |
+
The cloud instance provider is expected to be eventually consistent with the
|
| 167 |
+
cluster state. For example, when a cloud instance is request to be terminated
|
| 168 |
+
or launched, the provider may not immediately reflect the change in its state.
|
| 169 |
+
However, the provider is expected to eventually reflect the change in its state.
|
| 170 |
+
|
| 171 |
+
2. Asynchronous
|
| 172 |
+
The provider could also be asynchronous, where the termination/launch
|
| 173 |
+
request may not immediately return the result of the request.
|
| 174 |
+
|
| 175 |
+
3. Unique cloud instance ids
|
| 176 |
+
Cloud instance ids are expected to be unique across the cluster.
|
| 177 |
+
|
| 178 |
+
4. Idempotent updates
|
| 179 |
+
For the update APIs (e.g. ensure_min_nodes, terminate), the provider may use the
|
| 180 |
+
request ids to provide idempotency.
|
| 181 |
+
|
| 182 |
+
Usage:
|
| 183 |
+
```
|
| 184 |
+
provider: ICloudInstanceProvider = ...
|
| 185 |
+
|
| 186 |
+
# Update the cluster with a desired shape.
|
| 187 |
+
provider.launch(
|
| 188 |
+
shape={
|
| 189 |
+
"worker_nodes": 10,
|
| 190 |
+
"ray_head": 1,
|
| 191 |
+
},
|
| 192 |
+
request_id="1",
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
# Get the non-terminated nodes of the cloud instance provider.
|
| 196 |
+
running = provider.get_non_terminated()
|
| 197 |
+
|
| 198 |
+
# Poll the errors
|
| 199 |
+
errors = provider.poll_errors()
|
| 200 |
+
|
| 201 |
+
# Terminate nodes.
|
| 202 |
+
provider.terminate(
|
| 203 |
+
ids=["cloud_instance_id_1", "cloud_instance_id_2"],
|
| 204 |
+
request_id="2",
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
# Process the state of the provider.
|
| 208 |
+
...
|
| 209 |
+
```
|
| 210 |
+
"""
|
| 211 |
+
|
| 212 |
+
@abstractmethod
|
| 213 |
+
def get_non_terminated(self) -> Dict[CloudInstanceId, CloudInstance]:
|
| 214 |
+
"""Get the non-terminated cloud instances in the cluster.
|
| 215 |
+
|
| 216 |
+
Returns:
|
| 217 |
+
A dictionary of the non-terminated cloud instances in the cluster.
|
| 218 |
+
The key is the cloud instance id, and the value is the cloud instance.
|
| 219 |
+
"""
|
| 220 |
+
pass
|
| 221 |
+
|
| 222 |
+
@abstractmethod
|
| 223 |
+
def terminate(self, ids: List[CloudInstanceId], request_id: str) -> None:
|
| 224 |
+
"""
|
| 225 |
+
Terminate the cloud instances asynchronously.
|
| 226 |
+
|
| 227 |
+
This method is expected to be idempotent, i.e. if the same request id is used
|
| 228 |
+
to terminate the same cloud instances, this should be a no-op if
|
| 229 |
+
the cloud instances are already terminated or being terminated.
|
| 230 |
+
|
| 231 |
+
Args:
|
| 232 |
+
ids: the cloud instance ids to terminate.
|
| 233 |
+
request_id: a unique id that identifies the request.
|
| 234 |
+
"""
|
| 235 |
+
pass
|
| 236 |
+
|
| 237 |
+
@abstractmethod
|
| 238 |
+
def launch(
|
| 239 |
+
self,
|
| 240 |
+
shape: Dict[NodeType, int],
|
| 241 |
+
request_id: str,
|
| 242 |
+
) -> None:
|
| 243 |
+
"""Launch the cloud instances asynchronously.
|
| 244 |
+
|
| 245 |
+
Args:
|
| 246 |
+
shape: A map from node type to number of nodes to launch.
|
| 247 |
+
request_id: a unique id that identifies the update request.
|
| 248 |
+
"""
|
| 249 |
+
pass
|
| 250 |
+
|
| 251 |
+
@abstractmethod
|
| 252 |
+
def poll_errors(self) -> List[CloudInstanceProviderError]:
|
| 253 |
+
"""
|
| 254 |
+
Poll the errors that happened since the last poll.
|
| 255 |
+
|
| 256 |
+
This method would also clear the errors that happened since the last poll.
|
| 257 |
+
|
| 258 |
+
Returns:
|
| 259 |
+
The errors that happened since the last poll.
|
| 260 |
+
"""
|
| 261 |
+
pass
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
@dataclass(frozen=True)
|
| 265 |
+
class CloudInstanceLaunchRequest:
|
| 266 |
+
"""
|
| 267 |
+
The arguments to launch a node.
|
| 268 |
+
"""
|
| 269 |
+
|
| 270 |
+
# The node type to launch.
|
| 271 |
+
node_type: NodeType
|
| 272 |
+
# Number of nodes to launch.
|
| 273 |
+
count: int
|
| 274 |
+
# A unique id that identifies the request.
|
| 275 |
+
request_id: str
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
@dataclass(frozen=True)
|
| 279 |
+
class CloudInstanceTerminateRequest:
|
| 280 |
+
"""
|
| 281 |
+
The arguments to terminate a node.
|
| 282 |
+
"""
|
| 283 |
+
|
| 284 |
+
# The cloud instance id of the node to terminate.
|
| 285 |
+
cloud_instance_id: CloudInstanceId
|
| 286 |
+
# A unique id that identifies the request.
|
| 287 |
+
request_id: str
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
class NodeProviderAdapter(ICloudInstanceProvider):
|
| 291 |
+
"""
|
| 292 |
+
Warps a NodeProviderV1 to a ICloudInstanceProvider.
|
| 293 |
+
|
| 294 |
+
TODO(rickyx):
|
| 295 |
+
The current adapter right now consists of two sets of APIs:
|
| 296 |
+
- v1: the old APIs that are used by the autoscaler, where
|
| 297 |
+
we forward the calls to the NodeProviderV1.
|
| 298 |
+
- v2: the new APIs that are used by the autoscaler v2, this is
|
| 299 |
+
defined in the ICloudInstanceProvider interface.
|
| 300 |
+
|
| 301 |
+
We should eventually remove the v1 APIs and only use the v2 APIs.
|
| 302 |
+
It's currently left as a TODO since changing the v1 APIs would
|
| 303 |
+
requires a lot of changes in the cluster launcher codebase.
|
| 304 |
+
"""
|
| 305 |
+
|
| 306 |
+
def __init__(
|
| 307 |
+
self,
|
| 308 |
+
v1_provider: NodeProviderV1,
|
| 309 |
+
config_reader: IConfigReader,
|
| 310 |
+
max_launch_batch_per_type: int = AUTOSCALER_MAX_LAUNCH_BATCH,
|
| 311 |
+
max_concurrent_launches: int = AUTOSCALER_MAX_CONCURRENT_LAUNCHES,
|
| 312 |
+
) -> None:
|
| 313 |
+
"""
|
| 314 |
+
Args:
|
| 315 |
+
v1_provider: The v1 node provider to wrap.
|
| 316 |
+
config_reader: The config reader to read the autoscaling config.
|
| 317 |
+
max_launch_batch_per_type: The maximum number of nodes to launch per
|
| 318 |
+
node type in a single batch.
|
| 319 |
+
max_concurrent_launches: The maximum number of concurrent launches.
|
| 320 |
+
"""
|
| 321 |
+
|
| 322 |
+
super().__init__()
|
| 323 |
+
self._v1_provider = v1_provider
|
| 324 |
+
self._config_reader = config_reader
|
| 325 |
+
# Executor to async launching and terminating nodes.
|
| 326 |
+
self._main_executor = ThreadPoolExecutor(
|
| 327 |
+
max_workers=1, thread_name_prefix="ray::NodeProviderAdapter"
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
# v1 legacy rate limiting on the node provider launch calls.
|
| 331 |
+
self._max_launch_batch_per_type = max_launch_batch_per_type
|
| 332 |
+
max_batches = math.ceil(
|
| 333 |
+
max_concurrent_launches / float(max_launch_batch_per_type)
|
| 334 |
+
)
|
| 335 |
+
self._node_launcher_executors = ThreadPoolExecutor(
|
| 336 |
+
max_workers=max_batches,
|
| 337 |
+
thread_name_prefix="ray::NodeLauncherPool",
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
# Queue to retrieve new errors occur in the multi-thread executors
|
| 341 |
+
# temporarily.
|
| 342 |
+
self._errors_queue = Queue()
|
| 343 |
+
|
| 344 |
+
@property
|
| 345 |
+
def v1_provider(self) -> NodeProviderV1:
|
| 346 |
+
return self._v1_provider
|
| 347 |
+
|
| 348 |
+
def get_non_terminated(self) -> Dict[CloudInstanceId, CloudInstance]:
|
| 349 |
+
nodes = {}
|
| 350 |
+
|
| 351 |
+
cloud_instance_ids = self._v1_non_terminated_nodes({})
|
| 352 |
+
# Filter out nodes that are not running.
|
| 353 |
+
# This is efficient since the provider is expected to cache the
|
| 354 |
+
# running status of the nodes.
|
| 355 |
+
for cloud_instance_id in cloud_instance_ids:
|
| 356 |
+
node_tags = self._v1_node_tags(cloud_instance_id)
|
| 357 |
+
node_kind_tag = node_tags.get(TAG_RAY_NODE_KIND, NODE_KIND_UNMANAGED)
|
| 358 |
+
if node_kind_tag == NODE_KIND_UNMANAGED:
|
| 359 |
+
# Filter out unmanaged nodes.
|
| 360 |
+
continue
|
| 361 |
+
elif node_kind_tag == NODE_KIND_WORKER:
|
| 362 |
+
node_kind = NodeKind.WORKER
|
| 363 |
+
elif node_kind_tag == NODE_KIND_HEAD:
|
| 364 |
+
node_kind = NodeKind.HEAD
|
| 365 |
+
else:
|
| 366 |
+
raise ValueError(f"Invalid node kind: {node_kind_tag}")
|
| 367 |
+
|
| 368 |
+
nodes[cloud_instance_id] = CloudInstance(
|
| 369 |
+
cloud_instance_id=cloud_instance_id,
|
| 370 |
+
node_type=node_tags.get(TAG_RAY_USER_NODE_TYPE, ""),
|
| 371 |
+
is_running=self._v1_is_running(cloud_instance_id),
|
| 372 |
+
request_id=node_tags.get(TAG_RAY_LAUNCH_REQUEST, ""),
|
| 373 |
+
node_kind=node_kind,
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
return nodes
|
| 377 |
+
|
| 378 |
+
def poll_errors(self) -> List[CloudInstanceProviderError]:
|
| 379 |
+
errors = []
|
| 380 |
+
while not self._errors_queue.empty():
|
| 381 |
+
errors.append(self._errors_queue.get_nowait())
|
| 382 |
+
return errors
|
| 383 |
+
|
| 384 |
+
def launch(
|
| 385 |
+
self,
|
| 386 |
+
shape: Dict[NodeType, int],
|
| 387 |
+
request_id: str,
|
| 388 |
+
) -> None:
|
| 389 |
+
self._main_executor.submit(self._do_launch, shape, request_id)
|
| 390 |
+
|
| 391 |
+
def terminate(self, ids: List[CloudInstanceId], request_id: str) -> None:
|
| 392 |
+
self._main_executor.submit(self._do_terminate, ids, request_id)
|
| 393 |
+
|
| 394 |
+
###########################################
|
| 395 |
+
# Private APIs
|
| 396 |
+
###########################################
|
| 397 |
+
|
| 398 |
+
def _do_launch(
|
| 399 |
+
self,
|
| 400 |
+
shape: Dict[NodeType, int],
|
| 401 |
+
request_id: str,
|
| 402 |
+
) -> None:
|
| 403 |
+
"""
|
| 404 |
+
Launch the cloud instances by calling into the v1 base node provider.
|
| 405 |
+
|
| 406 |
+
Args:
|
| 407 |
+
shape: The requested to launch node type and number of nodes.
|
| 408 |
+
request_id: The request id that identifies the request.
|
| 409 |
+
"""
|
| 410 |
+
for node_type, count in shape.items():
|
| 411 |
+
# Keep submitting the launch requests to the launch pool in batches.
|
| 412 |
+
while count > 0:
|
| 413 |
+
to_launch = min(count, self._max_launch_batch_per_type)
|
| 414 |
+
self._node_launcher_executors.submit(
|
| 415 |
+
self._launch_nodes_by_type,
|
| 416 |
+
node_type,
|
| 417 |
+
to_launch,
|
| 418 |
+
request_id,
|
| 419 |
+
)
|
| 420 |
+
count -= to_launch
|
| 421 |
+
|
| 422 |
+
def _do_terminate(self, ids: List[CloudInstanceId], request_id: str) -> None:
|
| 423 |
+
"""
|
| 424 |
+
Terminate the cloud instances by calling into the v1 base node provider.
|
| 425 |
+
|
| 426 |
+
If errors happen during the termination, the errors will be put into the
|
| 427 |
+
errors queue.
|
| 428 |
+
|
| 429 |
+
Args:
|
| 430 |
+
ids: The cloud instance ids to terminate.
|
| 431 |
+
request_id: The request id that identifies the request.
|
| 432 |
+
"""
|
| 433 |
+
|
| 434 |
+
try:
|
| 435 |
+
self._v1_terminate_nodes(ids)
|
| 436 |
+
except Exception as e:
|
| 437 |
+
for id in ids:
|
| 438 |
+
error = TerminateNodeError(id, request_id, int(time.time_ns()))
|
| 439 |
+
error.__cause__ = e
|
| 440 |
+
self._errors_queue.put(error)
|
| 441 |
+
|
| 442 |
+
def _launch_nodes_by_type(
|
| 443 |
+
self,
|
| 444 |
+
node_type: NodeType,
|
| 445 |
+
count: int,
|
| 446 |
+
request_id: str,
|
| 447 |
+
) -> None:
|
| 448 |
+
"""
|
| 449 |
+
Launch nodes of the given node type.
|
| 450 |
+
|
| 451 |
+
Args:
|
| 452 |
+
node_type: The node type to launch.
|
| 453 |
+
count: Number of nodes to launch.
|
| 454 |
+
request_id: A unique id that identifies the request.
|
| 455 |
+
|
| 456 |
+
Raises:
|
| 457 |
+
ValueError: If the node type is invalid.
|
| 458 |
+
LaunchNodeError: If the launch failed and raised by the underlying provider.
|
| 459 |
+
"""
|
| 460 |
+
# Check node type is valid.
|
| 461 |
+
try:
|
| 462 |
+
config = self._config_reader.get_cached_autoscaling_config()
|
| 463 |
+
launch_config = config.get_cloud_node_config(node_type)
|
| 464 |
+
resources = config.get_node_resources(node_type)
|
| 465 |
+
labels = config.get_node_labels(node_type)
|
| 466 |
+
|
| 467 |
+
# This is to be compatible with the v1 node launcher.
|
| 468 |
+
# See more in https://github.com/ray-project/ray/blob/6f5a189bc463e52c51a70f8aea41fb2950b443e8/python/ray/autoscaler/_private/node_launcher.py#L78-L85 # noqa
|
| 469 |
+
# TODO: this should be synced with what's stored in the IM, it should
|
| 470 |
+
# probably be made as a metadata field in the cloud instance. This is
|
| 471 |
+
# another incompatibility with KubeRay.
|
| 472 |
+
launch_hash = hash_launch_conf(launch_config, config.get_config("auth", {}))
|
| 473 |
+
node_tags = {
|
| 474 |
+
TAG_RAY_NODE_NAME: "ray-{}-worker".format(
|
| 475 |
+
config.get_config("cluster_name", "")
|
| 476 |
+
),
|
| 477 |
+
TAG_RAY_NODE_KIND: NODE_KIND_WORKER,
|
| 478 |
+
TAG_RAY_NODE_STATUS: STATUS_UNINITIALIZED,
|
| 479 |
+
TAG_RAY_LAUNCH_CONFIG: launch_hash,
|
| 480 |
+
TAG_RAY_LAUNCH_REQUEST: request_id,
|
| 481 |
+
TAG_RAY_USER_NODE_TYPE: node_type,
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
logger.info("Launching {} nodes of type {}.".format(count, node_type))
|
| 485 |
+
self._v1_provider.create_node_with_resources_and_labels(
|
| 486 |
+
launch_config, node_tags, count, resources, labels
|
| 487 |
+
)
|
| 488 |
+
logger.info("Launched {} nodes of type {}.".format(count, node_type))
|
| 489 |
+
except Exception as e:
|
| 490 |
+
logger.info(
|
| 491 |
+
"Failed to launch {} nodes of type {}: {}".format(count, node_type, e)
|
| 492 |
+
)
|
| 493 |
+
error = LaunchNodeError(node_type, count, request_id, int(time.time_ns()))
|
| 494 |
+
error.__cause__ = e
|
| 495 |
+
self._errors_queue.put(error)
|
| 496 |
+
|
| 497 |
+
###########################################
|
| 498 |
+
# V1 Legacy APIs
|
| 499 |
+
###########################################
|
| 500 |
+
"""
|
| 501 |
+
Below are the necessary legacy APIs from the V1 node provider.
|
| 502 |
+
These are needed as of now to provide the needed features
|
| 503 |
+
for V2 node provider.
|
| 504 |
+
The goal is to eventually remove these APIs and only use the
|
| 505 |
+
V2 APIs by modifying the individual node provider to inherit
|
| 506 |
+
from ICloudInstanceProvider.
|
| 507 |
+
"""
|
| 508 |
+
|
| 509 |
+
def _v1_terminate_nodes(
|
| 510 |
+
self, ids: List[CloudInstanceId]
|
| 511 |
+
) -> Optional[Dict[str, Any]]:
|
| 512 |
+
return self._v1_provider.terminate_nodes(ids)
|
| 513 |
+
|
| 514 |
+
def _v1_non_terminated_nodes(
|
| 515 |
+
self, tag_filters: Dict[str, str]
|
| 516 |
+
) -> List[CloudInstanceId]:
|
| 517 |
+
return self._v1_provider.non_terminated_nodes(tag_filters)
|
| 518 |
+
|
| 519 |
+
def _v1_is_running(self, node_id: CloudInstanceId) -> bool:
|
| 520 |
+
return self._v1_provider.is_running(node_id)
|
| 521 |
+
|
| 522 |
+
def _v1_post_process(self) -> None:
|
| 523 |
+
self._v1_provider.post_process()
|
| 524 |
+
|
| 525 |
+
def _v1_node_tags(self, node_id: CloudInstanceId) -> Dict[str, str]:
|
| 526 |
+
return self._v1_provider.node_tags(node_id)
|
| 527 |
+
|
| 528 |
+
def _v1_safe_to_scale(self) -> bool:
|
| 529 |
+
return self._v1_provider.safe_to_scale()
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/ray_installer.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import subprocess
|
| 3 |
+
|
| 4 |
+
from ray.autoscaler._private.updater import (
|
| 5 |
+
STATUS_UP_TO_DATE,
|
| 6 |
+
TAG_RAY_NODE_STATUS,
|
| 7 |
+
NodeUpdater,
|
| 8 |
+
)
|
| 9 |
+
from ray.autoscaler._private.util import with_envs, with_head_node_ip
|
| 10 |
+
from ray.autoscaler.node_provider import NodeProvider as NodeProviderV1
|
| 11 |
+
from ray.autoscaler.v2.instance_manager.config import AutoscalingConfig
|
| 12 |
+
from ray.core.generated.instance_manager_pb2 import Instance
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class RayInstaller(object):
|
| 18 |
+
"""
|
| 19 |
+
RayInstaller is responsible for installing ray on the target instance.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
def __init__(
|
| 23 |
+
self,
|
| 24 |
+
provider: NodeProviderV1,
|
| 25 |
+
config: AutoscalingConfig,
|
| 26 |
+
process_runner=subprocess,
|
| 27 |
+
) -> None:
|
| 28 |
+
self._provider = provider
|
| 29 |
+
self._config = config
|
| 30 |
+
self._process_runner = process_runner
|
| 31 |
+
|
| 32 |
+
def install_ray(self, instance: Instance, head_node_ip: str) -> None:
|
| 33 |
+
"""
|
| 34 |
+
Install ray on the target instance synchronously.
|
| 35 |
+
TODO:(rickyx): This runs in another thread, and errors are silently
|
| 36 |
+
ignored. We should propagate the error to the main thread.
|
| 37 |
+
"""
|
| 38 |
+
setup_commands = self._config.get_worker_setup_commands(instance.instance_type)
|
| 39 |
+
ray_start_commands = self._config.get_worker_start_ray_commands()
|
| 40 |
+
docker_config = self._config.get_docker_config(instance.instance_type)
|
| 41 |
+
|
| 42 |
+
logger.info(
|
| 43 |
+
f"Creating new (spawn_updater) updater thread for node"
|
| 44 |
+
f" {instance.cloud_instance_id}."
|
| 45 |
+
)
|
| 46 |
+
provider_instance_type_name = self._config.get_provider_instance_type(
|
| 47 |
+
instance.instance_type
|
| 48 |
+
)
|
| 49 |
+
updater = NodeUpdater(
|
| 50 |
+
node_id=instance.cloud_instance_id,
|
| 51 |
+
provider_config=self._config.get_config("provider"),
|
| 52 |
+
provider=self._provider,
|
| 53 |
+
auth_config=self._config.get_config("auth"),
|
| 54 |
+
cluster_name=self._config.get_config("cluster_name"),
|
| 55 |
+
file_mounts=self._config.get_config("file_mounts"),
|
| 56 |
+
initialization_commands=with_head_node_ip(
|
| 57 |
+
self._config.get_initialization_commands(instance.instance_type),
|
| 58 |
+
head_node_ip,
|
| 59 |
+
),
|
| 60 |
+
setup_commands=with_head_node_ip(setup_commands, head_node_ip),
|
| 61 |
+
# This will prepend envs to the begin of the ray start commands, e.g.
|
| 62 |
+
# `RAY_HEAD_IP=<head_node_ip> \
|
| 63 |
+
# RAY_CLOUD_INSTANCE_ID=<instance_id> \
|
| 64 |
+
# ray start --head ...`
|
| 65 |
+
# See src/ray/common/constants.h for ENV name definitions.
|
| 66 |
+
ray_start_commands=with_envs(
|
| 67 |
+
ray_start_commands,
|
| 68 |
+
{
|
| 69 |
+
"RAY_HEAD_IP": head_node_ip,
|
| 70 |
+
"RAY_CLOUD_INSTANCE_ID": instance.cloud_instance_id,
|
| 71 |
+
"RAY_NODE_TYPE_NAME": instance.instance_type,
|
| 72 |
+
"RAY_CLOUD_INSTANCE_TYPE_NAME": provider_instance_type_name,
|
| 73 |
+
},
|
| 74 |
+
),
|
| 75 |
+
runtime_hash=self._config.runtime_hash,
|
| 76 |
+
file_mounts_contents_hash=self._config.file_mounts_contents_hash,
|
| 77 |
+
is_head_node=False,
|
| 78 |
+
cluster_synced_files=self._config.get_config("cluster_synced_files"),
|
| 79 |
+
rsync_options={
|
| 80 |
+
"rsync_exclude": self._config.get_config("rsync_exclude"),
|
| 81 |
+
"rsync_filter": self._config.get_config("rsync_filter"),
|
| 82 |
+
},
|
| 83 |
+
use_internal_ip=True,
|
| 84 |
+
docker_config=docker_config,
|
| 85 |
+
node_resources=self._config.get_node_resources(instance.instance_type),
|
| 86 |
+
node_labels=self._config.get_node_labels(instance.instance_type),
|
| 87 |
+
process_runner=self._process_runner,
|
| 88 |
+
)
|
| 89 |
+
updater.run()
|
| 90 |
+
# check if the updater was successful by checking the node tags
|
| 91 |
+
# since the updater could hide exceptions and just set the status tag
|
| 92 |
+
tags = self._provider.node_tags(instance.cloud_instance_id)
|
| 93 |
+
if tags.get(TAG_RAY_NODE_STATUS) != STATUS_UP_TO_DATE:
|
| 94 |
+
raise Exception(
|
| 95 |
+
f"Ray installation failed with unexpected status: {tags.get(TAG_RAY_NODE_STATUS)}"
|
| 96 |
+
)
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/reconciler.py
ADDED
|
@@ -0,0 +1,1601 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import math
|
| 3 |
+
import time
|
| 4 |
+
import uuid
|
| 5 |
+
from collections import defaultdict
|
| 6 |
+
from typing import Dict, List, Optional, Set, Tuple
|
| 7 |
+
|
| 8 |
+
from ray._common.utils import binary_to_hex
|
| 9 |
+
from ray.autoscaler.v2.instance_manager.common import InstanceUtil
|
| 10 |
+
from ray.autoscaler.v2.instance_manager.config import (
|
| 11 |
+
AutoscalingConfig,
|
| 12 |
+
InstanceReconcileConfig,
|
| 13 |
+
Provider,
|
| 14 |
+
)
|
| 15 |
+
from ray.autoscaler.v2.instance_manager.instance_manager import InstanceManager
|
| 16 |
+
from ray.autoscaler.v2.instance_manager.node_provider import (
|
| 17 |
+
CloudInstance,
|
| 18 |
+
CloudInstanceId,
|
| 19 |
+
CloudInstanceProviderError,
|
| 20 |
+
ICloudInstanceProvider,
|
| 21 |
+
LaunchNodeError,
|
| 22 |
+
TerminateNodeError,
|
| 23 |
+
)
|
| 24 |
+
from ray.autoscaler.v2.instance_manager.subscribers.ray_stopper import RayStopError
|
| 25 |
+
from ray.autoscaler.v2.instance_manager.subscribers.threaded_ray_installer import (
|
| 26 |
+
RayInstallError,
|
| 27 |
+
)
|
| 28 |
+
from ray.autoscaler.v2.metrics_reporter import AutoscalerMetricsReporter
|
| 29 |
+
from ray.autoscaler.v2.scheduler import IResourceScheduler, SchedulingRequest
|
| 30 |
+
from ray.autoscaler.v2.schema import AutoscalerInstance, NodeType
|
| 31 |
+
from ray.autoscaler.v2.utils import is_head_node
|
| 32 |
+
from ray.core.generated.autoscaler_pb2 import (
|
| 33 |
+
AutoscalingState,
|
| 34 |
+
ClusterResourceState,
|
| 35 |
+
FailedInstanceRequest,
|
| 36 |
+
NodeState,
|
| 37 |
+
NodeStatus,
|
| 38 |
+
PendingInstance,
|
| 39 |
+
PendingInstanceRequest,
|
| 40 |
+
)
|
| 41 |
+
from ray.core.generated.instance_manager_pb2 import (
|
| 42 |
+
GetInstanceManagerStateRequest,
|
| 43 |
+
Instance as IMInstance,
|
| 44 |
+
InstanceUpdateEvent as IMInstanceUpdateEvent,
|
| 45 |
+
NodeKind,
|
| 46 |
+
StatusCode,
|
| 47 |
+
UpdateInstanceManagerStateRequest,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
logger = logging.getLogger(__name__)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class Reconciler:
|
| 54 |
+
"""
|
| 55 |
+
A singleton class that reconciles the instance states of the instance manager
|
| 56 |
+
for autoscaler.
|
| 57 |
+
|
| 58 |
+
"""
|
| 59 |
+
|
| 60 |
+
@staticmethod
|
| 61 |
+
def reconcile(
|
| 62 |
+
instance_manager: InstanceManager,
|
| 63 |
+
scheduler: IResourceScheduler,
|
| 64 |
+
cloud_provider: ICloudInstanceProvider,
|
| 65 |
+
ray_cluster_resource_state: ClusterResourceState,
|
| 66 |
+
non_terminated_cloud_instances: Dict[CloudInstanceId, CloudInstance],
|
| 67 |
+
autoscaling_config: AutoscalingConfig,
|
| 68 |
+
cloud_provider_errors: Optional[List[CloudInstanceProviderError]] = None,
|
| 69 |
+
ray_install_errors: Optional[List[RayInstallError]] = None,
|
| 70 |
+
ray_stop_errors: Optional[List[RayStopError]] = None,
|
| 71 |
+
metrics_reporter: Optional[AutoscalerMetricsReporter] = None,
|
| 72 |
+
_logger: Optional[logging.Logger] = None,
|
| 73 |
+
) -> AutoscalingState:
|
| 74 |
+
"""
|
| 75 |
+
The reconcile method computes InstanceUpdateEvents for the instance manager
|
| 76 |
+
by:
|
| 77 |
+
|
| 78 |
+
1. Reconciling the instance manager's instances with external states like
|
| 79 |
+
the cloud provider's, the ray cluster's states, the ray installer's results.
|
| 80 |
+
It performs "passive" status transitions for the instances (where the status
|
| 81 |
+
transition should only be reflecting the external states of the cloud provider
|
| 82 |
+
and the ray cluster, and should not be actively changing them)
|
| 83 |
+
|
| 84 |
+
2. Stepping the instances to the active states by computing instance status
|
| 85 |
+
transitions that are needed and updating the instance manager's state.
|
| 86 |
+
These transitions should be "active" where the transitions have side effects
|
| 87 |
+
(through InstanceStatusSubscriber) to the cloud provider and the ray cluster.
|
| 88 |
+
|
| 89 |
+
Args:
|
| 90 |
+
instance_manager: The instance manager to reconcile.
|
| 91 |
+
ray_cluster_resource_state: The ray cluster's resource state.
|
| 92 |
+
non_terminated_cloud_instances: The non-terminated cloud instances from
|
| 93 |
+
the cloud provider.
|
| 94 |
+
cloud_provider_errors: The errors from the cloud provider.
|
| 95 |
+
ray_install_errors: The errors from RayInstaller.
|
| 96 |
+
ray_stop_errors: The errors from RayStopper.
|
| 97 |
+
metrics_reporter: The metric reporter to report the autoscaler metrics.
|
| 98 |
+
_logger: The logger (for testing).
|
| 99 |
+
|
| 100 |
+
"""
|
| 101 |
+
cloud_provider_errors = cloud_provider_errors or []
|
| 102 |
+
ray_install_errors = ray_install_errors or []
|
| 103 |
+
ray_stop_errors = ray_stop_errors or []
|
| 104 |
+
|
| 105 |
+
autoscaling_state = AutoscalingState()
|
| 106 |
+
autoscaling_state.last_seen_cluster_resource_state_version = (
|
| 107 |
+
ray_cluster_resource_state.cluster_resource_state_version
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
Reconciler._sync_from(
|
| 111 |
+
instance_manager=instance_manager,
|
| 112 |
+
ray_nodes=ray_cluster_resource_state.node_states,
|
| 113 |
+
non_terminated_cloud_instances=non_terminated_cloud_instances,
|
| 114 |
+
cloud_provider_errors=cloud_provider_errors,
|
| 115 |
+
ray_install_errors=ray_install_errors,
|
| 116 |
+
ray_stop_errors=ray_stop_errors,
|
| 117 |
+
autoscaling_config=autoscaling_config,
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
Reconciler._step_next(
|
| 121 |
+
autoscaling_state=autoscaling_state,
|
| 122 |
+
instance_manager=instance_manager,
|
| 123 |
+
scheduler=scheduler,
|
| 124 |
+
cloud_provider=cloud_provider,
|
| 125 |
+
ray_cluster_resource_state=ray_cluster_resource_state,
|
| 126 |
+
non_terminated_cloud_instances=non_terminated_cloud_instances,
|
| 127 |
+
autoscaling_config=autoscaling_config,
|
| 128 |
+
_logger=_logger,
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
Reconciler._report_metrics(
|
| 132 |
+
instance_manager=instance_manager,
|
| 133 |
+
autoscaling_config=autoscaling_config,
|
| 134 |
+
metrics_reporter=metrics_reporter,
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
return autoscaling_state
|
| 138 |
+
|
| 139 |
+
@staticmethod
|
| 140 |
+
def _sync_from(
|
| 141 |
+
instance_manager: InstanceManager,
|
| 142 |
+
ray_nodes: List[NodeState],
|
| 143 |
+
non_terminated_cloud_instances: Dict[CloudInstanceId, CloudInstance],
|
| 144 |
+
cloud_provider_errors: List[CloudInstanceProviderError],
|
| 145 |
+
ray_install_errors: List[RayInstallError],
|
| 146 |
+
ray_stop_errors: List[RayStopError],
|
| 147 |
+
autoscaling_config: AutoscalingConfig,
|
| 148 |
+
):
|
| 149 |
+
"""
|
| 150 |
+
Reconcile the instance states of the instance manager from external states like
|
| 151 |
+
the cloud provider's, the ray cluster's states, the ray installer's results,
|
| 152 |
+
etc.
|
| 153 |
+
|
| 154 |
+
For each instance, we try to figure out if we need to transition the instance
|
| 155 |
+
status to a new status, and if so, what the new status should be.
|
| 156 |
+
|
| 157 |
+
These transitions should be purely "passive", meaning they should only be
|
| 158 |
+
reflecting the external states of the cloud provider and the ray cluster,
|
| 159 |
+
and should not be actively changing the states of the cloud provider or the ray
|
| 160 |
+
cluster.
|
| 161 |
+
|
| 162 |
+
More specifically, we will reconcile status transitions for:
|
| 163 |
+
1. QUEUED/REQUESTED -> ALLOCATED:
|
| 164 |
+
When a instance with launch request id (indicating a previous launch
|
| 165 |
+
request was made) could be assigned to an unassigned cloud instance
|
| 166 |
+
of the same instance type.
|
| 167 |
+
2. REQUESTED -> ALLOCATION_FAILED:
|
| 168 |
+
When there's an error from the cloud provider for launch failure so
|
| 169 |
+
that the instance becomes ALLOCATION_FAILED.
|
| 170 |
+
3. * -> RAY_RUNNING:
|
| 171 |
+
When a ray node on a cloud instance joins the ray cluster, we will
|
| 172 |
+
transition the instance to RAY_RUNNING.
|
| 173 |
+
4. * -> TERMINATED:
|
| 174 |
+
When the cloud instance is already terminated, we will transition the
|
| 175 |
+
instance to TERMINATED.
|
| 176 |
+
5. TERMINATING -> TERMINATION_FAILED:
|
| 177 |
+
When there's an error from the cloud provider for termination failure.
|
| 178 |
+
6. * -> RAY_STOPPED:
|
| 179 |
+
When ray was stopped on the cloud instance, we will transition the
|
| 180 |
+
instance to RAY_STOPPED.
|
| 181 |
+
7. * -> RAY_INSTALL_FAILED:
|
| 182 |
+
When there's an error from RayInstaller.
|
| 183 |
+
8. RAY_STOP_REQUESTED -> RAY_RUNNING:
|
| 184 |
+
When requested to stop ray, but failed to stop/drain the ray node
|
| 185 |
+
(e.g. idle termination drain rejected by the node).
|
| 186 |
+
|
| 187 |
+
Args:
|
| 188 |
+
instance_manager: The instance manager to reconcile.
|
| 189 |
+
ray_nodes: The ray cluster's states of ray nodes.
|
| 190 |
+
non_terminated_cloud_instances: The non-terminated cloud instances from
|
| 191 |
+
the cloud provider.
|
| 192 |
+
cloud_provider_errors: The errors from the cloud provider.
|
| 193 |
+
ray_install_errors: The errors from RayInstaller.
|
| 194 |
+
ray_stop_errors: The errors from RayStopper.
|
| 195 |
+
|
| 196 |
+
"""
|
| 197 |
+
|
| 198 |
+
# Handle 1 & 2 for cloud instance allocation.
|
| 199 |
+
Reconciler._handle_cloud_instance_allocation(
|
| 200 |
+
instance_manager,
|
| 201 |
+
non_terminated_cloud_instances,
|
| 202 |
+
cloud_provider_errors,
|
| 203 |
+
)
|
| 204 |
+
Reconciler._handle_cloud_instance_terminated(
|
| 205 |
+
instance_manager, non_terminated_cloud_instances
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
Reconciler._handle_cloud_instance_termination_errors(
|
| 209 |
+
instance_manager, cloud_provider_errors
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
Reconciler._handle_extra_cloud_instances(
|
| 213 |
+
instance_manager, non_terminated_cloud_instances, ray_nodes
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
Reconciler._handle_ray_status_transition(
|
| 217 |
+
instance_manager, ray_nodes, autoscaling_config
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
Reconciler._handle_ray_install_failed(instance_manager, ray_install_errors)
|
| 221 |
+
|
| 222 |
+
Reconciler._handle_ray_stop_failed(instance_manager, ray_stop_errors, ray_nodes)
|
| 223 |
+
|
| 224 |
+
@staticmethod
|
| 225 |
+
def _step_next(
|
| 226 |
+
autoscaling_state: AutoscalingState,
|
| 227 |
+
instance_manager: InstanceManager,
|
| 228 |
+
scheduler: IResourceScheduler,
|
| 229 |
+
cloud_provider: ICloudInstanceProvider,
|
| 230 |
+
ray_cluster_resource_state: ClusterResourceState,
|
| 231 |
+
non_terminated_cloud_instances: Dict[CloudInstanceId, CloudInstance],
|
| 232 |
+
autoscaling_config: AutoscalingConfig,
|
| 233 |
+
_logger: Optional[logging.Logger] = None,
|
| 234 |
+
):
|
| 235 |
+
"""
|
| 236 |
+
Step the reconciler to the next state by computing instance status transitions
|
| 237 |
+
that are needed and updating the instance manager's state.
|
| 238 |
+
|
| 239 |
+
Specifically, we will:
|
| 240 |
+
1. Shut down leak cloud instances
|
| 241 |
+
Leaked cloud instances that are not managed by the instance manager.
|
| 242 |
+
2. Terminating instances with ray stopped or ray install failure.
|
| 243 |
+
3. Scale down the cluster:
|
| 244 |
+
(* -> RAY_STOP_REQUESTED/TERMINATING)
|
| 245 |
+
b. Extra cloud due to max nodes config.
|
| 246 |
+
c. Cloud instances with outdated configs.
|
| 247 |
+
4. Scale up the cluster:
|
| 248 |
+
(new QUEUED)
|
| 249 |
+
Create new instances based on the IResourceScheduler's decision for
|
| 250 |
+
scaling up.
|
| 251 |
+
5. Request cloud provider to launch new instances.
|
| 252 |
+
(QUEUED -> REQUESTED)
|
| 253 |
+
6. Install ray
|
| 254 |
+
(ALLOCATED -> RAY_INSTALLING)
|
| 255 |
+
When ray could be installed and launched.
|
| 256 |
+
7. Handle any stuck instances with timeouts.
|
| 257 |
+
|
| 258 |
+
Args:
|
| 259 |
+
instance_manager: The instance manager to reconcile.
|
| 260 |
+
scheduler: The resource scheduler to make scaling decisions.
|
| 261 |
+
ray_cluster_resource_state: The ray cluster's resource state.
|
| 262 |
+
non_terminated_cloud_instances: The non-terminated cloud instances from
|
| 263 |
+
the cloud provider.
|
| 264 |
+
autoscaling_config: The autoscaling config.
|
| 265 |
+
_logger: The logger (for testing).
|
| 266 |
+
|
| 267 |
+
"""
|
| 268 |
+
|
| 269 |
+
Reconciler._handle_stuck_instances(
|
| 270 |
+
instance_manager=instance_manager,
|
| 271 |
+
reconcile_config=autoscaling_config.get_instance_reconcile_config(),
|
| 272 |
+
_logger=_logger or logger,
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
Reconciler._scale_cluster(
|
| 276 |
+
autoscaling_state=autoscaling_state,
|
| 277 |
+
instance_manager=instance_manager,
|
| 278 |
+
ray_state=ray_cluster_resource_state,
|
| 279 |
+
scheduler=scheduler,
|
| 280 |
+
autoscaling_config=autoscaling_config,
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
Reconciler._handle_instances_launch(
|
| 284 |
+
instance_manager=instance_manager, autoscaling_config=autoscaling_config
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
Reconciler._terminate_instances(instance_manager=instance_manager)
|
| 288 |
+
if not autoscaling_config.disable_node_updaters():
|
| 289 |
+
Reconciler._install_ray(
|
| 290 |
+
instance_manager=instance_manager,
|
| 291 |
+
non_terminated_cloud_instances=non_terminated_cloud_instances,
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
Reconciler._fill_autoscaling_state(
|
| 295 |
+
instance_manager=instance_manager, autoscaling_state=autoscaling_state
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
#######################################################
|
| 299 |
+
# Utility methods for reconciling instance states.
|
| 300 |
+
#######################################################
|
| 301 |
+
|
| 302 |
+
@staticmethod
|
| 303 |
+
def _handle_cloud_instance_allocation(
|
| 304 |
+
instance_manager: InstanceManager,
|
| 305 |
+
non_terminated_cloud_instances: Dict[CloudInstanceId, CloudInstance],
|
| 306 |
+
cloud_provider_errors: List[CloudInstanceProviderError],
|
| 307 |
+
):
|
| 308 |
+
im_instances, version = Reconciler._get_im_instances(instance_manager)
|
| 309 |
+
updates = {}
|
| 310 |
+
|
| 311 |
+
# Compute intermediate states.
|
| 312 |
+
|
| 313 |
+
instances_with_launch_requests: List[IMInstance] = []
|
| 314 |
+
for instance in im_instances:
|
| 315 |
+
if instance.status != IMInstance.REQUESTED:
|
| 316 |
+
continue
|
| 317 |
+
|
| 318 |
+
assert (
|
| 319 |
+
instance.launch_request_id
|
| 320 |
+
), "Instance in REQUESTED status should have launch_request_id set."
|
| 321 |
+
instances_with_launch_requests.append(instance)
|
| 322 |
+
|
| 323 |
+
assigned_cloud_instance_ids: Set[CloudInstanceId] = {
|
| 324 |
+
instance.cloud_instance_id
|
| 325 |
+
for instance in im_instances
|
| 326 |
+
if instance.cloud_instance_id
|
| 327 |
+
and instance.status
|
| 328 |
+
not in [IMInstance.TERMINATED, IMInstance.ALLOCATION_FAILED]
|
| 329 |
+
}
|
| 330 |
+
launch_errors: Dict[str, LaunchNodeError] = {
|
| 331 |
+
error.request_id: error
|
| 332 |
+
for error in cloud_provider_errors
|
| 333 |
+
if isinstance(error, LaunchNodeError)
|
| 334 |
+
}
|
| 335 |
+
unassigned_cloud_instances_by_type: Dict[
|
| 336 |
+
str, List[CloudInstance]
|
| 337 |
+
] = defaultdict(list)
|
| 338 |
+
|
| 339 |
+
for cloud_instance_id, cloud_instance in non_terminated_cloud_instances.items():
|
| 340 |
+
if cloud_instance_id not in assigned_cloud_instance_ids:
|
| 341 |
+
unassigned_cloud_instances_by_type[cloud_instance.node_type].append(
|
| 342 |
+
cloud_instance
|
| 343 |
+
)
|
| 344 |
+
|
| 345 |
+
# Sort the request instance by the increasing request time.
|
| 346 |
+
instances_with_launch_requests.sort(
|
| 347 |
+
key=lambda instance: InstanceUtil.get_status_transition_times_ns(
|
| 348 |
+
instance, IMInstance.REQUESTED
|
| 349 |
+
)
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
# For each instance, try to allocate or fail the allocation.
|
| 353 |
+
for instance in instances_with_launch_requests:
|
| 354 |
+
# Try allocate or fail with errors.
|
| 355 |
+
update_event = Reconciler._try_resolve_pending_allocation(
|
| 356 |
+
instance, unassigned_cloud_instances_by_type, launch_errors
|
| 357 |
+
)
|
| 358 |
+
if not update_event:
|
| 359 |
+
continue
|
| 360 |
+
|
| 361 |
+
updates[instance.instance_id] = update_event
|
| 362 |
+
|
| 363 |
+
# Update the instance manager for the events.
|
| 364 |
+
Reconciler._update_instance_manager(instance_manager, version, updates)
|
| 365 |
+
|
| 366 |
+
@staticmethod
|
| 367 |
+
def _try_resolve_pending_allocation(
|
| 368 |
+
im_instance: IMInstance,
|
| 369 |
+
unassigned_cloud_instances_by_type: Dict[str, List[CloudInstance]],
|
| 370 |
+
launch_errors: Dict[str, LaunchNodeError],
|
| 371 |
+
) -> Optional[IMInstanceUpdateEvent]:
|
| 372 |
+
"""
|
| 373 |
+
Allocate, or fail the cloud instance allocation for the instance.
|
| 374 |
+
|
| 375 |
+
Args:
|
| 376 |
+
im_instance: The instance to allocate or fail.
|
| 377 |
+
unassigned_cloud_instances_by_type: The unassigned cloud instances by type.
|
| 378 |
+
launch_errors: The launch errors from the cloud provider.
|
| 379 |
+
|
| 380 |
+
Returns:
|
| 381 |
+
Instance update to ALLOCATED: if there's a matching unassigned cloud
|
| 382 |
+
instance with the same type.
|
| 383 |
+
Instance update to ALLOCATION_FAILED: if the instance allocation failed
|
| 384 |
+
with errors.
|
| 385 |
+
None: if there's no update.
|
| 386 |
+
|
| 387 |
+
"""
|
| 388 |
+
unassigned_cloud_instance = None
|
| 389 |
+
|
| 390 |
+
# Try to allocate an unassigned cloud instance.
|
| 391 |
+
# TODO(rickyx): We could also look at the launch request id
|
| 392 |
+
# on the cloud node and the im instance later once all node providers
|
| 393 |
+
# support request id. For now, we only look at the instance type.
|
| 394 |
+
if len(unassigned_cloud_instances_by_type.get(im_instance.instance_type, [])):
|
| 395 |
+
unassigned_cloud_instance = unassigned_cloud_instances_by_type[
|
| 396 |
+
im_instance.instance_type
|
| 397 |
+
].pop()
|
| 398 |
+
|
| 399 |
+
if unassigned_cloud_instance:
|
| 400 |
+
return IMInstanceUpdateEvent(
|
| 401 |
+
instance_id=im_instance.instance_id,
|
| 402 |
+
new_instance_status=IMInstance.ALLOCATED,
|
| 403 |
+
cloud_instance_id=unassigned_cloud_instance.cloud_instance_id,
|
| 404 |
+
node_kind=unassigned_cloud_instance.node_kind,
|
| 405 |
+
instance_type=unassigned_cloud_instance.node_type,
|
| 406 |
+
details=(
|
| 407 |
+
"allocated unassigned cloud instance "
|
| 408 |
+
f"{unassigned_cloud_instance.cloud_instance_id}"
|
| 409 |
+
),
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
# If there's a launch error, transition to ALLOCATION_FAILED.
|
| 413 |
+
launch_error = launch_errors.get(im_instance.launch_request_id)
|
| 414 |
+
if launch_error and launch_error.node_type == im_instance.instance_type:
|
| 415 |
+
return IMInstanceUpdateEvent(
|
| 416 |
+
instance_id=im_instance.instance_id,
|
| 417 |
+
new_instance_status=IMInstance.ALLOCATION_FAILED,
|
| 418 |
+
details=f"launch failed with {str(launch_error)}",
|
| 419 |
+
)
|
| 420 |
+
# No update.
|
| 421 |
+
return None
|
| 422 |
+
|
| 423 |
+
@staticmethod
|
| 424 |
+
def _handle_ray_stop_failed(
|
| 425 |
+
instance_manager: InstanceManager,
|
| 426 |
+
ray_stop_errors: List[RayStopError],
|
| 427 |
+
ray_nodes: List[NodeState],
|
| 428 |
+
):
|
| 429 |
+
"""
|
| 430 |
+
The instance requested to stop ray, but failed to stop/drain the ray node.
|
| 431 |
+
E.g. connection errors, idle termination drain rejected by the node.
|
| 432 |
+
|
| 433 |
+
We will transition the instance back to RAY_RUNNING.
|
| 434 |
+
|
| 435 |
+
Args:
|
| 436 |
+
instance_manager: The instance manager to reconcile.
|
| 437 |
+
ray_stop_errors: The errors from RayStopper.
|
| 438 |
+
|
| 439 |
+
"""
|
| 440 |
+
instances, version = Reconciler._get_im_instances(instance_manager)
|
| 441 |
+
updates = {}
|
| 442 |
+
|
| 443 |
+
ray_stop_errors_by_instance_id = {
|
| 444 |
+
error.im_instance_id: error for error in ray_stop_errors
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
ray_nodes_by_ray_node_id = {binary_to_hex(n.node_id): n for n in ray_nodes}
|
| 448 |
+
|
| 449 |
+
ray_stop_requested_instances = {
|
| 450 |
+
instance.instance_id: instance
|
| 451 |
+
for instance in instances
|
| 452 |
+
if instance.status == IMInstance.RAY_STOP_REQUESTED
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
for instance_id, instance in ray_stop_requested_instances.items():
|
| 456 |
+
stop_error = ray_stop_errors_by_instance_id.get(instance_id)
|
| 457 |
+
if not stop_error:
|
| 458 |
+
continue
|
| 459 |
+
|
| 460 |
+
assert instance.node_id
|
| 461 |
+
ray_node = ray_nodes_by_ray_node_id.get(instance.node_id)
|
| 462 |
+
assert ray_node is not None and ray_node.status in [
|
| 463 |
+
NodeStatus.RUNNING,
|
| 464 |
+
NodeStatus.IDLE,
|
| 465 |
+
], (
|
| 466 |
+
"There should be a running ray node for instance with ray stop "
|
| 467 |
+
"requested failed."
|
| 468 |
+
)
|
| 469 |
+
|
| 470 |
+
updates[instance_id] = IMInstanceUpdateEvent(
|
| 471 |
+
instance_id=instance_id,
|
| 472 |
+
new_instance_status=IMInstance.RAY_RUNNING,
|
| 473 |
+
details="failed to stop/drain ray",
|
| 474 |
+
ray_node_id=instance.node_id,
|
| 475 |
+
)
|
| 476 |
+
|
| 477 |
+
Reconciler._update_instance_manager(instance_manager, version, updates)
|
| 478 |
+
|
| 479 |
+
@staticmethod
|
| 480 |
+
def _handle_ray_install_failed(
|
| 481 |
+
instance_manager: InstanceManager, ray_install_errors: List[RayInstallError]
|
| 482 |
+
):
|
| 483 |
+
|
| 484 |
+
instances, version = Reconciler._get_im_instances(instance_manager)
|
| 485 |
+
updates = {}
|
| 486 |
+
|
| 487 |
+
# Get all instances with RAY_INSTALLING status.
|
| 488 |
+
instances_with_ray_installing = {
|
| 489 |
+
instance.instance_id: instance
|
| 490 |
+
for instance in instances
|
| 491 |
+
if instance.status == IMInstance.RAY_INSTALLING
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
install_errors = {error.im_instance_id: error for error in ray_install_errors}
|
| 495 |
+
|
| 496 |
+
# For each instance with RAY_INSTALLING status, check if there's any
|
| 497 |
+
# install error.
|
| 498 |
+
for instance_id, instance in instances_with_ray_installing.items():
|
| 499 |
+
install_error = install_errors.get(instance_id)
|
| 500 |
+
if install_error:
|
| 501 |
+
updates[instance_id] = IMInstanceUpdateEvent(
|
| 502 |
+
instance_id=instance_id,
|
| 503 |
+
new_instance_status=IMInstance.RAY_INSTALL_FAILED,
|
| 504 |
+
details=(
|
| 505 |
+
f"failed to install ray with errors: {install_error.details}"
|
| 506 |
+
),
|
| 507 |
+
)
|
| 508 |
+
|
| 509 |
+
# Update the instance manager for the events.
|
| 510 |
+
Reconciler._update_instance_manager(instance_manager, version, updates)
|
| 511 |
+
|
| 512 |
+
@staticmethod
|
| 513 |
+
def _handle_cloud_instance_terminated(
|
| 514 |
+
instance_manager: InstanceManager,
|
| 515 |
+
non_terminated_cloud_instances: Dict[CloudInstanceId, CloudInstance],
|
| 516 |
+
):
|
| 517 |
+
"""
|
| 518 |
+
For any IM (instance manager) instance with a cloud node id, if the mapped
|
| 519 |
+
cloud instance is no longer running, transition the instance to TERMINATED.
|
| 520 |
+
|
| 521 |
+
Args:
|
| 522 |
+
instance_manager: The instance manager to reconcile.
|
| 523 |
+
non_terminated_cloud_instances: The non-terminated cloud instances from
|
| 524 |
+
the cloud provider.
|
| 525 |
+
"""
|
| 526 |
+
updates = {}
|
| 527 |
+
instances, version = Reconciler._get_im_instances(instance_manager)
|
| 528 |
+
|
| 529 |
+
non_terminated_instances_with_cloud_instance_assigned = {
|
| 530 |
+
instance.cloud_instance_id: instance
|
| 531 |
+
for instance in instances
|
| 532 |
+
if instance.cloud_instance_id and instance.status != IMInstance.TERMINATED
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
for (
|
| 536 |
+
cloud_instance_id,
|
| 537 |
+
instance,
|
| 538 |
+
) in non_terminated_instances_with_cloud_instance_assigned.items():
|
| 539 |
+
if cloud_instance_id in non_terminated_cloud_instances.keys():
|
| 540 |
+
# The cloud instance is still running.
|
| 541 |
+
continue
|
| 542 |
+
|
| 543 |
+
# The cloud instance is terminated.
|
| 544 |
+
updates[instance.instance_id] = IMInstanceUpdateEvent(
|
| 545 |
+
instance_id=instance.instance_id,
|
| 546 |
+
new_instance_status=IMInstance.TERMINATED,
|
| 547 |
+
details=f"cloud instance {cloud_instance_id} no longer found",
|
| 548 |
+
)
|
| 549 |
+
|
| 550 |
+
Reconciler._update_instance_manager(instance_manager, version, updates)
|
| 551 |
+
|
| 552 |
+
@staticmethod
|
| 553 |
+
def _handle_cloud_instance_termination_errors(
|
| 554 |
+
instance_manager: InstanceManager,
|
| 555 |
+
cloud_provider_errors: List[CloudInstanceProviderError],
|
| 556 |
+
):
|
| 557 |
+
"""
|
| 558 |
+
If any TERMINATING instances have termination errors, transition the instance to
|
| 559 |
+
TERMINATION_FAILED.
|
| 560 |
+
|
| 561 |
+
We will retry the termination for the TERMINATION_FAILED instances in the next
|
| 562 |
+
reconciler step.
|
| 563 |
+
|
| 564 |
+
Args:
|
| 565 |
+
instance_manager: The instance manager to reconcile.
|
| 566 |
+
cloud_provider_errors: The errors from the cloud provider.
|
| 567 |
+
|
| 568 |
+
"""
|
| 569 |
+
instances, version = Reconciler._get_im_instances(instance_manager)
|
| 570 |
+
updates = {}
|
| 571 |
+
|
| 572 |
+
termination_errors = {
|
| 573 |
+
error.cloud_instance_id: error
|
| 574 |
+
for error in cloud_provider_errors
|
| 575 |
+
if isinstance(error, TerminateNodeError)
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
terminating_instances_by_cloud_instance_id = {
|
| 579 |
+
instance.cloud_instance_id: instance
|
| 580 |
+
for instance in instances
|
| 581 |
+
if instance.status == IMInstance.TERMINATING
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
for cloud_instance_id, failure in termination_errors.items():
|
| 585 |
+
instance = terminating_instances_by_cloud_instance_id.get(cloud_instance_id)
|
| 586 |
+
if not instance:
|
| 587 |
+
# The instance is no longer in TERMINATING status.
|
| 588 |
+
continue
|
| 589 |
+
|
| 590 |
+
updates[instance.instance_id] = IMInstanceUpdateEvent(
|
| 591 |
+
instance_id=instance.instance_id,
|
| 592 |
+
new_instance_status=IMInstance.TERMINATION_FAILED,
|
| 593 |
+
details=f"termination failed: {str(failure)}",
|
| 594 |
+
)
|
| 595 |
+
|
| 596 |
+
Reconciler._update_instance_manager(instance_manager, version, updates)
|
| 597 |
+
|
| 598 |
+
@staticmethod
|
| 599 |
+
def _get_im_instances(
|
| 600 |
+
instance_manager: InstanceManager,
|
| 601 |
+
) -> Tuple[List[IMInstance], int]:
|
| 602 |
+
reply = instance_manager.get_instance_manager_state(
|
| 603 |
+
request=GetInstanceManagerStateRequest()
|
| 604 |
+
)
|
| 605 |
+
assert reply.status.code == StatusCode.OK
|
| 606 |
+
im_state = reply.state
|
| 607 |
+
return im_state.instances, im_state.version
|
| 608 |
+
|
| 609 |
+
@staticmethod
|
| 610 |
+
def _update_instance_manager(
|
| 611 |
+
instance_manager: InstanceManager,
|
| 612 |
+
version: int,
|
| 613 |
+
updates: Dict[str, IMInstanceUpdateEvent],
|
| 614 |
+
) -> None:
|
| 615 |
+
if not updates:
|
| 616 |
+
return
|
| 617 |
+
|
| 618 |
+
updates = list(updates.values()) or []
|
| 619 |
+
|
| 620 |
+
reply = instance_manager.update_instance_manager_state(
|
| 621 |
+
request=UpdateInstanceManagerStateRequest(
|
| 622 |
+
expected_version=version,
|
| 623 |
+
updates=updates,
|
| 624 |
+
)
|
| 625 |
+
)
|
| 626 |
+
# TODO: While it's possible that a version mismatch
|
| 627 |
+
# happens, or some other failures could happen. But given
|
| 628 |
+
# the current implementation:
|
| 629 |
+
# 1. There's only 1 writer (the reconciler) for updating the instance
|
| 630 |
+
# manager states, so there shouldn't be version mismatch.
|
| 631 |
+
# 2. Any failures in one reconciler step should be caught at a higher
|
| 632 |
+
# level and be retried in the next reconciler step. If the IM
|
| 633 |
+
# fails to be updated, we don't have sufficient info to handle it
|
| 634 |
+
# here.
|
| 635 |
+
assert (
|
| 636 |
+
reply.status.code == StatusCode.OK
|
| 637 |
+
), f"Failed to update instance manager: {reply}"
|
| 638 |
+
|
| 639 |
+
@staticmethod
|
| 640 |
+
def _handle_ray_status_transition(
|
| 641 |
+
instance_manager: InstanceManager,
|
| 642 |
+
ray_nodes: List[NodeState],
|
| 643 |
+
autoscaling_config: AutoscalingConfig,
|
| 644 |
+
):
|
| 645 |
+
"""
|
| 646 |
+
Handle the ray status transition for the instance manager.
|
| 647 |
+
|
| 648 |
+
If a new ray node running on the instance, transition it to RAY_RUNNING.
|
| 649 |
+
If a ray node stopped, transition it to RAY_STOPPED.
|
| 650 |
+
If a ray node is draining, transition it to RAY_STOPPING.
|
| 651 |
+
|
| 652 |
+
Args:
|
| 653 |
+
instance_manager: The instance manager to reconcile.
|
| 654 |
+
ray_nodes: The ray cluster's states of ray nodes.
|
| 655 |
+
"""
|
| 656 |
+
instances, version = Reconciler._get_im_instances(instance_manager)
|
| 657 |
+
updates = {}
|
| 658 |
+
|
| 659 |
+
im_instances_by_cloud_instance_id = {
|
| 660 |
+
instance.cloud_instance_id: instance
|
| 661 |
+
for instance in instances
|
| 662 |
+
if instance.cloud_instance_id
|
| 663 |
+
and instance.status
|
| 664 |
+
not in [IMInstance.TERMINATED, IMInstance.ALLOCATION_FAILED]
|
| 665 |
+
}
|
| 666 |
+
im_instances_by_ray_node_id = {
|
| 667 |
+
instance.node_id: instance for instance in instances if instance.node_id
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
for ray_node in ray_nodes:
|
| 671 |
+
im_instance = None
|
| 672 |
+
ray_node_id = binary_to_hex(ray_node.node_id)
|
| 673 |
+
if ray_node_id in im_instances_by_ray_node_id:
|
| 674 |
+
im_instance = im_instances_by_ray_node_id[ray_node_id]
|
| 675 |
+
else:
|
| 676 |
+
if autoscaling_config.provider == Provider.READ_ONLY:
|
| 677 |
+
# We will use the node id as the cloud instance id for read-only
|
| 678 |
+
# provider.
|
| 679 |
+
im_instance = im_instances_by_cloud_instance_id[ray_node_id]
|
| 680 |
+
elif ray_node.instance_id:
|
| 681 |
+
im_instance = im_instances_by_cloud_instance_id[
|
| 682 |
+
ray_node.instance_id
|
| 683 |
+
]
|
| 684 |
+
else:
|
| 685 |
+
# This should only happen to a ray node that's not managed by us.
|
| 686 |
+
logger.warning(
|
| 687 |
+
f"Ray node {ray_node_id} has no instance id. "
|
| 688 |
+
"This only happens to a ray node not managed by autoscaler. "
|
| 689 |
+
"If not, please file a bug at "
|
| 690 |
+
"https://github.com/ray-project/ray"
|
| 691 |
+
)
|
| 692 |
+
continue
|
| 693 |
+
|
| 694 |
+
assert im_instance is not None, (
|
| 695 |
+
f"Ray node {ray_node_id} has no matching "
|
| 696 |
+
f"instance with cloud instance id={ray_node.instance_id}. We should "
|
| 697 |
+
"not see a ray node with cloud instance id not found in IM since "
|
| 698 |
+
"we have reconciled all cloud instances, and ray nodes by now."
|
| 699 |
+
)
|
| 700 |
+
|
| 701 |
+
reconciled_im_status = Reconciler._reconciled_im_status_from_ray_status(
|
| 702 |
+
ray_node.status, im_instance.status
|
| 703 |
+
)
|
| 704 |
+
|
| 705 |
+
if reconciled_im_status != im_instance.status:
|
| 706 |
+
updates[ray_node_id] = IMInstanceUpdateEvent(
|
| 707 |
+
instance_id=im_instance.instance_id,
|
| 708 |
+
new_instance_status=reconciled_im_status,
|
| 709 |
+
details=(
|
| 710 |
+
f"ray node {ray_node_id} is "
|
| 711 |
+
f"{NodeStatus.Name(ray_node.status)}"
|
| 712 |
+
),
|
| 713 |
+
ray_node_id=ray_node_id,
|
| 714 |
+
)
|
| 715 |
+
|
| 716 |
+
Reconciler._update_instance_manager(instance_manager, version, updates)
|
| 717 |
+
|
| 718 |
+
@staticmethod
|
| 719 |
+
def _reconciled_im_status_from_ray_status(
|
| 720 |
+
ray_status: NodeStatus, cur_im_status: IMInstance.InstanceStatus
|
| 721 |
+
) -> "IMInstance.InstanceStatus":
|
| 722 |
+
"""
|
| 723 |
+
Reconcile the instance status from the ray node status.
|
| 724 |
+
Args:
|
| 725 |
+
ray_status: the current ray node status.
|
| 726 |
+
cur_im_status: the current IM instance status.
|
| 727 |
+
Returns:
|
| 728 |
+
The reconciled IM instance status
|
| 729 |
+
|
| 730 |
+
Raises:
|
| 731 |
+
ValueError: If the ray status is unknown.
|
| 732 |
+
"""
|
| 733 |
+
reconciled_im_status = None
|
| 734 |
+
if ray_status in [NodeStatus.RUNNING, NodeStatus.IDLE]:
|
| 735 |
+
reconciled_im_status = IMInstance.RAY_RUNNING
|
| 736 |
+
elif ray_status == NodeStatus.DEAD:
|
| 737 |
+
reconciled_im_status = IMInstance.RAY_STOPPED
|
| 738 |
+
elif ray_status == NodeStatus.DRAINING:
|
| 739 |
+
reconciled_im_status = IMInstance.RAY_STOPPING
|
| 740 |
+
else:
|
| 741 |
+
raise ValueError(f"Unknown ray status: {ray_status}")
|
| 742 |
+
|
| 743 |
+
if (
|
| 744 |
+
cur_im_status == reconciled_im_status
|
| 745 |
+
or cur_im_status
|
| 746 |
+
in InstanceUtil.get_reachable_statuses(reconciled_im_status)
|
| 747 |
+
):
|
| 748 |
+
# No need to reconcile if the instance is already in the reconciled status
|
| 749 |
+
# or has already transitioned beyond it.
|
| 750 |
+
return cur_im_status
|
| 751 |
+
|
| 752 |
+
return reconciled_im_status
|
| 753 |
+
|
| 754 |
+
@staticmethod
|
| 755 |
+
def _handle_instances_launch(
|
| 756 |
+
instance_manager: InstanceManager, autoscaling_config: AutoscalingConfig
|
| 757 |
+
):
|
| 758 |
+
|
| 759 |
+
instances, version = Reconciler._get_im_instances(instance_manager)
|
| 760 |
+
|
| 761 |
+
queued_instances = []
|
| 762 |
+
requested_instances = []
|
| 763 |
+
running_instances = []
|
| 764 |
+
|
| 765 |
+
for instance in instances:
|
| 766 |
+
if instance.status == IMInstance.QUEUED:
|
| 767 |
+
queued_instances.append(instance)
|
| 768 |
+
elif instance.status == IMInstance.REQUESTED:
|
| 769 |
+
requested_instances.append(instance)
|
| 770 |
+
elif instance.status == IMInstance.RAY_RUNNING:
|
| 771 |
+
running_instances.append(instance)
|
| 772 |
+
|
| 773 |
+
if not queued_instances:
|
| 774 |
+
# No QUEUED instances
|
| 775 |
+
return
|
| 776 |
+
|
| 777 |
+
to_launch = Reconciler._compute_to_launch(
|
| 778 |
+
queued_instances,
|
| 779 |
+
requested_instances,
|
| 780 |
+
running_instances,
|
| 781 |
+
autoscaling_config.get_upscaling_speed(),
|
| 782 |
+
autoscaling_config.get_max_concurrent_launches(),
|
| 783 |
+
)
|
| 784 |
+
|
| 785 |
+
# Transition the instances to REQUESTED for instance launcher to
|
| 786 |
+
# launch them.
|
| 787 |
+
updates = {}
|
| 788 |
+
new_launch_request_id = str(uuid.uuid4())
|
| 789 |
+
for instance_type, instances in to_launch.items():
|
| 790 |
+
for instance in instances:
|
| 791 |
+
# Reuse launch request id for any QUEUED instances that have been
|
| 792 |
+
# requested before due to retry.
|
| 793 |
+
launch_request_id = (
|
| 794 |
+
new_launch_request_id
|
| 795 |
+
if len(instance.launch_request_id) == 0
|
| 796 |
+
else instance.launch_request_id
|
| 797 |
+
)
|
| 798 |
+
updates[instance.instance_id] = IMInstanceUpdateEvent(
|
| 799 |
+
instance_id=instance.instance_id,
|
| 800 |
+
new_instance_status=IMInstance.REQUESTED,
|
| 801 |
+
launch_request_id=launch_request_id,
|
| 802 |
+
instance_type=instance_type,
|
| 803 |
+
details=(
|
| 804 |
+
f"requested to launch {instance_type} with request id "
|
| 805 |
+
f"{launch_request_id}"
|
| 806 |
+
),
|
| 807 |
+
)
|
| 808 |
+
|
| 809 |
+
Reconciler._update_instance_manager(instance_manager, version, updates)
|
| 810 |
+
|
| 811 |
+
@staticmethod
|
| 812 |
+
def _compute_to_launch(
|
| 813 |
+
queued_instances: List[IMInstance],
|
| 814 |
+
requested_instances: List[IMInstance],
|
| 815 |
+
running_instances: List[IMInstance],
|
| 816 |
+
upscaling_speed: float,
|
| 817 |
+
max_concurrent_launches: int,
|
| 818 |
+
) -> Dict[NodeType, List[IMInstance]]:
|
| 819 |
+
def _group_by_type(instances):
|
| 820 |
+
instances_by_type = defaultdict(list)
|
| 821 |
+
for instance in instances:
|
| 822 |
+
instances_by_type[instance.instance_type].append(instance)
|
| 823 |
+
return instances_by_type
|
| 824 |
+
|
| 825 |
+
# Sort the instances by the time they were queued.
|
| 826 |
+
def _sort_by_earliest_queued(instance: IMInstance) -> List[int]:
|
| 827 |
+
queue_times = InstanceUtil.get_status_transition_times_ns(
|
| 828 |
+
instance, IMInstance.QUEUED
|
| 829 |
+
)
|
| 830 |
+
return sorted(queue_times)
|
| 831 |
+
|
| 832 |
+
queued_instances_by_type = _group_by_type(queued_instances)
|
| 833 |
+
running_instances_by_type = _group_by_type(running_instances)
|
| 834 |
+
|
| 835 |
+
total_num_requested_to_launch = len(requested_instances)
|
| 836 |
+
all_to_launch: Dict[NodeType : List[IMInstance]] = defaultdict(list)
|
| 837 |
+
|
| 838 |
+
for (
|
| 839 |
+
instance_type,
|
| 840 |
+
queued_instances_for_type,
|
| 841 |
+
) in queued_instances_by_type.items():
|
| 842 |
+
running_instances_for_type = running_instances_by_type.get(
|
| 843 |
+
instance_type, []
|
| 844 |
+
)
|
| 845 |
+
|
| 846 |
+
# Enforce the max allowed pending nodes based on current running nodes
|
| 847 |
+
num_desired_to_upscale = max(
|
| 848 |
+
1,
|
| 849 |
+
math.ceil(upscaling_speed * max(len(running_instances_for_type), 1)),
|
| 850 |
+
)
|
| 851 |
+
|
| 852 |
+
# Enforce global limit, at most we can launch `max_concurrent_launches`
|
| 853 |
+
num_to_launch = min(
|
| 854 |
+
max_concurrent_launches - total_num_requested_to_launch,
|
| 855 |
+
num_desired_to_upscale,
|
| 856 |
+
)
|
| 857 |
+
|
| 858 |
+
# Cap both ends 0 <= num_to_launch <= num_queued
|
| 859 |
+
num_to_launch = max(0, num_to_launch)
|
| 860 |
+
num_to_launch = min(len(queued_instances_for_type), num_to_launch)
|
| 861 |
+
|
| 862 |
+
to_launch = sorted(queued_instances_for_type, key=_sort_by_earliest_queued)[
|
| 863 |
+
:num_to_launch
|
| 864 |
+
]
|
| 865 |
+
|
| 866 |
+
all_to_launch[instance_type].extend(to_launch)
|
| 867 |
+
total_num_requested_to_launch += num_to_launch
|
| 868 |
+
|
| 869 |
+
return all_to_launch
|
| 870 |
+
|
| 871 |
+
@staticmethod
|
| 872 |
+
def _handle_stuck_instances(
|
| 873 |
+
instance_manager: InstanceManager,
|
| 874 |
+
reconcile_config: InstanceReconcileConfig,
|
| 875 |
+
_logger: logging.Logger,
|
| 876 |
+
):
|
| 877 |
+
"""
|
| 878 |
+
Handle stuck instances with timeouts.
|
| 879 |
+
|
| 880 |
+
Instances could be stuck in the following status and needs to be updated:
|
| 881 |
+
- REQUESTED: cloud provider is slow/fails to launch instances.
|
| 882 |
+
- ALLOCATED: ray fails to be started on the instance.
|
| 883 |
+
- RAY_INSTALLING: ray fails to be installed on the instance.
|
| 884 |
+
- TERMINATING: cloud provider is slow/fails to terminate instances.
|
| 885 |
+
|
| 886 |
+
Instances could be in the following status which could be unbounded or
|
| 887 |
+
transient, and we don't have a timeout mechanism to handle them. We would
|
| 888 |
+
warn if they are stuck for too long:
|
| 889 |
+
- RAY_STOPPING: ray taking time to drain.
|
| 890 |
+
- QUEUED: cloud provider is slow to launch instances, resulting in long
|
| 891 |
+
queue.
|
| 892 |
+
|
| 893 |
+
Reconciler should handle below statuses, if not, could be slow
|
| 894 |
+
reconcilation loop or a bug:
|
| 895 |
+
- RAY_INSTALL_FAILED
|
| 896 |
+
- RAY_STOPPED
|
| 897 |
+
- TERMINATION_FAILED
|
| 898 |
+
|
| 899 |
+
|
| 900 |
+
Args:
|
| 901 |
+
instance_manager: The instance manager to reconcile.
|
| 902 |
+
reconcile_config: The instance reconcile config.
|
| 903 |
+
_logger: The logger to log the warning messages. It's used for testing.
|
| 904 |
+
|
| 905 |
+
"""
|
| 906 |
+
instances, version = Reconciler._get_im_instances(instance_manager)
|
| 907 |
+
|
| 908 |
+
instances_by_status = defaultdict(list)
|
| 909 |
+
for instance in instances:
|
| 910 |
+
instances_by_status[instance.status].append(instance)
|
| 911 |
+
|
| 912 |
+
im_updates = {}
|
| 913 |
+
|
| 914 |
+
# Fail or retry the cloud instance allocation if it's stuck
|
| 915 |
+
# in the REQUESTED state.
|
| 916 |
+
for instance in instances_by_status[IMInstance.REQUESTED]:
|
| 917 |
+
update = Reconciler._handle_stuck_requested_instance(
|
| 918 |
+
instance,
|
| 919 |
+
reconcile_config.request_status_timeout_s,
|
| 920 |
+
reconcile_config.max_num_retry_request_to_allocate,
|
| 921 |
+
)
|
| 922 |
+
if update:
|
| 923 |
+
im_updates[instance.instance_id] = update
|
| 924 |
+
|
| 925 |
+
# Leaked ALLOCATED instances should be terminated.
|
| 926 |
+
# This usually happens when ray fails to be started on the instance, so
|
| 927 |
+
# it's unable to be RAY_RUNNING after a long time.
|
| 928 |
+
for instance in instances_by_status[IMInstance.ALLOCATED]:
|
| 929 |
+
assert (
|
| 930 |
+
instance.cloud_instance_id
|
| 931 |
+
), "cloud instance id should be set on ALLOCATED instance"
|
| 932 |
+
update = Reconciler._handle_stuck_instance(
|
| 933 |
+
instance,
|
| 934 |
+
reconcile_config.allocate_status_timeout_s,
|
| 935 |
+
new_status=IMInstance.TERMINATING,
|
| 936 |
+
cloud_instance_id=instance.cloud_instance_id,
|
| 937 |
+
)
|
| 938 |
+
if update:
|
| 939 |
+
im_updates[instance.instance_id] = update
|
| 940 |
+
|
| 941 |
+
# Fail the installation if it's stuck in RAY_INSTALLING for too long.
|
| 942 |
+
# If RAY_INSTALLING is stuck for too long, it's likely that the instance
|
| 943 |
+
# is not able to install ray, so we should also fail the installation.
|
| 944 |
+
for instance in instances_by_status[IMInstance.RAY_INSTALLING]:
|
| 945 |
+
update = Reconciler._handle_stuck_instance(
|
| 946 |
+
instance,
|
| 947 |
+
reconcile_config.ray_install_status_timeout_s,
|
| 948 |
+
new_status=IMInstance.RAY_INSTALL_FAILED,
|
| 949 |
+
)
|
| 950 |
+
if update:
|
| 951 |
+
im_updates[instance.instance_id] = update
|
| 952 |
+
|
| 953 |
+
# If we tried to terminate the instance, but it doesn't terminate (disappear
|
| 954 |
+
# from the cloud provider) after a long time, we fail the termination.
|
| 955 |
+
# This will trigger another attempt to terminate the instance.
|
| 956 |
+
for instance in instances_by_status[IMInstance.TERMINATING]:
|
| 957 |
+
update = Reconciler._handle_stuck_instance(
|
| 958 |
+
instance,
|
| 959 |
+
reconcile_config.terminating_status_timeout_s,
|
| 960 |
+
new_status=IMInstance.TERMINATION_FAILED,
|
| 961 |
+
)
|
| 962 |
+
if update:
|
| 963 |
+
im_updates[instance.instance_id] = update
|
| 964 |
+
|
| 965 |
+
# If we tried to stop ray on the instance, but it doesn't stop after a long
|
| 966 |
+
# time, we will transition it back to RAY_RUNNING as the stop/drain somehow
|
| 967 |
+
# failed. If it had succeed, we should have transitioned it to RAY_STOPPING
|
| 968 |
+
# or RAY_STOPPED.
|
| 969 |
+
for instance in instances_by_status[IMInstance.RAY_STOP_REQUESTED]:
|
| 970 |
+
update = Reconciler._handle_stuck_instance(
|
| 971 |
+
instance,
|
| 972 |
+
reconcile_config.ray_stop_requested_status_timeout_s,
|
| 973 |
+
new_status=IMInstance.RAY_RUNNING,
|
| 974 |
+
ray_node_id=instance.node_id,
|
| 975 |
+
)
|
| 976 |
+
if update:
|
| 977 |
+
im_updates[instance.instance_id] = update
|
| 978 |
+
|
| 979 |
+
# These statues could be unbounded or transient, and we don't have a timeout
|
| 980 |
+
# mechanism to handle them. We only warn if they are stuck for too long.
|
| 981 |
+
for status in [
|
| 982 |
+
# Ray taking time to drain. We could also have a timeout when Drain protocol
|
| 983 |
+
# supports timeout.
|
| 984 |
+
IMInstance.RAY_STOPPING,
|
| 985 |
+
# These should just be transient, we will terminate instances with this
|
| 986 |
+
# status in the next reconciler step.
|
| 987 |
+
IMInstance.RAY_INSTALL_FAILED,
|
| 988 |
+
IMInstance.RAY_STOPPED,
|
| 989 |
+
IMInstance.TERMINATION_FAILED,
|
| 990 |
+
# Instances could be in the QUEUED status for a long time if the cloud
|
| 991 |
+
# provider is slow to launch instances.
|
| 992 |
+
IMInstance.QUEUED,
|
| 993 |
+
]:
|
| 994 |
+
Reconciler._warn_stuck_instances(
|
| 995 |
+
instances_by_status[status],
|
| 996 |
+
status=status,
|
| 997 |
+
warn_interval_s=reconcile_config.transient_status_warn_interval_s,
|
| 998 |
+
logger=_logger,
|
| 999 |
+
)
|
| 1000 |
+
|
| 1001 |
+
Reconciler._update_instance_manager(instance_manager, version, im_updates)
|
| 1002 |
+
|
| 1003 |
+
@staticmethod
|
| 1004 |
+
def _warn_stuck_instances(
|
| 1005 |
+
instances: List[IMInstance],
|
| 1006 |
+
status: IMInstance.InstanceStatus,
|
| 1007 |
+
warn_interval_s: int,
|
| 1008 |
+
logger: logging.Logger,
|
| 1009 |
+
):
|
| 1010 |
+
"""Warn if any instance is stuck in a transient/unbounded status for too
|
| 1011 |
+
long.
|
| 1012 |
+
"""
|
| 1013 |
+
for instance in instances:
|
| 1014 |
+
status_times_ns = InstanceUtil.get_status_transition_times_ns(
|
| 1015 |
+
instance, select_instance_status=status
|
| 1016 |
+
)
|
| 1017 |
+
assert len(status_times_ns) >= 1
|
| 1018 |
+
status_time_ns = sorted(status_times_ns)[-1]
|
| 1019 |
+
|
| 1020 |
+
if time.time_ns() - status_time_ns > warn_interval_s * 1e9:
|
| 1021 |
+
logger.warning(
|
| 1022 |
+
"Instance {}({}) is stuck in {} for {} seconds.".format(
|
| 1023 |
+
instance.instance_id,
|
| 1024 |
+
IMInstance.InstanceStatus.Name(instance.status),
|
| 1025 |
+
IMInstance.InstanceStatus.Name(status),
|
| 1026 |
+
(time.time_ns() - status_time_ns) // 1e9,
|
| 1027 |
+
)
|
| 1028 |
+
)
|
| 1029 |
+
|
| 1030 |
+
@staticmethod
|
| 1031 |
+
def _is_head_node_running(instance_manager: InstanceManager) -> bool:
|
| 1032 |
+
"""
|
| 1033 |
+
Check if the head node is running and ready.
|
| 1034 |
+
|
| 1035 |
+
If we scale up the cluster before head node is running,
|
| 1036 |
+
it would cause issues when launching the worker nodes.
|
| 1037 |
+
|
| 1038 |
+
There are corner cases when the GCS is up (so the ray cluster resource
|
| 1039 |
+
state is retrievable from the GCS), but the head node's raylet is not
|
| 1040 |
+
running so the head node is missing from the reported nodes. This happens
|
| 1041 |
+
when the head node is still starting up, or the raylet is not running
|
| 1042 |
+
due to some issues, and this would yield false.
|
| 1043 |
+
|
| 1044 |
+
Args:
|
| 1045 |
+
instance_manager: The instance manager to reconcile.
|
| 1046 |
+
|
| 1047 |
+
Returns:
|
| 1048 |
+
True if the head node is running and ready, False otherwise.
|
| 1049 |
+
"""
|
| 1050 |
+
|
| 1051 |
+
im_instances, _ = Reconciler._get_im_instances(instance_manager)
|
| 1052 |
+
|
| 1053 |
+
for instance in im_instances:
|
| 1054 |
+
if instance.node_kind == NodeKind.HEAD:
|
| 1055 |
+
if instance.status == IMInstance.RAY_RUNNING:
|
| 1056 |
+
return True
|
| 1057 |
+
return False
|
| 1058 |
+
|
| 1059 |
+
@staticmethod
|
| 1060 |
+
def _scale_cluster(
|
| 1061 |
+
autoscaling_state: AutoscalingState,
|
| 1062 |
+
instance_manager: InstanceManager,
|
| 1063 |
+
ray_state: ClusterResourceState,
|
| 1064 |
+
scheduler: IResourceScheduler,
|
| 1065 |
+
autoscaling_config: AutoscalingConfig,
|
| 1066 |
+
) -> None:
|
| 1067 |
+
"""
|
| 1068 |
+
Scale the cluster based on the resource state and the resource scheduler's
|
| 1069 |
+
decision:
|
| 1070 |
+
|
| 1071 |
+
- It launches new instances if needed.
|
| 1072 |
+
- It terminates extra ray nodes if they should be shut down (preemption
|
| 1073 |
+
or idle termination)
|
| 1074 |
+
|
| 1075 |
+
Args:
|
| 1076 |
+
autoscaling_state: The autoscaling state to reconcile.
|
| 1077 |
+
instance_manager: The instance manager to reconcile.
|
| 1078 |
+
ray_state: The ray cluster's resource state.
|
| 1079 |
+
scheduler: The resource scheduler to make scaling decisions.
|
| 1080 |
+
autoscaling_config: The autoscaling config.
|
| 1081 |
+
|
| 1082 |
+
"""
|
| 1083 |
+
|
| 1084 |
+
# Get the current instance states.
|
| 1085 |
+
im_instances, version = Reconciler._get_im_instances(instance_manager)
|
| 1086 |
+
|
| 1087 |
+
im_instances_by_instance_id = {
|
| 1088 |
+
i.instance_id: i for i in im_instances if i.instance_id
|
| 1089 |
+
}
|
| 1090 |
+
autoscaler_instances = []
|
| 1091 |
+
ray_nodes_by_id = {
|
| 1092 |
+
binary_to_hex(node.node_id): node for node in ray_state.node_states
|
| 1093 |
+
}
|
| 1094 |
+
|
| 1095 |
+
for im_instance in im_instances:
|
| 1096 |
+
ray_node = ray_nodes_by_id.get(im_instance.node_id)
|
| 1097 |
+
autoscaler_instances.append(
|
| 1098 |
+
AutoscalerInstance(
|
| 1099 |
+
ray_node=ray_node,
|
| 1100 |
+
im_instance=im_instance,
|
| 1101 |
+
cloud_instance_id=(
|
| 1102 |
+
im_instance.cloud_instance_id
|
| 1103 |
+
if im_instance.cloud_instance_id
|
| 1104 |
+
else None
|
| 1105 |
+
),
|
| 1106 |
+
)
|
| 1107 |
+
)
|
| 1108 |
+
|
| 1109 |
+
# TODO(rickyx): We should probably name it as "Planner" or "Scaler"
|
| 1110 |
+
# or "ClusterScaler"
|
| 1111 |
+
sched_request = SchedulingRequest(
|
| 1112 |
+
node_type_configs=autoscaling_config.get_node_type_configs(),
|
| 1113 |
+
max_num_nodes=autoscaling_config.get_max_num_nodes(),
|
| 1114 |
+
resource_requests=ray_state.pending_resource_requests,
|
| 1115 |
+
gang_resource_requests=ray_state.pending_gang_resource_requests,
|
| 1116 |
+
cluster_resource_constraints=ray_state.cluster_resource_constraints,
|
| 1117 |
+
current_instances=autoscaler_instances,
|
| 1118 |
+
idle_timeout_s=autoscaling_config.get_idle_timeout_s(),
|
| 1119 |
+
disable_launch_config_check=(
|
| 1120 |
+
autoscaling_config.disable_launch_config_check()
|
| 1121 |
+
),
|
| 1122 |
+
)
|
| 1123 |
+
|
| 1124 |
+
# Ask scheduler for updates to the cluster shape.
|
| 1125 |
+
reply = scheduler.schedule(sched_request)
|
| 1126 |
+
|
| 1127 |
+
# Populate the autoscaling state.
|
| 1128 |
+
autoscaling_state.infeasible_resource_requests.extend(
|
| 1129 |
+
reply.infeasible_resource_requests
|
| 1130 |
+
)
|
| 1131 |
+
autoscaling_state.infeasible_gang_resource_requests.extend(
|
| 1132 |
+
reply.infeasible_gang_resource_requests
|
| 1133 |
+
)
|
| 1134 |
+
autoscaling_state.infeasible_cluster_resource_constraints.extend(
|
| 1135 |
+
reply.infeasible_cluster_resource_constraints
|
| 1136 |
+
)
|
| 1137 |
+
|
| 1138 |
+
if not Reconciler._is_head_node_running(instance_manager):
|
| 1139 |
+
# We shouldn't be scaling the cluster until the head node is ready.
|
| 1140 |
+
# This could happen when the head node (i.e. the raylet) is still
|
| 1141 |
+
# pending registration even though GCS is up.
|
| 1142 |
+
# We will wait until the head node is running and ready to avoid
|
| 1143 |
+
# scaling the cluster from min worker nodes constraint.
|
| 1144 |
+
return
|
| 1145 |
+
|
| 1146 |
+
if autoscaling_config.provider == Provider.READ_ONLY:
|
| 1147 |
+
# We shouldn't be scaling the cluster if the provider is read-only.
|
| 1148 |
+
return
|
| 1149 |
+
|
| 1150 |
+
# Scale the clusters if needed.
|
| 1151 |
+
to_launch = reply.to_launch
|
| 1152 |
+
to_terminate = reply.to_terminate
|
| 1153 |
+
updates = {}
|
| 1154 |
+
# Add terminating instances.
|
| 1155 |
+
for terminate_request in to_terminate:
|
| 1156 |
+
instance_id = terminate_request.instance_id
|
| 1157 |
+
if terminate_request.instance_status == IMInstance.ALLOCATED:
|
| 1158 |
+
# The instance is not yet running, so we can't request to stop/drain Ray.
|
| 1159 |
+
# Therefore, we can skip the RAY_STOP_REQUESTED state and directly terminate the node.
|
| 1160 |
+
im_instance_to_terminate = im_instances_by_instance_id[instance_id]
|
| 1161 |
+
updates[terminate_request.instance_id] = IMInstanceUpdateEvent(
|
| 1162 |
+
instance_id=instance_id,
|
| 1163 |
+
new_instance_status=IMInstance.TERMINATING,
|
| 1164 |
+
cloud_instance_id=im_instance_to_terminate.cloud_instance_id,
|
| 1165 |
+
termination_request=terminate_request,
|
| 1166 |
+
details=f"terminating ray: {terminate_request.details}",
|
| 1167 |
+
)
|
| 1168 |
+
else:
|
| 1169 |
+
updates[terminate_request.instance_id] = IMInstanceUpdateEvent(
|
| 1170 |
+
instance_id=instance_id,
|
| 1171 |
+
new_instance_status=IMInstance.RAY_STOP_REQUESTED,
|
| 1172 |
+
termination_request=terminate_request,
|
| 1173 |
+
details=f"draining ray: {terminate_request.details}",
|
| 1174 |
+
)
|
| 1175 |
+
|
| 1176 |
+
# Add new instances.
|
| 1177 |
+
for launch_request in to_launch:
|
| 1178 |
+
for _ in range(launch_request.count):
|
| 1179 |
+
instance_id = InstanceUtil.random_instance_id()
|
| 1180 |
+
updates[instance_id] = IMInstanceUpdateEvent(
|
| 1181 |
+
instance_id=instance_id,
|
| 1182 |
+
new_instance_status=IMInstance.QUEUED,
|
| 1183 |
+
instance_type=launch_request.instance_type,
|
| 1184 |
+
upsert=True,
|
| 1185 |
+
details=(
|
| 1186 |
+
f"queuing new instance of {launch_request.instance_type} "
|
| 1187 |
+
"from scheduler"
|
| 1188 |
+
),
|
| 1189 |
+
)
|
| 1190 |
+
|
| 1191 |
+
Reconciler._update_instance_manager(instance_manager, version, updates)
|
| 1192 |
+
|
| 1193 |
+
@staticmethod
|
| 1194 |
+
def _terminate_instances(instance_manager: InstanceManager):
|
| 1195 |
+
"""
|
| 1196 |
+
Terminate instances with the below statuses:
|
| 1197 |
+
- RAY_STOPPED: ray was stopped on the cloud instance.
|
| 1198 |
+
- RAY_INSTALL_FAILED: ray installation failed on the cloud instance,
|
| 1199 |
+
we will not retry.
|
| 1200 |
+
- TERMINATION_FAILED: cloud provider failed to terminate the instance
|
| 1201 |
+
or timeout for termination happened, we will retry again.
|
| 1202 |
+
|
| 1203 |
+
Args:
|
| 1204 |
+
instance_manager: The instance manager to reconcile.
|
| 1205 |
+
"""
|
| 1206 |
+
|
| 1207 |
+
im_instances, version = Reconciler._get_im_instances(instance_manager)
|
| 1208 |
+
updates = {}
|
| 1209 |
+
for instance in im_instances:
|
| 1210 |
+
if instance.status not in [
|
| 1211 |
+
IMInstance.RAY_STOPPED,
|
| 1212 |
+
IMInstance.RAY_INSTALL_FAILED,
|
| 1213 |
+
IMInstance.TERMINATION_FAILED,
|
| 1214 |
+
]:
|
| 1215 |
+
continue
|
| 1216 |
+
|
| 1217 |
+
# Terminate the instance.
|
| 1218 |
+
updates[instance.instance_id] = IMInstanceUpdateEvent(
|
| 1219 |
+
instance_id=instance.instance_id,
|
| 1220 |
+
new_instance_status=IMInstance.TERMINATING,
|
| 1221 |
+
cloud_instance_id=instance.cloud_instance_id,
|
| 1222 |
+
details="terminating instance from "
|
| 1223 |
+
f"{IMInstance.InstanceStatus.Name(instance.status)}",
|
| 1224 |
+
)
|
| 1225 |
+
|
| 1226 |
+
Reconciler._update_instance_manager(instance_manager, version, updates)
|
| 1227 |
+
|
| 1228 |
+
@staticmethod
|
| 1229 |
+
def _install_ray(
|
| 1230 |
+
instance_manager: InstanceManager,
|
| 1231 |
+
non_terminated_cloud_instances: Dict[CloudInstanceId, CloudInstance],
|
| 1232 |
+
) -> None:
|
| 1233 |
+
"""
|
| 1234 |
+
Install ray on the allocated instances when it's ready (cloud instance
|
| 1235 |
+
should be running)
|
| 1236 |
+
|
| 1237 |
+
This is needed if ray installation needs to be performed by
|
| 1238 |
+
the instance manager.
|
| 1239 |
+
|
| 1240 |
+
Args:
|
| 1241 |
+
instance_manager: The instance manager to reconcile.
|
| 1242 |
+
"""
|
| 1243 |
+
im_instances, version = Reconciler._get_im_instances(instance_manager)
|
| 1244 |
+
updates = {}
|
| 1245 |
+
for instance in im_instances:
|
| 1246 |
+
if instance.status != IMInstance.ALLOCATED:
|
| 1247 |
+
continue
|
| 1248 |
+
|
| 1249 |
+
if instance.node_kind == NodeKind.HEAD:
|
| 1250 |
+
# Skip head node.
|
| 1251 |
+
continue
|
| 1252 |
+
|
| 1253 |
+
cloud_instance = non_terminated_cloud_instances.get(
|
| 1254 |
+
instance.cloud_instance_id
|
| 1255 |
+
)
|
| 1256 |
+
|
| 1257 |
+
assert cloud_instance, (
|
| 1258 |
+
f"Cloud instance {instance.cloud_instance_id} is not found "
|
| 1259 |
+
"in non_terminated_cloud_instances."
|
| 1260 |
+
)
|
| 1261 |
+
|
| 1262 |
+
if not cloud_instance.is_running:
|
| 1263 |
+
# It might still be pending (e.g. setting up ssh)
|
| 1264 |
+
continue
|
| 1265 |
+
|
| 1266 |
+
# Install ray on the running cloud instance
|
| 1267 |
+
updates[instance.instance_id] = IMInstanceUpdateEvent(
|
| 1268 |
+
instance_id=instance.instance_id,
|
| 1269 |
+
new_instance_status=IMInstance.RAY_INSTALLING,
|
| 1270 |
+
details="installing ray",
|
| 1271 |
+
)
|
| 1272 |
+
|
| 1273 |
+
Reconciler._update_instance_manager(instance_manager, version, updates)
|
| 1274 |
+
|
| 1275 |
+
@staticmethod
|
| 1276 |
+
def _fill_autoscaling_state(
|
| 1277 |
+
instance_manager: InstanceManager,
|
| 1278 |
+
autoscaling_state: AutoscalingState,
|
| 1279 |
+
) -> None:
|
| 1280 |
+
|
| 1281 |
+
# Use the IM instance version for the autoscaler_state_version
|
| 1282 |
+
instances, version = Reconciler._get_im_instances(instance_manager)
|
| 1283 |
+
autoscaling_state.autoscaler_state_version = version
|
| 1284 |
+
|
| 1285 |
+
# Group instances by status
|
| 1286 |
+
instances_by_status = defaultdict(list)
|
| 1287 |
+
for instance in instances:
|
| 1288 |
+
instances_by_status[instance.status].append(instance)
|
| 1289 |
+
|
| 1290 |
+
# Pending instance requests
|
| 1291 |
+
instances_by_launch_request = defaultdict(list)
|
| 1292 |
+
queued_instances = []
|
| 1293 |
+
for instance in (
|
| 1294 |
+
instances_by_status[IMInstance.REQUESTED]
|
| 1295 |
+
+ instances_by_status[IMInstance.QUEUED]
|
| 1296 |
+
):
|
| 1297 |
+
if instance.launch_request_id:
|
| 1298 |
+
instances_by_launch_request[instance.launch_request_id].append(instance)
|
| 1299 |
+
else:
|
| 1300 |
+
queued_instances.append(instance)
|
| 1301 |
+
|
| 1302 |
+
for _, instances in instances_by_launch_request.items():
|
| 1303 |
+
num_instances_by_type = defaultdict(int)
|
| 1304 |
+
for instance in instances:
|
| 1305 |
+
num_instances_by_type[instance.instance_type] += 1
|
| 1306 |
+
|
| 1307 |
+
# All instances with same request id should have the same
|
| 1308 |
+
# request time.
|
| 1309 |
+
request_update = InstanceUtil.get_last_status_transition(
|
| 1310 |
+
instances[0], IMInstance.REQUESTED
|
| 1311 |
+
)
|
| 1312 |
+
request_time_ns = request_update.timestamp_ns if request_update else 0
|
| 1313 |
+
|
| 1314 |
+
for instance_type, count in num_instances_by_type.items():
|
| 1315 |
+
autoscaling_state.pending_instance_requests.append(
|
| 1316 |
+
PendingInstanceRequest(
|
| 1317 |
+
ray_node_type_name=instance_type,
|
| 1318 |
+
count=int(count),
|
| 1319 |
+
request_ts=int(request_time_ns // 1e9),
|
| 1320 |
+
)
|
| 1321 |
+
)
|
| 1322 |
+
|
| 1323 |
+
# Pending instances
|
| 1324 |
+
for instance in (
|
| 1325 |
+
instances_by_status[IMInstance.ALLOCATED]
|
| 1326 |
+
+ instances_by_status[IMInstance.RAY_INSTALLING]
|
| 1327 |
+
):
|
| 1328 |
+
|
| 1329 |
+
status_history = sorted(
|
| 1330 |
+
instance.status_history, key=lambda x: x.timestamp_ns, reverse=True
|
| 1331 |
+
)
|
| 1332 |
+
autoscaling_state.pending_instances.append(
|
| 1333 |
+
PendingInstance(
|
| 1334 |
+
instance_id=instance.instance_id,
|
| 1335 |
+
ray_node_type_name=instance.instance_type,
|
| 1336 |
+
details=status_history[0].details,
|
| 1337 |
+
)
|
| 1338 |
+
)
|
| 1339 |
+
|
| 1340 |
+
# Failed instance requests
|
| 1341 |
+
for instance in instances_by_status[IMInstance.ALLOCATION_FAILED]:
|
| 1342 |
+
request_status_update = InstanceUtil.get_last_status_transition(
|
| 1343 |
+
instance, IMInstance.REQUESTED
|
| 1344 |
+
)
|
| 1345 |
+
failed_status_update = InstanceUtil.get_last_status_transition(
|
| 1346 |
+
instance, IMInstance.ALLOCATION_FAILED
|
| 1347 |
+
)
|
| 1348 |
+
failed_time = (
|
| 1349 |
+
failed_status_update.timestamp_ns if failed_status_update else 0
|
| 1350 |
+
)
|
| 1351 |
+
request_time = (
|
| 1352 |
+
request_status_update.timestamp_ns if request_status_update else 0
|
| 1353 |
+
)
|
| 1354 |
+
autoscaling_state.failed_instance_requests.append(
|
| 1355 |
+
FailedInstanceRequest(
|
| 1356 |
+
ray_node_type_name=instance.instance_type,
|
| 1357 |
+
start_ts=int(request_time // 1e9),
|
| 1358 |
+
failed_ts=int(
|
| 1359 |
+
failed_time // 1e9,
|
| 1360 |
+
),
|
| 1361 |
+
reason=failed_status_update.details,
|
| 1362 |
+
count=1,
|
| 1363 |
+
)
|
| 1364 |
+
)
|
| 1365 |
+
|
| 1366 |
+
@staticmethod
|
| 1367 |
+
def _handle_stuck_requested_instance(
|
| 1368 |
+
instance: IMInstance, timeout_s: int, max_num_retry_request_to_allocate: int
|
| 1369 |
+
) -> Optional[IMInstanceUpdateEvent]:
|
| 1370 |
+
"""
|
| 1371 |
+
Fail the cloud instance allocation if it's stuck in the REQUESTED state.
|
| 1372 |
+
|
| 1373 |
+
Args:
|
| 1374 |
+
instance: The instance to handle.
|
| 1375 |
+
timeout_s: The timeout in seconds.
|
| 1376 |
+
max_num_retry_request_to_allocate: The maximum number of times an instance
|
| 1377 |
+
could be requested to allocate.
|
| 1378 |
+
|
| 1379 |
+
Returns:
|
| 1380 |
+
Instance update to ALLOCATION_FAILED: if the instance allocation failed
|
| 1381 |
+
with errors.
|
| 1382 |
+
None: if there's no update.
|
| 1383 |
+
|
| 1384 |
+
"""
|
| 1385 |
+
if not InstanceUtil.has_timeout(instance, timeout_s):
|
| 1386 |
+
# Not timeout yet, be patient.
|
| 1387 |
+
return None
|
| 1388 |
+
|
| 1389 |
+
all_request_times_ns = sorted(
|
| 1390 |
+
InstanceUtil.get_status_transition_times_ns(
|
| 1391 |
+
instance, select_instance_status=IMInstance.REQUESTED
|
| 1392 |
+
)
|
| 1393 |
+
)
|
| 1394 |
+
|
| 1395 |
+
# Fail the allocation if we have tried too many times.
|
| 1396 |
+
if len(all_request_times_ns) > max_num_retry_request_to_allocate:
|
| 1397 |
+
return IMInstanceUpdateEvent(
|
| 1398 |
+
instance_id=instance.instance_id,
|
| 1399 |
+
new_instance_status=IMInstance.ALLOCATION_FAILED,
|
| 1400 |
+
details=(
|
| 1401 |
+
"failed to allocate cloud instance after "
|
| 1402 |
+
f"{len(all_request_times_ns)} attempts > "
|
| 1403 |
+
f"max_num_retry_request_to_allocate={max_num_retry_request_to_allocate}" # noqa
|
| 1404 |
+
),
|
| 1405 |
+
)
|
| 1406 |
+
|
| 1407 |
+
# Retry the allocation if we could by transitioning to QUEUED again.
|
| 1408 |
+
return IMInstanceUpdateEvent(
|
| 1409 |
+
instance_id=instance.instance_id,
|
| 1410 |
+
new_instance_status=IMInstance.QUEUED,
|
| 1411 |
+
details=f"queue again to launch after timeout={timeout_s}s",
|
| 1412 |
+
)
|
| 1413 |
+
|
| 1414 |
+
@staticmethod
|
| 1415 |
+
def _handle_stuck_instance(
|
| 1416 |
+
instance: IMInstance,
|
| 1417 |
+
timeout_s: int,
|
| 1418 |
+
new_status: IMInstance.InstanceStatus,
|
| 1419 |
+
**update_kwargs: Dict,
|
| 1420 |
+
) -> Optional[IMInstanceUpdateEvent]:
|
| 1421 |
+
"""
|
| 1422 |
+
Fail the instance if it's stuck in the status for too long.
|
| 1423 |
+
|
| 1424 |
+
Args:
|
| 1425 |
+
instance: The instance to handle.
|
| 1426 |
+
timeout_s: The timeout in seconds.
|
| 1427 |
+
new_status: The new status to transition to.
|
| 1428 |
+
update_kwargs: The update kwargs for InstanceUpdateEvent
|
| 1429 |
+
|
| 1430 |
+
Returns:
|
| 1431 |
+
Instance update to the new status: if the instance is stuck in the status
|
| 1432 |
+
for too long.
|
| 1433 |
+
None: if there's no update.
|
| 1434 |
+
|
| 1435 |
+
"""
|
| 1436 |
+
if not InstanceUtil.has_timeout(instance, timeout_s):
|
| 1437 |
+
# Not timeout yet, be patient.
|
| 1438 |
+
return None
|
| 1439 |
+
|
| 1440 |
+
return IMInstanceUpdateEvent(
|
| 1441 |
+
instance_id=instance.instance_id,
|
| 1442 |
+
new_instance_status=new_status,
|
| 1443 |
+
details=f"timeout={timeout_s}s at status "
|
| 1444 |
+
f"{IMInstance.InstanceStatus.Name(instance.status)}",
|
| 1445 |
+
**update_kwargs,
|
| 1446 |
+
)
|
| 1447 |
+
|
| 1448 |
+
@staticmethod
|
| 1449 |
+
def _handle_extra_cloud_instances(
|
| 1450 |
+
instance_manager: InstanceManager,
|
| 1451 |
+
non_terminated_cloud_instances: Dict[CloudInstanceId, CloudInstance],
|
| 1452 |
+
ray_nodes: List[NodeState],
|
| 1453 |
+
):
|
| 1454 |
+
"""
|
| 1455 |
+
For extra cloud instances (i.e. cloud instances that are non terminated as
|
| 1456 |
+
returned by cloud provider, but not managed by the instance manager), we
|
| 1457 |
+
will create new IM instances with ALLOCATED status.
|
| 1458 |
+
|
| 1459 |
+
Such instances could either be:
|
| 1460 |
+
1. Leaked instances that are incorrectly started by the cloud instance
|
| 1461 |
+
provider, and they would be terminated eventually if they fail to
|
| 1462 |
+
transition to RAY_RUNNING by stuck instances reconciliation, or they
|
| 1463 |
+
would join the ray cluster and be terminated when the cluster scales down.
|
| 1464 |
+
2. Instances that are started by the cloud instance provider intentionally
|
| 1465 |
+
but not yet discovered by the instance manager. This could happen for
|
| 1466 |
+
a. Head node that's started before the autoscaler.
|
| 1467 |
+
b. Worker nodes that's started by the cloud provider upon users'
|
| 1468 |
+
actions: i.e. KubeRay scaling up the cluster with ray cluster config
|
| 1469 |
+
change.
|
| 1470 |
+
3. Ray nodes with cloud instance id not in the cloud provider. This could
|
| 1471 |
+
happen if there's delay in the Ray's state (i.e. cloud instance already
|
| 1472 |
+
terminated, but the ray node is still not dead yet).
|
| 1473 |
+
|
| 1474 |
+
Args:
|
| 1475 |
+
instance_manager: The instance manager to reconcile.
|
| 1476 |
+
non_terminated_cloud_instances: The non-terminated cloud instances from
|
| 1477 |
+
the cloud provider.
|
| 1478 |
+
ray_nodes: The ray cluster's states of ray nodes.
|
| 1479 |
+
"""
|
| 1480 |
+
Reconciler._handle_extra_cloud_instances_from_ray_nodes(
|
| 1481 |
+
instance_manager, ray_nodes
|
| 1482 |
+
)
|
| 1483 |
+
Reconciler._handle_extra_cloud_instances_from_cloud_provider(
|
| 1484 |
+
instance_manager, non_terminated_cloud_instances
|
| 1485 |
+
)
|
| 1486 |
+
|
| 1487 |
+
@staticmethod
|
| 1488 |
+
def _handle_extra_cloud_instances_from_cloud_provider(
|
| 1489 |
+
instance_manager: InstanceManager,
|
| 1490 |
+
non_terminated_cloud_instances: Dict[CloudInstanceId, CloudInstance],
|
| 1491 |
+
):
|
| 1492 |
+
"""
|
| 1493 |
+
For extra cloud instances that are not managed by the instance manager but
|
| 1494 |
+
are running in the cloud provider, we will create new IM instances with
|
| 1495 |
+
ALLOCATED status.
|
| 1496 |
+
|
| 1497 |
+
Args:
|
| 1498 |
+
instance_manager: The instance manager to reconcile.
|
| 1499 |
+
non_terminated_cloud_instances: The non-terminated cloud instances from
|
| 1500 |
+
the cloud provider.
|
| 1501 |
+
"""
|
| 1502 |
+
updates = {}
|
| 1503 |
+
|
| 1504 |
+
instances, version = Reconciler._get_im_instances(instance_manager)
|
| 1505 |
+
cloud_instance_ids_managed_by_im = {
|
| 1506 |
+
instance.cloud_instance_id
|
| 1507 |
+
for instance in instances
|
| 1508 |
+
if instance.cloud_instance_id
|
| 1509 |
+
and instance.status
|
| 1510 |
+
not in [IMInstance.TERMINATED, IMInstance.ALLOCATION_FAILED]
|
| 1511 |
+
}
|
| 1512 |
+
|
| 1513 |
+
# Find the extra cloud instances that are not managed by the instance manager.
|
| 1514 |
+
for cloud_instance_id, cloud_instance in non_terminated_cloud_instances.items():
|
| 1515 |
+
if cloud_instance_id in cloud_instance_ids_managed_by_im:
|
| 1516 |
+
continue
|
| 1517 |
+
updates[cloud_instance_id] = IMInstanceUpdateEvent(
|
| 1518 |
+
instance_id=InstanceUtil.random_instance_id(), # Assign a new id.
|
| 1519 |
+
cloud_instance_id=cloud_instance_id,
|
| 1520 |
+
new_instance_status=IMInstance.ALLOCATED,
|
| 1521 |
+
node_kind=cloud_instance.node_kind,
|
| 1522 |
+
instance_type=cloud_instance.node_type,
|
| 1523 |
+
details=(
|
| 1524 |
+
"allocated unmanaged cloud instance :"
|
| 1525 |
+
f"{cloud_instance.cloud_instance_id} "
|
| 1526 |
+
f"({NodeKind.Name(cloud_instance.node_kind)}) from cloud provider"
|
| 1527 |
+
),
|
| 1528 |
+
upsert=True,
|
| 1529 |
+
)
|
| 1530 |
+
Reconciler._update_instance_manager(instance_manager, version, updates)
|
| 1531 |
+
|
| 1532 |
+
@staticmethod
|
| 1533 |
+
def _handle_extra_cloud_instances_from_ray_nodes(
|
| 1534 |
+
instance_manager: InstanceManager, ray_nodes: List[NodeState]
|
| 1535 |
+
):
|
| 1536 |
+
"""
|
| 1537 |
+
For extra cloud instances reported by Ray but not managed by the instance
|
| 1538 |
+
manager, we will create new IM instances with ALLOCATED status.
|
| 1539 |
+
|
| 1540 |
+
Args:
|
| 1541 |
+
instance_manager: The instance manager to reconcile.
|
| 1542 |
+
ray_nodes: The ray cluster's states of ray nodes.
|
| 1543 |
+
"""
|
| 1544 |
+
updates = {}
|
| 1545 |
+
|
| 1546 |
+
instances, version = Reconciler._get_im_instances(instance_manager)
|
| 1547 |
+
cloud_instance_ids_managed_by_im = {
|
| 1548 |
+
instance.cloud_instance_id
|
| 1549 |
+
for instance in instances
|
| 1550 |
+
if instance.cloud_instance_id
|
| 1551 |
+
and not instance.node_id
|
| 1552 |
+
and instance.status
|
| 1553 |
+
not in [IMInstance.TERMINATED, IMInstance.ALLOCATION_FAILED]
|
| 1554 |
+
}
|
| 1555 |
+
ray_node_ids_managed_by_im = {
|
| 1556 |
+
instance.node_id for instance in instances if instance.node_id
|
| 1557 |
+
}
|
| 1558 |
+
|
| 1559 |
+
for ray_node in ray_nodes:
|
| 1560 |
+
if not ray_node.instance_id:
|
| 1561 |
+
continue
|
| 1562 |
+
|
| 1563 |
+
ray_node_id = binary_to_hex(ray_node.node_id)
|
| 1564 |
+
if ray_node_id in ray_node_ids_managed_by_im:
|
| 1565 |
+
continue
|
| 1566 |
+
|
| 1567 |
+
cloud_instance_id = ray_node.instance_id
|
| 1568 |
+
if cloud_instance_id in cloud_instance_ids_managed_by_im:
|
| 1569 |
+
continue
|
| 1570 |
+
|
| 1571 |
+
is_head = is_head_node(ray_node)
|
| 1572 |
+
updates[ray_node_id] = IMInstanceUpdateEvent(
|
| 1573 |
+
instance_id=InstanceUtil.random_instance_id(), # Assign a new id.
|
| 1574 |
+
cloud_instance_id=cloud_instance_id,
|
| 1575 |
+
new_instance_status=IMInstance.ALLOCATED,
|
| 1576 |
+
node_kind=NodeKind.HEAD if is_head else NodeKind.WORKER,
|
| 1577 |
+
ray_node_id=ray_node_id,
|
| 1578 |
+
instance_type=ray_node.ray_node_type_name,
|
| 1579 |
+
details=(
|
| 1580 |
+
"allocated unmanaged worker cloud instance from ray node: "
|
| 1581 |
+
f"{ray_node_id}"
|
| 1582 |
+
),
|
| 1583 |
+
upsert=True,
|
| 1584 |
+
)
|
| 1585 |
+
|
| 1586 |
+
Reconciler._update_instance_manager(instance_manager, version, updates)
|
| 1587 |
+
|
| 1588 |
+
@staticmethod
|
| 1589 |
+
def _report_metrics(
|
| 1590 |
+
instance_manager: InstanceManager,
|
| 1591 |
+
autoscaling_config: AutoscalingConfig,
|
| 1592 |
+
metrics_reporter: Optional[AutoscalerMetricsReporter] = None,
|
| 1593 |
+
):
|
| 1594 |
+
if not metrics_reporter:
|
| 1595 |
+
return
|
| 1596 |
+
|
| 1597 |
+
instances, _ = Reconciler._get_im_instances(instance_manager)
|
| 1598 |
+
node_type_configs = autoscaling_config.get_node_type_configs()
|
| 1599 |
+
|
| 1600 |
+
metrics_reporter.report_instances(instances, node_type_configs)
|
| 1601 |
+
metrics_reporter.report_resources(instances, node_type_configs)
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/storage.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import copy
|
| 2 |
+
from abc import ABCMeta, abstractmethod
|
| 3 |
+
from collections import defaultdict, namedtuple
|
| 4 |
+
from threading import Lock
|
| 5 |
+
from typing import Dict, List, Optional, Tuple
|
| 6 |
+
|
| 7 |
+
StoreStatus = namedtuple("StoreStatus", ["success", "version"])
|
| 8 |
+
VersionedValue = namedtuple("VersionedValue", ["value", "version"])
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Storage(metaclass=ABCMeta):
|
| 12 |
+
"""Interface for a storage backend that stores the state of nodes in the cluster.
|
| 13 |
+
|
| 14 |
+
The storage is thread-safe.
|
| 15 |
+
|
| 16 |
+
The storage is versioned, which means that each successful stage change to the
|
| 17 |
+
storage will bump the version number. The version number can be used to
|
| 18 |
+
implement optimistic concurrency control.
|
| 19 |
+
|
| 20 |
+
Each entry in the storage table is also versioned. The version number of an entry
|
| 21 |
+
is the last version number when the entry is updated.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
@abstractmethod
|
| 25 |
+
def batch_update(
|
| 26 |
+
self,
|
| 27 |
+
table: str,
|
| 28 |
+
mutation: Optional[Dict[str, str]] = None,
|
| 29 |
+
deletion: Optional[List[str]] = None,
|
| 30 |
+
expected_storage_version: Optional[int] = None,
|
| 31 |
+
) -> StoreStatus:
|
| 32 |
+
"""Batch update the storage table. This method is atomic.
|
| 33 |
+
|
| 34 |
+
Args:
|
| 35 |
+
table: The name of the table.
|
| 36 |
+
mutation: A dictionary of key-value pairs to be updated.
|
| 37 |
+
deletion: A list of keys to be deleted.
|
| 38 |
+
expected_storage_version: The expected storage version. The
|
| 39 |
+
update will fail if the version does not match the
|
| 40 |
+
current storage version.
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
StoreStatus: A tuple of (success, version). If the update is
|
| 44 |
+
successful, returns (True, new_version).
|
| 45 |
+
Otherwise, returns (False, current_version).
|
| 46 |
+
"""
|
| 47 |
+
raise NotImplementedError("batch_update() has to be implemented")
|
| 48 |
+
|
| 49 |
+
@abstractmethod
|
| 50 |
+
def update(
|
| 51 |
+
self,
|
| 52 |
+
table: str,
|
| 53 |
+
key: str,
|
| 54 |
+
value: str,
|
| 55 |
+
expected_entry_version: Optional[int] = None,
|
| 56 |
+
insert_only: bool = False,
|
| 57 |
+
) -> StoreStatus:
|
| 58 |
+
"""Update a single entry in the storage table.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
table: The name of the table.
|
| 62 |
+
key: The key of the entry.
|
| 63 |
+
value: The value of the entry.
|
| 64 |
+
expected_entry_version: The expected version of the entry.
|
| 65 |
+
The update will fail if the version does not match the current
|
| 66 |
+
version of the entry.
|
| 67 |
+
insert_only: If True, the update will
|
| 68 |
+
fail if the entry already exists.
|
| 69 |
+
Returns:
|
| 70 |
+
StoreStatus: A tuple of (success, version). If the update is
|
| 71 |
+
successful, returns (True, new_version). Otherwise,
|
| 72 |
+
returns (False, current_version).
|
| 73 |
+
"""
|
| 74 |
+
raise NotImplementedError("update() has to be implemented")
|
| 75 |
+
|
| 76 |
+
@abstractmethod
|
| 77 |
+
def get_all(self, table: str) -> Tuple[Dict[str, Tuple[str, int]], int]:
|
| 78 |
+
raise NotImplementedError("get_all() has to be implemented")
|
| 79 |
+
|
| 80 |
+
@abstractmethod
|
| 81 |
+
def get(
|
| 82 |
+
self, table: str, keys: List[str]
|
| 83 |
+
) -> Tuple[Dict[str, Tuple[str, int]], int]:
|
| 84 |
+
"""Get a list of entries from the storage table.
|
| 85 |
+
|
| 86 |
+
Args:
|
| 87 |
+
table: The name of the table.
|
| 88 |
+
keys: A list of keys to be retrieved. If the list is empty,
|
| 89 |
+
all entries in the table will be returned.
|
| 90 |
+
|
| 91 |
+
Returns:
|
| 92 |
+
Tuple[Dict[str, VersionedValue], int]: A tuple of
|
| 93 |
+
(entries, storage_version). The entries is a dictionary of
|
| 94 |
+
(key, (value, entry_version)) pairs. The entry_version is the
|
| 95 |
+
version of the entry when it was last updated. The
|
| 96 |
+
storage_version is the current storage version.
|
| 97 |
+
"""
|
| 98 |
+
raise NotImplementedError("get() has to be implemented")
|
| 99 |
+
|
| 100 |
+
@abstractmethod
|
| 101 |
+
def get_version(self) -> int:
|
| 102 |
+
"""Get the current storage version.
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
int: The current storage version.
|
| 106 |
+
"""
|
| 107 |
+
raise NotImplementedError("get_version() has to be implemented")
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class InMemoryStorage(Storage):
|
| 111 |
+
"""An in-memory implementation of the Storage interface. This implementation
|
| 112 |
+
is not durable"""
|
| 113 |
+
|
| 114 |
+
def __init__(self):
|
| 115 |
+
self._version = 0
|
| 116 |
+
self._tables = defaultdict(dict)
|
| 117 |
+
self._lock = Lock()
|
| 118 |
+
|
| 119 |
+
def batch_update(
|
| 120 |
+
self,
|
| 121 |
+
table: str,
|
| 122 |
+
mutation: Dict[str, str] = None,
|
| 123 |
+
deletion: List[str] = None,
|
| 124 |
+
expected_version: Optional[int] = None,
|
| 125 |
+
) -> StoreStatus:
|
| 126 |
+
mutation = mutation if mutation else {}
|
| 127 |
+
deletion = deletion if deletion else []
|
| 128 |
+
with self._lock:
|
| 129 |
+
if expected_version is not None and expected_version != self._version:
|
| 130 |
+
return StoreStatus(False, self._version)
|
| 131 |
+
self._version += 1
|
| 132 |
+
key_value_pairs_with_version = {
|
| 133 |
+
key: VersionedValue(value, self._version)
|
| 134 |
+
for key, value in mutation.items()
|
| 135 |
+
}
|
| 136 |
+
self._tables[table].update(key_value_pairs_with_version)
|
| 137 |
+
for deleted_key in deletion:
|
| 138 |
+
self._tables[table].pop(deleted_key, None)
|
| 139 |
+
return StoreStatus(True, self._version)
|
| 140 |
+
|
| 141 |
+
def update(
|
| 142 |
+
self,
|
| 143 |
+
table: str,
|
| 144 |
+
key: str,
|
| 145 |
+
value: str,
|
| 146 |
+
expected_entry_version: Optional[int] = None,
|
| 147 |
+
expected_storage_version: Optional[int] = None,
|
| 148 |
+
insert_only: bool = False,
|
| 149 |
+
) -> StoreStatus:
|
| 150 |
+
with self._lock:
|
| 151 |
+
if (
|
| 152 |
+
expected_storage_version is not None
|
| 153 |
+
and expected_storage_version != self._version
|
| 154 |
+
):
|
| 155 |
+
return StoreStatus(False, self._version)
|
| 156 |
+
if insert_only and key in self._tables[table]:
|
| 157 |
+
return StoreStatus(False, self._version)
|
| 158 |
+
_, version = self._tables[table].get(key, (None, -1))
|
| 159 |
+
if expected_entry_version is not None and expected_entry_version != version:
|
| 160 |
+
return StoreStatus(False, self._version)
|
| 161 |
+
self._version += 1
|
| 162 |
+
self._tables[table][key] = VersionedValue(value, self._version)
|
| 163 |
+
return StoreStatus(True, self._version)
|
| 164 |
+
|
| 165 |
+
def get_all(self, table: str) -> Tuple[Dict[str, VersionedValue], int]:
|
| 166 |
+
with self._lock:
|
| 167 |
+
return (copy.deepcopy(self._tables[table]), self._version)
|
| 168 |
+
|
| 169 |
+
def get(self, table: str, keys: List[str]) -> Tuple[Dict[str, VersionedValue], int]:
|
| 170 |
+
if not keys:
|
| 171 |
+
return self.get_all(table)
|
| 172 |
+
with self._lock:
|
| 173 |
+
result = {}
|
| 174 |
+
for key in keys:
|
| 175 |
+
if key in self._tables.get(table, {}):
|
| 176 |
+
result[key] = self._tables[table][key]
|
| 177 |
+
return StoreStatus(result, self._version)
|
| 178 |
+
|
| 179 |
+
def get_version(self) -> int:
|
| 180 |
+
return self._version
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/__init__.py
ADDED
|
File without changes
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (225 Bytes). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/__pycache__/cloud_instance_updater.cpython-312.pyc
ADDED
|
Binary file (4.34 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/__pycache__/ray_stopper.cpython-312.pyc
ADDED
|
Binary file (6.73 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/__pycache__/threaded_ray_installer.cpython-312.pyc
ADDED
|
Binary file (4.96 kB). View file
|
|
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/cloud_instance_updater.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import uuid
|
| 3 |
+
from collections import defaultdict
|
| 4 |
+
from typing import List
|
| 5 |
+
|
| 6 |
+
from ray.autoscaler.v2.instance_manager.instance_manager import (
|
| 7 |
+
InstanceUpdatedSubscriber,
|
| 8 |
+
)
|
| 9 |
+
from ray.autoscaler.v2.instance_manager.node_provider import ICloudInstanceProvider
|
| 10 |
+
from ray.core.generated.instance_manager_pb2 import Instance, InstanceUpdateEvent
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class CloudInstanceUpdater(InstanceUpdatedSubscriber):
|
| 16 |
+
"""CloudInstanceUpdater is responsible for launching
|
| 17 |
+
new instances and terminating cloud instances
|
| 18 |
+
|
| 19 |
+
It requests the cloud instance provider to launch new instances when
|
| 20 |
+
there are new instance requests (with REQUESTED status change).
|
| 21 |
+
|
| 22 |
+
It requests the cloud instance provider to terminate instances when
|
| 23 |
+
there are new instance terminations (with TERMINATING status change).
|
| 24 |
+
|
| 25 |
+
The cloud instance APIs are async and non-blocking.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
def __init__(
|
| 29 |
+
self,
|
| 30 |
+
cloud_provider: ICloudInstanceProvider,
|
| 31 |
+
) -> None:
|
| 32 |
+
self._cloud_provider = cloud_provider
|
| 33 |
+
|
| 34 |
+
def notify(self, events: List[InstanceUpdateEvent]) -> None:
|
| 35 |
+
new_requests = [
|
| 36 |
+
event for event in events if event.new_instance_status == Instance.REQUESTED
|
| 37 |
+
]
|
| 38 |
+
new_terminations = [
|
| 39 |
+
event
|
| 40 |
+
for event in events
|
| 41 |
+
if event.new_instance_status == Instance.TERMINATING
|
| 42 |
+
]
|
| 43 |
+
self._launch_new_instances(new_requests)
|
| 44 |
+
self._terminate_instances(new_terminations)
|
| 45 |
+
|
| 46 |
+
def _terminate_instances(self, new_terminations: List[InstanceUpdateEvent]):
|
| 47 |
+
"""
|
| 48 |
+
Terminate cloud instances through cloud provider.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
new_terminations: List of new instance terminations.
|
| 52 |
+
"""
|
| 53 |
+
if not new_terminations:
|
| 54 |
+
logger.debug("No instances to terminate.")
|
| 55 |
+
return
|
| 56 |
+
|
| 57 |
+
# Terminate the instances.
|
| 58 |
+
cloud_instance_ids = [event.cloud_instance_id for event in new_terminations]
|
| 59 |
+
|
| 60 |
+
# This is an async call.
|
| 61 |
+
self._cloud_provider.terminate(
|
| 62 |
+
ids=cloud_instance_ids, request_id=str(uuid.uuid4())
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
def _launch_new_instances(self, new_requests: List[InstanceUpdateEvent]):
|
| 66 |
+
"""
|
| 67 |
+
Launches new instances by requesting the cloud provider.
|
| 68 |
+
|
| 69 |
+
Args:
|
| 70 |
+
new_requests: List of new instance requests.
|
| 71 |
+
|
| 72 |
+
"""
|
| 73 |
+
if not new_requests:
|
| 74 |
+
logger.debug("No instances to launch.")
|
| 75 |
+
return
|
| 76 |
+
|
| 77 |
+
# Group new requests by launch request id.
|
| 78 |
+
requests_by_launch_request_id = defaultdict(list)
|
| 79 |
+
|
| 80 |
+
for event in new_requests:
|
| 81 |
+
assert (
|
| 82 |
+
event.launch_request_id
|
| 83 |
+
), "Launch request id should have been set by the reconciler"
|
| 84 |
+
requests_by_launch_request_id[event.launch_request_id].append(event)
|
| 85 |
+
|
| 86 |
+
for launch_request_id, events in requests_by_launch_request_id.items():
|
| 87 |
+
request_shape = defaultdict(int)
|
| 88 |
+
for event in events:
|
| 89 |
+
request_shape[event.instance_type] += 1
|
| 90 |
+
# Make requests to the cloud provider.
|
| 91 |
+
self._cloud_provider.launch(
|
| 92 |
+
shape=request_shape, request_id=launch_request_id
|
| 93 |
+
)
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/ray_stopper.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from queue import Queue
|
| 5 |
+
from typing import List
|
| 6 |
+
|
| 7 |
+
from ray._common.utils import hex_to_binary
|
| 8 |
+
from ray._raylet import GcsClient
|
| 9 |
+
from ray.autoscaler.v2.instance_manager.instance_manager import (
|
| 10 |
+
InstanceUpdatedSubscriber,
|
| 11 |
+
)
|
| 12 |
+
from ray.core.generated.autoscaler_pb2 import DrainNodeReason
|
| 13 |
+
from ray.core.generated.instance_manager_pb2 import (
|
| 14 |
+
Instance,
|
| 15 |
+
InstanceUpdateEvent,
|
| 16 |
+
TerminationRequest,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass(frozen=True)
|
| 23 |
+
class RayStopError:
|
| 24 |
+
# Instance manager's instance id.
|
| 25 |
+
im_instance_id: str
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class RayStopper(InstanceUpdatedSubscriber):
|
| 29 |
+
"""RayStopper is responsible for stopping ray on instances.
|
| 30 |
+
|
| 31 |
+
It will drain the ray node if it's for idle termination.
|
| 32 |
+
For other terminations, it will stop the ray node. (e.g. scale down, etc.)
|
| 33 |
+
|
| 34 |
+
If any failures happen when stopping/draining the node, we will not retry
|
| 35 |
+
and rely on the reconciler to handle the failure.
|
| 36 |
+
|
| 37 |
+
TODO: we could also surface the errors back to the reconciler for
|
| 38 |
+
quicker failure detection.
|
| 39 |
+
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
def __init__(self, gcs_client: GcsClient, error_queue: Queue) -> None:
|
| 43 |
+
self._gcs_client = gcs_client
|
| 44 |
+
self._error_queue = error_queue
|
| 45 |
+
self._executor = ThreadPoolExecutor(max_workers=1)
|
| 46 |
+
|
| 47 |
+
def notify(self, events: List[InstanceUpdateEvent]) -> None:
|
| 48 |
+
for event in events:
|
| 49 |
+
if event.new_instance_status == Instance.RAY_STOP_REQUESTED:
|
| 50 |
+
fut = self._executor.submit(self._stop_or_drain_ray, event)
|
| 51 |
+
|
| 52 |
+
def _log_on_error(fut):
|
| 53 |
+
try:
|
| 54 |
+
fut.result()
|
| 55 |
+
except Exception:
|
| 56 |
+
logger.exception("Error stopping/drain ray.")
|
| 57 |
+
|
| 58 |
+
fut.add_done_callback(_log_on_error)
|
| 59 |
+
|
| 60 |
+
def _stop_or_drain_ray(self, event: InstanceUpdateEvent) -> None:
|
| 61 |
+
"""
|
| 62 |
+
Stops or drains the ray node based on the termination request.
|
| 63 |
+
"""
|
| 64 |
+
assert event.HasField("termination_request"), "Termination request is required."
|
| 65 |
+
termination_request = event.termination_request
|
| 66 |
+
ray_node_id = termination_request.ray_node_id
|
| 67 |
+
instance_id = event.instance_id
|
| 68 |
+
|
| 69 |
+
if termination_request.cause == TerminationRequest.Cause.IDLE:
|
| 70 |
+
reason = DrainNodeReason.DRAIN_NODE_REASON_IDLE_TERMINATION
|
| 71 |
+
reason_str = "Termination of node that's idle for {} seconds.".format(
|
| 72 |
+
termination_request.idle_duration_ms / 1000
|
| 73 |
+
)
|
| 74 |
+
self._drain_ray_node(
|
| 75 |
+
self._gcs_client,
|
| 76 |
+
self._error_queue,
|
| 77 |
+
ray_node_id,
|
| 78 |
+
instance_id,
|
| 79 |
+
reason,
|
| 80 |
+
reason_str,
|
| 81 |
+
)
|
| 82 |
+
return
|
| 83 |
+
|
| 84 |
+
# If it's not an idle termination, we stop the ray node.
|
| 85 |
+
self._stop_ray_node(
|
| 86 |
+
self._gcs_client, self._error_queue, ray_node_id, instance_id
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
@staticmethod
|
| 90 |
+
def _drain_ray_node(
|
| 91 |
+
gcs_client: GcsClient,
|
| 92 |
+
error_queue: Queue,
|
| 93 |
+
ray_node_id: str,
|
| 94 |
+
instance_id: str,
|
| 95 |
+
reason: DrainNodeReason,
|
| 96 |
+
reason_str: str,
|
| 97 |
+
):
|
| 98 |
+
"""
|
| 99 |
+
Drains the ray node.
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
gcs_client: The gcs client to use.
|
| 103 |
+
ray_node_id: The ray node id to drain.
|
| 104 |
+
reason: The reason to drain the node.
|
| 105 |
+
reason_str: The reason message to drain the node.
|
| 106 |
+
"""
|
| 107 |
+
try:
|
| 108 |
+
accepted, reject_msg_str = gcs_client.drain_node(
|
| 109 |
+
node_id=ray_node_id,
|
| 110 |
+
reason=reason,
|
| 111 |
+
reason_message=reason_str,
|
| 112 |
+
# TODO: we could probably add a deadline here that's derived
|
| 113 |
+
# from the stuck instance reconciliation configs.
|
| 114 |
+
deadline_timestamp_ms=0,
|
| 115 |
+
)
|
| 116 |
+
logger.info(
|
| 117 |
+
f"Drained ray on {ray_node_id}(success={accepted}, "
|
| 118 |
+
f"msg={reject_msg_str})"
|
| 119 |
+
)
|
| 120 |
+
if not accepted:
|
| 121 |
+
error_queue.put_nowait(RayStopError(im_instance_id=instance_id))
|
| 122 |
+
except Exception:
|
| 123 |
+
logger.exception(f"Error draining ray on {ray_node_id}")
|
| 124 |
+
error_queue.put_nowait(RayStopError(im_instance_id=instance_id))
|
| 125 |
+
|
| 126 |
+
@staticmethod
|
| 127 |
+
def _stop_ray_node(
|
| 128 |
+
gcs_client: GcsClient,
|
| 129 |
+
error_queue: Queue,
|
| 130 |
+
ray_node_id: str,
|
| 131 |
+
instance_id: str,
|
| 132 |
+
):
|
| 133 |
+
"""
|
| 134 |
+
Stops the ray node.
|
| 135 |
+
|
| 136 |
+
Args:
|
| 137 |
+
gcs_client: The gcs client to use.
|
| 138 |
+
ray_node_id: The ray node id to stop.
|
| 139 |
+
"""
|
| 140 |
+
try:
|
| 141 |
+
drained = gcs_client.drain_nodes(node_ids=[hex_to_binary(ray_node_id)])
|
| 142 |
+
success = len(drained) > 0
|
| 143 |
+
logger.info(
|
| 144 |
+
f"Stopping ray on {ray_node_id}(instance={instance_id}): "
|
| 145 |
+
f"success={success})"
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
if not success:
|
| 149 |
+
error_queue.put_nowait(RayStopError(im_instance_id=instance_id))
|
| 150 |
+
except Exception:
|
| 151 |
+
logger.exception(
|
| 152 |
+
f"Error stopping ray on {ray_node_id}(instance={instance_id})"
|
| 153 |
+
)
|
| 154 |
+
error_queue.put_nowait(RayStopError(im_instance_id=instance_id))
|
lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/threaded_ray_installer.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import dataclasses
|
| 2 |
+
import logging
|
| 3 |
+
import time
|
| 4 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 5 |
+
from queue import Queue
|
| 6 |
+
from typing import List
|
| 7 |
+
|
| 8 |
+
from ray.autoscaler.v2.instance_manager.instance_manager import (
|
| 9 |
+
InstanceUpdatedSubscriber,
|
| 10 |
+
)
|
| 11 |
+
from ray.autoscaler.v2.instance_manager.instance_storage import InstanceStorage
|
| 12 |
+
from ray.autoscaler.v2.instance_manager.ray_installer import RayInstaller
|
| 13 |
+
from ray.core.generated.instance_manager_pb2 import (
|
| 14 |
+
Instance,
|
| 15 |
+
InstanceUpdateEvent,
|
| 16 |
+
NodeKind,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclasses.dataclass(frozen=True)
|
| 23 |
+
class RayInstallError:
|
| 24 |
+
# Instance manager's instance id.
|
| 25 |
+
im_instance_id: str
|
| 26 |
+
# Error details.
|
| 27 |
+
details: str
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ThreadedRayInstaller(InstanceUpdatedSubscriber):
|
| 31 |
+
"""ThreadedRayInstaller is responsible for install ray on new nodes."""
|
| 32 |
+
|
| 33 |
+
def __init__(
|
| 34 |
+
self,
|
| 35 |
+
head_node_ip: str,
|
| 36 |
+
instance_storage: InstanceStorage,
|
| 37 |
+
ray_installer: RayInstaller,
|
| 38 |
+
error_queue: Queue,
|
| 39 |
+
max_install_attempts: int = 3,
|
| 40 |
+
install_retry_interval: int = 10,
|
| 41 |
+
max_concurrent_installs: int = 50,
|
| 42 |
+
) -> None:
|
| 43 |
+
self._head_node_ip = head_node_ip
|
| 44 |
+
self._instance_storage = instance_storage
|
| 45 |
+
self._ray_installer = ray_installer
|
| 46 |
+
self._max_concurrent_installs = max_concurrent_installs
|
| 47 |
+
self._max_install_attempts = max_install_attempts
|
| 48 |
+
self._install_retry_interval = install_retry_interval
|
| 49 |
+
self._error_queue = error_queue
|
| 50 |
+
self._ray_installation_executor = ThreadPoolExecutor(
|
| 51 |
+
max_workers=self._max_concurrent_installs
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
def notify(self, events: List[InstanceUpdateEvent]) -> None:
|
| 55 |
+
for event in events:
|
| 56 |
+
if event.new_instance_status == Instance.RAY_INSTALLING:
|
| 57 |
+
self._install_ray_on_new_nodes(event.instance_id)
|
| 58 |
+
|
| 59 |
+
def _install_ray_on_new_nodes(self, instance_id: str) -> None:
|
| 60 |
+
allocated_instance, _ = self._instance_storage.get_instances(
|
| 61 |
+
instance_ids={instance_id},
|
| 62 |
+
status_filter={Instance.RAY_INSTALLING},
|
| 63 |
+
)
|
| 64 |
+
for instance in allocated_instance.values():
|
| 65 |
+
assert instance.node_kind == NodeKind.WORKER
|
| 66 |
+
self._ray_installation_executor.submit(
|
| 67 |
+
self._install_ray_on_single_node, instance
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
def _install_ray_on_single_node(self, instance: Instance) -> None:
|
| 71 |
+
assert instance.status == Instance.RAY_INSTALLING
|
| 72 |
+
|
| 73 |
+
# install with exponential backoff
|
| 74 |
+
backoff_factor = 1
|
| 75 |
+
last_exception = None
|
| 76 |
+
for _ in range(self._max_install_attempts):
|
| 77 |
+
try:
|
| 78 |
+
self._ray_installer.install_ray(instance, self._head_node_ip)
|
| 79 |
+
return
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logger.info(
|
| 82 |
+
f"Ray installation failed on instance {instance.cloud_instance_id}: {e}"
|
| 83 |
+
)
|
| 84 |
+
last_exception = e
|
| 85 |
+
|
| 86 |
+
logger.warning("Failed to install ray, retrying...")
|
| 87 |
+
time.sleep(self._install_retry_interval * backoff_factor)
|
| 88 |
+
backoff_factor *= 2
|
| 89 |
+
|
| 90 |
+
self._error_queue.put_nowait(
|
| 91 |
+
RayInstallError(
|
| 92 |
+
im_instance_id=instance.instance_id,
|
| 93 |
+
details=str(last_exception),
|
| 94 |
+
)
|
| 95 |
+
)
|