2104 lines
75 KiB
Python
2104 lines
75 KiB
Python
import math
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
# coding: utf-8
|
|
from collections import defaultdict
|
|
from unittest import mock
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from ray._common.utils import binary_to_hex
|
|
from ray.autoscaler.v2.instance_manager.config import InstanceReconcileConfig, Provider
|
|
from ray.autoscaler.v2.instance_manager.instance_manager import InstanceManager
|
|
from ray.autoscaler.v2.instance_manager.instance_storage import InstanceStorage
|
|
from ray.autoscaler.v2.instance_manager.node_provider import ( # noqa
|
|
CloudInstance,
|
|
LaunchNodeError,
|
|
TerminateNodeError,
|
|
)
|
|
from ray.autoscaler.v2.instance_manager.reconciler import Reconciler, logger
|
|
from ray.autoscaler.v2.instance_manager.storage import InMemoryStorage
|
|
from ray.autoscaler.v2.instance_manager.subscribers.cloud_resource_monitor import (
|
|
CloudResourceMonitor,
|
|
)
|
|
from ray.autoscaler.v2.instance_manager.subscribers.ray_stopper import RayStopError
|
|
from ray.autoscaler.v2.instance_manager.subscribers.threaded_ray_installer import (
|
|
RayInstallError,
|
|
)
|
|
from ray.autoscaler.v2.scheduler import IResourceScheduler, SchedulingReply
|
|
from ray.autoscaler.v2.tests.util import MockSubscriber, create_instance
|
|
from ray.core.generated.autoscaler_pb2 import (
|
|
ClusterResourceState,
|
|
GangResourceRequest,
|
|
NodeState,
|
|
NodeStatus,
|
|
ResourceRequest,
|
|
)
|
|
from ray.core.generated.instance_manager_pb2 import (
|
|
Instance,
|
|
LaunchRequest,
|
|
NodeKind,
|
|
TerminationRequest,
|
|
)
|
|
|
|
s_to_ns = 1 * 1_000_000_000
|
|
|
|
logger.setLevel("DEBUG")
|
|
|
|
|
|
class MockAutoscalingConfig:
|
|
def __init__(self, configs=None):
|
|
if configs is None:
|
|
configs = {}
|
|
self._configs = configs
|
|
|
|
def get_node_type_configs(self):
|
|
return self._configs.get("node_type_configs", {})
|
|
|
|
def get_max_num_worker_nodes(self):
|
|
return self._configs.get("max_num_worker_nodes")
|
|
|
|
def get_max_num_nodes(self):
|
|
n = self._configs.get("max_num_worker_nodes")
|
|
return n + 1 if n is not None else None
|
|
|
|
def get_upscaling_speed(self):
|
|
return self._configs.get("upscaling_speed", 0.0)
|
|
|
|
def get_max_concurrent_launches(self):
|
|
return self._configs.get("max_concurrent_launches", 100)
|
|
|
|
def get_instance_reconcile_config(self):
|
|
return self._configs.get("instance_reconcile_config", InstanceReconcileConfig())
|
|
|
|
def disable_node_updaters(self):
|
|
return self._configs.get("disable_node_updaters", True)
|
|
|
|
def disable_launch_config_check(self):
|
|
return self._configs.get("disable_launch_config_check", False)
|
|
|
|
def get_idle_timeout_s(self):
|
|
return self._configs.get("idle_timeout_s", 999)
|
|
|
|
def get_provider_instance_type(self, ray_node_type):
|
|
return self._configs.get("provider_instance_types", {}).get(ray_node_type, "")
|
|
|
|
@property
|
|
def provider(self):
|
|
return self._configs.get("provider", Provider.UNKNOWN)
|
|
|
|
|
|
class MockScheduler(IResourceScheduler):
|
|
def __init__(self, to_launch=None, to_terminate=None):
|
|
if to_launch is None:
|
|
to_launch = []
|
|
|
|
if to_terminate is None:
|
|
to_terminate = []
|
|
|
|
self.to_launch = to_launch
|
|
self.to_terminate = to_terminate
|
|
|
|
def schedule(self, req):
|
|
return SchedulingReply(
|
|
to_launch=self.to_launch,
|
|
to_terminate=self.to_terminate,
|
|
)
|
|
|
|
|
|
@pytest.fixture()
|
|
def setup():
|
|
instance_storage = InstanceStorage(
|
|
cluster_id="test_cluster_id",
|
|
storage=InMemoryStorage(),
|
|
)
|
|
|
|
mock_subscriber = MockSubscriber()
|
|
|
|
instance_manager = InstanceManager(
|
|
instance_storage=instance_storage,
|
|
instance_status_update_subscribers=[mock_subscriber],
|
|
)
|
|
|
|
cloud_resource_monitor = CloudResourceMonitor()
|
|
|
|
yield instance_manager, instance_storage, mock_subscriber, cloud_resource_monitor
|
|
|
|
|
|
class TestReconciler:
|
|
@staticmethod
|
|
def _add_instances(instance_storage, instances):
|
|
for instance in instances:
|
|
ok, _ = instance_storage.upsert_instance(instance)
|
|
assert ok
|
|
|
|
@staticmethod
|
|
def test_requested_instance_no_op(setup):
|
|
instance_manager, instance_storage, _, cloud_resource_monitor = setup
|
|
# Request no timeout yet.
|
|
TestReconciler._add_instances(
|
|
instance_storage,
|
|
[
|
|
create_instance(
|
|
"i-1",
|
|
Instance.REQUESTED,
|
|
launch_request_id="l1",
|
|
instance_type="type-1",
|
|
status_times=[(Instance.REQUESTED, time.time_ns())],
|
|
),
|
|
],
|
|
)
|
|
|
|
Reconciler.reconcile(
|
|
instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(),
|
|
non_terminated_cloud_instances={},
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(),
|
|
)
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
assert len(instances) == 1
|
|
assert instances["i-1"].status == Instance.REQUESTED
|
|
|
|
@staticmethod
|
|
def test_requested_instance_to_allocated(setup):
|
|
# When there's a matching cloud instance for the requested instance.
|
|
# The requested instance should be moved to ALLOCATED.
|
|
instance_manager, instance_storage, _, cloud_resource_monitor = setup
|
|
TestReconciler._add_instances(
|
|
instance_storage,
|
|
[
|
|
create_instance(
|
|
"i-1",
|
|
status=Instance.REQUESTED,
|
|
instance_type="type-1",
|
|
status_times=[(Instance.REQUESTED, time.time_ns())],
|
|
launch_request_id="l1",
|
|
),
|
|
create_instance(
|
|
"i-2",
|
|
status=Instance.REQUESTED,
|
|
instance_type="type-2",
|
|
status_times=[(Instance.REQUESTED, time.time_ns())],
|
|
launch_request_id="l2",
|
|
),
|
|
],
|
|
)
|
|
|
|
cloud_instances = {
|
|
"c-1": CloudInstance("c-1", "type-1", True, NodeKind.WORKER),
|
|
}
|
|
|
|
Reconciler.reconcile(
|
|
instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(),
|
|
)
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
|
|
assert len(instances) == 2
|
|
assert instances["i-1"].status == Instance.ALLOCATED
|
|
assert instances["i-1"].cloud_instance_id == "c-1"
|
|
assert instances["i-2"].status == Instance.REQUESTED
|
|
|
|
@staticmethod
|
|
def test_requested_instance_to_allocation_failed(setup):
|
|
"""
|
|
Test that the instance should be transitioned to ALLOCATION_FAILED
|
|
when launch error happens.
|
|
"""
|
|
instance_manager, instance_storage, _, cloud_resource_monitor = setup
|
|
|
|
instances = [
|
|
# Should succeed with instance matched.
|
|
create_instance(
|
|
"i-1",
|
|
status=Instance.REQUESTED,
|
|
instance_type="type-1",
|
|
launch_request_id="l1",
|
|
),
|
|
# Should fail due to launch error.
|
|
create_instance(
|
|
"i-2",
|
|
status=Instance.REQUESTED,
|
|
instance_type="type-2",
|
|
launch_request_id="l1",
|
|
),
|
|
]
|
|
TestReconciler._add_instances(instance_storage, instances)
|
|
|
|
launch_errors = [
|
|
LaunchNodeError(
|
|
request_id="l1",
|
|
count=1, # The request failed.
|
|
node_type="type-2",
|
|
timestamp_ns=1,
|
|
)
|
|
]
|
|
|
|
cloud_instances = {
|
|
"c-1": CloudInstance("c-1", "type-1", True, NodeKind.WORKER),
|
|
}
|
|
Reconciler.reconcile(
|
|
instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=launch_errors,
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(),
|
|
)
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
assert len(instances) == 2
|
|
assert instances["i-1"].status == Instance.ALLOCATED
|
|
assert instances["i-1"].cloud_instance_id == "c-1"
|
|
assert instances["i-2"].status == Instance.ALLOCATION_FAILED
|
|
|
|
@staticmethod
|
|
def test_reconcile_terminated_cloud_instances(setup):
|
|
|
|
instance_manager, instance_storage, subscriber, cloud_resource_monitor = setup
|
|
|
|
instances = [
|
|
create_instance(
|
|
"i-1",
|
|
status=Instance.ALLOCATED,
|
|
instance_type="type-1",
|
|
cloud_instance_id="c-1",
|
|
),
|
|
create_instance(
|
|
"i-2",
|
|
status=Instance.TERMINATING, # pending terminated, no update
|
|
instance_type="type-2",
|
|
cloud_instance_id="c-2",
|
|
),
|
|
create_instance(
|
|
"i-3",
|
|
status=Instance.TERMINATED, # Already terminated, no update
|
|
instance_type="type-2",
|
|
cloud_instance_id="c-3",
|
|
),
|
|
]
|
|
TestReconciler._add_instances(instance_storage, instances)
|
|
|
|
cloud_instances = {
|
|
"c-2": CloudInstance("c-2", "type-2", False, NodeKind.WORKER),
|
|
}
|
|
|
|
termination_errors = [
|
|
TerminateNodeError(
|
|
cloud_instance_id="c-2",
|
|
timestamp_ns=1,
|
|
request_id="t1",
|
|
)
|
|
]
|
|
subscriber.clear()
|
|
Reconciler.reconcile(
|
|
instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=termination_errors,
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(),
|
|
)
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
assert len(instances) == 3
|
|
assert instances["i-1"].status == Instance.TERMINATED
|
|
assert instances["i-2"].status == Instance.TERMINATING
|
|
events = subscriber.events
|
|
assert len(events) == 3
|
|
|
|
events_i_1 = subscriber.events_by_id("i-1")
|
|
assert len(events_i_1) == 1
|
|
assert events_i_1[0].new_instance_status == Instance.TERMINATED
|
|
assert events_i_1[0].instance_id == "i-1"
|
|
assert events_i_1[0].cloud_instance_id == "c-1"
|
|
|
|
events_i_2 = subscriber.events_by_id("i-2")
|
|
assert len(events_i_2) == 2
|
|
assert events_i_2[0].new_instance_status == Instance.TERMINATION_FAILED
|
|
assert events_i_2[1].new_instance_status == Instance.TERMINATING
|
|
|
|
@staticmethod
|
|
def test_ray_reconciler_no_op(setup):
|
|
instance_manager, instance_storage, subscriber, cloud_resource_monitor = setup
|
|
|
|
im_instances = [
|
|
create_instance(
|
|
"i-1",
|
|
status=Instance.ALLOCATED,
|
|
launch_request_id="l1",
|
|
instance_type="type-1",
|
|
cloud_instance_id="c-1",
|
|
),
|
|
]
|
|
cloud_instances = {
|
|
"c-1": CloudInstance("c-1", "type-1", True, NodeKind.WORKER),
|
|
}
|
|
|
|
TestReconciler._add_instances(instance_storage, im_instances)
|
|
subscriber.clear()
|
|
|
|
Reconciler.reconcile(
|
|
instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(),
|
|
)
|
|
|
|
# Assert no changes.
|
|
assert subscriber.events == []
|
|
|
|
# Unknown ray node status - no action.
|
|
ray_nodes = [
|
|
NodeState(node_id=b"r-1", status=NodeStatus.UNSPECIFIED, instance_id="c-1"),
|
|
]
|
|
|
|
with pytest.raises(ValueError):
|
|
Reconciler.reconcile(
|
|
instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(node_states=ray_nodes),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(),
|
|
)
|
|
|
|
# Assert no changes.
|
|
assert subscriber.events == []
|
|
|
|
@staticmethod
|
|
def test_ray_reconciler_new_ray(setup):
|
|
instance_manager, instance_storage, _, cloud_resource_monitor = setup
|
|
|
|
# A newly running ray node with matching cloud instance id
|
|
node_states = [
|
|
NodeState(node_id=b"r-1", status=NodeStatus.RUNNING, instance_id="c-1"),
|
|
]
|
|
im_instances = [
|
|
create_instance(
|
|
"i-0", status=Instance.TERMINATED, cloud_instance_id="c-1"
|
|
), # this should not be matched.
|
|
create_instance("i-1", status=Instance.ALLOCATED, cloud_instance_id="c-1"),
|
|
]
|
|
cloud_instances = {
|
|
"c-1": CloudInstance("c-1", "type-1", True, NodeKind.WORKER),
|
|
}
|
|
TestReconciler._add_instances(instance_storage, im_instances)
|
|
Reconciler.reconcile(
|
|
instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(node_states=node_states),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(),
|
|
)
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
assert len(instances) == 2
|
|
assert instances["i-0"].status == Instance.TERMINATED
|
|
assert instances["i-1"].status == Instance.RAY_RUNNING
|
|
assert instances["i-1"].node_id == binary_to_hex(b"r-1")
|
|
|
|
@staticmethod
|
|
def test_ray_reconciler_already_ray_running(setup):
|
|
instance_manager, instance_storage, subscriber, cloud_resource_monitor = setup
|
|
# A running ray node already reconciled.
|
|
TestReconciler._add_instances(
|
|
instance_storage,
|
|
[
|
|
create_instance(
|
|
"i-1", status=Instance.RAY_RUNNING, cloud_instance_id="c-1"
|
|
),
|
|
create_instance(
|
|
"i-2", status=Instance.TERMINATING, cloud_instance_id="c-2"
|
|
), # Already reconciled.
|
|
],
|
|
)
|
|
ray_nodes = [
|
|
NodeState(node_id=b"r-1", status=NodeStatus.IDLE, instance_id="c-1"),
|
|
NodeState(
|
|
node_id=b"r-2", status=NodeStatus.IDLE, instance_id="c-2"
|
|
), # Already being stopped
|
|
]
|
|
cloud_instances = {
|
|
"c-1": CloudInstance("c-1", "type-1", True, NodeKind.WORKER),
|
|
"c-2": CloudInstance("c-2", "type-2", True, NodeKind.WORKER),
|
|
}
|
|
|
|
subscriber.clear()
|
|
Reconciler.reconcile(
|
|
instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(node_states=ray_nodes),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(),
|
|
)
|
|
|
|
assert len(subscriber.events) == 0
|
|
|
|
@staticmethod
|
|
def test_ray_reconciler_stopping_ray(setup):
|
|
instance_manager, instance_storage, _, cloud_resource_monitor = setup
|
|
|
|
# draining ray nodes
|
|
im_instances = [
|
|
create_instance(
|
|
"i-1", status=Instance.RAY_RUNNING, cloud_instance_id="c-1"
|
|
), # To be reconciled.
|
|
create_instance(
|
|
"i-2", status=Instance.RAY_STOPPING, cloud_instance_id="c-2"
|
|
), # Already reconciled.
|
|
create_instance(
|
|
"i-3", status=Instance.TERMINATING, cloud_instance_id="c-3"
|
|
), # Already reconciled.
|
|
]
|
|
|
|
TestReconciler._add_instances(instance_storage, im_instances)
|
|
|
|
ray_nodes = [
|
|
NodeState(node_id=b"r-1", status=NodeStatus.DRAINING, instance_id="c-1"),
|
|
NodeState(node_id=b"r-2", status=NodeStatus.DRAINING, instance_id="c-2"),
|
|
NodeState(node_id=b"r-3", status=NodeStatus.DRAINING, instance_id="c-3"),
|
|
]
|
|
cloud_instances = {
|
|
"c-1": CloudInstance("c-1", "type-1", True, NodeKind.WORKER),
|
|
"c-2": CloudInstance("c-2", "type-2", True, NodeKind.WORKER),
|
|
"c-3": CloudInstance("c-3", "type-3", True, NodeKind.WORKER),
|
|
}
|
|
|
|
Reconciler.reconcile(
|
|
instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(node_states=ray_nodes),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(),
|
|
)
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
assert len(instances) == 3
|
|
assert instances["i-1"].status == Instance.RAY_STOPPING
|
|
assert instances["i-2"].status == Instance.RAY_STOPPING
|
|
assert instances["i-3"].status == Instance.TERMINATING
|
|
|
|
@staticmethod
|
|
def test_ray_reconciler_stopped_ray(setup):
|
|
instance_manager, instance_storage, _, cloud_resource_monitor = setup
|
|
|
|
# dead ray nodes
|
|
im_instances = [
|
|
create_instance(
|
|
"i-1", status=Instance.ALLOCATED, cloud_instance_id="c-1"
|
|
), # To be reconciled.
|
|
create_instance(
|
|
"i-2", status=Instance.RAY_STOPPING, cloud_instance_id="c-2"
|
|
), # To be reconciled.
|
|
create_instance(
|
|
"i-3", status=Instance.TERMINATING, cloud_instance_id="c-3"
|
|
), # Already reconciled.
|
|
]
|
|
TestReconciler._add_instances(instance_storage, im_instances)
|
|
|
|
ray_nodes = [
|
|
NodeState(node_id=b"r-1", status=NodeStatus.DEAD, instance_id="c-1"),
|
|
NodeState(node_id=b"r-2", status=NodeStatus.DEAD, instance_id="c-2"),
|
|
NodeState(node_id=b"r-3", status=NodeStatus.DEAD, instance_id="c-3"),
|
|
]
|
|
|
|
cloud_instances = {
|
|
"c-1": CloudInstance("c-1", "type-1", True, NodeKind.WORKER),
|
|
"c-2": CloudInstance("c-2", "type-2", True, NodeKind.WORKER),
|
|
"c-3": CloudInstance("c-3", "type-3", True, NodeKind.WORKER),
|
|
}
|
|
|
|
Reconciler.reconcile(
|
|
instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(node_states=ray_nodes),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(),
|
|
)
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
assert len(instances) == 3
|
|
assert instances["i-1"].status == Instance.TERMINATING
|
|
assert instances["i-2"].status == Instance.TERMINATING
|
|
assert instances["i-3"].status == Instance.TERMINATING
|
|
|
|
@staticmethod
|
|
def test_reconcile_ray_installer_failures(setup):
|
|
instance_manager, instance_storage, _, cloud_resource_monitor = setup
|
|
|
|
# dead ray nodes
|
|
im_instances = [
|
|
create_instance(
|
|
"i-1", status=Instance.RAY_INSTALLING, cloud_instance_id="c-1"
|
|
), # To be reconciled.
|
|
]
|
|
TestReconciler._add_instances(instance_storage, im_instances)
|
|
|
|
ray_install_errors = [
|
|
RayInstallError(
|
|
im_instance_id="i-1",
|
|
details="failed to install",
|
|
)
|
|
]
|
|
|
|
cloud_instances = {
|
|
"c-1": CloudInstance("c-1", "type-1", True, NodeKind.WORKER),
|
|
}
|
|
|
|
Reconciler.reconcile(
|
|
instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(node_states=[]),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=ray_install_errors,
|
|
autoscaling_config=MockAutoscalingConfig(),
|
|
)
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
assert len(instances) == 1
|
|
assert instances["i-1"].status == Instance.TERMINATING
|
|
|
|
@staticmethod
|
|
def test_draining_ray_node_also_terminated(setup):
|
|
"""
|
|
A draining ray node due to a cloud instance termination should also
|
|
transition the instance to TERMINATED.
|
|
"""
|
|
|
|
instance_manager, instance_storage, _, cloud_resource_monitor = setup
|
|
|
|
im_instances = [
|
|
create_instance(
|
|
"i-1",
|
|
status=Instance.RAY_RUNNING,
|
|
cloud_instance_id="c-1",
|
|
ray_node_id=binary_to_hex(b"r-1"),
|
|
), # To be reconciled.
|
|
create_instance(
|
|
"i-2",
|
|
status=Instance.RAY_RUNNING,
|
|
cloud_instance_id="c-2",
|
|
ray_node_id=binary_to_hex(b"r-2"),
|
|
), # To be reconciled.
|
|
]
|
|
TestReconciler._add_instances(instance_storage, im_instances)
|
|
|
|
ray_nodes = [
|
|
NodeState(node_id=b"r-1", status=NodeStatus.DEAD, instance_id="c-1"),
|
|
NodeState(node_id=b"r-2", status=NodeStatus.DRAINING, instance_id="c-2"),
|
|
]
|
|
|
|
cloud_instances = {
|
|
# Terminated cloud instance.
|
|
}
|
|
|
|
Reconciler.reconcile(
|
|
instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(node_states=ray_nodes),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(),
|
|
)
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
assert len(instances) == 2
|
|
assert instances["i-1"].status == Instance.TERMINATED
|
|
assert instances["i-2"].status == Instance.TERMINATED
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize(
|
|
"max_concurrent_launches,num_allocated,num_requested,num_running",
|
|
[
|
|
(1, 0, 0, 0),
|
|
(10, 0, 0, 0),
|
|
(1, 0, 1, 1),
|
|
(1, 1, 0, 1),
|
|
(10, 1, 0, 1),
|
|
(10, 0, 1, 1),
|
|
(10, 5, 5, 5),
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"upscaling_speed",
|
|
[0.0, 0.1, 0.5, 1.0, 100.0],
|
|
)
|
|
def test_max_concurrent_launches(
|
|
max_concurrent_launches,
|
|
num_allocated,
|
|
num_requested,
|
|
num_running,
|
|
upscaling_speed,
|
|
setup,
|
|
):
|
|
instance_manager, instance_storage, subscriber, cloud_resource_monitor = setup
|
|
next_id = 0
|
|
|
|
# Add some allocated instances.
|
|
cloud_instances = {}
|
|
for _ in range(num_allocated):
|
|
instance = create_instance(
|
|
str(next_id),
|
|
status=Instance.ALLOCATED,
|
|
instance_type="type-1",
|
|
cloud_instance_id=f"c-{next_id}",
|
|
)
|
|
next_id += 1
|
|
cloud_instances[instance.cloud_instance_id] = CloudInstance(
|
|
instance.cloud_instance_id, "type-1", True, NodeKind.WORKER
|
|
)
|
|
TestReconciler._add_instances(instance_storage, [instance])
|
|
|
|
# Add some requested instances.
|
|
for _ in range(num_requested):
|
|
instance = create_instance(
|
|
str(next_id),
|
|
status=Instance.REQUESTED,
|
|
instance_type="type-1",
|
|
launch_request_id="l-1",
|
|
)
|
|
TestReconciler._add_instances(instance_storage, [instance])
|
|
next_id += 1
|
|
|
|
# Add many queued instances.
|
|
queued_instances = [
|
|
create_instance(
|
|
str(i + next_id), status=Instance.QUEUED, instance_type="type-1"
|
|
)
|
|
for i in range(1000)
|
|
]
|
|
TestReconciler._add_instances(instance_storage, queued_instances)
|
|
|
|
# Add some running instances.
|
|
for _ in range(num_running):
|
|
instance = create_instance(
|
|
str(next_id),
|
|
status=Instance.RAY_RUNNING,
|
|
instance_type="type-1",
|
|
launch_request_id="l-1",
|
|
)
|
|
TestReconciler._add_instances(instance_storage, [instance])
|
|
next_id += 1
|
|
|
|
num_desired_upscale = max(1, math.ceil(upscaling_speed * (max(num_running, 1))))
|
|
expected_launch_num = min(
|
|
num_desired_upscale,
|
|
max(0, max_concurrent_launches - num_requested), # global limit
|
|
)
|
|
|
|
subscriber.clear()
|
|
Reconciler.reconcile(
|
|
instance_manager=instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(
|
|
configs={
|
|
"upscaling_speed": upscaling_speed,
|
|
"max_concurrent_launches": max_concurrent_launches,
|
|
}
|
|
),
|
|
)
|
|
instances, _ = instance_storage.get_instances()
|
|
assert len(subscriber.events) == expected_launch_num
|
|
for event in subscriber.events:
|
|
assert event.new_instance_status == Instance.REQUESTED
|
|
assert (
|
|
event.launch_request_id
|
|
== instances[event.instance_id].launch_request_id
|
|
)
|
|
assert event.instance_type == "type-1"
|
|
|
|
@staticmethod
|
|
@mock.patch("time.time_ns")
|
|
def test_stuck_instances_requested(mock_time_ns, setup):
|
|
instance_manager, instance_storage, subscriber, cloud_resource_monitor = setup
|
|
cur_time_s = 10
|
|
mock_time_ns.return_value = cur_time_s * s_to_ns
|
|
|
|
reconcile_config = InstanceReconcileConfig(
|
|
request_status_timeout_s=5,
|
|
max_num_retry_request_to_allocate=1, # max 1 retry.
|
|
)
|
|
|
|
instances = [
|
|
create_instance(
|
|
"no-update",
|
|
Instance.REQUESTED,
|
|
status_times=[(Instance.REQUESTED, 9 * s_to_ns)],
|
|
launch_request_id="l1",
|
|
),
|
|
create_instance(
|
|
"retry",
|
|
Instance.REQUESTED,
|
|
status_times=[(Instance.REQUESTED, 2 * s_to_ns)],
|
|
launch_request_id="l2",
|
|
),
|
|
create_instance(
|
|
"failed",
|
|
Instance.REQUESTED,
|
|
status_times=[
|
|
(Instance.REQUESTED, 1 * s_to_ns),
|
|
(Instance.REQUESTED, 2 * s_to_ns), # This is a retry.
|
|
],
|
|
launch_request_id="l3",
|
|
),
|
|
]
|
|
|
|
TestReconciler._add_instances(instance_storage, instances)
|
|
|
|
Reconciler.reconcile(
|
|
instance_manager=instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(),
|
|
non_terminated_cloud_instances={},
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(
|
|
configs={
|
|
"instance_reconcile_config": reconcile_config,
|
|
"max_concurrent_launches": 0, # prevent launches
|
|
}
|
|
),
|
|
)
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
assert instances["no-update"].status == Instance.REQUESTED
|
|
assert instances["retry"].status == Instance.QUEUED
|
|
assert instances["failed"].status == Instance.ALLOCATION_FAILED
|
|
|
|
@staticmethod
|
|
@mock.patch("time.time_ns")
|
|
def test_stuck_instances_ray_stop_requested(mock_time_ns, setup):
|
|
instance_manager, instance_storage, subscriber, cloud_resource_monitor = setup
|
|
timeout_s = 5
|
|
cur_time_s = 20
|
|
mock_time_ns.return_value = cur_time_s * s_to_ns
|
|
|
|
config = InstanceReconcileConfig(
|
|
ray_stop_requested_status_timeout_s=timeout_s,
|
|
)
|
|
cur_status = Instance.RAY_STOP_REQUESTED
|
|
|
|
instances = [
|
|
create_instance(
|
|
"no-update",
|
|
cur_status,
|
|
status_times=[(cur_status, (cur_time_s - timeout_s + 1) * s_to_ns)],
|
|
ray_node_id=binary_to_hex(b"r-1"),
|
|
),
|
|
create_instance(
|
|
"updated",
|
|
cur_status,
|
|
status_times=[(cur_status, (cur_time_s - timeout_s - 1) * s_to_ns)],
|
|
ray_node_id=binary_to_hex(b"r-2"),
|
|
),
|
|
]
|
|
# Both nodes are still RUNNING in GCS, so the drain is treated as having
|
|
# genuinely failed: the stuck handler must fall back to RAY_RUNNING.
|
|
ray_nodes = [
|
|
NodeState(node_id=b"r-1", status=NodeStatus.RUNNING),
|
|
NodeState(node_id=b"r-2", status=NodeStatus.RUNNING),
|
|
]
|
|
|
|
TestReconciler._add_instances(instance_storage, instances)
|
|
|
|
Reconciler.reconcile(
|
|
instance_manager=instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(node_states=ray_nodes),
|
|
non_terminated_cloud_instances={},
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(
|
|
configs={
|
|
"instance_reconcile_config": config,
|
|
"max_concurrent_launches": 0, # prevent launches
|
|
}
|
|
),
|
|
)
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
assert instances["no-update"].status == cur_status
|
|
assert instances["updated"].status == Instance.RAY_RUNNING
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize(
|
|
"cur_status",
|
|
[
|
|
Instance.RAY_RUNNING,
|
|
Instance.RAY_STOP_REQUESTED,
|
|
Instance.RAY_STOPPING,
|
|
],
|
|
ids=["ray_running", "ray_stop_requested", "ray_stopping"],
|
|
)
|
|
def test_missing_ray_node_marks_ray_stopped_and_terminates(cur_status, setup):
|
|
# If an instance that should have a ray node points at a node_id that no
|
|
# longer appears in GCS, the ray node has either been GC'd from the DEAD
|
|
# list or is otherwise no longer alive. Reconcile it to RAY_STOPPED so it
|
|
# can move through the normal termination path instead of lingering with a
|
|
# stale node_id (especially after RAY_STOP_REQUESTED fell back to
|
|
# RAY_RUNNING in a previous iteration).
|
|
instance_manager, instance_storage, subscriber, cloud_resource_monitor = setup
|
|
|
|
instances = [
|
|
create_instance(
|
|
"updated",
|
|
cur_status,
|
|
ray_node_id=binary_to_hex(b"r-stale"),
|
|
cloud_instance_id="c-stale",
|
|
),
|
|
]
|
|
|
|
TestReconciler._add_instances(instance_storage, instances)
|
|
|
|
Reconciler.reconcile(
|
|
instance_manager=instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(node_states=[]),
|
|
non_terminated_cloud_instances={
|
|
"c-stale": CloudInstance(
|
|
cloud_instance_id="c-stale",
|
|
node_type="worker_nodes1",
|
|
node_kind=NodeKind.WORKER,
|
|
is_running=True,
|
|
),
|
|
},
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(
|
|
configs={"max_concurrent_launches": 0}
|
|
),
|
|
)
|
|
|
|
events = subscriber.events
|
|
assert len(events) == 2
|
|
assert events[0].new_instance_status == Instance.RAY_STOPPED
|
|
assert events[0].ray_node_id == binary_to_hex(b"r-stale")
|
|
assert events[1].new_instance_status == Instance.TERMINATING
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
assert instances["updated"].status == Instance.TERMINATING
|
|
|
|
@staticmethod
|
|
def test_ray_stopped_shared_cloud_terminates_stale_record_only(setup):
|
|
instance_manager, instance_storage, subscriber, _ = setup
|
|
|
|
TestReconciler._add_instances(
|
|
instance_storage,
|
|
[
|
|
create_instance(
|
|
"stale",
|
|
Instance.RAY_STOPPED,
|
|
ray_node_id=binary_to_hex(b"r-stale"),
|
|
cloud_instance_id="c-shared",
|
|
),
|
|
create_instance(
|
|
"healthy",
|
|
Instance.RAY_RUNNING,
|
|
ray_node_id=binary_to_hex(b"r-live"),
|
|
cloud_instance_id="c-shared",
|
|
),
|
|
],
|
|
)
|
|
|
|
Reconciler._terminate_instances(instance_manager)
|
|
|
|
assert [e.new_instance_status for e in subscriber.events_by_id("stale")] == [
|
|
Instance.TERMINATED
|
|
]
|
|
assert all(
|
|
e.new_instance_status != Instance.TERMINATING for e in subscriber.events
|
|
)
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
assert instances["stale"].status == Instance.TERMINATED
|
|
assert instances["healthy"].status == Instance.RAY_RUNNING
|
|
|
|
@staticmethod
|
|
@mock.patch("time.time_ns")
|
|
def test_ray_stop_requested_fail(mock_time_ns, setup):
|
|
# Test that the instance should be transitioned to RAY_RUNNING
|
|
# when the ray stop request fails.
|
|
|
|
instance_manager, instance_storage, _, cloud_resource_monitor = setup
|
|
mock_time_ns.return_value = 10 * s_to_ns
|
|
|
|
instances = [
|
|
create_instance(
|
|
"i-1",
|
|
status=Instance.RAY_STOP_REQUESTED,
|
|
ray_node_id=binary_to_hex(b"r-1"),
|
|
cloud_instance_id="c-1",
|
|
status_times=[(Instance.RAY_STOP_REQUESTED, 10 * s_to_ns)],
|
|
),
|
|
create_instance(
|
|
"i-2",
|
|
status=Instance.RAY_STOP_REQUESTED,
|
|
ray_node_id=binary_to_hex(b"r-2"),
|
|
cloud_instance_id="c-2",
|
|
status_times=[(Instance.RAY_STOP_REQUESTED, 10 * s_to_ns)],
|
|
),
|
|
]
|
|
|
|
ray_nodes = [
|
|
NodeState(node_id=b"r-1", status=NodeStatus.RUNNING, instance_id="c-1"),
|
|
NodeState(node_id=b"r-2", status=NodeStatus.RUNNING, instance_id="c-2"),
|
|
]
|
|
|
|
ray_stop_errors = [
|
|
RayStopError(im_instance_id="i-1"),
|
|
]
|
|
|
|
cloud_instances = {
|
|
"c-1": CloudInstance("c-1", "type-1", True, NodeKind.WORKER),
|
|
"c-2": CloudInstance("c-2", "type-2", True, NodeKind.WORKER),
|
|
}
|
|
|
|
TestReconciler._add_instances(instance_storage, instances)
|
|
|
|
Reconciler.reconcile(
|
|
instance_manager=instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(node_states=ray_nodes),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
ray_stop_errors=ray_stop_errors,
|
|
autoscaling_config=MockAutoscalingConfig(),
|
|
)
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
assert len(instances) == 2
|
|
assert instances["i-1"].status == Instance.RAY_RUNNING
|
|
assert instances["i-2"].status == Instance.RAY_STOP_REQUESTED
|
|
|
|
@staticmethod
|
|
@mock.patch("time.time_ns")
|
|
@pytest.mark.parametrize(
|
|
"cur_status,expect_status",
|
|
[
|
|
(Instance.ALLOCATED, Instance.TERMINATING),
|
|
(Instance.RAY_INSTALLING, Instance.TERMINATING),
|
|
(Instance.TERMINATING, Instance.TERMINATING),
|
|
],
|
|
)
|
|
def test_stuck_instances(mock_time_ns, cur_status, expect_status, setup):
|
|
instance_manager, instance_storage, subscriber, cloud_resource_monitor = setup
|
|
timeout_s = 5
|
|
cur_time_s = 20
|
|
mock_time_ns.return_value = cur_time_s * s_to_ns
|
|
config = InstanceReconcileConfig(
|
|
allocate_status_timeout_s=timeout_s,
|
|
terminating_status_timeout_s=timeout_s,
|
|
ray_install_status_timeout_s=timeout_s,
|
|
)
|
|
instances = [
|
|
create_instance(
|
|
"no-update",
|
|
cur_status,
|
|
status_times=[(cur_status, (cur_time_s - timeout_s + 1) * s_to_ns)],
|
|
instance_type="type-1",
|
|
cloud_instance_id="c-1",
|
|
),
|
|
create_instance(
|
|
"updated",
|
|
cur_status,
|
|
status_times=[(cur_status, (cur_time_s - timeout_s - 1) * s_to_ns)],
|
|
instance_type="type-1",
|
|
cloud_instance_id="c-2",
|
|
),
|
|
]
|
|
|
|
cloud_instances = {
|
|
"c-1": CloudInstance("c-1", "type-1", True, NodeKind.WORKER),
|
|
"c-2": CloudInstance("c-2", "type-1", True, NodeKind.WORKER),
|
|
}
|
|
|
|
TestReconciler._add_instances(instance_storage, instances)
|
|
|
|
Reconciler.reconcile(
|
|
instance_manager=instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(
|
|
configs={
|
|
"instance_reconcile_config": config,
|
|
"max_concurrent_launches": 0, # prevent launches
|
|
}
|
|
),
|
|
)
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
assert instances["no-update"].status == cur_status
|
|
assert instances["updated"].status == expect_status
|
|
|
|
@staticmethod
|
|
@mock.patch("time.time_ns")
|
|
@pytest.mark.parametrize(
|
|
"status",
|
|
[
|
|
Instance.InstanceStatus.Name(Instance.RAY_STOPPING),
|
|
Instance.InstanceStatus.Name(Instance.QUEUED),
|
|
],
|
|
)
|
|
def test_warn_stuck_transient_instances(mock_time_ns, status, setup):
|
|
instance_manager, instance_storage, subscriber, cloud_resource_monitor = setup
|
|
cur_time_s = 10
|
|
mock_time_ns.return_value = cur_time_s * s_to_ns
|
|
timeout_s = 5
|
|
status = Instance.InstanceStatus.Value(status)
|
|
|
|
config = InstanceReconcileConfig(
|
|
transient_status_warn_interval_s=timeout_s,
|
|
)
|
|
# TODO: we are only mocking the necessary fields for testing.
|
|
# Probably better to have a more comprehensive mocker for instance
|
|
# of various statuses.
|
|
instances = [
|
|
create_instance(
|
|
"no-warn",
|
|
status,
|
|
status_times=[(status, (cur_time_s - timeout_s + 1) * s_to_ns)],
|
|
cloud_instance_id="c-1" if status != Instance.QUEUED else "",
|
|
instance_type="type-1",
|
|
),
|
|
create_instance(
|
|
"warn",
|
|
status,
|
|
status_times=[(status, (cur_time_s - timeout_s - 1) * s_to_ns)],
|
|
cloud_instance_id="c-2" if status != Instance.QUEUED else "",
|
|
instance_type="type-1",
|
|
),
|
|
]
|
|
TestReconciler._add_instances(instance_storage, instances)
|
|
mock_logger = mock.MagicMock()
|
|
|
|
cloud_instances = {
|
|
"c-1": CloudInstance("c-1", "type-1", True, NodeKind.WORKER),
|
|
"c-2": CloudInstance("c-2", "type-1", True, NodeKind.WORKER),
|
|
}
|
|
|
|
Reconciler.reconcile(
|
|
instance_manager=instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(),
|
|
non_terminated_cloud_instances=cloud_instances
|
|
if status != Instance.QUEUED
|
|
else {},
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(
|
|
configs={
|
|
"instance_reconcile_config": config,
|
|
"max_concurrent_launches": 0, # prevent launches
|
|
}
|
|
),
|
|
_logger=mock_logger,
|
|
)
|
|
|
|
assert mock_logger.warning.call_count == 1
|
|
|
|
@staticmethod
|
|
@mock.patch("time.time_ns")
|
|
def test_stuck_instances_no_op(mock_time_ns, setup):
|
|
instance_manager, instance_storage, subscriber, cloud_resource_monitor = setup
|
|
# Large enough to not trigger any timeouts
|
|
mock_time_ns.return_value = 999999 * s_to_ns
|
|
|
|
config = InstanceReconcileConfig()
|
|
|
|
all_status = set(Instance.InstanceStatus.values())
|
|
reconciled_stuck_statuses = {
|
|
Instance.REQUESTED,
|
|
Instance.ALLOCATED,
|
|
Instance.RAY_INSTALLING,
|
|
Instance.TERMINATING,
|
|
Instance.RAY_STOP_REQUESTED,
|
|
}
|
|
|
|
transient_statuses = {
|
|
Instance.RAY_STOPPING,
|
|
Instance.RAY_INSTALL_FAILED,
|
|
Instance.RAY_STOPPED,
|
|
Instance.TERMINATION_FAILED,
|
|
Instance.QUEUED,
|
|
Instance.ALLOCATION_TIMEOUT,
|
|
}
|
|
no_op_statuses = all_status - reconciled_stuck_statuses - transient_statuses
|
|
|
|
for status in no_op_statuses:
|
|
instances = [
|
|
create_instance(
|
|
f"no-op-{status}",
|
|
status,
|
|
status_times=[(status, 1 * s_to_ns)],
|
|
),
|
|
]
|
|
TestReconciler._add_instances(instance_storage, instances)
|
|
|
|
subscriber.clear()
|
|
mock_logger = mock.MagicMock()
|
|
Reconciler.reconcile(
|
|
instance_manager=instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(),
|
|
non_terminated_cloud_instances={},
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(
|
|
configs={
|
|
"instance_reconcile_config": config,
|
|
"max_concurrent_launches": 0, # prevent launches
|
|
}
|
|
),
|
|
_logger=mock_logger,
|
|
)
|
|
|
|
assert subscriber.events == []
|
|
assert mock_logger.warning.call_count == 0
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize(
|
|
"status,expected_running",
|
|
[(Instance.RAY_RUNNING, True), (Instance.ALLOCATED, False)],
|
|
ids=["ray_running", "allocated"],
|
|
)
|
|
def test_is_head_node_running(status, expected_running, setup):
|
|
instance_manager, instance_storage, subscriber, cloud_resource_monitor = setup
|
|
|
|
instances = [
|
|
create_instance(
|
|
"i-1",
|
|
status=status,
|
|
cloud_instance_id="c-1",
|
|
node_kind=NodeKind.HEAD,
|
|
),
|
|
]
|
|
|
|
TestReconciler._add_instances(instance_storage, instances)
|
|
assert Reconciler._is_head_node_running(instance_manager) is expected_running
|
|
|
|
@staticmethod
|
|
def test_scaling_updates(setup):
|
|
"""
|
|
Tests that new instances should be launched due to autoscaling
|
|
decisions, and existing instances should be terminated if needed.
|
|
"""
|
|
instance_manager, instance_storage, _, cloud_resource_monitor = setup
|
|
|
|
im_instances = [
|
|
create_instance(
|
|
"head",
|
|
status=Instance.RAY_RUNNING,
|
|
cloud_instance_id="c-0",
|
|
ray_node_id=binary_to_hex(b"r-0"),
|
|
node_kind=NodeKind.HEAD,
|
|
),
|
|
create_instance(
|
|
"i-1",
|
|
status=Instance.RAY_RUNNING,
|
|
cloud_instance_id="c-1",
|
|
ray_node_id=binary_to_hex(b"r-1"),
|
|
node_kind=NodeKind.WORKER,
|
|
), # To be reconciled.
|
|
]
|
|
TestReconciler._add_instances(instance_storage, im_instances)
|
|
|
|
ray_nodes = [
|
|
NodeState(node_id=b"r-0", status=NodeStatus.RUNNING, instance_id="c-1"),
|
|
NodeState(node_id=b"r-1", status=NodeStatus.RUNNING, instance_id="c-1"),
|
|
]
|
|
|
|
cloud_instances = {
|
|
"c-0": CloudInstance("c-0", "head", True, NodeKind.HEAD),
|
|
"c-1": CloudInstance("c-1", "type-1", True, NodeKind.WORKER),
|
|
}
|
|
|
|
mock_scheduler = MagicMock()
|
|
mock_scheduler.schedule.return_value = SchedulingReply(
|
|
to_launch=[
|
|
LaunchRequest(instance_type="type-1", count=2),
|
|
],
|
|
to_terminate=[
|
|
TerminationRequest(
|
|
id="t1",
|
|
ray_node_id="r-1",
|
|
instance_id="i-1",
|
|
cause=TerminationRequest.Cause.IDLE,
|
|
idle_duration_ms=1000,
|
|
)
|
|
],
|
|
infeasible_gang_resource_requests=[
|
|
GangResourceRequest(
|
|
requests=[ResourceRequest(resources_bundle={"CPU": 1})]
|
|
)
|
|
],
|
|
)
|
|
|
|
state = Reconciler.reconcile(
|
|
instance_manager=instance_manager,
|
|
scheduler=mock_scheduler,
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(node_states=ray_nodes),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(
|
|
configs={
|
|
"max_concurrent_launches": 0, # don't launch anything.
|
|
}
|
|
),
|
|
)
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
|
|
assert len(instances) == 3 + 1 # for head node
|
|
for id, instance in instances.items():
|
|
if id == "head":
|
|
assert instance.status == Instance.RAY_RUNNING
|
|
elif id == "i-1":
|
|
assert instance.status == Instance.RAY_STOP_REQUESTED
|
|
else:
|
|
assert instance.status == Instance.QUEUED
|
|
assert instance.instance_type == "type-1"
|
|
|
|
assert len(state.infeasible_gang_resource_requests) == 1
|
|
|
|
@staticmethod
|
|
def test_terminating_instances(setup):
|
|
instance_manager, instance_storage, subscriber, cloud_resource_monitor = setup
|
|
|
|
instances = [
|
|
create_instance(
|
|
"i-1",
|
|
status=Instance.RAY_STOPPED,
|
|
cloud_instance_id="c-1",
|
|
),
|
|
create_instance(
|
|
"i-2",
|
|
status=Instance.RAY_INSTALL_FAILED,
|
|
cloud_instance_id="c-2",
|
|
),
|
|
create_instance(
|
|
"i-3",
|
|
status=Instance.TERMINATION_FAILED,
|
|
cloud_instance_id="c-3",
|
|
),
|
|
]
|
|
|
|
cloud_instances = {
|
|
"c-1": CloudInstance("c-1", "type-1", True, NodeKind.WORKER),
|
|
"c-2": CloudInstance("c-2", "type-2", True, NodeKind.WORKER),
|
|
"c-3": CloudInstance("c-3", "type-3", True, NodeKind.WORKER),
|
|
}
|
|
|
|
TestReconciler._add_instances(instance_storage, instances)
|
|
|
|
Reconciler.reconcile(
|
|
instance_manager=instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(),
|
|
)
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
assert instances["i-1"].status == Instance.TERMINATING
|
|
assert instances["i-2"].status == Instance.TERMINATING
|
|
assert instances["i-3"].status == Instance.TERMINATING
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize(
|
|
"disable_node_updaters",
|
|
[True, False],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"cloud_instance_running",
|
|
[True, False],
|
|
)
|
|
def test_ray_install(disable_node_updaters, cloud_instance_running, setup):
|
|
instance_manager, instance_storage, _, cloud_resource_monitor = setup
|
|
|
|
instances = [
|
|
create_instance(
|
|
"i-1",
|
|
status=Instance.ALLOCATED,
|
|
instance_type="type-1",
|
|
launch_request_id="l1",
|
|
cloud_instance_id="c-1",
|
|
),
|
|
]
|
|
|
|
cloud_instances = {
|
|
"c-1": CloudInstance(
|
|
"c-1", "type-1", "", cloud_instance_running, NodeKind.WORKER
|
|
),
|
|
}
|
|
|
|
TestReconciler._add_instances(instance_storage, instances)
|
|
|
|
Reconciler.reconcile(
|
|
instance_manager=instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(
|
|
configs={
|
|
"disable_node_updaters": disable_node_updaters,
|
|
}
|
|
),
|
|
)
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
if disable_node_updaters or not cloud_instance_running:
|
|
assert instances["i-1"].status == Instance.ALLOCATED
|
|
else:
|
|
assert instances["i-1"].status == Instance.RAY_INSTALLING
|
|
|
|
@staticmethod
|
|
@mock.patch("time.time_ns")
|
|
def test_autoscaler_state(mock_time_ns, setup):
|
|
instance_manager, instance_storage, _, cloud_resource_monitor = setup
|
|
mock_time_ns.return_value = 5
|
|
|
|
instances = [
|
|
create_instance(
|
|
"head",
|
|
status=Instance.RAY_RUNNING,
|
|
cloud_instance_id="c-0",
|
|
ray_node_id=binary_to_hex(b"r-0"),
|
|
node_kind=NodeKind.HEAD,
|
|
),
|
|
create_instance(
|
|
"i-1",
|
|
status=Instance.ALLOCATED,
|
|
instance_type="type-1",
|
|
cloud_instance_id="c-1",
|
|
launch_request_id="l1",
|
|
status_times=[
|
|
(Instance.QUEUED, 0),
|
|
(Instance.REQUESTED, 1),
|
|
(Instance.ALLOCATED, 2),
|
|
],
|
|
),
|
|
# requested instance
|
|
create_instance(
|
|
"i-2",
|
|
status=Instance.REQUESTED,
|
|
instance_type="type-2",
|
|
launch_request_id="l2",
|
|
status_times=[(Instance.QUEUED, 0), (Instance.REQUESTED, 1)],
|
|
),
|
|
# queued instance
|
|
create_instance(
|
|
"i-3",
|
|
status=Instance.QUEUED,
|
|
instance_type="type-3",
|
|
launch_request_id="l3",
|
|
status_times=[
|
|
(Instance.QUEUED, 0),
|
|
],
|
|
),
|
|
# allocation failed
|
|
create_instance(
|
|
"i-4",
|
|
status=Instance.ALLOCATION_FAILED,
|
|
instance_type="type-4",
|
|
launch_request_id="l4",
|
|
status_times=[
|
|
(Instance.QUEUED, 0),
|
|
(Instance.REQUESTED, 1),
|
|
(Instance.ALLOCATION_FAILED, 2),
|
|
],
|
|
),
|
|
# ray installing
|
|
create_instance(
|
|
"i-5",
|
|
status=Instance.RAY_INSTALLING,
|
|
instance_type="type-5",
|
|
launch_request_id="l5",
|
|
cloud_instance_id="c-5",
|
|
status_times=[
|
|
(Instance.QUEUED, 0),
|
|
(Instance.REQUESTED, 1),
|
|
(Instance.ALLOCATED, 2),
|
|
(Instance.RAY_INSTALLING, 3),
|
|
],
|
|
),
|
|
]
|
|
|
|
cloud_instances = {
|
|
"c-0": CloudInstance("c-0", "head", True, NodeKind.HEAD),
|
|
"c-1": CloudInstance("c-1", "type-1", True, NodeKind.WORKER),
|
|
"c-5": CloudInstance("c-5", "type-5", True, NodeKind.WORKER),
|
|
}
|
|
|
|
TestReconciler._add_instances(instance_storage, instances)
|
|
|
|
mock_scheduler = MagicMock()
|
|
mock_scheduler.schedule.return_value = SchedulingReply(
|
|
to_launch=[],
|
|
to_terminate=[],
|
|
infeasible_gang_resource_requests=[
|
|
GangResourceRequest(
|
|
requests=[ResourceRequest(resources_bundle={"CPU": 1})]
|
|
)
|
|
],
|
|
infeasible_resource_requests=[ResourceRequest(resources_bundle={"CPU": 1})],
|
|
)
|
|
|
|
autoscaling_state = Reconciler.reconcile(
|
|
instance_manager=instance_manager,
|
|
scheduler=mock_scheduler,
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(
|
|
cluster_resource_state_version=1,
|
|
),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(
|
|
configs={
|
|
"max_concurrent_launches": 0, # don't launch anything.
|
|
"provider_instance_types": {
|
|
"type-1": "m5.large",
|
|
"type-2": "m5.xlarge",
|
|
"type-3": "c5.large",
|
|
"type-4": "g5.xlarge",
|
|
"type-5": "r5.large",
|
|
},
|
|
}
|
|
),
|
|
)
|
|
|
|
assert autoscaling_state.last_seen_cluster_resource_state_version == 1
|
|
assert len(autoscaling_state.infeasible_gang_resource_requests) == 1
|
|
assert len(autoscaling_state.infeasible_resource_requests) == 1
|
|
assert len(autoscaling_state.pending_instances) == 2
|
|
pending_instances = {
|
|
(i.instance_id, i.instance_type_name, i.ray_node_type_name)
|
|
for i in autoscaling_state.pending_instances
|
|
}
|
|
assert pending_instances == {
|
|
("i-1", "m5.large", "type-1"),
|
|
("i-5", "r5.large", "type-5"),
|
|
}
|
|
pending_instance_requests = defaultdict(int)
|
|
for r in autoscaling_state.pending_instance_requests:
|
|
pending_instance_requests[
|
|
(r.instance_type_name, r.ray_node_type_name)
|
|
] += r.count
|
|
failed_instance_requests = defaultdict(int)
|
|
for r in autoscaling_state.failed_instance_requests:
|
|
failed_instance_requests[
|
|
(r.instance_type_name, r.ray_node_type_name)
|
|
] += r.count
|
|
assert pending_instance_requests == {
|
|
("m5.xlarge", "type-2"): 1,
|
|
("c5.large", "type-3"): 1,
|
|
}
|
|
assert failed_instance_requests == {("g5.xlarge", "type-4"): 1}
|
|
|
|
@staticmethod
|
|
def test_extra_cloud_instances_cloud_provider(setup):
|
|
"""
|
|
Test that extra cloud instances should be terminated.
|
|
"""
|
|
instance_manager, instance_storage, subscriber, cloud_resource_monitor = setup
|
|
|
|
im_instances = [
|
|
create_instance(
|
|
"i-1", status=Instance.RAY_RUNNING, cloud_instance_id="c-1"
|
|
), # To be reconciled.
|
|
]
|
|
TestReconciler._add_instances(instance_storage, im_instances)
|
|
|
|
ray_nodes = [
|
|
NodeState(node_id=b"r-1", status=NodeStatus.RUNNING, instance_id="c-1"),
|
|
# Out of band ray nodes.
|
|
NodeState(
|
|
node_id=b"r-2",
|
|
status=NodeStatus.RUNNING,
|
|
instance_id="c-3",
|
|
ray_node_type_name="type-1",
|
|
),
|
|
]
|
|
|
|
cloud_instances = {
|
|
"c-1": CloudInstance("c-1", "type-1", True, NodeKind.WORKER),
|
|
# Out of band cloud instance.
|
|
"c-2": CloudInstance("c-2", "type-2", True, NodeKind.WORKER),
|
|
}
|
|
|
|
subscriber.clear()
|
|
Reconciler.reconcile(
|
|
instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(node_states=ray_nodes),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(),
|
|
)
|
|
events = subscriber.events
|
|
for e in events:
|
|
if e.new_instance_status == Instance.ALLOCATED:
|
|
assert e.cloud_instance_id in {"c-2", "c-3"}
|
|
else:
|
|
assert e.new_instance_status == Instance.RAY_RUNNING
|
|
assert e.ray_node_id == binary_to_hex(b"r-2")
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
assert len(instances) == 3
|
|
statuses = {instance.status for instance in instances.values()}
|
|
assert statuses == {Instance.RAY_RUNNING, Instance.ALLOCATED}
|
|
|
|
@staticmethod
|
|
def test_cloud_instance_reboot(setup):
|
|
"""
|
|
Test that the case of booting up a previous stopped cloud instance.
|
|
"""
|
|
instance_manager, instance_storage, subscriber, cloud_resource_monitor = setup
|
|
|
|
im_instances = [
|
|
create_instance(
|
|
"i-1",
|
|
status=Instance.TERMINATED,
|
|
cloud_instance_id="c-1",
|
|
ray_node_id=binary_to_hex(b"r-1"),
|
|
),
|
|
]
|
|
TestReconciler._add_instances(instance_storage, im_instances)
|
|
|
|
ray_nodes = [
|
|
NodeState(
|
|
node_id=b"r-1",
|
|
status=NodeStatus.DEAD,
|
|
instance_id="c-1",
|
|
ray_node_type_name="type-1",
|
|
),
|
|
]
|
|
|
|
cloud_instances = {
|
|
"c-1": CloudInstance("c-1", "type-1", True, NodeKind.WORKER),
|
|
}
|
|
|
|
subscriber.clear()
|
|
Reconciler.reconcile(
|
|
instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(node_states=ray_nodes),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(),
|
|
)
|
|
events = subscriber.events
|
|
for e in events:
|
|
assert e.new_instance_status == Instance.ALLOCATED
|
|
assert e.cloud_instance_id == "c-1"
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
assert len(instances) == 2
|
|
statuses = {instance.status for instance in instances.values()}
|
|
assert statuses == {Instance.ALLOCATED, Instance.TERMINATED}
|
|
|
|
@staticmethod
|
|
def test_ray_node_restarted_on_the_same_cloud_instance(setup):
|
|
"""
|
|
Test that the case of reusing cloud instances.
|
|
"""
|
|
instance_manager, instance_storage, subscriber, cloud_resource_monitor = setup
|
|
|
|
im_instances = [
|
|
create_instance(
|
|
"i-1",
|
|
status=Instance.RAY_RUNNING,
|
|
cloud_instance_id="c-1",
|
|
ray_node_id=binary_to_hex(b"r-1"),
|
|
),
|
|
]
|
|
TestReconciler._add_instances(instance_storage, im_instances)
|
|
|
|
ray_nodes = [
|
|
NodeState(
|
|
node_id=b"r-2",
|
|
status=NodeStatus.IDLE,
|
|
instance_id="c-1",
|
|
ray_node_type_name="type-1",
|
|
),
|
|
NodeState(
|
|
node_id=b"r-1",
|
|
status=NodeStatus.DEAD,
|
|
instance_id="c-1",
|
|
ray_node_type_name="type-1",
|
|
),
|
|
]
|
|
|
|
cloud_instances = {
|
|
"c-1": CloudInstance("c-1", "type-1", True, NodeKind.WORKER),
|
|
}
|
|
|
|
subscriber.clear()
|
|
Reconciler.reconcile(
|
|
instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(node_states=ray_nodes),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(),
|
|
)
|
|
events = subscriber.events
|
|
assert len(events) == 4
|
|
assert events[0].new_instance_status == Instance.ALLOCATED
|
|
assert events[0].cloud_instance_id == "c-1"
|
|
assert events[0].ray_node_id == binary_to_hex(b"r-2")
|
|
|
|
assert events[1].new_instance_status == Instance.RAY_RUNNING
|
|
assert events[1].instance_id == events[0].instance_id
|
|
assert events[1].ray_node_id == binary_to_hex(b"r-2")
|
|
|
|
assert events[2].new_instance_status == Instance.RAY_STOPPED
|
|
assert events[2].instance_id == "i-1"
|
|
assert events[2].ray_node_id == binary_to_hex(b"r-1")
|
|
# c-1 has been reused by the new ray node r-2 (now RAY_RUNNING), so the
|
|
# stale i-1 row must not terminate c-1. It is marked TERMINATED to clean
|
|
# up the record without issuing a cloud termination request.
|
|
assert events[3].new_instance_status == Instance.TERMINATED
|
|
assert events[3].instance_id == "i-1"
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
assert len(instances) == 2
|
|
statuses = {instance.status for instance in instances.values()}
|
|
assert statuses == {Instance.RAY_RUNNING, Instance.TERMINATED}
|
|
|
|
@staticmethod
|
|
def test_ray_head_restarted_on_the_same_cloud_instance(setup):
|
|
"""
|
|
Test that the case of restarting Head node with GCS FT.
|
|
"""
|
|
instance_manager, instance_storage, subscriber, cloud_resource_monitor = setup
|
|
|
|
ray_nodes = [
|
|
NodeState(
|
|
node_id=b"r-2",
|
|
status=NodeStatus.IDLE,
|
|
instance_id="c-1",
|
|
ray_node_type_name="type-1",
|
|
),
|
|
NodeState(
|
|
node_id=b"r-1",
|
|
status=NodeStatus.DEAD,
|
|
instance_id="c-1",
|
|
ray_node_type_name="type-1",
|
|
),
|
|
]
|
|
|
|
cloud_instances = {
|
|
"c-1": CloudInstance("c-1", "type-1", True, NodeKind.HEAD),
|
|
}
|
|
|
|
subscriber.clear()
|
|
Reconciler.reconcile(
|
|
instance_manager,
|
|
scheduler=MockScheduler(),
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(node_states=ray_nodes),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(),
|
|
)
|
|
events = subscriber.events
|
|
assert len(events) == 5
|
|
assert events[0].new_instance_status == Instance.ALLOCATED
|
|
assert events[0].cloud_instance_id == "c-1"
|
|
assert events[0].ray_node_id == binary_to_hex(b"r-2")
|
|
|
|
assert events[1].new_instance_status == Instance.ALLOCATED
|
|
assert events[1].cloud_instance_id == "c-1"
|
|
assert events[1].ray_node_id == binary_to_hex(b"r-1")
|
|
|
|
assert events[1].instance_id != events[0].instance_id
|
|
|
|
assert events[2].new_instance_status == Instance.RAY_RUNNING
|
|
assert events[2].instance_id == events[0].instance_id
|
|
assert events[2].ray_node_id == binary_to_hex(b"r-2")
|
|
|
|
assert events[3].new_instance_status == Instance.RAY_STOPPED
|
|
assert events[3].instance_id == events[1].instance_id
|
|
assert events[3].ray_node_id == binary_to_hex(b"r-1")
|
|
|
|
# c-1 has been reused by the new ray node r-2 (now RAY_RUNNING), so the
|
|
# stale row must not terminate c-1. It is marked TERMINATED to clean up
|
|
# the record without issuing a cloud termination request.
|
|
assert events[4].new_instance_status == Instance.TERMINATED
|
|
assert events[4].instance_id == events[1].instance_id
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
assert len(instances) == 2
|
|
statuses = {instance.status for instance in instances.values()}
|
|
assert statuses == {Instance.RAY_RUNNING, Instance.TERMINATED}
|
|
|
|
@staticmethod
|
|
def test_reconcile_max_worker_nodes_limit_triggers_termination(setup):
|
|
instance_manager, instance_storage, _, cloud_resource_monitor = setup
|
|
|
|
instances = [
|
|
create_instance(
|
|
"head",
|
|
status=Instance.RAY_RUNNING,
|
|
node_kind=NodeKind.HEAD,
|
|
cloud_instance_id="c-head",
|
|
ray_node_id=binary_to_hex(b"r-head"),
|
|
),
|
|
create_instance(
|
|
"i-0",
|
|
status=Instance.ALLOCATED,
|
|
instance_type="type-1",
|
|
cloud_instance_id="c-0",
|
|
ray_node_id=binary_to_hex(b"r-0"),
|
|
),
|
|
create_instance(
|
|
"i-1",
|
|
status=Instance.ALLOCATED,
|
|
instance_type="type-1",
|
|
cloud_instance_id="c-1",
|
|
ray_node_id=binary_to_hex(b"r-1"),
|
|
),
|
|
]
|
|
TestReconciler._add_instances(instance_storage, instances)
|
|
|
|
# Empty list of Ray nodes - i.e. when instances are pending but not scheduled
|
|
ray_nodes = []
|
|
|
|
# Cloud instances corresponding to the 3 IM instances
|
|
cloud_instances = {
|
|
"c-head": CloudInstance("c-head", "head", True, NodeKind.HEAD),
|
|
"c-0": CloudInstance("c-0", "type-1", True, NodeKind.WORKER),
|
|
"c-1": CloudInstance("c-1", "type-1", True, NodeKind.WORKER),
|
|
}
|
|
|
|
# Scheduler should add both workers to to_terminate due to max nodes
|
|
mock_scheduler = MockScheduler(
|
|
to_launch=[],
|
|
to_terminate=[
|
|
TerminationRequest(
|
|
id="t0",
|
|
ray_node_id="r-0",
|
|
instance_id="i-0",
|
|
instance_status=Instance.ALLOCATED,
|
|
cause=TerminationRequest.Cause.MAX_NUM_NODE_PER_TYPE,
|
|
),
|
|
TerminationRequest(
|
|
id="t1",
|
|
ray_node_id="r-1",
|
|
instance_id="i-1",
|
|
instance_status=Instance.ALLOCATED,
|
|
cause=TerminationRequest.Cause.MAX_NUM_NODE_PER_TYPE,
|
|
),
|
|
],
|
|
)
|
|
|
|
Reconciler.reconcile(
|
|
instance_manager=instance_manager,
|
|
scheduler=mock_scheduler,
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(
|
|
node_states=ray_nodes,
|
|
cluster_resource_state_version=1,
|
|
),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(
|
|
configs={
|
|
"node_type_configs": {
|
|
"type-1": {
|
|
"name": "type-1",
|
|
"resources": {"CPU": 1},
|
|
"min_worker_nodes": 0,
|
|
"max_worker_nodes": 0,
|
|
}
|
|
},
|
|
}
|
|
),
|
|
)
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
|
|
assert instances["i-0"].status == Instance.TERMINATING
|
|
assert instances["i-1"].status == Instance.TERMINATING
|
|
|
|
@staticmethod
|
|
def test_terminate_queued_instances_max_workers(setup):
|
|
instance_manager, instance_storage, _, cloud_resource_monitor = setup
|
|
|
|
instances = [
|
|
create_instance(
|
|
"head",
|
|
status=Instance.RAY_RUNNING,
|
|
node_kind=NodeKind.HEAD,
|
|
cloud_instance_id="c-head",
|
|
ray_node_id=binary_to_hex(b"r-head"),
|
|
),
|
|
create_instance(
|
|
"i-0",
|
|
status=Instance.QUEUED,
|
|
instance_type="type-1",
|
|
),
|
|
create_instance(
|
|
"i-1",
|
|
status=Instance.QUEUED,
|
|
instance_type="type-1",
|
|
),
|
|
]
|
|
TestReconciler._add_instances(instance_storage, instances)
|
|
|
|
# Empty list of Ray nodes - QUEUED instances have not launched ray yet
|
|
ray_nodes = []
|
|
|
|
# Cloud instance for head node only
|
|
cloud_instances = {
|
|
"c-head": CloudInstance("c-head", "head", True, NodeKind.HEAD),
|
|
}
|
|
|
|
# Scheduler should terminate QUEUED instances due to max_workers limit
|
|
mock_scheduler = MockScheduler(
|
|
to_launch=[],
|
|
to_terminate=[
|
|
TerminationRequest(
|
|
id="t0",
|
|
instance_id="i-0",
|
|
instance_status=Instance.QUEUED,
|
|
cause=TerminationRequest.Cause.MAX_NUM_NODE_PER_TYPE,
|
|
),
|
|
TerminationRequest(
|
|
id="t1",
|
|
instance_id="i-1",
|
|
instance_status=Instance.QUEUED,
|
|
cause=TerminationRequest.Cause.MAX_NUM_NODE_PER_TYPE,
|
|
),
|
|
],
|
|
)
|
|
|
|
Reconciler.reconcile(
|
|
instance_manager=instance_manager,
|
|
scheduler=mock_scheduler,
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(
|
|
node_states=ray_nodes,
|
|
cluster_resource_state_version=1,
|
|
),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(
|
|
configs={
|
|
"node_type_configs": {
|
|
"type-1": {
|
|
"name": "type-1",
|
|
"resources": {"CPU": 1},
|
|
"min_worker_nodes": 0,
|
|
"max_worker_nodes": 0,
|
|
}
|
|
},
|
|
}
|
|
),
|
|
)
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
|
|
assert instances["i-0"].status == Instance.TERMINATED
|
|
assert instances["i-1"].status == Instance.TERMINATED
|
|
|
|
@staticmethod
|
|
def test_terminate_ray_installing_instances_max_workers(setup):
|
|
instance_manager, instance_storage, _, cloud_resource_monitor = setup
|
|
|
|
instances = [
|
|
create_instance(
|
|
"head",
|
|
status=Instance.RAY_RUNNING,
|
|
node_kind=NodeKind.HEAD,
|
|
cloud_instance_id="c-head",
|
|
ray_node_id=binary_to_hex(b"r-head"),
|
|
),
|
|
create_instance(
|
|
"i-0",
|
|
status=Instance.RAY_INSTALLING,
|
|
instance_type="type-1",
|
|
cloud_instance_id="c-0",
|
|
),
|
|
create_instance(
|
|
"i-1",
|
|
status=Instance.RAY_INSTALLING,
|
|
instance_type="type-1",
|
|
cloud_instance_id="c-1",
|
|
),
|
|
]
|
|
TestReconciler._add_instances(instance_storage, instances)
|
|
|
|
# Empty list of Ray nodes - RAY_INSTALLING instances have not started ray yet
|
|
ray_nodes = []
|
|
|
|
# Cloud instances for head and installing workers
|
|
cloud_instances = {
|
|
"c-head": CloudInstance("c-head", "head", True, NodeKind.HEAD),
|
|
"c-0": CloudInstance("c-0", "type-1", True, NodeKind.WORKER),
|
|
"c-1": CloudInstance("c-1", "type-1", True, NodeKind.WORKER),
|
|
}
|
|
|
|
# Scheduler should terminate RAY_INSTALLING instances due to max_workers limit
|
|
mock_scheduler = MockScheduler(
|
|
to_launch=[],
|
|
to_terminate=[
|
|
TerminationRequest(
|
|
id="t0",
|
|
instance_id="i-0",
|
|
instance_status=Instance.RAY_INSTALLING,
|
|
cause=TerminationRequest.Cause.MAX_NUM_NODE_PER_TYPE,
|
|
),
|
|
TerminationRequest(
|
|
id="t1",
|
|
instance_id="i-1",
|
|
instance_status=Instance.RAY_INSTALLING,
|
|
cause=TerminationRequest.Cause.MAX_NUM_NODE_PER_TYPE,
|
|
),
|
|
],
|
|
)
|
|
|
|
Reconciler.reconcile(
|
|
instance_manager=instance_manager,
|
|
scheduler=mock_scheduler,
|
|
cloud_provider=MagicMock(),
|
|
cloud_resource_monitor=cloud_resource_monitor,
|
|
ray_cluster_resource_state=ClusterResourceState(
|
|
node_states=ray_nodes,
|
|
cluster_resource_state_version=1,
|
|
),
|
|
non_terminated_cloud_instances=cloud_instances,
|
|
cloud_provider_errors=[],
|
|
ray_install_errors=[],
|
|
autoscaling_config=MockAutoscalingConfig(
|
|
configs={
|
|
"node_type_configs": {
|
|
"type-1": {
|
|
"name": "type-1",
|
|
"resources": {"CPU": 1},
|
|
"min_worker_nodes": 0,
|
|
"max_worker_nodes": 0,
|
|
}
|
|
},
|
|
}
|
|
),
|
|
)
|
|
|
|
instances, _ = instance_storage.get_instances()
|
|
|
|
assert instances["i-0"].status == Instance.TERMINATING
|
|
assert instances["i-1"].status == Instance.TERMINATING
|
|
|
|
|
|
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__]))
|