ZhengyangZhang commited on
Commit
09eb2a3
·
verified ·
1 Parent(s): 2f00a8b

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. lib/python3.12/site-packages/ray/autoscaler/__pycache__/node_launch_exception.cpython-312.pyc +0 -0
  2. lib/python3.12/site-packages/ray/autoscaler/__pycache__/tags.cpython-312.pyc +0 -0
  3. lib/python3.12/site-packages/ray/autoscaler/v2/__init__.py +0 -0
  4. lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/__init__.cpython-312.pyc +0 -0
  5. lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/autoscaler.cpython-312.pyc +0 -0
  6. lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/event_logger.cpython-312.pyc +0 -0
  7. lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/metrics_reporter.cpython-312.pyc +0 -0
  8. lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/monitor.cpython-312.pyc +0 -0
  9. lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/scheduler.cpython-312.pyc +0 -0
  10. lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/schema.cpython-312.pyc +0 -0
  11. lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/sdk.cpython-312.pyc +0 -0
  12. lib/python3.12/site-packages/ray/autoscaler/v2/__pycache__/utils.cpython-312.pyc +0 -0
  13. lib/python3.12/site-packages/ray/autoscaler/v2/autoscaler.py +216 -0
  14. lib/python3.12/site-packages/ray/autoscaler/v2/event_logger.py +185 -0
  15. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__init__.py +0 -0
  16. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/__init__.cpython-312.pyc +0 -0
  17. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/common.cpython-312.pyc +0 -0
  18. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/config.cpython-312.pyc +0 -0
  19. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/instance_manager.cpython-312.pyc +0 -0
  20. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/instance_storage.cpython-312.pyc +0 -0
  21. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/node_provider.cpython-312.pyc +0 -0
  22. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/ray_installer.cpython-312.pyc +0 -0
  23. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/reconciler.cpython-312.pyc +0 -0
  24. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/__pycache__/storage.cpython-312.pyc +0 -0
  25. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/__init__.py +0 -0
  26. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/__pycache__/__init__.cpython-312.pyc +0 -0
  27. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/kuberay/__init__.py +0 -0
  28. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/kuberay/__pycache__/__init__.cpython-312.pyc +0 -0
  29. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/kuberay/__pycache__/cloud_provider.cpython-312.pyc +0 -0
  30. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/kuberay/cloud_provider.py +595 -0
  31. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/read_only/__init__.py +0 -0
  32. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/read_only/__pycache__/__init__.cpython-312.pyc +0 -0
  33. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/read_only/__pycache__/cloud_provider.cpython-312.pyc +0 -0
  34. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/cloud_providers/read_only/cloud_provider.py +73 -0
  35. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/common.py +472 -0
  36. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/config.py +552 -0
  37. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/instance_manager.py +271 -0
  38. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/instance_storage.py +151 -0
  39. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/node_provider.py +529 -0
  40. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/ray_installer.py +96 -0
  41. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/reconciler.py +1601 -0
  42. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/storage.py +180 -0
  43. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/__init__.py +0 -0
  44. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/__pycache__/__init__.cpython-312.pyc +0 -0
  45. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/__pycache__/cloud_instance_updater.cpython-312.pyc +0 -0
  46. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/__pycache__/ray_stopper.cpython-312.pyc +0 -0
  47. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/__pycache__/threaded_ray_installer.cpython-312.pyc +0 -0
  48. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/cloud_instance_updater.py +93 -0
  49. lib/python3.12/site-packages/ray/autoscaler/v2/instance_manager/subscribers/ray_stopper.py +154 -0
  50. 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
+ )