1005 lines
36 KiB
Python
1005 lines
36 KiB
Python
import logging
|
|
import os
|
|
import sys
|
|
import time
|
|
import unittest
|
|
|
|
# coding: utf-8
|
|
# coding: utf-8
|
|
from collections import defaultdict
|
|
from typing import Any, Dict, List, Union
|
|
from unittest import mock
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest # noqa
|
|
|
|
import ray
|
|
from ray._common.test_utils import wait_for_condition
|
|
from ray._private.test_utils import get_test_config_path
|
|
from ray.autoscaler._private.constants import (
|
|
AUTOSCALER_MAX_CONCURRENT_LAUNCHES,
|
|
AUTOSCALER_MAX_LAUNCH_BATCH,
|
|
)
|
|
from ray.autoscaler._private.fake_multi_node.node_provider import FakeMultiNodeProvider
|
|
from ray.autoscaler._private.kuberay.node_provider import IKubernetesHttpApiClient
|
|
from ray.autoscaler.v2.instance_manager.cloud_providers.kuberay.cloud_provider import (
|
|
NO_DRIVER_TTL_EXPIRED_ANNOTATION,
|
|
KubeRayProvider,
|
|
)
|
|
from ray.autoscaler.v2.instance_manager.config import (
|
|
AutoscalingConfig,
|
|
FileConfigReader,
|
|
)
|
|
from ray.autoscaler.v2.instance_manager.node_provider import (
|
|
CloudInstance,
|
|
ICloudInstanceProvider,
|
|
LaunchNodeError,
|
|
NodeProviderAdapter,
|
|
TerminateNodeError,
|
|
logger,
|
|
)
|
|
from ray.core.generated.instance_manager_pb2 import NodeKind
|
|
from ray.tests.autoscaler_test_utils import MockProvider
|
|
from ray.tests.kuberay.test_autoscaling_config import get_basic_ray_cr
|
|
from ray.tests.kuberay.test_kuberay_node_provider import _get_test_yaml
|
|
|
|
logger.setLevel(logging.DEBUG)
|
|
|
|
|
|
class CloudInstanceProviderTesterBase(ICloudInstanceProvider):
|
|
def __init__(
|
|
self,
|
|
inner_provider: ICloudInstanceProvider,
|
|
config: AutoscalingConfig,
|
|
):
|
|
self.inner_provider = inner_provider
|
|
self.config = config
|
|
|
|
def __del__(self):
|
|
self.shutdown()
|
|
|
|
def shutdown(self):
|
|
pass
|
|
|
|
def launch(self, request_id, shape):
|
|
self.inner_provider.launch(shape=shape, request_id=request_id)
|
|
|
|
def terminate(self, request_id, ids):
|
|
self.inner_provider.terminate(ids=ids, request_id=request_id)
|
|
|
|
def poll_errors(self):
|
|
return self.inner_provider.poll_errors()
|
|
|
|
def get_non_terminated(self):
|
|
return self.inner_provider.get_non_terminated()
|
|
|
|
############################
|
|
# Test mock methods
|
|
############################
|
|
def _add_creation_error(self, e: Exception):
|
|
raise NotImplementedError("Subclass should implement it")
|
|
|
|
def _add_termination_errors(self, e: Exception):
|
|
raise NotImplementedError("Subclass should implement it")
|
|
|
|
|
|
class FakeMultiNodeProviderTester(CloudInstanceProviderTesterBase):
|
|
def __init__(self, **kwargs):
|
|
self.config_reader = FileConfigReader(
|
|
get_test_config_path("test_ray_complex.yaml"), skip_content_hash=True
|
|
)
|
|
self.config = self.config_reader.get_cached_autoscaling_config()
|
|
self.ray_session = None
|
|
|
|
os.environ["RAY_FAKE_CLUSTER"] = "1"
|
|
provider_config = self.config.get_provider_config()
|
|
# This is a bit hacky but we need a fake head node.
|
|
self.ray_session = ray.init()
|
|
provider_config["gcs_address"] = self.ray_session.address_info["gcs_address"]
|
|
provider_config["head_node_id"] = self.ray_session.address_info["node_id"]
|
|
provider_config["launch_multiple"] = True
|
|
self.base_provider = FakeMultiNodeProvider(
|
|
provider_config,
|
|
cluster_name="test",
|
|
)
|
|
|
|
provider = NodeProviderAdapter(
|
|
self.base_provider,
|
|
self.config_reader,
|
|
)
|
|
super().__init__(provider, self.config)
|
|
|
|
def get_non_terminated(self):
|
|
nodes = self.inner_provider.get_non_terminated()
|
|
nodes.pop(self.ray_session.address_info["node_id"], None)
|
|
return nodes
|
|
|
|
def shutdown(self):
|
|
ray.shutdown()
|
|
|
|
def _add_creation_error(self, e: Exception):
|
|
self.base_provider._test_set_creation_error(e)
|
|
|
|
def _add_termination_errors(self, e: Exception):
|
|
self.base_provider._test_add_termination_errors(e)
|
|
|
|
|
|
class MockProviderTester(CloudInstanceProviderTesterBase):
|
|
def __init__(self, **kwargs):
|
|
self.config_reader = FileConfigReader(
|
|
get_test_config_path("test_ray_complex.yaml"), skip_content_hash=True
|
|
)
|
|
self.config = self.config_reader.get_cached_autoscaling_config()
|
|
self.base_provider = MockProvider()
|
|
provider = NodeProviderAdapter(
|
|
self.base_provider,
|
|
self.config_reader,
|
|
)
|
|
super().__init__(provider, self.config)
|
|
|
|
def _add_creation_error(self, e: Exception):
|
|
self.base_provider.creation_error = e
|
|
|
|
def _add_termination_errors(self, e: Exception):
|
|
self.base_provider.termination_errors = e
|
|
|
|
|
|
class MagicMockProviderTester(CloudInstanceProviderTesterBase):
|
|
def __init__(
|
|
self,
|
|
max_concurrent_launches=AUTOSCALER_MAX_CONCURRENT_LAUNCHES,
|
|
max_launch_batch_per_type=AUTOSCALER_MAX_LAUNCH_BATCH,
|
|
**kwargs,
|
|
):
|
|
self.config_reader = FileConfigReader(
|
|
get_test_config_path("test_ray_complex.yaml"), skip_content_hash=True
|
|
)
|
|
self.config = self.config_reader.get_cached_autoscaling_config()
|
|
self.base_provider = MagicMock()
|
|
provider = NodeProviderAdapter(
|
|
self.base_provider,
|
|
self.config_reader,
|
|
max_launch_batch_per_type=max_launch_batch_per_type,
|
|
max_concurrent_launches=max_concurrent_launches,
|
|
)
|
|
super().__init__(provider, self.config)
|
|
|
|
def _add_creation_error(self, e: Exception):
|
|
self.base_provider.create_node_with_resources_and_labels.side_effect = e
|
|
|
|
def _add_termination_errors(self, e: Exception):
|
|
self.base_provider.terminate_nodes.side_effect = e
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def get_provider():
|
|
def _get_provider(name, **kwargs):
|
|
if name == "fake_multi":
|
|
provider = FakeMultiNodeProviderTester(**kwargs)
|
|
elif name == "mock":
|
|
provider = MockProviderTester(**kwargs)
|
|
elif name == "magic_mock":
|
|
provider = MagicMockProviderTester(**kwargs)
|
|
else:
|
|
raise ValueError(f"Invalid provider type: {name}")
|
|
|
|
return provider
|
|
|
|
yield _get_provider
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"provider_name",
|
|
["fake_multi", "mock"],
|
|
)
|
|
def test_node_providers_basic(get_provider, provider_name):
|
|
# Test launching.
|
|
provider = get_provider(name=provider_name)
|
|
timeout_s = 30 if provider_name == "fake_multi" else 10
|
|
provider.launch(
|
|
shape={"worker_nodes": 2},
|
|
request_id="1",
|
|
)
|
|
|
|
provider.launch(
|
|
request_id="2",
|
|
shape={"worker_nodes": 2, "worker_nodes1": 1},
|
|
)
|
|
|
|
def verify():
|
|
nodes_by_type = defaultdict(int)
|
|
for node in provider.get_non_terminated().values():
|
|
nodes_by_type[node.node_type] += 1
|
|
errors = provider.poll_errors()
|
|
print(errors)
|
|
assert nodes_by_type == {"worker_nodes": 4, "worker_nodes1": 1}
|
|
return True
|
|
|
|
wait_for_condition(verify, timeout=timeout_s)
|
|
|
|
nodes = provider.get_non_terminated().keys()
|
|
|
|
# Terminate them all
|
|
provider.terminate(
|
|
ids=nodes,
|
|
request_id="3",
|
|
)
|
|
|
|
# Launch some.
|
|
provider.launch(
|
|
shape={"worker_nodes": 1},
|
|
request_id="4",
|
|
)
|
|
|
|
def verify():
|
|
nodes_by_type = defaultdict(int)
|
|
for node in provider.get_non_terminated().values():
|
|
nodes_by_type[node.node_type] += 1
|
|
|
|
assert nodes_by_type == {"worker_nodes": 1}
|
|
for node in provider.get_non_terminated().values():
|
|
assert node.request_id == "4"
|
|
return True
|
|
|
|
wait_for_condition(verify, timeout=timeout_s)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"provider_name",
|
|
["fake_multi", "mock"],
|
|
)
|
|
def test_launch_failure(get_provider, provider_name):
|
|
provider = get_provider(name=provider_name)
|
|
provider._add_creation_error(Exception("failed to create node"))
|
|
|
|
provider.launch(
|
|
shape={"worker_nodes": 2},
|
|
request_id="2",
|
|
)
|
|
|
|
def verify():
|
|
errors = provider.poll_errors()
|
|
assert len(errors) == 1
|
|
assert isinstance(errors[0], LaunchNodeError)
|
|
assert errors[0].node_type == "worker_nodes"
|
|
assert errors[0].request_id == "2"
|
|
return True
|
|
|
|
wait_for_condition(verify)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"provider_name",
|
|
["fake_multi", "mock"],
|
|
)
|
|
def test_terminate_node_failure(get_provider, provider_name):
|
|
provider = get_provider(name=provider_name)
|
|
provider._add_termination_errors(Exception("failed to terminate node"))
|
|
|
|
provider.launch(request_id="launch1", shape={"worker_nodes": 1})
|
|
|
|
def nodes_launched():
|
|
nodes = provider.get_non_terminated()
|
|
return len(nodes) == 1
|
|
|
|
wait_for_condition(nodes_launched)
|
|
|
|
provider.terminate(request_id="terminate1", ids=["0"])
|
|
|
|
def verify():
|
|
errors = provider.poll_errors()
|
|
nodes = provider.get_non_terminated()
|
|
assert len(nodes) == 1
|
|
assert len(errors) == 1
|
|
assert isinstance(errors[0], TerminateNodeError)
|
|
assert errors[0].cloud_instance_id == "0"
|
|
assert errors[0].request_id == "terminate1"
|
|
return True
|
|
|
|
wait_for_condition(verify)
|
|
|
|
|
|
def test_launch_executor_concurrency(get_provider):
|
|
import threading
|
|
|
|
provider = get_provider(
|
|
name="magic_mock", max_concurrent_launches=1, max_launch_batch_per_type=1
|
|
)
|
|
|
|
launch_event = threading.Event()
|
|
|
|
def loop(*args, **kwargs):
|
|
launch_event.wait()
|
|
|
|
provider.base_provider.create_node_with_resources_and_labels.side_effect = loop
|
|
|
|
provider.launch(
|
|
shape={
|
|
"worker_nodes": 1,
|
|
"worker_nodes1": 1,
|
|
}, # 2 types, but concurrent types to launch is 1.
|
|
request_id="1",
|
|
)
|
|
# Assert called only once.
|
|
for _ in range(10):
|
|
assert (
|
|
provider.base_provider.create_node_with_resources_and_labels.call_count <= 1
|
|
)
|
|
time.sleep(0.1)
|
|
|
|
# Finish the call.
|
|
launch_event.set()
|
|
|
|
def verify():
|
|
assert (
|
|
provider.base_provider.create_node_with_resources_and_labels.call_count == 2
|
|
)
|
|
return True
|
|
|
|
wait_for_condition(verify)
|
|
|
|
|
|
#######################################
|
|
# Integration test for KubeRay Provider
|
|
#######################################
|
|
class MockKubernetesHttpApiClient(IKubernetesHttpApiClient):
|
|
def __init__(self, pod_list: List[Dict[str, Any]], ray_cluster: Dict[str, Any]):
|
|
self._ray_cluster = ray_cluster
|
|
self._pod_list = pod_list
|
|
self._patches = {}
|
|
|
|
def get(self, path: str) -> Dict[str, Any]:
|
|
if "pods" in path:
|
|
return self._pod_list
|
|
if "rayclusters" in path:
|
|
return self._ray_cluster
|
|
|
|
raise NotImplementedError(f"get {path}")
|
|
|
|
def patch(
|
|
self,
|
|
path: str,
|
|
patches: Union[List[Dict[str, Any]], Dict[str, Any]],
|
|
content_type: str = "application/json-patch+json",
|
|
):
|
|
self._patches[path] = patches
|
|
return {path: patches}
|
|
|
|
def get_patches(self, path: str) -> Union[List[Dict[str, Any]], Dict[str, Any]]:
|
|
return self._patches[path]
|
|
|
|
|
|
class KubeRayProviderIntegrationTest(unittest.TestCase):
|
|
def setUp(self):
|
|
raycluster_cr = get_basic_ray_cr()
|
|
# Remove fake TPU and GPU worker groups from CR since podlist1 only
|
|
# contains small-group.
|
|
raycluster_cr["spec"]["workerGroupSpecs"][1]["replicas"] = 0
|
|
raycluster_cr["spec"]["workerGroupSpecs"][2]["replicas"] = 0
|
|
self.mock_client = MockKubernetesHttpApiClient(
|
|
_get_test_yaml("podlist1.yaml"), raycluster_cr
|
|
)
|
|
self.provider = KubeRayProvider(
|
|
cluster_name="test",
|
|
provider_config={
|
|
"namespace": "default",
|
|
"head_node_type": "headgroup",
|
|
},
|
|
gcs_client=MagicMock(),
|
|
k8s_api_client=self.mock_client,
|
|
)
|
|
# In production _sync_with_api_server caches the CR before the
|
|
# no-driver annotation is set; mirror that for the dispatch tests.
|
|
self.provider._ray_cluster = raycluster_cr
|
|
|
|
def test_get_nodes(self):
|
|
nodes = self.provider.get_non_terminated()
|
|
errors = self.provider.poll_errors()
|
|
|
|
assert len(nodes) == 2
|
|
assert len(errors) == 0
|
|
|
|
assert sorted(nodes) == sorted(
|
|
{
|
|
"raycluster-autoscaler-head-8zsc8": CloudInstance(
|
|
cloud_instance_id="raycluster-autoscaler-head-8zsc8",
|
|
node_kind=NodeKind.HEAD,
|
|
node_type="headgroup",
|
|
is_running=True,
|
|
), # up-to-date status because the Ray container is in running status
|
|
"raycluster-autoscaler-worker-small-group-dkz2r": CloudInstance(
|
|
cloud_instance_id="raycluster-autoscaler-worker-small-group-dkz2r",
|
|
node_kind=NodeKind.WORKER,
|
|
node_type="small-group",
|
|
is_running=False,
|
|
), # waiting status, because Ray container's state is pending.
|
|
}
|
|
)
|
|
|
|
def test_launch_node(self):
|
|
launch_request = {"small-group": 1}
|
|
self.provider.launch(shape=launch_request, request_id="launch-1")
|
|
|
|
patches = self.mock_client.get_patches(
|
|
f"rayclusters/{self.provider._cluster_name}"
|
|
)
|
|
assert len(patches) == 1
|
|
assert patches[0] == {
|
|
"op": "replace",
|
|
"path": "/spec/workerGroupSpecs/0/replicas",
|
|
"value": 2, # 1 + 1
|
|
}
|
|
|
|
def test_terminate_node(self):
|
|
self.provider.terminate(
|
|
ids=["raycluster-autoscaler-worker-small-group-dkz2r"], request_id="term-1"
|
|
)
|
|
patches = self.mock_client.get_patches(
|
|
f"rayclusters/{self.provider._cluster_name}"
|
|
)
|
|
assert len(patches) == 2
|
|
assert patches == [
|
|
{
|
|
"op": "replace",
|
|
"path": "/spec/workerGroupSpecs/0/replicas",
|
|
"value": 0,
|
|
},
|
|
{
|
|
"op": "replace",
|
|
"path": "/spec/workerGroupSpecs/0/scaleStrategy",
|
|
"value": {
|
|
"workersToDelete": [
|
|
"raycluster-autoscaler-worker-small-group-dkz2r"
|
|
]
|
|
},
|
|
},
|
|
]
|
|
|
|
def test_pending_deletes(self):
|
|
# Modify the cr.yaml to have a pending delete.
|
|
self.mock_client._ray_cluster["spec"]["workerGroupSpecs"][0][
|
|
"scaleStrategy"
|
|
] = {"workersToDelete": ["raycluster-autoscaler-worker-small-group-dkz2r"]}
|
|
self.mock_client._ray_cluster["spec"]["workerGroupSpecs"][0]["replicas"] = 0
|
|
|
|
# Launching new nodes should fail.
|
|
self.provider.launch(shape={"small-group": 1}, request_id="launch-1")
|
|
errors = self.provider.poll_errors()
|
|
assert errors[0].node_type == "small-group"
|
|
assert errors[0].request_id == "launch-1"
|
|
assert "There are workers to be deleted" in str(errors[0]), errors[0]
|
|
|
|
# Terminating new nodes should fail.
|
|
self.provider.terminate(
|
|
ids=["raycluster-autoscaler-worker-small-group-dkz2r"], request_id="term-1"
|
|
)
|
|
errors = self.provider.poll_errors()
|
|
assert (
|
|
errors[0].cloud_instance_id
|
|
== "raycluster-autoscaler-worker-small-group-dkz2r"
|
|
)
|
|
assert errors[0].request_id == "term-1"
|
|
assert "There are workers to be deleted" in str(errors[0]), errors[0]
|
|
|
|
# Remove the pod from the pod list.
|
|
self.mock_client._pod_list["items"] = [
|
|
pod
|
|
for pod in self.mock_client._pod_list["items"]
|
|
if pod["metadata"]["name"]
|
|
!= "raycluster-autoscaler-worker-small-group-dkz2r"
|
|
]
|
|
|
|
# Launch OK now, and we should also clears the pending delete.
|
|
self.provider.launch(shape={"small-group": 1}, request_id="launch-2")
|
|
errors = self.provider.poll_errors()
|
|
assert len(errors) == 0
|
|
patches = self.mock_client.get_patches(
|
|
f"rayclusters/{self.provider._cluster_name}"
|
|
)
|
|
assert len(patches) == 2
|
|
assert patches == [
|
|
{
|
|
"op": "replace",
|
|
"path": "/spec/workerGroupSpecs/0/replicas",
|
|
"value": 1,
|
|
},
|
|
{
|
|
"op": "replace",
|
|
"path": "/spec/workerGroupSpecs/0/scaleStrategy",
|
|
"value": {"workersToDelete": []},
|
|
},
|
|
]
|
|
|
|
def test_increase_min_replicas_to_scale_up(self):
|
|
# Simulate the case where users manually increase the `minReplicas` field
|
|
# from 0 to $num_pods. KubeRay will create $num_pods worker Pods to meet the new
|
|
# `minReplicas`, even though the `replicas` field is still 0.
|
|
small_group = "small-group"
|
|
num_pods = 0
|
|
assert (
|
|
self.mock_client._ray_cluster["spec"]["workerGroupSpecs"][0]["groupName"]
|
|
== small_group
|
|
)
|
|
for pod in self.mock_client._pod_list["items"]:
|
|
if pod["metadata"]["labels"]["ray.io/group"] == small_group:
|
|
num_pods += 1
|
|
assert num_pods > 0
|
|
self.mock_client._ray_cluster["spec"]["workerGroupSpecs"][0]["replicas"] = 0
|
|
self.mock_client._ray_cluster["spec"]["workerGroupSpecs"][0][
|
|
"minReplicas"
|
|
] = num_pods
|
|
|
|
# Launching a new node and `replicas` should be
|
|
# `max(replicas, minReplicas) + 1`.
|
|
self.provider.launch(shape={small_group: 1}, request_id="launch-1")
|
|
patches = self.mock_client.get_patches(
|
|
f"rayclusters/{self.provider._cluster_name}"
|
|
)
|
|
assert len(patches) == 1
|
|
assert patches[0] == {
|
|
"op": "replace",
|
|
"path": "/spec/workerGroupSpecs/0/replicas",
|
|
"value": num_pods + 1,
|
|
}
|
|
|
|
def test_inconsistent_pods_raycr_scale_up(self):
|
|
"""
|
|
Test the case where the cluster state has not yet reached the desired state.
|
|
Specifically, the replicas field in the RayCluster CR does not match the actual
|
|
number of Pods.
|
|
"""
|
|
# Check the assumptions of the test
|
|
small_group = "small-group"
|
|
num_pods = 0
|
|
for pod in self.mock_client._pod_list["items"]:
|
|
if pod["metadata"]["labels"]["ray.io/group"] == small_group:
|
|
num_pods += 1
|
|
|
|
assert (
|
|
self.mock_client._ray_cluster["spec"]["workerGroupSpecs"][0]["groupName"]
|
|
== small_group
|
|
)
|
|
desired_replicas = num_pods + 1
|
|
self.mock_client._ray_cluster["spec"]["workerGroupSpecs"][0][
|
|
"replicas"
|
|
] = desired_replicas
|
|
|
|
# Launch a new node. The replicas field should be incremented by 1, even though
|
|
# the cluster state has not yet reached the goal state.
|
|
launch_request = {"small-group": 1}
|
|
self.provider.launch(shape=launch_request, request_id="launch-1")
|
|
|
|
patches = self.mock_client.get_patches(
|
|
f"rayclusters/{self.provider._cluster_name}"
|
|
)
|
|
assert len(patches) == 1
|
|
assert patches[0] == {
|
|
"op": "replace",
|
|
"path": "/spec/workerGroupSpecs/0/replicas",
|
|
"value": desired_replicas + 1,
|
|
}
|
|
|
|
def test_inconsistent_pods_raycr_scale_down(self):
|
|
"""
|
|
Test the case where the cluster state has not yet reached the desired state.
|
|
Specifically, the replicas field in the RayCluster CR does not match the actual
|
|
number of Pods.
|
|
"""
|
|
# Check the assumptions of the test
|
|
small_group = "small-group"
|
|
num_pods = 0
|
|
pod_to_delete = None
|
|
for pod in self.mock_client._pod_list["items"]:
|
|
if pod["metadata"]["labels"]["ray.io/group"] == small_group:
|
|
num_pods += 1
|
|
pod_to_delete = pod["metadata"]["name"]
|
|
assert pod_to_delete is not None
|
|
|
|
assert (
|
|
self.mock_client._ray_cluster["spec"]["workerGroupSpecs"][0]["groupName"]
|
|
== small_group
|
|
)
|
|
desired_replicas = num_pods + 1
|
|
self.mock_client._ray_cluster["spec"]["workerGroupSpecs"][0][
|
|
"replicas"
|
|
] = desired_replicas
|
|
|
|
# Terminate a node. The replicas field should be decremented by 1, even though
|
|
# the cluster state has not yet reached the goal state.
|
|
self.provider.terminate(ids=[pod_to_delete], request_id="term-1")
|
|
patches = self.mock_client.get_patches(
|
|
f"rayclusters/{self.provider._cluster_name}"
|
|
)
|
|
assert len(patches) == 2
|
|
assert patches == [
|
|
{
|
|
"op": "replace",
|
|
"path": "/spec/workerGroupSpecs/0/replicas",
|
|
"value": desired_replicas - 1,
|
|
},
|
|
{
|
|
"op": "replace",
|
|
"path": "/spec/workerGroupSpecs/0/scaleStrategy",
|
|
"value": {
|
|
"workersToDelete": [
|
|
pod_to_delete,
|
|
]
|
|
},
|
|
},
|
|
]
|
|
|
|
def test_decrease_cr_replicas_below_observed_then_scale_down(self):
|
|
"""
|
|
If a user/operator decreases the CR's replicas below the observed number of
|
|
Pods without specifying workersToDelete, scaling down should base the
|
|
new desired on observed (floor), decrement by one, and add the pod to
|
|
workersToDelete.
|
|
"""
|
|
# Prepare a RayCluster CR with replicas set to 0 for the small-group
|
|
# while the pod list contains multiple small-group pods.
|
|
raycluster_cr = get_basic_ray_cr()
|
|
mock_client = MockKubernetesHttpApiClient(
|
|
_get_test_yaml("podlist2.yaml"), raycluster_cr
|
|
)
|
|
|
|
small_group = "small-group"
|
|
pod_names = []
|
|
for pod in mock_client._pod_list["items"]:
|
|
if pod["metadata"]["labels"]["ray.io/group"] == small_group:
|
|
pod_names.append(pod["metadata"]["name"])
|
|
assert len(pod_names) >= 2
|
|
|
|
# Decrease CR replicas below observed without workersToDelete.
|
|
assert raycluster_cr["spec"]["workerGroupSpecs"][0]["groupName"] == small_group
|
|
raycluster_cr["spec"]["workerGroupSpecs"][0]["replicas"] = 0
|
|
|
|
provider = KubeRayProvider(
|
|
cluster_name="test",
|
|
provider_config={
|
|
"namespace": "default",
|
|
"head_node_type": "headgroup",
|
|
},
|
|
gcs_client=MagicMock(),
|
|
k8s_api_client=mock_client,
|
|
)
|
|
|
|
# Terminate a single observed pod.
|
|
pod_to_delete = pod_names[0]
|
|
provider.terminate(ids=[pod_to_delete], request_id="term-decrease")
|
|
|
|
# Expected: replicas becomes observed-1; workersToDelete contains the pod.
|
|
patches = mock_client.get_patches(f"rayclusters/{provider._cluster_name}")
|
|
assert len(patches) == 2
|
|
assert patches == [
|
|
{
|
|
"op": "replace",
|
|
"path": "/spec/workerGroupSpecs/0/replicas",
|
|
"value": len(pod_names) - 1,
|
|
},
|
|
{
|
|
"op": "replace",
|
|
"path": "/spec/workerGroupSpecs/0/scaleStrategy",
|
|
"value": {
|
|
"workersToDelete": [
|
|
pod_to_delete,
|
|
]
|
|
},
|
|
},
|
|
]
|
|
|
|
def test_scale_down_multiple_pods_of_node_type(self):
|
|
"""
|
|
Test the case where multiple pods of the same node type are scaled
|
|
down on one autoscaler iteration. This test verifies that the provider
|
|
properly handles multiple pod deletions and counting workers_to_delete.
|
|
"""
|
|
# Setup provider with multiple worker pods in podlist. We use podlist2
|
|
# here because podlist1 only contains one running worker.
|
|
raycluster_cr = get_basic_ray_cr()
|
|
raycluster_cr["spec"]["workerGroupSpecs"][0]["replicas"] = 2
|
|
mock_client = MockKubernetesHttpApiClient(
|
|
_get_test_yaml("podlist2.yaml"), raycluster_cr
|
|
)
|
|
provider = KubeRayProvider(
|
|
cluster_name="test",
|
|
provider_config={
|
|
"namespace": "default",
|
|
"head_node_type": "headgroup",
|
|
},
|
|
gcs_client=MagicMock(),
|
|
k8s_api_client=mock_client,
|
|
)
|
|
|
|
# Identify all pods in the target group
|
|
small_group = "small-group"
|
|
pod_names = []
|
|
for pod in mock_client._pod_list["items"]:
|
|
if pod["metadata"]["labels"]["ray.io/group"] == small_group:
|
|
pod_names.append(pod["metadata"]["name"])
|
|
|
|
# Terminate all pods in the group
|
|
provider._sync_with_api_server()
|
|
cur_instance_ids = set(provider.instances.keys())
|
|
pods_to_terminate = [name for name in pod_names if name in cur_instance_ids]
|
|
|
|
assert (
|
|
len(pods_to_terminate) > 1
|
|
), "Expected multiple pods to terminate in the target group."
|
|
|
|
provider.terminate(ids=pods_to_terminate, request_id="term-2")
|
|
|
|
# Check the patches applied to the RayCluster resource
|
|
patches = mock_client.get_patches(f"rayclusters/{provider._cluster_name}")
|
|
|
|
assert len(patches) == 2
|
|
assert patches == [
|
|
{
|
|
"op": "replace",
|
|
"path": "/spec/workerGroupSpecs/0/replicas",
|
|
"value": 0,
|
|
},
|
|
{
|
|
"op": "replace",
|
|
"path": "/spec/workerGroupSpecs/0/scaleStrategy",
|
|
"value": {
|
|
"workersToDelete": pods_to_terminate,
|
|
},
|
|
},
|
|
]
|
|
|
|
def test_worker_to_delete_info(self):
|
|
"""
|
|
Validate _get_workers_delete_info correctly returns the worker groups with pending
|
|
deletes, worker groups with finished deletes, and the set of workers to delete.
|
|
"""
|
|
# Create a RayCluster CR and set replicas to 0 to simulate the case where the autoscaler
|
|
# patches the RayCluster with `replicas: 0`, but alive Pods still exist in workersToDelete.
|
|
raycluster_cr = get_basic_ray_cr()
|
|
raycluster_cr["spec"]["workerGroupSpecs"][0]["replicas"] = 0
|
|
mock_client = MockKubernetesHttpApiClient(
|
|
_get_test_yaml("podlist2.yaml"), raycluster_cr
|
|
)
|
|
|
|
# Add some workers to workersToDelete.
|
|
small_group = "small-group"
|
|
pod_names = []
|
|
for pod in mock_client._pod_list["items"]:
|
|
if pod["metadata"]["labels"]["ray.io/group"] == small_group:
|
|
pod_names.append(pod["metadata"]["name"])
|
|
raycluster_cr["spec"]["workerGroupSpecs"][0]["scaleStrategy"] = {
|
|
"workersToDelete": pod_names,
|
|
}
|
|
|
|
(
|
|
pending_deletes,
|
|
finished_deletes,
|
|
workers_to_delete,
|
|
) = KubeRayProvider._get_workers_delete_info(raycluster_cr, set(pod_names))
|
|
|
|
# Validate _get_workers_delete_info populates sets as expected.
|
|
assert pending_deletes == {"small-group"}
|
|
assert finished_deletes == set()
|
|
assert workers_to_delete == {pod_names[0], pod_names[1]}
|
|
|
|
def test_set_no_driver_annotation_adds_when_absent(self):
|
|
self.provider._set_no_driver_annotation()
|
|
path = f"rayclusters/{self.provider._cluster_name}"
|
|
patch = self.mock_client.get_patches(path)
|
|
assert patch == {
|
|
"metadata": {"annotations": {NO_DRIVER_TTL_EXPIRED_ANNOTATION: "true"}}
|
|
}
|
|
|
|
def test_set_no_driver_annotation_idempotent(self):
|
|
self.provider._ray_cluster.setdefault("metadata", {}).setdefault(
|
|
"annotations", {}
|
|
)[NO_DRIVER_TTL_EXPIRED_ANNOTATION] = "true"
|
|
|
|
self.provider._set_no_driver_annotation()
|
|
path = f"rayclusters/{self.provider._cluster_name}"
|
|
assert path not in self.mock_client._patches
|
|
|
|
def test_set_no_driver_annotation_swallows_patch_failure(self):
|
|
path = f"rayclusters/{self.provider._cluster_name}"
|
|
|
|
def failing_patch(*args, **kwargs):
|
|
raise RuntimeError("k8s unreachable")
|
|
|
|
self.mock_client.patch = failing_patch
|
|
# Should not raise.
|
|
self.provider._set_no_driver_annotation()
|
|
assert path not in self.mock_client._patches
|
|
|
|
# --- No-driver termination predicate + dispatch ---
|
|
|
|
def _make_gcs(self, *jobs):
|
|
class _Config:
|
|
def __init__(self, ray_namespace):
|
|
self.ray_namespace = ray_namespace
|
|
|
|
class _Job:
|
|
def __init__(self, dead, ray_namespace, end_time=0):
|
|
self.is_dead = dead
|
|
self.end_time = end_time
|
|
self.config = _Config(ray_namespace)
|
|
|
|
class _Gcs:
|
|
def get_all_job_info(self, **_):
|
|
return {i: _Job(*job) for i, job in enumerate(jobs)}
|
|
|
|
return _Gcs()
|
|
|
|
def test_driver_status_filters_internal(self):
|
|
gcs = self._make_gcs(
|
|
(False, "_ray_internal_dashboard"),
|
|
(False, "_ray_internal_something"),
|
|
)
|
|
self.provider._gcs_client = gcs
|
|
assert self.provider._driver_status()[0] is False
|
|
|
|
def test_driver_status_counts_user_driver(self):
|
|
gcs = self._make_gcs(
|
|
(False, "_ray_internal_dashboard"),
|
|
(False, "default"),
|
|
)
|
|
self.provider._gcs_client = gcs
|
|
assert self.provider._driver_status()[0] is True
|
|
|
|
def test_driver_status_ignores_dead(self):
|
|
gcs = self._make_gcs((True, "default", 42))
|
|
self.provider._gcs_client = gcs
|
|
assert self.provider._driver_status() == (False, 42)
|
|
|
|
def test_driver_status_fail_closed(self):
|
|
class _FailingGcs:
|
|
def get_all_job_info(self, **_):
|
|
raise RuntimeError("gcs unreachable")
|
|
|
|
self.provider._gcs_client = _FailingGcs()
|
|
assert self.provider._driver_status()[0] is True
|
|
|
|
def test_evaluate_no_driver_termination_disabled_when_timeout_none(self):
|
|
path = f"rayclusters/{self.provider._cluster_name}"
|
|
self.provider._gcs_client = self._make_gcs() # no drivers
|
|
self.provider._no_driver_timeout_seconds = None
|
|
self.provider._evaluate_no_driver_termination()
|
|
assert path not in self.mock_client._patches
|
|
assert self.provider._no_driver_observed_since is None
|
|
|
|
def test_evaluate_no_driver_termination_waits_for_timeout(self):
|
|
self.provider._gcs_client = self._make_gcs() # no drivers
|
|
self.provider._no_driver_timeout_seconds = 100.0
|
|
|
|
def evaluate_at(t):
|
|
with mock.patch("time.monotonic", return_value=t):
|
|
self.provider._evaluate_no_driver_termination()
|
|
|
|
path = f"rayclusters/{self.provider._cluster_name}"
|
|
evaluate_at(0.0)
|
|
assert path not in self.mock_client._patches # anchored, not yet
|
|
evaluate_at(50.0)
|
|
assert path not in self.mock_client._patches # still below timeout
|
|
evaluate_at(100.0)
|
|
assert self.mock_client._patches.get(path) == {
|
|
"metadata": {"annotations": {NO_DRIVER_TTL_EXPIRED_ANNOTATION: "true"}}
|
|
}
|
|
|
|
def test_evaluate_no_driver_termination_resets_when_driver_attaches(self):
|
|
path = f"rayclusters/{self.provider._cluster_name}"
|
|
self.provider._no_driver_timeout_seconds = 100.0
|
|
|
|
with mock.patch("time.monotonic", return_value=0.0):
|
|
self.provider._gcs_client = self._make_gcs() # no drivers
|
|
self.provider._evaluate_no_driver_termination()
|
|
assert self.provider._no_driver_observed_since == 0.0
|
|
|
|
# Driver attaches → anchor cleared, no patch.
|
|
with mock.patch("time.monotonic", return_value=50.0):
|
|
self.provider._gcs_client = self._make_gcs((False, "default"))
|
|
self.provider._evaluate_no_driver_termination()
|
|
assert self.provider._no_driver_observed_since is None
|
|
assert path not in self.mock_client._patches
|
|
|
|
def test_evaluate_no_driver_termination_resets_on_intermittent_driver(self):
|
|
self.provider._no_driver_timeout_seconds = 100.0
|
|
|
|
# No driver: anchor at t=0.
|
|
with mock.patch("time.monotonic", return_value=0.0):
|
|
self.provider._gcs_client = self._make_gcs()
|
|
self.provider._evaluate_no_driver_termination()
|
|
assert self.provider._no_driver_observed_since == 0.0
|
|
|
|
# A short-lived driver started and finished between loops (a dead job
|
|
# with a newer end time): the timer must restart.
|
|
with mock.patch("time.monotonic", return_value=50.0):
|
|
self.provider._gcs_client = self._make_gcs((True, "default", 42))
|
|
self.provider._evaluate_no_driver_termination()
|
|
assert self.provider._no_driver_observed_since == 50.0
|
|
assert self.provider._last_seen_job_end_time == 42
|
|
|
|
def test_refresh_no_driver_timeout_seconds_reads_value(self):
|
|
self.provider._ray_cluster = {
|
|
"spec": {"autoscalerOptions": {"noDriverTimeoutSeconds": 1800}}
|
|
}
|
|
self.provider._refresh_no_driver_timeout_seconds()
|
|
assert self.provider._no_driver_timeout_seconds == 1800.0
|
|
|
|
def test_refresh_no_driver_timeout_seconds_unset(self):
|
|
self.provider._ray_cluster = {"spec": {"autoscalerOptions": {}}}
|
|
self.provider._refresh_no_driver_timeout_seconds()
|
|
assert self.provider._no_driver_timeout_seconds is None
|
|
|
|
def test_refresh_no_driver_timeout_seconds_no_autoscaler_options(self):
|
|
self.provider._ray_cluster = {"spec": {}}
|
|
self.provider._refresh_no_driver_timeout_seconds()
|
|
assert self.provider._no_driver_timeout_seconds is None
|
|
|
|
def test_scale_down_with_multi_host_group(self):
|
|
"""
|
|
Test the case where a worker group has numOfHosts > 1.
|
|
This ensures that the KubeRay provider accounts for multi-host replicas
|
|
during scale down and properly updates the workersToDelete field.
|
|
"""
|
|
# Setup mock RayCluster CR with numOfHosts: 2 and replicas: 1,
|
|
# resulting in 2 workers total.
|
|
raycluster_cr = get_basic_ray_cr()
|
|
raycluster_cr["spec"]["workerGroupSpecs"][0]["replicas"] = 2
|
|
mock_client = MockKubernetesHttpApiClient(
|
|
_get_test_yaml("podlist2.yaml"), raycluster_cr
|
|
)
|
|
provider = KubeRayProvider(
|
|
cluster_name="test",
|
|
provider_config={
|
|
"namespace": "default",
|
|
"head_node_type": "headgroup",
|
|
},
|
|
gcs_client=MagicMock(),
|
|
k8s_api_client=mock_client,
|
|
)
|
|
|
|
# Identify all pods in the multi-host group
|
|
pod_names = []
|
|
for pod in mock_client._pod_list["items"]:
|
|
if pod["metadata"]["labels"]["ray.io/group"] == "tpu-group":
|
|
pod_names.append(pod["metadata"]["name"])
|
|
|
|
# Expect 2 pods since replicas=1 and numOfHosts=2
|
|
assert len(pod_names) == 2, "Expected 2 pods in the multi-host group."
|
|
|
|
# Sync provider state and mark all pods for deletion
|
|
provider._sync_with_api_server()
|
|
cur_instance_ids = set(provider.instances.keys())
|
|
pods_to_terminate = [name for name in pod_names if name in cur_instance_ids]
|
|
|
|
assert (
|
|
len(pods_to_terminate) == 2
|
|
), "Expected all multi-host pods to be tracked by the provider."
|
|
|
|
# Terminate all pods in the group
|
|
provider.terminate(ids=pods_to_terminate, request_id="term-multi")
|
|
|
|
# Check that scale request successfully created
|
|
patches = mock_client.get_patches(f"rayclusters/{provider._cluster_name}")
|
|
|
|
assert len(patches) == 2
|
|
assert patches == [
|
|
{
|
|
"op": "replace",
|
|
"path": "/spec/workerGroupSpecs/2/replicas",
|
|
"value": 0,
|
|
},
|
|
{
|
|
"op": "replace",
|
|
"path": "/spec/workerGroupSpecs/2/scaleStrategy",
|
|
"value": {
|
|
"workersToDelete": pods_to_terminate,
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if os.environ.get("PARALLEL_CI"):
|
|
sys.exit(pytest.main(["-n", "auto", "--boxed", "-vs", __file__]))
|
|
else:
|
|
sys.exit(pytest.main(["-sv", __file__]))
|