3905 lines
144 KiB
Python
3905 lines
144 KiB
Python
import copy
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
from collections import defaultdict
|
|
from enum import Enum
|
|
from unittest.mock import (
|
|
Mock,
|
|
patch,
|
|
)
|
|
|
|
import pytest
|
|
import yaml
|
|
from jsonschema.exceptions import ValidationError
|
|
|
|
import ray
|
|
from ray.autoscaler._private import commands
|
|
from ray.autoscaler._private.autoscaler import NonTerminatedNodes, StandardAutoscaler
|
|
from ray.autoscaler._private.commands import get_or_create_head_node
|
|
from ray.autoscaler._private.constants import (
|
|
AUTOSCALER_HEARTBEAT_TIMEOUT_S,
|
|
DISABLE_LAUNCH_CONFIG_CHECK_KEY,
|
|
DISABLE_NODE_UPDATERS_KEY,
|
|
FOREGROUND_NODE_LAUNCH_KEY,
|
|
WORKER_LIVENESS_CHECK_KEY,
|
|
)
|
|
from ray.autoscaler._private.load_metrics import LoadMetrics
|
|
from ray.autoscaler._private.monitor import Monitor
|
|
from ray.autoscaler._private.prom_metrics import (
|
|
AutoscalerPrometheusMetrics,
|
|
NullMetric,
|
|
)
|
|
from ray.autoscaler._private.providers import (
|
|
_DEFAULT_CONFIGS,
|
|
_NODE_PROVIDERS,
|
|
_clear_provider_cache,
|
|
)
|
|
from ray.autoscaler._private.readonly.node_provider import ReadOnlyNodeProvider
|
|
from ray.autoscaler._private.util import prepare_config, validate_config
|
|
from ray.autoscaler.node_launch_exception import NodeLaunchException
|
|
from ray.autoscaler.node_provider import NodeProvider
|
|
from ray.autoscaler.sdk import get_docker_host_mount_location
|
|
from ray.autoscaler.tags import (
|
|
NODE_KIND_HEAD,
|
|
NODE_KIND_WORKER,
|
|
STATUS_UNINITIALIZED,
|
|
STATUS_UP_TO_DATE,
|
|
STATUS_UPDATE_FAILED,
|
|
TAG_RAY_CLUSTER_NAME,
|
|
TAG_RAY_NODE_KIND,
|
|
TAG_RAY_NODE_STATUS,
|
|
TAG_RAY_USER_NODE_TYPE,
|
|
)
|
|
from ray.core.generated import common_pb2, gcs_pb2
|
|
from ray.exceptions import RpcError
|
|
from ray.tests.autoscaler_test_utils import (
|
|
MockNode,
|
|
MockProcessRunner,
|
|
MockProvider,
|
|
)
|
|
from ray.tests.test_batch_node_provider_unit import (
|
|
MockBatchingNodeProvider,
|
|
)
|
|
|
|
WORKER_FILTER = {TAG_RAY_NODE_KIND: NODE_KIND_WORKER}
|
|
|
|
|
|
class DrainNodeOutcome(str, Enum):
|
|
"""Potential outcomes of DrainNode calls, each of which is handled
|
|
differently by the autoscaler.
|
|
"""
|
|
|
|
# Return a reponse indicating all nodes were successfully drained.
|
|
Succeeded = "Succeeded"
|
|
# Return response indicating at least one node failed to be drained.
|
|
NotAllDrained = "NotAllDrained"
|
|
# Return an unimplemented gRPC error, indicating an old GCS.
|
|
Unimplemented = "Unimplemented"
|
|
# Raise a generic unexpected RPC error.
|
|
GenericRpcError = "GenericRpcError"
|
|
# Raise a generic unexpected exception.
|
|
GenericException = "GenericException"
|
|
# Tell the autoscaler to fail finding ips during drain
|
|
FailedToFindIp = "FailedToFindIp"
|
|
|
|
|
|
class MockGcsClient:
|
|
"""Mock for GCSClient used by autoscaler to drain Ray nodes.
|
|
|
|
Can simulate DrainNode failures via the `drain_node_outcome` parameter.
|
|
Comments in DrainNodeOutcome enum class indicate the behavior for each
|
|
outcome.
|
|
"""
|
|
|
|
def __init__(self, drain_node_outcome=DrainNodeOutcome.Succeeded):
|
|
self.drain_node_outcome = drain_node_outcome
|
|
# Tracks how many times we've called DrainNode.
|
|
self.drain_node_call_count = 0
|
|
# Tracks how many times DrainNode returned a successful RPC response.
|
|
self.drain_node_reply_success = 0
|
|
|
|
def drain_nodes(self, node_ids_to_drain, timeout: int):
|
|
"""Simulate NodeInfo stub's DrainNode call.
|
|
|
|
Outcome determined by self.drain_outcome.
|
|
"""
|
|
self.drain_node_call_count += 1
|
|
if self.drain_node_outcome == DrainNodeOutcome.Unimplemented:
|
|
raise RpcError(
|
|
"not implemented!", rpc_code=ray._raylet.GRPC_STATUS_CODE_UNIMPLEMENTED
|
|
)
|
|
elif self.drain_node_outcome == DrainNodeOutcome.GenericRpcError:
|
|
# Any StatusCode besides UNIMPLEMENTED will do here.
|
|
raise RpcError(
|
|
"server is not feeling well",
|
|
rpc_code=ray._raylet.GRPC_STATUS_CODE_UNAVAILABLE,
|
|
)
|
|
elif self.drain_node_outcome == DrainNodeOutcome.GenericException:
|
|
raise Exception("DrainNode failed in some unexpected way.")
|
|
|
|
self.drain_node_reply_success += 1
|
|
|
|
if self.drain_node_outcome in [
|
|
DrainNodeOutcome.Succeeded,
|
|
DrainNodeOutcome.FailedToFindIp,
|
|
]:
|
|
return node_ids_to_drain
|
|
elif self.drain_node_outcome == DrainNodeOutcome.NotAllDrained:
|
|
# All but the last.
|
|
return node_ids_to_drain[:-1]
|
|
else:
|
|
# Shouldn't land here.
|
|
assert False, "Possible drain node outcomes exhausted."
|
|
|
|
|
|
def mock_node_id() -> bytes:
|
|
"""Random node id to pass to load_metrics.update."""
|
|
return os.urandom(10)
|
|
|
|
|
|
def fill_in_node_ids(provider, load_metrics) -> None:
|
|
"""Node ids for each ip are usually obtained by polling the GCS
|
|
in monitor.py. For test purposes, we sometimes need to manually fill
|
|
these fields with mocks.
|
|
"""
|
|
for node in provider.non_terminated_nodes({}):
|
|
ip = provider.internal_ip(node)
|
|
load_metrics.node_id_by_ip[ip] = mock_node_id()
|
|
|
|
|
|
class MockAutoscaler(StandardAutoscaler):
|
|
"""Test autoscaler constructed to verify the property that each
|
|
autoscaler update issues at most one provider.non_terminated_nodes call.
|
|
"""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.fail_to_find_ip_during_drain = False
|
|
|
|
def _update(self):
|
|
# Only works with MockProvider or MockBatchingNodeProvider.
|
|
assert isinstance(self.provider, MockProvider) or isinstance(
|
|
self.provider, MockBatchingNodeProvider
|
|
)
|
|
start_calls = self.provider.num_non_terminated_nodes_calls
|
|
super()._update()
|
|
end_calls = self.provider.num_non_terminated_nodes_calls
|
|
|
|
# Strict inequality if update is called twice within the throttling
|
|
# interval `self.update_interval_s`
|
|
assert end_calls <= start_calls + 1
|
|
|
|
def drain_nodes_via_gcs(self, provider_node_ids_to_drain):
|
|
if self.fail_to_find_ip_during_drain:
|
|
self.provider.fail_to_fetch_ip = True
|
|
super().drain_nodes_via_gcs(provider_node_ids_to_drain)
|
|
self.provider.fail_to_fetch_ip = False
|
|
|
|
|
|
class NoUpdaterMockAutoscaler(MockAutoscaler):
|
|
def update_nodes(self):
|
|
raise AssertionError(
|
|
"Node updaters are disabled. This method should not be accessed!"
|
|
)
|
|
|
|
|
|
SMALL_CLUSTER = {
|
|
"cluster_name": "default",
|
|
"idle_timeout_minutes": 5,
|
|
"max_workers": 2,
|
|
"provider": {
|
|
"type": "mock",
|
|
"region": "us-east-1",
|
|
"availability_zone": "us-east-1a",
|
|
},
|
|
"docker": {
|
|
"image": "example",
|
|
"container_name": "mock",
|
|
},
|
|
"auth": {
|
|
"ssh_user": "ubuntu",
|
|
"ssh_private_key": os.devnull,
|
|
},
|
|
"available_node_types": {
|
|
"head": {
|
|
"node_config": {
|
|
"TestProp": 1,
|
|
},
|
|
"resources": {"CPU": 1},
|
|
"max_workers": 0,
|
|
},
|
|
"worker": {
|
|
"node_config": {
|
|
"TestProp": 2,
|
|
},
|
|
"resources": {"CPU": 1},
|
|
"min_workers": 0,
|
|
"max_workers": 2,
|
|
},
|
|
},
|
|
"head_node_type": "head",
|
|
"file_mounts": {},
|
|
"cluster_synced_files": [],
|
|
"initialization_commands": ["init_cmd"],
|
|
"setup_commands": ["setup_cmd"],
|
|
"head_setup_commands": ["head_setup_cmd"],
|
|
"worker_setup_commands": ["worker_setup_cmd"],
|
|
"head_start_ray_commands": ["start_ray_head"],
|
|
"worker_start_ray_commands": ["start_ray_worker"],
|
|
}
|
|
|
|
MOCK_DEFAULT_CONFIG = {
|
|
"cluster_name": "default",
|
|
"max_workers": 2,
|
|
"upscaling_speed": 1.0,
|
|
"idle_timeout_minutes": 5,
|
|
"provider": {
|
|
"type": "mock",
|
|
"region": "us-east-1",
|
|
"availability_zone": "us-east-1a",
|
|
},
|
|
"docker": {
|
|
"image": "example",
|
|
"container_name": "mock",
|
|
},
|
|
"auth": {
|
|
"ssh_user": "ubuntu",
|
|
"ssh_private_key": os.devnull,
|
|
},
|
|
"available_node_types": {
|
|
"ray.head.default": {"resources": {}, "node_config": {"head_default_prop": 4}},
|
|
"ray.worker.default": {
|
|
"min_workers": 0,
|
|
"max_workers": 2,
|
|
"resources": {},
|
|
"node_config": {"worker_default_prop": 7},
|
|
},
|
|
},
|
|
"head_node_type": "ray.head.default",
|
|
"head_node": {},
|
|
"worker_nodes": {},
|
|
"file_mounts": {},
|
|
"cluster_synced_files": [],
|
|
"initialization_commands": [],
|
|
"setup_commands": [],
|
|
"head_setup_commands": [],
|
|
"worker_setup_commands": [],
|
|
"head_start_ray_commands": [],
|
|
"worker_start_ray_commands": [],
|
|
}
|
|
|
|
TYPES_A = {
|
|
"empty_node": {
|
|
"node_config": {
|
|
"FooProperty": 42,
|
|
"TestProp": 1,
|
|
},
|
|
"resources": {},
|
|
"max_workers": 0,
|
|
},
|
|
"m4.large": {
|
|
"node_config": {},
|
|
"resources": {"CPU": 2},
|
|
"max_workers": 10,
|
|
},
|
|
"m4.4xlarge": {
|
|
"node_config": {},
|
|
"resources": {"CPU": 16},
|
|
"max_workers": 8,
|
|
},
|
|
"m4.16xlarge": {
|
|
"node_config": {},
|
|
"resources": {"CPU": 64},
|
|
"max_workers": 4,
|
|
},
|
|
"p2.xlarge": {
|
|
"node_config": {},
|
|
"resources": {"CPU": 16, "GPU": 1},
|
|
"max_workers": 10,
|
|
},
|
|
"p2.8xlarge": {
|
|
"node_config": {},
|
|
"resources": {"CPU": 32, "GPU": 8},
|
|
"max_workers": 4,
|
|
},
|
|
}
|
|
|
|
MULTI_WORKER_CLUSTER = dict(
|
|
SMALL_CLUSTER, **{"available_node_types": TYPES_A, "head_node_type": "empty_node"}
|
|
)
|
|
|
|
# `DUMMY_IDLE_DURATION_S` is used as a dummy value for
|
|
# `node_idle_duration_s` in load_metrics.update()
|
|
# when we want to simulate worker node's idle duration
|
|
# (`total_resources` == `available_resources`),
|
|
# but don't want to cause autoscaler downscaling.
|
|
# (`DUMMY_IDLE_DURATION_S` < `idle_timeout_minutes`).
|
|
DUMMY_IDLE_DURATION_S = 3
|
|
|
|
exc_info = None
|
|
try:
|
|
raise Exception("Test exception.")
|
|
except Exception:
|
|
exc_info = sys.exc_info()
|
|
assert exc_info is not None
|
|
|
|
|
|
class LoadMetricsTest(unittest.TestCase):
|
|
def testHeartbeat(self):
|
|
lm = LoadMetrics()
|
|
lm.update("1.1.1.1", mock_node_id(), {"CPU": 2}, {"CPU": 1}, 0)
|
|
lm.mark_active("2.2.2.2")
|
|
assert "1.1.1.1" in lm.last_heartbeat_time_by_ip
|
|
assert "2.2.2.2" in lm.last_heartbeat_time_by_ip
|
|
assert "3.3.3.3" not in lm.last_heartbeat_time_by_ip
|
|
|
|
def testDebugString(self):
|
|
lm = LoadMetrics()
|
|
lm.update("1.1.1.1", mock_node_id(), {"CPU": 2}, {"CPU": 0}, 0)
|
|
lm.update(
|
|
"2.2.2.2", mock_node_id(), {"CPU": 2, "GPU": 16}, {"CPU": 2, "GPU": 2}, 0
|
|
)
|
|
lm.update(
|
|
"3.3.3.3",
|
|
mock_node_id(),
|
|
{
|
|
"memory": 1.05 * 1024 * 1024 * 1024,
|
|
"object_store_memory": 2.1 * 1024 * 1024 * 1024,
|
|
},
|
|
{
|
|
"memory": 0,
|
|
"object_store_memory": 1.05 * 1024 * 1024 * 1024,
|
|
},
|
|
0,
|
|
)
|
|
debug = lm.info_string()
|
|
assert (
|
|
"ResourceUsage: 2.0/4.0 CPU, 14.0/16.0 GPU, "
|
|
"1.05 GiB/1.05 GiB memory, "
|
|
"1.05 GiB/2.1 GiB object_store_memory"
|
|
) in debug
|
|
|
|
|
|
class AutoscalingTest(unittest.TestCase):
|
|
def setUp(self):
|
|
_NODE_PROVIDERS["mock"] = lambda config: self.create_provider
|
|
_DEFAULT_CONFIGS["mock"] = _DEFAULT_CONFIGS["aws"]
|
|
self.provider = None
|
|
self.tmpdir = tempfile.mkdtemp()
|
|
|
|
def tearDown(self):
|
|
self.provider = None
|
|
del _NODE_PROVIDERS["mock"]
|
|
_clear_provider_cache()
|
|
shutil.rmtree(self.tmpdir)
|
|
ray.shutdown()
|
|
|
|
def waitFor(self, condition, num_retries=50, fail_msg=None):
|
|
for _ in range(num_retries):
|
|
if condition():
|
|
return
|
|
time.sleep(0.1)
|
|
fail_msg = fail_msg or "Timed out waiting for {}".format(condition)
|
|
raise TimeoutError(fail_msg)
|
|
|
|
def waitForUpdatersToFinish(self, autoscaler):
|
|
self.waitFor(
|
|
lambda: all(
|
|
not updater.is_alive() for updater in autoscaler.updaters.values()
|
|
),
|
|
num_retries=500,
|
|
fail_msg="Last round of updaters didn't complete on time.",
|
|
)
|
|
|
|
def num_nodes(self, tag_filters=None):
|
|
if tag_filters is None:
|
|
tag_filters = {}
|
|
return len(self.provider.non_terminated_nodes(tag_filters))
|
|
|
|
def waitForNodes(self, expected, comparison=None, tag_filters=None):
|
|
if comparison is None:
|
|
comparison = self.assertEqual
|
|
MAX_ITER = 50
|
|
for i in range(MAX_ITER):
|
|
n = self.num_nodes(tag_filters)
|
|
try:
|
|
comparison(n, expected, msg="Unexpected node quantity.")
|
|
return
|
|
except Exception:
|
|
if i == MAX_ITER - 1:
|
|
print(self.provider.non_terminated_nodes(tag_filters))
|
|
raise
|
|
time.sleep(0.1)
|
|
|
|
def create_provider(self, config, cluster_name):
|
|
assert self.provider
|
|
return self.provider
|
|
|
|
def write_config(self, config, call_prepare_config=True):
|
|
new_config = copy.deepcopy(config)
|
|
if call_prepare_config:
|
|
new_config = prepare_config(new_config)
|
|
path = os.path.join(self.tmpdir, "simple.yaml")
|
|
with open(path, "w") as f:
|
|
f.write(yaml.dump(new_config))
|
|
return path
|
|
|
|
def worker_node_thread_check(self, foreground_node_launcher: bool):
|
|
"""Confirms that worker nodes were launched in the main thread if foreground
|
|
node launch is enabled, in a subthread otherwise.
|
|
|
|
Args:
|
|
foreground_node_launcher: Whether workers nodes are expected to be
|
|
launched in the foreground.
|
|
|
|
"""
|
|
worker_ids = self.provider.non_terminated_nodes(tag_filters=WORKER_FILTER)
|
|
worker_nodes = [self.provider.mock_nodes[worker_id] for worker_id in worker_ids]
|
|
if foreground_node_launcher:
|
|
# All workers were created in the main thread.
|
|
assert all(
|
|
worker_node.created_in_main_thread for worker_node in worker_nodes
|
|
)
|
|
else:
|
|
# All workers were created in a background thread.
|
|
assert not any(
|
|
worker_node.created_in_main_thread for worker_node in worker_nodes
|
|
)
|
|
|
|
def testAutoscalerConfigValidationFailNotFatal(self):
|
|
invalid_config = {**SMALL_CLUSTER, "invalid_property_12345": "test"}
|
|
# First check that this config is actually invalid
|
|
with pytest.raises(ValidationError):
|
|
validate_config(invalid_config)
|
|
config_path = self.write_config(invalid_config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
LoadMetrics(),
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
)
|
|
assert len(self.provider.non_terminated_nodes({})) == 0
|
|
autoscaler.update()
|
|
self.waitForNodes(1)
|
|
autoscaler.update()
|
|
self.waitForNodes(1)
|
|
|
|
def testValidation(self):
|
|
"""Ensures that schema validation is working."""
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
try:
|
|
validate_config(config)
|
|
except Exception:
|
|
self.fail("Test config did not pass validation test!")
|
|
|
|
config["blah"] = "blah"
|
|
with pytest.raises(ValidationError):
|
|
validate_config(config)
|
|
del config["blah"]
|
|
|
|
del config["provider"]
|
|
with pytest.raises(ValidationError):
|
|
validate_config(config)
|
|
|
|
def testValidateDefaultConfig(self):
|
|
config = {}
|
|
config["provider"] = {
|
|
"type": "aws",
|
|
"region": "us-east-1",
|
|
"availability_zone": "us-east-1a",
|
|
}
|
|
config = prepare_config(config)
|
|
try:
|
|
validate_config(config)
|
|
except ValidationError:
|
|
self.fail("Default config did not pass validation test!")
|
|
|
|
def testGetOrCreateHeadNode(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
head_run_option = "--kernel-memory=10g"
|
|
standard_run_option = "--memory-swap=5g"
|
|
config["docker"]["head_run_options"] = [head_run_option]
|
|
config["docker"]["run_options"] = [standard_run_option]
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Mounts", ["[]"])
|
|
# Two initial calls to rsync, + 2 more calls during run_init
|
|
runner.respond_to_call(".State.Running", ["false", "false", "false", "false"])
|
|
runner.respond_to_call("json .Config.Env", ["[]"])
|
|
|
|
def _create_node(node_config, tags, count, _skip_wait=False):
|
|
assert tags[TAG_RAY_NODE_STATUS] == STATUS_UNINITIALIZED
|
|
if not _skip_wait:
|
|
self.provider.ready_to_create.wait()
|
|
if self.provider.fail_creates:
|
|
return
|
|
with self.provider.lock:
|
|
if self.provider.cache_stopped:
|
|
for node in self.provider.mock_nodes.values():
|
|
if node.state == "stopped" and count > 0:
|
|
count -= 1
|
|
node.state = "pending"
|
|
node.tags.update(tags)
|
|
for _ in range(count):
|
|
self.provider.mock_nodes[str(self.provider.next_id)] = MockNode(
|
|
str(self.provider.next_id),
|
|
tags.copy(),
|
|
node_config,
|
|
tags.get(TAG_RAY_USER_NODE_TYPE),
|
|
unique_ips=self.provider.unique_ips,
|
|
)
|
|
self.provider.next_id += 1
|
|
|
|
self.provider.create_node = _create_node
|
|
commands.get_or_create_head_node(
|
|
config,
|
|
printable_config_file=config_path,
|
|
no_restart=False,
|
|
restart_only=False,
|
|
yes=True,
|
|
override_cluster_name=None,
|
|
_provider=self.provider,
|
|
_runner=runner,
|
|
)
|
|
self.waitForNodes(1)
|
|
runner.assert_has_call("1.2.3.4", "init_cmd")
|
|
runner.assert_has_call("1.2.3.4", "head_setup_cmd")
|
|
runner.assert_has_call("1.2.3.4", "start_ray_head")
|
|
self.assertEqual(self.provider.mock_nodes["0"].node_type, "head")
|
|
runner.assert_has_call("1.2.3.4", pattern="docker run")
|
|
runner.assert_has_call("1.2.3.4", pattern=head_run_option)
|
|
runner.assert_has_call("1.2.3.4", pattern=standard_run_option)
|
|
|
|
docker_mount_prefix = get_docker_host_mount_location(
|
|
SMALL_CLUSTER["cluster_name"]
|
|
)
|
|
runner.assert_not_has_call(
|
|
"1.2.3.4", pattern=f"-v {docker_mount_prefix}/~/ray_bootstrap_config"
|
|
)
|
|
common_container_copy = f"rsync -e.*docker exec -i.*{docker_mount_prefix}/~/"
|
|
runner.assert_has_call(
|
|
"1.2.3.4", pattern=common_container_copy + "ray_bootstrap_key.pem"
|
|
)
|
|
runner.assert_has_call(
|
|
"1.2.3.4", pattern=common_container_copy + "ray_bootstrap_config.yaml"
|
|
)
|
|
return config
|
|
|
|
def testNodeTypeNameChange(self):
|
|
"""
|
|
Tests that cluster launcher and autoscaler have correct behavior under
|
|
changes and deletions of node type keys.
|
|
|
|
Specifically if we change the key from "old-type" to "new-type", nodes
|
|
of type "old-type" are deleted and (if required by the config) replaced
|
|
by nodes of type "new-type".
|
|
|
|
Strategy:
|
|
1. launch a test cluster with a head and one `min_worker`
|
|
2. change node type keys for both head and worker in cluster yaml
|
|
3. update cluster with new yaml
|
|
4. verify graceful replacement of the two nodes with old node types
|
|
with two nodes with new node types.
|
|
"""
|
|
|
|
# Default config with renamed node types, min_worker 1, docker off.
|
|
config = copy.deepcopy(MOCK_DEFAULT_CONFIG)
|
|
config["docker"] = {}
|
|
node_types = config["available_node_types"]
|
|
node_types["ray.head.old"] = node_types.pop("ray.head.default")
|
|
node_types["ray.worker.old"] = node_types.pop("ray.worker.default")
|
|
config["head_node_type"] = "ray.head.old"
|
|
node_types["ray.worker.old"]["min_workers"] = 1
|
|
|
|
# Create head and launch autoscaler
|
|
runner = MockProcessRunner()
|
|
self.provider = MockProvider()
|
|
|
|
config_path = self.write_config(config)
|
|
commands.get_or_create_head_node(
|
|
config,
|
|
printable_config_file=config_path,
|
|
no_restart=False,
|
|
restart_only=False,
|
|
yes=True,
|
|
override_cluster_name=None,
|
|
_provider=self.provider,
|
|
_runner=runner,
|
|
)
|
|
self.waitForNodes(1)
|
|
lm = LoadMetrics()
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
)
|
|
autoscaler.update()
|
|
|
|
self.waitForNodes(2)
|
|
head_list = self.provider.non_terminated_nodes(
|
|
{TAG_RAY_NODE_KIND: NODE_KIND_HEAD}
|
|
)
|
|
worker_list = self.provider.non_terminated_nodes(
|
|
{TAG_RAY_NODE_KIND: NODE_KIND_WORKER}
|
|
)
|
|
# One head (as always)
|
|
# One worker (min_workers 1 with no resource demands)
|
|
assert len(head_list) == 1 and len(worker_list) == 1
|
|
worker, head = worker_list.pop(), head_list.pop()
|
|
|
|
# Confirm node type tags
|
|
assert (
|
|
self.provider.node_tags(head).get(TAG_RAY_USER_NODE_TYPE) == "ray.head.old"
|
|
)
|
|
assert (
|
|
self.provider.node_tags(worker).get(TAG_RAY_USER_NODE_TYPE)
|
|
== "ray.worker.old"
|
|
)
|
|
|
|
# Rename head and worker types
|
|
new_config = copy.deepcopy(config)
|
|
node_types = new_config["available_node_types"]
|
|
node_types["ray.head.new"] = node_types.pop("ray.head.old")
|
|
node_types["ray.worker.new"] = node_types.pop("ray.worker.old")
|
|
new_config["head_node_type"] = "ray.head.new"
|
|
config_path = self.write_config(new_config)
|
|
|
|
# Expect this to delete "ray.head.old" head and create "ray.head.new"
|
|
# head.
|
|
commands.get_or_create_head_node(
|
|
new_config,
|
|
printable_config_file=config_path,
|
|
no_restart=False,
|
|
restart_only=False,
|
|
yes=True,
|
|
override_cluster_name=None,
|
|
_provider=self.provider,
|
|
_runner=runner,
|
|
)
|
|
|
|
self.waitForNodes(2)
|
|
head_list = self.provider.non_terminated_nodes(
|
|
{TAG_RAY_NODE_KIND: NODE_KIND_HEAD}
|
|
)
|
|
worker_list = self.provider.non_terminated_nodes(
|
|
{TAG_RAY_NODE_KIND: NODE_KIND_WORKER}
|
|
)
|
|
# One head (as always)
|
|
# One worker (maintained from previous autoscaler update)
|
|
assert len(head_list) == 1 and len(worker_list) == 1
|
|
worker, head = worker_list.pop(), head_list.pop()
|
|
# Confirm new head
|
|
assert (
|
|
self.provider.node_tags(head).get(TAG_RAY_USER_NODE_TYPE) == "ray.head.new"
|
|
)
|
|
# Still old worker, as we haven't made an autoscaler update yet.
|
|
assert (
|
|
self.provider.node_tags(worker).get(TAG_RAY_USER_NODE_TYPE)
|
|
== "ray.worker.old"
|
|
)
|
|
|
|
fill_in_node_ids(self.provider, lm)
|
|
autoscaler.update()
|
|
self.waitForNodes(2)
|
|
events = autoscaler.event_summarizer.summary()
|
|
# Just one node (node_id 1) terminated in the last update.
|
|
# Validates that we didn't try to double-terminate node 0.
|
|
assert sorted(events) == [
|
|
"Adding 1 node(s) of type ray.worker.new.",
|
|
"Adding 1 node(s) of type ray.worker.old.",
|
|
"Removing 1 nodes of type ray.worker.old (not "
|
|
"in available_node_types: ['ray.head.new', 'ray.worker.new']).",
|
|
]
|
|
|
|
head_list = self.provider.non_terminated_nodes(
|
|
{TAG_RAY_NODE_KIND: NODE_KIND_HEAD}
|
|
)
|
|
worker_list = self.provider.non_terminated_nodes(
|
|
{TAG_RAY_NODE_KIND: NODE_KIND_WORKER}
|
|
)
|
|
# One head (as always)
|
|
# One worker (min_workers 1 with no resource demands)
|
|
assert len(head_list) == 1 and len(worker_list) == 1
|
|
worker, head = worker_list.pop(), head_list.pop()
|
|
|
|
# After the autoscaler update, new head and new worker.
|
|
assert (
|
|
self.provider.node_tags(head).get(TAG_RAY_USER_NODE_TYPE) == "ray.head.new"
|
|
)
|
|
assert (
|
|
self.provider.node_tags(worker).get(TAG_RAY_USER_NODE_TYPE)
|
|
== "ray.worker.new"
|
|
)
|
|
|
|
def testGetOrCreateHeadNodePodman(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["docker"]["use_podman"] = True
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Mounts", ["[]"])
|
|
# Two initial calls to rsync, + 2 more calls during run_init
|
|
runner.respond_to_call(".State.Running", ["false", "false", "false", "false"])
|
|
runner.respond_to_call("json .Config.Env", ["[]"])
|
|
commands.get_or_create_head_node(
|
|
config,
|
|
printable_config_file=config_path,
|
|
no_restart=False,
|
|
restart_only=False,
|
|
yes=True,
|
|
override_cluster_name=None,
|
|
_provider=self.provider,
|
|
_runner=runner,
|
|
)
|
|
self.waitForNodes(1)
|
|
runner.assert_has_call("1.2.3.4", "init_cmd")
|
|
runner.assert_has_call("1.2.3.4", "podman exec .*head_setup_cmd.*")
|
|
runner.assert_has_call("1.2.3.4", "podman exec .*start_ray_head.*")
|
|
self.assertEqual(self.provider.mock_nodes["0"].node_type, "head")
|
|
runner.assert_has_call("1.2.3.4", pattern="podman run")
|
|
|
|
docker_mount_prefix = get_docker_host_mount_location(
|
|
SMALL_CLUSTER["cluster_name"]
|
|
)
|
|
runner.assert_not_has_call(
|
|
"1.2.3.4", pattern=f"-v {docker_mount_prefix}/~/ray_bootstrap_config"
|
|
)
|
|
common_container_copy = f"rsync -e.*podman exec -i.*{docker_mount_prefix}/~/"
|
|
runner.assert_has_call(
|
|
"1.2.3.4", pattern=common_container_copy + "ray_bootstrap_key.pem"
|
|
)
|
|
runner.assert_has_call(
|
|
"1.2.3.4", pattern=common_container_copy + "ray_bootstrap_config.yaml"
|
|
)
|
|
|
|
for cmd in runner.command_history():
|
|
assert "docker" not in cmd, "Docker (not podman) found in call: " f"{cmd}"
|
|
|
|
runner.assert_has_call("1.2.3.4", "podman inspect")
|
|
runner.assert_has_call("1.2.3.4", "podman exec")
|
|
|
|
def testGetOrCreateHeadNodeFromStopped(self):
|
|
config = self.testGetOrCreateHeadNode()
|
|
self.provider.cache_stopped = True
|
|
existing_nodes = self.provider.non_terminated_nodes({})
|
|
assert len(existing_nodes) == 1
|
|
self.provider.terminate_node(existing_nodes[0])
|
|
config_path = self.write_config(config)
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Mounts", ["[]"])
|
|
# Two initial calls to rsync, + 2 more calls during run_init
|
|
runner.respond_to_call(".State.Running", ["false", "false", "false", "false"])
|
|
runner.respond_to_call("json .Config.Env", ["[]"])
|
|
commands.get_or_create_head_node(
|
|
config,
|
|
printable_config_file=config_path,
|
|
no_restart=False,
|
|
restart_only=False,
|
|
yes=True,
|
|
override_cluster_name=None,
|
|
_provider=self.provider,
|
|
_runner=runner,
|
|
)
|
|
self.waitForNodes(1)
|
|
# Init & Setup commands must be run for Docker!
|
|
runner.assert_has_call("1.2.3.4", "init_cmd")
|
|
runner.assert_has_call("1.2.3.4", "head_setup_cmd")
|
|
runner.assert_has_call("1.2.3.4", "start_ray_head")
|
|
self.assertEqual(self.provider.mock_nodes["0"].node_type, "head")
|
|
runner.assert_has_call("1.2.3.4", pattern="docker run")
|
|
|
|
docker_mount_prefix = get_docker_host_mount_location(
|
|
SMALL_CLUSTER["cluster_name"]
|
|
)
|
|
runner.assert_not_has_call(
|
|
"1.2.3.4", pattern=f"-v {docker_mount_prefix}/~/ray_bootstrap_config"
|
|
)
|
|
common_container_copy = f"rsync -e.*docker exec -i.*{docker_mount_prefix}/~/"
|
|
runner.assert_has_call(
|
|
"1.2.3.4", pattern=common_container_copy + "ray_bootstrap_key.pem"
|
|
)
|
|
runner.assert_has_call(
|
|
"1.2.3.4", pattern=common_container_copy + "ray_bootstrap_config.yaml"
|
|
)
|
|
|
|
# This next section of code ensures that the following order of
|
|
# commands are executed:
|
|
# 1. mkdir -p {docker_mount_prefix}
|
|
# 2. rsync bootstrap files (over ssh)
|
|
# 3. rsync bootstrap files into container
|
|
commands_with_mount = [
|
|
(i, cmd)
|
|
for i, cmd in enumerate(runner.command_history())
|
|
if docker_mount_prefix in cmd
|
|
]
|
|
rsync_commands = [x for x in commands_with_mount if "rsync --rsh" in x[1]]
|
|
copy_into_container = [
|
|
x
|
|
for x in commands_with_mount
|
|
if re.search("rsync -e.*docker exec -i", x[1])
|
|
]
|
|
first_mkdir = min(x[0] for x in commands_with_mount if "mkdir" in x[1])
|
|
docker_run_cmd_indx = [
|
|
i for i, cmd in enumerate(runner.command_history()) if "docker run" in cmd
|
|
][0]
|
|
for file_to_check in ["ray_bootstrap_config.yaml", "ray_bootstrap_key.pem"]:
|
|
first_rsync = min(
|
|
x[0] for x in rsync_commands if "ray_bootstrap_config.yaml" in x[1]
|
|
)
|
|
first_cp = min(x[0] for x in copy_into_container if file_to_check in x[1])
|
|
# Ensures that `mkdir -p` precedes `docker run` because Docker
|
|
# will auto-create the folder with wrong permissions.
|
|
assert first_mkdir < docker_run_cmd_indx
|
|
# Ensures that the folder is created before running rsync.
|
|
assert first_mkdir < first_rsync
|
|
# Checks that the file is present before copying into the container
|
|
assert first_rsync < first_cp
|
|
|
|
def testGetOrCreateHeadNodeFromStoppedRestartOnly(self):
|
|
config = self.testGetOrCreateHeadNode()
|
|
self.provider.cache_stopped = True
|
|
existing_nodes = self.provider.non_terminated_nodes({})
|
|
assert len(existing_nodes) == 1
|
|
self.provider.terminate_node(existing_nodes[0])
|
|
config_path = self.write_config(config)
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Mounts", ["[]"])
|
|
# Two initial calls to rsync, + 2 more calls during run_init
|
|
runner.respond_to_call(".State.Running", ["false", "false", "false", "false"])
|
|
runner.respond_to_call("json .Config.Env", ["[]"])
|
|
commands.get_or_create_head_node(
|
|
config,
|
|
printable_config_file=config_path,
|
|
no_restart=False,
|
|
restart_only=True,
|
|
yes=True,
|
|
override_cluster_name=None,
|
|
_provider=self.provider,
|
|
_runner=runner,
|
|
)
|
|
self.waitForNodes(1)
|
|
# Init & Setup commands must be run for Docker!
|
|
runner.assert_has_call("1.2.3.4", "init_cmd")
|
|
runner.assert_has_call("1.2.3.4", "head_setup_cmd")
|
|
runner.assert_has_call("1.2.3.4", "start_ray_head")
|
|
|
|
def testDockerFileMountsAdded(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["file_mounts"] = {"source": "/dev/null"}
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
mounts = [
|
|
{
|
|
"Type": "bind",
|
|
"Source": "/sys",
|
|
"Destination": "/sys",
|
|
"Mode": "ro",
|
|
"RW": False,
|
|
"Propagation": "rprivate",
|
|
}
|
|
]
|
|
runner.respond_to_call("json .Mounts", [json.dumps(mounts)])
|
|
# Two initial calls to rsync, +1 more call during run_init
|
|
runner.respond_to_call(".State.Running", ["false", "false", "true", "true"])
|
|
runner.respond_to_call("json .Config.Env", ["[]"])
|
|
commands.get_or_create_head_node(
|
|
config,
|
|
printable_config_file=config_path,
|
|
no_restart=False,
|
|
restart_only=False,
|
|
yes=True,
|
|
override_cluster_name=None,
|
|
_provider=self.provider,
|
|
_runner=runner,
|
|
)
|
|
self.waitForNodes(1)
|
|
runner.assert_has_call("1.2.3.4", "init_cmd")
|
|
runner.assert_has_call("1.2.3.4", "head_setup_cmd")
|
|
runner.assert_has_call("1.2.3.4", "start_ray_head")
|
|
self.assertEqual(self.provider.mock_nodes["0"].node_type, "head")
|
|
runner.assert_has_call("1.2.3.4", pattern="docker stop")
|
|
runner.assert_has_call("1.2.3.4", pattern="docker run")
|
|
|
|
docker_mount_prefix = get_docker_host_mount_location(
|
|
SMALL_CLUSTER["cluster_name"]
|
|
)
|
|
runner.assert_not_has_call(
|
|
"1.2.3.4", pattern=f"-v {docker_mount_prefix}/~/ray_bootstrap_config"
|
|
)
|
|
common_container_copy = f"rsync -e.*docker exec -i.*{docker_mount_prefix}/~/"
|
|
runner.assert_has_call(
|
|
"1.2.3.4", pattern=common_container_copy + "ray_bootstrap_key.pem"
|
|
)
|
|
runner.assert_has_call(
|
|
"1.2.3.4", pattern=common_container_copy + "ray_bootstrap_config.yaml"
|
|
)
|
|
|
|
def testDockerFileMountsRemoved(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["file_mounts"] = {}
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
mounts = [
|
|
{
|
|
"Type": "bind",
|
|
"Source": "/sys",
|
|
"Destination": "/sys",
|
|
"Mode": "ro",
|
|
"RW": False,
|
|
"Propagation": "rprivate",
|
|
}
|
|
]
|
|
runner.respond_to_call("json .Mounts", [json.dumps(mounts)])
|
|
# Two initial calls to rsync, +1 more call during run_init
|
|
runner.respond_to_call(".State.Running", ["false", "false", "true", "true"])
|
|
runner.respond_to_call("json .Config.Env", ["[]"])
|
|
commands.get_or_create_head_node(
|
|
config,
|
|
printable_config_file=config_path,
|
|
no_restart=False,
|
|
restart_only=False,
|
|
yes=True,
|
|
override_cluster_name=None,
|
|
_provider=self.provider,
|
|
_runner=runner,
|
|
)
|
|
self.waitForNodes(1)
|
|
runner.assert_has_call("1.2.3.4", "init_cmd")
|
|
runner.assert_has_call("1.2.3.4", "head_setup_cmd")
|
|
runner.assert_has_call("1.2.3.4", "start_ray_head")
|
|
self.assertEqual(self.provider.mock_nodes["0"].node_type, "head")
|
|
# We only removed amount from the YAML, no changes should happen.
|
|
runner.assert_not_has_call("1.2.3.4", pattern="docker stop")
|
|
runner.assert_not_has_call("1.2.3.4", pattern="docker run")
|
|
|
|
docker_mount_prefix = get_docker_host_mount_location(
|
|
SMALL_CLUSTER["cluster_name"]
|
|
)
|
|
runner.assert_not_has_call(
|
|
"1.2.3.4", pattern=f"-v {docker_mount_prefix}/~/ray_bootstrap_config"
|
|
)
|
|
common_container_copy = f"rsync -e.*docker exec -i.*{docker_mount_prefix}/~/"
|
|
runner.assert_has_call(
|
|
"1.2.3.4", pattern=common_container_copy + "ray_bootstrap_key.pem"
|
|
)
|
|
runner.assert_has_call(
|
|
"1.2.3.4", pattern=common_container_copy + "ray_bootstrap_config.yaml"
|
|
)
|
|
|
|
def testRsyncCommandWithDocker(self):
|
|
assert SMALL_CLUSTER["docker"]["container_name"]
|
|
config_path = self.write_config(SMALL_CLUSTER)
|
|
self.provider = MockProvider(unique_ips=True)
|
|
self.provider.create_node(
|
|
{}, {TAG_RAY_NODE_KIND: "head", TAG_RAY_NODE_STATUS: "up-to-date"}, 1
|
|
)
|
|
self.provider.create_node(
|
|
{}, {TAG_RAY_NODE_KIND: "worker", TAG_RAY_NODE_STATUS: "up-to-date"}, 10
|
|
)
|
|
self.provider.finish_starting_nodes()
|
|
ray.autoscaler.node_provider._get_node_provider = Mock(
|
|
return_value=self.provider
|
|
)
|
|
ray.autoscaler._private.commands._bootstrap_config = Mock(
|
|
return_value=SMALL_CLUSTER
|
|
)
|
|
runner = MockProcessRunner()
|
|
commands.rsync(
|
|
config_path,
|
|
source=config_path,
|
|
target="/tmp/test_path",
|
|
override_cluster_name=None,
|
|
down=True,
|
|
_runner=runner,
|
|
)
|
|
runner.assert_has_call("1.2.3.0", pattern="rsync -e.*docker exec -i")
|
|
runner.assert_has_call("1.2.3.0", pattern="rsync --rsh")
|
|
runner.clear_history()
|
|
|
|
commands.rsync(
|
|
config_path,
|
|
source=config_path,
|
|
target="/tmp/test_path",
|
|
override_cluster_name=None,
|
|
down=True,
|
|
ip_address="1.2.3.5",
|
|
_runner=runner,
|
|
)
|
|
runner.assert_has_call("1.2.3.5", pattern="rsync -e.*docker exec -i")
|
|
runner.assert_has_call("1.2.3.5", pattern="rsync --rsh")
|
|
runner.clear_history()
|
|
|
|
commands.rsync(
|
|
config_path,
|
|
source=config_path,
|
|
target="/tmp/test_path",
|
|
ip_address="172.0.0.4",
|
|
override_cluster_name=None,
|
|
down=True,
|
|
use_internal_ip=True,
|
|
_runner=runner,
|
|
)
|
|
runner.assert_has_call("172.0.0.4", pattern="rsync -e.*docker exec -i")
|
|
runner.assert_has_call("172.0.0.4", pattern="rsync --rsh")
|
|
|
|
def testRsyncCommandWithoutDocker(self):
|
|
cluster_cfg = copy.deepcopy(SMALL_CLUSTER)
|
|
cluster_cfg["docker"] = {}
|
|
config_path = self.write_config(cluster_cfg)
|
|
self.provider = MockProvider(unique_ips=True)
|
|
self.provider.create_node(
|
|
{}, {TAG_RAY_NODE_KIND: "head", TAG_RAY_NODE_STATUS: "up-to-date"}, 1
|
|
)
|
|
self.provider.create_node(
|
|
{}, {TAG_RAY_NODE_KIND: "worker", TAG_RAY_NODE_STATUS: "up-to-date"}, 10
|
|
)
|
|
self.provider.finish_starting_nodes()
|
|
runner = MockProcessRunner()
|
|
ray.autoscaler.node_provider._get_node_provider = Mock(
|
|
return_value=self.provider
|
|
)
|
|
ray.autoscaler._private.commands._bootstrap_config = Mock(
|
|
return_value=cluster_cfg
|
|
)
|
|
commands.rsync(
|
|
config_path,
|
|
source=config_path,
|
|
target="/tmp/test_path",
|
|
override_cluster_name=None,
|
|
down=True,
|
|
_runner=runner,
|
|
)
|
|
runner.assert_has_call("1.2.3.0", pattern="rsync")
|
|
|
|
commands.rsync(
|
|
config_path,
|
|
source=config_path,
|
|
target="/tmp/test_path",
|
|
override_cluster_name=None,
|
|
down=True,
|
|
ip_address="1.2.3.5",
|
|
_runner=runner,
|
|
)
|
|
runner.assert_has_call("1.2.3.5", pattern="rsync")
|
|
runner.clear_history()
|
|
|
|
commands.rsync(
|
|
config_path,
|
|
source=config_path,
|
|
target="/tmp/test_path",
|
|
override_cluster_name=None,
|
|
down=True,
|
|
ip_address="172.0.0.4",
|
|
use_internal_ip=True,
|
|
_runner=runner,
|
|
)
|
|
runner.assert_has_call("172.0.0.4", pattern="rsync")
|
|
runner.clear_history()
|
|
|
|
def testSummarizerFailedCreate(self):
|
|
"""Checks that event summarizer reports failed node creation."""
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 2
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
mock_metrics = Mock(spec=AutoscalerPrometheusMetrics())
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
self.provider.creation_error = Exception(":(")
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
LoadMetrics(),
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
prom_metrics=mock_metrics,
|
|
)
|
|
assert len(self.provider.non_terminated_nodes(WORKER_FILTER)) == 0
|
|
autoscaler.update()
|
|
|
|
# Expect the next two messages in the logs.
|
|
msg = "Failed to launch 2 node(s) of type worker."
|
|
|
|
def expected_message_logged():
|
|
return msg in autoscaler.event_summarizer.summary()
|
|
|
|
self.waitFor(expected_message_logged)
|
|
|
|
def testSummarizerFailedCreateStructuredError(self):
|
|
"""Checks that event summarizer reports failed node creation with
|
|
additional details when the node provider thorws a
|
|
NodeLaunchException."""
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 2
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
mock_metrics = Mock(spec=AutoscalerPrometheusMetrics())
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
self.provider.creation_error = NodeLaunchException(
|
|
"didn't work", "never did", exc_info
|
|
)
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
LoadMetrics(),
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
prom_metrics=mock_metrics,
|
|
)
|
|
assert len(self.provider.non_terminated_nodes(WORKER_FILTER)) == 0
|
|
autoscaler.update()
|
|
|
|
# Expect the next message in the logs.
|
|
msg = "Failed to launch 2 node(s) of type worker. (didn't work): never did."
|
|
|
|
def expected_message_logged():
|
|
print(autoscaler.event_summarizer.summary())
|
|
return msg in autoscaler.event_summarizer.summary()
|
|
|
|
self.waitFor(expected_message_logged)
|
|
|
|
def testSummarizerFailedCreateStructuredErrorNoUnderlyingException(self):
|
|
"""Checks that event summarizer reports failed node creation with
|
|
additional details when the node provider thorws a
|
|
NodeLaunchException."""
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 2
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
mock_metrics = Mock(spec=AutoscalerPrometheusMetrics())
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
self.provider.creation_error = NodeLaunchException(
|
|
"didn't work", "never did", src_exc_info=None
|
|
)
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
LoadMetrics(),
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
prom_metrics=mock_metrics,
|
|
)
|
|
assert len(self.provider.non_terminated_nodes(WORKER_FILTER)) == 0
|
|
autoscaler.update()
|
|
|
|
# Expect the next message in the logs.
|
|
msg = "Failed to launch 2 node(s) of type worker. (didn't work): never did."
|
|
|
|
def expected_message_logged():
|
|
print(autoscaler.event_summarizer.summary())
|
|
return msg in autoscaler.event_summarizer.summary()
|
|
|
|
self.waitFor(expected_message_logged)
|
|
|
|
def testReadonlyNodeProvider(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 2
|
|
config_path = self.write_config(config)
|
|
self.provider = ReadOnlyNodeProvider(config_path, "readonly")
|
|
runner = MockProcessRunner()
|
|
mock_metrics = Mock(spec=AutoscalerPrometheusMetrics())
|
|
autoscaler = StandardAutoscaler(
|
|
config_path,
|
|
LoadMetrics(),
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
prom_metrics=mock_metrics,
|
|
)
|
|
assert len(self.provider.non_terminated_nodes({})) == 0
|
|
|
|
# No updates in read-only mode.
|
|
autoscaler.update()
|
|
self.waitForNodes(0)
|
|
assert mock_metrics.started_nodes.inc.call_count == 0
|
|
assert len(runner.calls) == 0
|
|
|
|
# Reflect updates to the readonly provider.
|
|
self.provider._set_nodes(
|
|
[
|
|
("foo1", "1.1.1.1"),
|
|
("foo2", "1.1.1.1"),
|
|
("foo3", "1.1.1.1"),
|
|
]
|
|
)
|
|
|
|
# No updates in read-only mode.
|
|
autoscaler.update()
|
|
self.waitForNodes(3)
|
|
assert mock_metrics.started_nodes.inc.call_count == 0
|
|
assert mock_metrics.stopped_nodes.inc.call_count == 0
|
|
assert mock_metrics.drain_node_exceptions.inc.call_count == 0
|
|
assert len(runner.calls) == 0
|
|
events = autoscaler.event_summarizer.summary()
|
|
assert not events, events
|
|
|
|
def ScaleUpHelper(self, disable_node_updaters):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 2
|
|
config_path = self.write_config(config)
|
|
config["provider"]["disable_node_updaters"] = disable_node_updaters
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
mock_metrics = Mock(spec=AutoscalerPrometheusMetrics())
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
if disable_node_updaters:
|
|
# This class raises an assertion error if we try to create
|
|
# a node updater thread.
|
|
autoscaler_class = NoUpdaterMockAutoscaler
|
|
else:
|
|
autoscaler_class = MockAutoscaler
|
|
autoscaler = autoscaler_class(
|
|
config_path,
|
|
LoadMetrics(),
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
prom_metrics=mock_metrics,
|
|
)
|
|
assert len(self.provider.non_terminated_nodes(WORKER_FILTER)) == 0
|
|
autoscaler.update()
|
|
self.waitForNodes(2, tag_filters=WORKER_FILTER)
|
|
|
|
# started_nodes metric should have been incremented by 2
|
|
assert mock_metrics.started_nodes.inc.call_count == 1
|
|
mock_metrics.started_nodes.inc.assert_called_with(2)
|
|
assert mock_metrics.worker_create_node_time.observe.call_count == 2
|
|
autoscaler.update()
|
|
# The two autoscaler update iterations in this test led to two
|
|
# observations of the update time.
|
|
assert mock_metrics.update_time.observe.call_count == 2
|
|
self.waitForNodes(2, tag_filters=WORKER_FILTER)
|
|
|
|
# running_workers metric should be set to 2
|
|
mock_metrics.running_workers.set.assert_called_with(2)
|
|
|
|
if disable_node_updaters:
|
|
# Node Updaters have NOT been invoked because they were explicitly
|
|
# disabled.
|
|
assert len(runner.calls) == 0
|
|
# Nodes were create in uninitialized and not updated.
|
|
self.waitForNodes(
|
|
2,
|
|
tag_filters={
|
|
TAG_RAY_NODE_STATUS: STATUS_UNINITIALIZED,
|
|
**WORKER_FILTER,
|
|
},
|
|
)
|
|
else:
|
|
# Node Updaters have been invoked.
|
|
self.waitFor(lambda: len(runner.calls) > 0)
|
|
# The updates failed. Key thing is that the updates completed.
|
|
self.waitForNodes(
|
|
2,
|
|
tag_filters={
|
|
TAG_RAY_NODE_STATUS: STATUS_UPDATE_FAILED,
|
|
**WORKER_FILTER,
|
|
},
|
|
)
|
|
assert mock_metrics.drain_node_exceptions.inc.call_count == 0
|
|
|
|
def testScaleUp(self):
|
|
self.ScaleUpHelper(disable_node_updaters=False)
|
|
|
|
def testScaleUpNoUpdaters(self):
|
|
self.ScaleUpHelper(disable_node_updaters=True)
|
|
|
|
def testTerminateOutdatedNodesGracefully(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 5
|
|
config["max_workers"] = 5
|
|
config["available_node_types"]["worker"]["max_workers"] = 5
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: "worker",
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "worker",
|
|
},
|
|
10,
|
|
)
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Config.Env", ["[]" for i in range(10)])
|
|
mock_metrics = Mock(spec=AutoscalerPrometheusMetrics())
|
|
lm = LoadMetrics()
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
prom_metrics=mock_metrics,
|
|
)
|
|
self.waitForNodes(10, tag_filters=WORKER_FILTER)
|
|
|
|
fill_in_node_ids(self.provider, lm)
|
|
# Gradually scales down to meet target size, never going too low
|
|
for _ in range(10):
|
|
autoscaler.update()
|
|
self.waitForNodes(
|
|
5, comparison=self.assertLessEqual, tag_filters=WORKER_FILTER
|
|
)
|
|
self.waitForNodes(
|
|
4, comparison=self.assertGreaterEqual, tag_filters=WORKER_FILTER
|
|
)
|
|
|
|
# Eventually reaches steady state
|
|
self.waitForNodes(5, tag_filters=WORKER_FILTER)
|
|
|
|
# Check the outdated node removal event is generated.
|
|
autoscaler.update()
|
|
events = autoscaler.event_summarizer.summary()
|
|
assert "Removing 10 nodes of type worker (outdated)." in events, events
|
|
assert mock_metrics.stopped_nodes.inc.call_count == 10
|
|
mock_metrics.started_nodes.inc.assert_called_with(5)
|
|
assert mock_metrics.worker_create_node_time.observe.call_count == 5
|
|
assert mock_metrics.drain_node_exceptions.inc.call_count == 0
|
|
|
|
# Parameterization functionality in the unittest module is not great.
|
|
# To test scale-down behavior, we parameterize the DynamicScaling test
|
|
# manually over outcomes for the DrainNode RPC call.
|
|
def testDynamicScaling1(self):
|
|
self.helperDynamicScaling(DrainNodeOutcome.Succeeded)
|
|
|
|
def testDynamicScaling2(self):
|
|
self.helperDynamicScaling(DrainNodeOutcome.NotAllDrained)
|
|
|
|
def testDynamicScaling3(self):
|
|
self.helperDynamicScaling(DrainNodeOutcome.Unimplemented)
|
|
|
|
def testDynamicScaling4(self):
|
|
self.helperDynamicScaling(DrainNodeOutcome.GenericRpcError)
|
|
|
|
def testDynamicScaling5(self):
|
|
self.helperDynamicScaling(DrainNodeOutcome.GenericException)
|
|
|
|
def testDynamicScaling6(self):
|
|
self.helperDynamicScaling(DrainNodeOutcome.FailedToFindIp)
|
|
|
|
def testDynamicScalingForegroundLauncher(self):
|
|
"""Test autoscaling with node launcher in the foreground."""
|
|
self.helperDynamicScaling(foreground_node_launcher=True)
|
|
|
|
def testDynamicScalingBatchingNodeProvider(self):
|
|
"""Test autoscaling with BatchingNodeProvider"""
|
|
self.helperDynamicScaling(
|
|
foreground_node_launcher=True, batching_node_provider=True
|
|
)
|
|
|
|
def helperDynamicScaling(
|
|
self,
|
|
drain_node_outcome: DrainNodeOutcome = DrainNodeOutcome.Succeeded,
|
|
foreground_node_launcher: bool = False,
|
|
batching_node_provider: bool = False,
|
|
):
|
|
mock_metrics = Mock(spec=AutoscalerPrometheusMetrics())
|
|
mock_gcs_client = MockGcsClient(drain_node_outcome)
|
|
|
|
# Run the core of the test logic.
|
|
self._helperDynamicScaling(
|
|
mock_metrics,
|
|
mock_gcs_client,
|
|
foreground_node_launcher=foreground_node_launcher,
|
|
batching_node_provider=batching_node_provider,
|
|
)
|
|
|
|
# Make assertions about DrainNode error handling during scale-down.
|
|
|
|
if drain_node_outcome == DrainNodeOutcome.Succeeded:
|
|
# DrainNode call was made.
|
|
assert mock_gcs_client.drain_node_call_count > 0
|
|
# No drain node exceptions.
|
|
assert mock_metrics.drain_node_exceptions.inc.call_count == 0
|
|
# Each drain node call succeeded.
|
|
assert (
|
|
mock_gcs_client.drain_node_reply_success
|
|
== mock_gcs_client.drain_node_call_count
|
|
)
|
|
elif drain_node_outcome == DrainNodeOutcome.Unimplemented:
|
|
# DrainNode call was made.
|
|
assert mock_gcs_client.drain_node_call_count > 0
|
|
# All errors were supressed.
|
|
assert mock_metrics.drain_node_exceptions.inc.call_count == 0
|
|
# Every call failed.
|
|
assert mock_gcs_client.drain_node_reply_success == 0
|
|
elif drain_node_outcome in (
|
|
DrainNodeOutcome.GenericRpcError,
|
|
DrainNodeOutcome.GenericException,
|
|
):
|
|
# DrainNode call was made.
|
|
assert mock_gcs_client.drain_node_call_count > 0
|
|
|
|
# We encountered an exception.
|
|
assert mock_metrics.drain_node_exceptions.inc.call_count > 0
|
|
# Every call failed.
|
|
assert (
|
|
mock_metrics.drain_node_exceptions.inc.call_count
|
|
== mock_gcs_client.drain_node_call_count
|
|
)
|
|
assert mock_gcs_client.drain_node_reply_success == 0
|
|
elif drain_node_outcome == DrainNodeOutcome.FailedToFindIp:
|
|
# We never called the drain node api because we were unable to
|
|
# fetch ips
|
|
assert mock_gcs_client.drain_node_call_count == 0
|
|
# We encountered an exception fetching ip.
|
|
assert mock_metrics.drain_node_exceptions.inc.call_count > 0
|
|
|
|
def _helperDynamicScaling(
|
|
self,
|
|
mock_metrics,
|
|
mock_gcs_client,
|
|
foreground_node_launcher=False,
|
|
batching_node_provider=False,
|
|
):
|
|
if batching_node_provider:
|
|
assert (
|
|
foreground_node_launcher
|
|
), "BatchingNodeProvider requires foreground node launch."
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 2
|
|
if foreground_node_launcher:
|
|
config["provider"][FOREGROUND_NODE_LAUNCH_KEY] = True
|
|
if batching_node_provider:
|
|
config["provider"][FOREGROUND_NODE_LAUNCH_KEY] = True
|
|
config["provider"][DISABLE_LAUNCH_CONFIG_CHECK_KEY] = True
|
|
config["provider"][DISABLE_NODE_UPDATERS_KEY] = True
|
|
|
|
config_path = self.write_config(config)
|
|
if batching_node_provider:
|
|
self.provider = MockBatchingNodeProvider(
|
|
provider_config={
|
|
DISABLE_LAUNCH_CONFIG_CHECK_KEY: True,
|
|
DISABLE_NODE_UPDATERS_KEY: True,
|
|
FOREGROUND_NODE_LAUNCH_KEY: True,
|
|
},
|
|
cluster_name="test-cluster",
|
|
)
|
|
else:
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Config.Env", ["[]" for i in range(12)])
|
|
lm = LoadMetrics()
|
|
|
|
# As part of setup for this test, ensure there is a head node.
|
|
if batching_node_provider:
|
|
# MockBatchingNodeProvider creates a head node in the __init__ method.
|
|
pass
|
|
else:
|
|
# MockProvider needs to create a head node with create_node.
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
lm.update("172.0.0.0", mock_node_id(), {"CPU": 1}, {"CPU": 0}, 0)
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
mock_gcs_client,
|
|
max_launch_batch=5,
|
|
max_concurrent_launches=5,
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
prom_metrics=mock_metrics,
|
|
)
|
|
if mock_gcs_client.drain_node_outcome == DrainNodeOutcome.FailedToFindIp:
|
|
autoscaler.fail_to_find_ip_during_drain = True
|
|
self.waitForNodes(0, tag_filters=WORKER_FILTER)
|
|
# Test aborting an autoscaler update with the batching NodeProvider.
|
|
if batching_node_provider:
|
|
self.provider.safe_to_scale_flag = False
|
|
autoscaler.update()
|
|
# The autoscaler update was aborted, so there's no change in worker count.
|
|
assert self.num_nodes(tag_filters=WORKER_FILTER) == 0
|
|
self.provider.safe_to_scale_flag = True
|
|
|
|
autoscaler.update()
|
|
if foreground_node_launcher:
|
|
# If we launched in the foreground, shouldn't need to wait for nodes
|
|
# to be available. (Node creation should block.)
|
|
assert self.num_nodes(tag_filters=WORKER_FILTER) == 2, (
|
|
self.provider.non_terminated_nodes(tag_filters=WORKER_FILTER),
|
|
self.provider.non_terminated_nodes(tag_filters={}),
|
|
)
|
|
else:
|
|
self.waitForNodes(2, tag_filters=WORKER_FILTER)
|
|
|
|
# Update the config to reduce the cluster size
|
|
new_config = copy.deepcopy(SMALL_CLUSTER)
|
|
new_config["max_workers"] = 1
|
|
new_config["available_node_types"]["worker"]["max_workers"] = 1
|
|
new_config["available_node_types"]["worker"]["min_workers"] = 1
|
|
self.write_config(new_config)
|
|
fill_in_node_ids(self.provider, lm)
|
|
autoscaler.update()
|
|
self.waitForNodes(1, tag_filters={TAG_RAY_NODE_KIND: NODE_KIND_WORKER})
|
|
|
|
# Check the scale-down event is generated.
|
|
events = autoscaler.event_summarizer.summary()
|
|
assert "Removing 1 nodes of type worker (max_workers_per_type)." in events
|
|
assert mock_metrics.stopped_nodes.inc.call_count == 1
|
|
|
|
# Update the config to increase the cluster size
|
|
new_config["available_node_types"]["worker"]["min_workers"] = 10
|
|
new_config["available_node_types"]["worker"]["max_workers"] = 10
|
|
new_config["max_workers"] = 10
|
|
self.write_config(new_config)
|
|
autoscaler.update()
|
|
# TODO(rickyx): This is a hack to avoid running into race conditions
|
|
# within v1 autoscaler. These should no longer be relevant in v2.
|
|
time.sleep(3)
|
|
# Because one worker already started, the scheduler waits for its
|
|
# resources to be updated before it launches the remaining min_workers.
|
|
worker_ip = self.provider.non_terminated_node_ips(
|
|
tag_filters={TAG_RAY_NODE_KIND: NODE_KIND_WORKER},
|
|
)[0]
|
|
lm.update(
|
|
worker_ip, mock_node_id(), {"CPU": 1}, {"CPU": 1}, DUMMY_IDLE_DURATION_S
|
|
)
|
|
|
|
autoscaler.update()
|
|
# TODO(rickyx): This is a hack to avoid running into race conditions
|
|
# within v1 autoscaler. These should no longer be relevant in v2.
|
|
time.sleep(3)
|
|
if foreground_node_launcher:
|
|
# If we launched in the foreground, shouldn't need to wait for nodes
|
|
# to be available. (Node creation should block.)
|
|
assert self.num_nodes(tag_filters=WORKER_FILTER) == 10
|
|
else:
|
|
self.waitForNodes(10, tag_filters=WORKER_FILTER)
|
|
|
|
# Awkward and unecessary to repeat the following check for BatchingNodeProvider.
|
|
if not batching_node_provider:
|
|
# Verify that worker nodes were launched in the main thread if foreground
|
|
# node launch is enabled, in a subthread otherwise.
|
|
self.worker_node_thread_check(foreground_node_launcher)
|
|
|
|
autoscaler.update()
|
|
assert mock_metrics.running_workers.set.call_args_list[-1][0][0] >= 10
|
|
|
|
# def testAggressiveAutoscaling(self):
|
|
# self._aggressiveAutoscalingHelper()
|
|
|
|
# def testAggressiveAutoscalingWithForegroundLauncher(self):
|
|
# self._aggressiveAutoscalingHelper(foreground_node_launcher=True)
|
|
|
|
# def _aggressiveAutoscalingHelper(self, foreground_node_launcher: bool = False):
|
|
# config = copy.deepcopy(SMALL_CLUSTER)
|
|
# config["available_node_types"]["worker"]["min_workers"] = 0
|
|
# config["available_node_types"]["worker"]["max_workers"] = 10
|
|
# config["max_workers"] = 10
|
|
# config["idle_timeout_minutes"] = 0
|
|
# config["upscaling_speed"] = config["available_node_types"]["worker"][
|
|
# "max_workers"
|
|
# ]
|
|
# if foreground_node_launcher:
|
|
# config["provider"][FOREGROUND_NODE_LAUNCH_KEY] = True
|
|
# config_path = self.write_config(config)
|
|
|
|
# self.provider = MockProvider()
|
|
# self.provider.create_node(
|
|
# {},
|
|
# {
|
|
# TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
# TAG_RAY_USER_NODE_TYPE: "head",
|
|
# },
|
|
# 1,
|
|
# )
|
|
# head_ip = self.provider.non_terminated_node_ips(
|
|
# tag_filters={TAG_RAY_NODE_KIND: NODE_KIND_HEAD},
|
|
# )[0]
|
|
# runner = MockProcessRunner()
|
|
# runner.respond_to_call("json .Config.Env", ["[]" for i in range(11)])
|
|
# lm = LoadMetrics()
|
|
|
|
# autoscaler = MockAutoscaler(
|
|
# config_path,
|
|
# lm,
|
|
# MockGcsClient(),
|
|
# max_launch_batch=5,
|
|
# max_concurrent_launches=5,
|
|
# max_failures=0,
|
|
# process_runner=runner,
|
|
# update_interval_s=0,
|
|
# )
|
|
|
|
# self.waitForNodes(1)
|
|
# lm.update(
|
|
# head_ip,
|
|
# mock_node_id(),
|
|
# {"CPU": 1},
|
|
# {"CPU": 0},
|
|
# waiting_bundles=[{"CPU": 1}] * 7,
|
|
# infeasible_bundles=[{"CPU": 1}] * 3,
|
|
# )
|
|
# autoscaler.update()
|
|
|
|
# if foreground_node_launcher:
|
|
# # No wait if node launch is blocking and happens in the foreground.
|
|
# assert self.num_nodes() == 11
|
|
# else:
|
|
# self.waitForNodes(11)
|
|
# self.worker_node_thread_check(foreground_node_launcher)
|
|
|
|
# worker_ips = self.provider.non_terminated_node_ips(
|
|
# tag_filters={TAG_RAY_NODE_KIND: NODE_KIND_WORKER},
|
|
# )
|
|
# for ip in worker_ips:
|
|
# # Mark workers inactive.
|
|
# lm.last_used_time_by_ip[ip] = 0
|
|
# # Clear the resource demands.
|
|
# # Otherwise in "foreground launcher" mode, workers would be deleted
|
|
# # for being idle and instantly re-created due to resource demand!
|
|
# lm.update(
|
|
# head_ip,
|
|
# mock_node_id(),
|
|
# {},
|
|
# {},
|
|
# waiting_bundles=[],
|
|
# infeasible_bundles=[],
|
|
# )
|
|
# autoscaler.update()
|
|
# self.waitForNodes(1) # only the head node
|
|
# # Make sure they don't get overwritten.
|
|
# assert autoscaler.resource_demand_scheduler.node_types["head"]["resources"]
|
|
# == {
|
|
# "CPU": 1
|
|
# }
|
|
# assert autoscaler.resource_demand_scheduler.node_types["worker"][
|
|
# "resources"
|
|
# ] == {"CPU": 1}
|
|
|
|
def testUnmanagedNodes(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 0
|
|
config["available_node_types"]["worker"]["max_workers"] = 20
|
|
config["max_workers"] = 20
|
|
config["idle_timeout_minutes"] = 0
|
|
config["upscaling_speed"] = 9999
|
|
config_path = self.write_config(config)
|
|
|
|
self.provider = MockProvider()
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: "head",
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
},
|
|
1,
|
|
)
|
|
head_ip = self.provider.non_terminated_node_ips(
|
|
tag_filters={TAG_RAY_NODE_KIND: "head"},
|
|
)[0]
|
|
|
|
self.provider.create_node({}, {TAG_RAY_NODE_KIND: "unmanaged"}, 1)
|
|
unmanaged_ip = self.provider.non_terminated_node_ips(
|
|
tag_filters={TAG_RAY_NODE_KIND: "unmanaged"},
|
|
)[0]
|
|
|
|
runner = MockProcessRunner()
|
|
|
|
lm = LoadMetrics()
|
|
lm.local_ip = head_ip
|
|
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
MockGcsClient(),
|
|
max_launch_batch=5,
|
|
max_concurrent_launches=5,
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
)
|
|
|
|
autoscaler.update()
|
|
self.waitForNodes(2)
|
|
# This node has num_cpus=0
|
|
lm.update(head_ip, mock_node_id(), {"CPU": 1}, {"CPU": 0}, 0)
|
|
lm.update(
|
|
unmanaged_ip,
|
|
mock_node_id(),
|
|
{"CPU": 0},
|
|
{"CPU": 0},
|
|
DUMMY_IDLE_DURATION_S,
|
|
)
|
|
autoscaler.update()
|
|
self.waitForNodes(2)
|
|
# 1 CPU task cannot be scheduled.
|
|
lm.update(
|
|
unmanaged_ip,
|
|
mock_node_id(),
|
|
{"CPU": 0},
|
|
{"CPU": 0},
|
|
DUMMY_IDLE_DURATION_S,
|
|
waiting_bundles=[{"CPU": 1}],
|
|
)
|
|
autoscaler.update()
|
|
self.waitForNodes(3)
|
|
|
|
def testUnmanagedNodes2(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 0
|
|
config["available_node_types"]["worker"]["max_workers"] = 20
|
|
config["max_workers"] = 20
|
|
config["idle_timeout_minutes"] = 0
|
|
config["upscaling_speed"] = 9999
|
|
config_path = self.write_config(config)
|
|
|
|
self.provider = MockProvider()
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: "head",
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
},
|
|
1,
|
|
)
|
|
head_ip = self.provider.non_terminated_node_ips(
|
|
tag_filters={TAG_RAY_NODE_KIND: "head"},
|
|
)[0]
|
|
|
|
self.provider.create_node({}, {TAG_RAY_NODE_KIND: "unmanaged"}, 1)
|
|
unmanaged_ip = self.provider.non_terminated_node_ips(
|
|
tag_filters={TAG_RAY_NODE_KIND: "unmanaged"},
|
|
)[0]
|
|
|
|
runner = MockProcessRunner()
|
|
|
|
lm = LoadMetrics()
|
|
lm.local_ip = head_ip
|
|
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
MockGcsClient(),
|
|
max_launch_batch=5,
|
|
max_concurrent_launches=5,
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
)
|
|
|
|
lm.update(head_ip, mock_node_id(), {"CPU": 1}, {"CPU": 0}, 0)
|
|
lm.update(
|
|
unmanaged_ip,
|
|
mock_node_id(),
|
|
{"CPU": 0},
|
|
{"CPU": 0},
|
|
DUMMY_IDLE_DURATION_S,
|
|
)
|
|
|
|
# Note that we shouldn't autoscale here because the resource demand
|
|
# vector is not set and target utilization fraction = 1.
|
|
autoscaler.update()
|
|
# If the autoscaler was behaving incorrectly, it needs time to start
|
|
# the new node, otherwise it could scale up after this check.
|
|
time.sleep(0.2)
|
|
self.waitForNodes(2)
|
|
|
|
def testDelayedLaunch(self):
|
|
config_path = self.write_config(SMALL_CLUSTER)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Config.Env", ["[]" for i in range(2)])
|
|
lm = LoadMetrics()
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
head_ip = self.provider.non_terminated_node_ips(
|
|
tag_filters={TAG_RAY_NODE_KIND: NODE_KIND_HEAD},
|
|
)[0]
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
MockGcsClient(),
|
|
max_launch_batch=5,
|
|
max_concurrent_launches=5,
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
)
|
|
assert (
|
|
len(
|
|
self.provider.non_terminated_nodes(
|
|
{TAG_RAY_NODE_KIND: NODE_KIND_WORKER}
|
|
)
|
|
)
|
|
== 0
|
|
)
|
|
|
|
# Update will try to create, but will block until we set the flag
|
|
self.provider.ready_to_create.clear()
|
|
lm.update(
|
|
head_ip,
|
|
mock_node_id(),
|
|
{"CPU": 1},
|
|
{"CPU": 0},
|
|
0,
|
|
waiting_bundles=[{"CPU": 1}] * 2,
|
|
)
|
|
autoscaler.update()
|
|
assert (
|
|
len(
|
|
self.provider.non_terminated_nodes(
|
|
{TAG_RAY_NODE_KIND: NODE_KIND_WORKER}
|
|
)
|
|
)
|
|
== 0
|
|
)
|
|
assert autoscaler.pending_launches.value == 2
|
|
|
|
# Set the flag, check it updates
|
|
self.provider.ready_to_create.set()
|
|
self.waitForNodes(2, tag_filters={TAG_RAY_NODE_KIND: NODE_KIND_WORKER})
|
|
assert autoscaler.pending_launches.value == 0
|
|
|
|
# Update the config to reduce the cluster size
|
|
new_config = copy.deepcopy(SMALL_CLUSTER)
|
|
new_config["available_node_types"]["worker"]["max_workers"] = 1
|
|
self.write_config(new_config)
|
|
fill_in_node_ids(self.provider, lm)
|
|
autoscaler.update()
|
|
assert (
|
|
len(
|
|
self.provider.non_terminated_nodes(
|
|
{TAG_RAY_NODE_KIND: NODE_KIND_WORKER}
|
|
)
|
|
)
|
|
== 1
|
|
)
|
|
|
|
def testDelayedLaunchWithMinWorkers(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 10
|
|
config["available_node_types"]["worker"]["max_workers"] = 10
|
|
config["max_workers"] = 10
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Config.Env", ["[]" for i in range(10)])
|
|
mock_metrics = Mock(spec=AutoscalerPrometheusMetrics())
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
LoadMetrics(),
|
|
MockGcsClient(),
|
|
max_launch_batch=5,
|
|
max_concurrent_launches=8,
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
prom_metrics=mock_metrics,
|
|
)
|
|
assert (
|
|
len(
|
|
self.provider.non_terminated_nodes(
|
|
{TAG_RAY_NODE_KIND: NODE_KIND_WORKER}
|
|
)
|
|
)
|
|
== 0
|
|
)
|
|
|
|
# update() should launch a wave of 5 nodes (max_launch_batch)
|
|
# Force this first wave to block.
|
|
rtc1 = self.provider.ready_to_create
|
|
rtc1.clear()
|
|
autoscaler.update()
|
|
# Synchronization: wait for launchy thread to be blocked on rtc1
|
|
waiters = rtc1._cond._waiters
|
|
self.waitFor(lambda: len(waiters) == 2)
|
|
assert autoscaler.pending_launches.value == 10
|
|
assert (
|
|
len(
|
|
self.provider.non_terminated_nodes(
|
|
{TAG_RAY_NODE_KIND: NODE_KIND_WORKER}
|
|
)
|
|
)
|
|
== 0
|
|
)
|
|
autoscaler.update()
|
|
self.waitForNodes(
|
|
0, tag_filters={TAG_RAY_NODE_KIND: NODE_KIND_WORKER}
|
|
) # Nodes are not added on top of pending.
|
|
rtc1.set()
|
|
self.waitFor(lambda: autoscaler.pending_launches.value == 0)
|
|
assert (
|
|
len(
|
|
self.provider.non_terminated_nodes(
|
|
{TAG_RAY_NODE_KIND: NODE_KIND_WORKER}
|
|
)
|
|
)
|
|
== 10
|
|
)
|
|
self.waitForNodes(10, tag_filters={TAG_RAY_NODE_KIND: NODE_KIND_WORKER})
|
|
assert autoscaler.pending_launches.value == 0
|
|
autoscaler.update()
|
|
self.waitForNodes(10, tag_filters={TAG_RAY_NODE_KIND: NODE_KIND_WORKER})
|
|
assert autoscaler.pending_launches.value == 0
|
|
assert mock_metrics.drain_node_exceptions.inc.call_count == 0
|
|
|
|
def testUpdateThrottling(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 2
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
LoadMetrics(),
|
|
MockGcsClient(),
|
|
max_launch_batch=5,
|
|
max_concurrent_launches=5,
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=10,
|
|
)
|
|
autoscaler.update()
|
|
self.waitForNodes(2, tag_filters=WORKER_FILTER)
|
|
assert autoscaler.pending_launches.value == 0
|
|
new_config = copy.deepcopy(SMALL_CLUSTER)
|
|
new_config["max_workers"] = 1
|
|
self.write_config(new_config)
|
|
autoscaler.update()
|
|
# not updated yet
|
|
# note that node termination happens in the main thread, so
|
|
# we do not need to add any delay here before checking
|
|
assert len(self.provider.non_terminated_nodes(WORKER_FILTER)) == 2
|
|
assert autoscaler.pending_launches.value == 0
|
|
|
|
def testLaunchConfigChange(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 2
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
lm = LoadMetrics()
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
autoscaler = MockAutoscaler(
|
|
config_path, lm, MockGcsClient(), max_failures=0, update_interval_s=0
|
|
)
|
|
autoscaler.update()
|
|
self.waitForNodes(2, tag_filters=WORKER_FILTER)
|
|
|
|
# Update the config to change the node type
|
|
new_config = copy.deepcopy(config)
|
|
new_config["available_node_types"]["worker"]["node_config"][
|
|
"InstanceType"
|
|
] = "updated"
|
|
self.write_config(new_config)
|
|
self.provider.ready_to_create.clear()
|
|
fill_in_node_ids(self.provider, lm)
|
|
for _ in range(5):
|
|
autoscaler.update()
|
|
self.waitForNodes(0, tag_filters=WORKER_FILTER)
|
|
self.provider.ready_to_create.set()
|
|
self.waitForNodes(2, tag_filters=WORKER_FILTER)
|
|
|
|
def testIgnoresCorruptedConfig(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 2
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Config.Env", ["[]" for i in range(11)])
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
lm = LoadMetrics()
|
|
lm.update("172.0.0.0", mock_node_id(), {"CPU": 1}, {"CPU": 0}, 0)
|
|
mock_metrics = Mock(spec=AutoscalerPrometheusMetrics())
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
MockGcsClient(),
|
|
max_launch_batch=10,
|
|
max_concurrent_launches=10,
|
|
process_runner=runner,
|
|
max_failures=0,
|
|
update_interval_s=0,
|
|
prom_metrics=mock_metrics,
|
|
)
|
|
autoscaler.update()
|
|
assert mock_metrics.config_validation_exceptions.inc.call_count == 0
|
|
self.waitForNodes(2, tag_filters=WORKER_FILTER)
|
|
|
|
# Write a corrupted config
|
|
self.write_config("asdf", call_prepare_config=False)
|
|
for _ in range(10):
|
|
autoscaler.update()
|
|
# config validation exceptions metrics should be incremented 10 times
|
|
assert mock_metrics.config_validation_exceptions.inc.call_count == 10
|
|
time.sleep(0.1)
|
|
assert autoscaler.pending_launches.value == 0
|
|
assert (
|
|
len(
|
|
self.provider.non_terminated_nodes(
|
|
{TAG_RAY_NODE_KIND: NODE_KIND_WORKER}
|
|
)
|
|
)
|
|
== 2
|
|
)
|
|
|
|
# New a good config again
|
|
new_config = copy.deepcopy(SMALL_CLUSTER)
|
|
new_config["available_node_types"]["worker"]["min_workers"] = 10
|
|
new_config["max_workers"] = 10
|
|
new_config["available_node_types"]["worker"]["max_workers"] = 10
|
|
self.write_config(new_config)
|
|
worker_ip = self.provider.non_terminated_node_ips(
|
|
tag_filters={TAG_RAY_NODE_KIND: NODE_KIND_WORKER},
|
|
)[0]
|
|
# Because one worker already started, the scheduler waits for its
|
|
# resources to be updated before it launches the remaining min_workers.
|
|
lm.update(
|
|
worker_ip, mock_node_id(), {"CPU": 1}, {"CPU": 1}, DUMMY_IDLE_DURATION_S
|
|
)
|
|
autoscaler.update()
|
|
self.waitForNodes(10, tag_filters={TAG_RAY_NODE_KIND: NODE_KIND_WORKER})
|
|
assert mock_metrics.drain_node_exceptions.inc.call_count == 0
|
|
|
|
def testMaxFailures(self):
|
|
config_path = self.write_config(SMALL_CLUSTER)
|
|
self.provider = MockProvider()
|
|
self.provider.throw = True
|
|
runner = MockProcessRunner()
|
|
mock_metrics = Mock(spec=AutoscalerPrometheusMetrics())
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
LoadMetrics(),
|
|
MockGcsClient(),
|
|
max_failures=2,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
prom_metrics=mock_metrics,
|
|
)
|
|
autoscaler.update()
|
|
assert autoscaler.summary() is None
|
|
assert mock_metrics.update_loop_exceptions.inc.call_count == 1
|
|
autoscaler.update()
|
|
assert mock_metrics.update_loop_exceptions.inc.call_count == 2
|
|
with pytest.raises(Exception):
|
|
autoscaler.update()
|
|
assert mock_metrics.drain_node_exceptions.inc.call_count == 0
|
|
|
|
def testLaunchNewNodeOnOutOfBandTerminate(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 2
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Config.Env", ["[]" for i in range(4)])
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
head_ip = self.provider.non_terminated_node_ips(
|
|
tag_filters={TAG_RAY_NODE_KIND: NODE_KIND_HEAD},
|
|
)[0]
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
LoadMetrics(),
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
)
|
|
autoscaler.update()
|
|
autoscaler.update()
|
|
self.waitForNodes(2, tag_filters=WORKER_FILTER)
|
|
for node in self.provider.mock_nodes.values():
|
|
if node.internal_ip == head_ip:
|
|
continue
|
|
node.state = "terminated"
|
|
assert len(self.provider.non_terminated_nodes(WORKER_FILTER)) == 0
|
|
autoscaler.update()
|
|
self.waitForNodes(2, tag_filters=WORKER_FILTER)
|
|
|
|
def testConfiguresNewNodes(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 1
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Config.Env", ["[]" for i in range(2)])
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
LoadMetrics(),
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
)
|
|
|
|
autoscaler.update()
|
|
# TODO(rueian): This is a hack to avoid running into race conditions
|
|
# within v1 autoscaler. These should no longer be relevant in v2.
|
|
self.waitForNodes(2)
|
|
autoscaler.update()
|
|
self.waitForNodes(2)
|
|
self.provider.finish_starting_nodes()
|
|
autoscaler.update()
|
|
self.waitForNodes(2, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE})
|
|
|
|
def testReportsConfigFailures(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 2
|
|
config_path = self.write_config(config)
|
|
config["provider"]["type"] = "mock"
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner(fail_cmds=["setup_cmd"])
|
|
runner.respond_to_call("json .Config.Env", ["[]" for i in range(2)])
|
|
lm = LoadMetrics()
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
)
|
|
autoscaler.update()
|
|
self.waitForNodes(2, tag_filters=WORKER_FILTER)
|
|
self.provider.finish_starting_nodes()
|
|
fill_in_node_ids(self.provider, lm)
|
|
autoscaler.update()
|
|
try:
|
|
self.waitForNodes(
|
|
2,
|
|
tag_filters={
|
|
TAG_RAY_NODE_STATUS: STATUS_UPDATE_FAILED,
|
|
**WORKER_FILTER,
|
|
},
|
|
)
|
|
except AssertionError:
|
|
# The failed nodes might have been already terminated by autoscaler
|
|
assert len(self.provider.non_terminated_nodes({})) < 2
|
|
|
|
# Check the launch failure event is generated.
|
|
autoscaler.update()
|
|
events = autoscaler.event_summarizer.summary()
|
|
assert "Removing 2 nodes of type worker (launch failed)." in events, events
|
|
|
|
def testConfiguresOutdatedNodes(self):
|
|
from ray.autoscaler._private.cli_logger import cli_logger
|
|
|
|
def do_nothing(*args, **kwargs):
|
|
pass
|
|
|
|
cli_logger._print = type(cli_logger._print)(do_nothing, type(cli_logger))
|
|
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 1
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Config.Env", ["[]" for i in range(4)])
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
LoadMetrics(),
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
)
|
|
autoscaler.update()
|
|
# TODO(rickyx): This is a hack to avoid running into race conditions
|
|
# within v1 autoscaler. These should no longer be relevant in v2.
|
|
time.sleep(3)
|
|
autoscaler.update()
|
|
self.waitForNodes(2)
|
|
self.provider.finish_starting_nodes()
|
|
time.sleep(3)
|
|
autoscaler.update()
|
|
time.sleep(3)
|
|
self.waitForNodes(2, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE})
|
|
runner.calls = []
|
|
new_config = copy.deepcopy(SMALL_CLUSTER)
|
|
new_config["worker_setup_commands"] = ["cmdX", "cmdY"]
|
|
self.write_config(new_config)
|
|
autoscaler.update()
|
|
autoscaler.update()
|
|
self.waitFor(lambda: len(runner.calls) > 0)
|
|
|
|
def testScaleDownMaxWorkers(self):
|
|
"""Tests terminating nodes due to max_nodes per type."""
|
|
config = copy.deepcopy(MULTI_WORKER_CLUSTER)
|
|
config["available_node_types"]["m4.large"]["min_workers"] = 3
|
|
config["available_node_types"]["m4.large"]["max_workers"] = 3
|
|
config["available_node_types"]["m4.large"]["resources"] = {}
|
|
config["available_node_types"]["m4.16xlarge"]["resources"] = {}
|
|
config["available_node_types"]["p2.xlarge"]["min_workers"] = 5
|
|
config["available_node_types"]["p2.xlarge"]["max_workers"] = 8
|
|
config["available_node_types"]["p2.xlarge"]["resources"] = {}
|
|
config["available_node_types"]["p2.8xlarge"]["min_workers"] = 2
|
|
config["available_node_types"]["p2.8xlarge"]["max_workers"] = 4
|
|
config["available_node_types"]["p2.8xlarge"]["resources"] = {}
|
|
config["max_workers"] = 13
|
|
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Config.Env", ["[]" for i in range(15)])
|
|
lm = LoadMetrics()
|
|
|
|
get_or_create_head_node(
|
|
config,
|
|
printable_config_file=config_path,
|
|
no_restart=False,
|
|
restart_only=False,
|
|
yes=True,
|
|
override_cluster_name=None,
|
|
_provider=self.provider,
|
|
_runner=runner,
|
|
)
|
|
self.waitForNodes(1)
|
|
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
max_concurrent_launches=13,
|
|
max_launch_batch=13,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
)
|
|
autoscaler.update()
|
|
self.waitForNodes(11)
|
|
assert autoscaler.pending_launches.value == 0
|
|
assert (
|
|
len(
|
|
self.provider.non_terminated_nodes(
|
|
{TAG_RAY_NODE_KIND: NODE_KIND_WORKER}
|
|
)
|
|
)
|
|
== 10
|
|
)
|
|
|
|
# Terminate some nodes
|
|
config["available_node_types"]["m4.large"]["min_workers"] = 2 # 3
|
|
config["available_node_types"]["m4.large"]["max_workers"] = 2
|
|
config["available_node_types"]["p2.8xlarge"]["min_workers"] = 0 # 2
|
|
config["available_node_types"]["p2.8xlarge"]["max_workers"] = 0
|
|
# And spawn one.
|
|
config["available_node_types"]["p2.xlarge"]["min_workers"] = 6 # 5
|
|
config["available_node_types"]["p2.xlarge"]["max_workers"] = 6
|
|
self.write_config(config)
|
|
fill_in_node_ids(self.provider, lm)
|
|
autoscaler.update()
|
|
events = autoscaler.event_summarizer.summary()
|
|
self.waitFor(lambda: autoscaler.pending_launches.value == 0)
|
|
self.waitForNodes(8, tag_filters={TAG_RAY_NODE_KIND: NODE_KIND_WORKER})
|
|
assert autoscaler.pending_launches.value == 0
|
|
events = autoscaler.event_summarizer.summary()
|
|
assert "Removing 1 nodes of type m4.large (max_workers_per_type)." in events
|
|
assert "Removing 2 nodes of type p2.8xlarge (max_workers_per_type)." in events
|
|
|
|
# We should not be starting/stopping empty_node at all.
|
|
for event in events:
|
|
assert "empty_node" not in event
|
|
|
|
node_type_counts = defaultdict(int)
|
|
for node_id in NonTerminatedNodes(self.provider).worker_ids:
|
|
tags = self.provider.node_tags(node_id)
|
|
if TAG_RAY_USER_NODE_TYPE in tags:
|
|
node_type = tags[TAG_RAY_USER_NODE_TYPE]
|
|
node_type_counts[node_type] += 1
|
|
assert node_type_counts == {"m4.large": 2, "p2.xlarge": 6}
|
|
|
|
def testFalseyLoadMetrics(self):
|
|
lm = LoadMetrics()
|
|
assert not lm
|
|
lm.update("172.0.0.0", mock_node_id(), {"CPU": 1}, {"CPU": 0}, 0)
|
|
assert lm
|
|
|
|
def testRecoverUnhealthyWorkers(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 2
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Config.Env", ["[]" for i in range(3)])
|
|
lm = LoadMetrics()
|
|
mock_metrics = Mock(spec=AutoscalerPrometheusMetrics())
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
prom_metrics=mock_metrics,
|
|
)
|
|
autoscaler.update()
|
|
self.waitForNodes(2, tag_filters=WORKER_FILTER)
|
|
self.provider.finish_starting_nodes()
|
|
autoscaler.update()
|
|
self.waitForNodes(
|
|
2, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE, **WORKER_FILTER}
|
|
)
|
|
|
|
# Mark a node as unhealthy
|
|
for _ in range(5):
|
|
if autoscaler.updaters:
|
|
time.sleep(0.05)
|
|
autoscaler.update()
|
|
assert not autoscaler.updaters
|
|
mock_metrics.recovering_nodes.set.assert_called_with(0)
|
|
num_calls = len(runner.calls)
|
|
lm.last_heartbeat_time_by_ip["172.0.0.1"] = 0
|
|
autoscaler.update()
|
|
mock_metrics.recovering_nodes.set.assert_called_with(1)
|
|
self.waitFor(lambda: len(runner.calls) > num_calls, num_retries=150)
|
|
|
|
# Check the node removal event is generated.
|
|
autoscaler.update()
|
|
events = autoscaler.event_summarizer.summary()
|
|
assert (
|
|
"Restarting 1 nodes of type worker (lost contact with raylet)." in events
|
|
), events
|
|
assert mock_metrics.drain_node_exceptions.inc.call_count == 0
|
|
|
|
def testTerminateUnhealthyWorkers(self):
|
|
"""Test termination of unhealthy workers, when
|
|
autoscaler.disable_node_updaters == True.
|
|
|
|
Similar to testRecoverUnhealthyWorkers.
|
|
"""
|
|
self.unhealthyWorkerHelper(disable_liveness_check=False)
|
|
|
|
def testDontTerminateUnhealthyWorkers(self):
|
|
"""Test that the autoscaler leaves unhealthy workers alone when the worker
|
|
liveness check is disabled.
|
|
"""
|
|
self.unhealthyWorkerHelper(disable_liveness_check=True)
|
|
|
|
def unhealthyWorkerHelper(self, disable_liveness_check: bool):
|
|
"""Helper used to test the autoscaler's handling of unhealthy worker nodes.
|
|
If disable liveness check is False, the default code path is tested and we
|
|
expect to see workers terminated.
|
|
|
|
If disable liveness check is True, we expect the autoscaler not to take action
|
|
on unhealthy nodes, instead delegating node management to another component.
|
|
"""
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 2
|
|
# Make it clear we're not timing out idle nodes here.
|
|
config["idle_timeout_minutes"] = 1000000000
|
|
if disable_liveness_check:
|
|
config["provider"][WORKER_LIVENESS_CHECK_KEY] = False
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Config.Env", ["[]" for i in range(3)])
|
|
lm = LoadMetrics()
|
|
mock_metrics = Mock(spec=AutoscalerPrometheusMetrics())
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
prom_metrics=mock_metrics,
|
|
)
|
|
autoscaler.update()
|
|
self.waitForNodes(2, tag_filters=WORKER_FILTER)
|
|
self.provider.finish_starting_nodes()
|
|
autoscaler.update()
|
|
self.waitForNodes(
|
|
2, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE, **WORKER_FILTER}
|
|
)
|
|
|
|
# Clear out updaters.
|
|
for _ in range(5):
|
|
if autoscaler.updaters:
|
|
time.sleep(0.05)
|
|
autoscaler.update()
|
|
assert not autoscaler.updaters
|
|
|
|
num_calls = len(runner.calls)
|
|
|
|
# Mark a node as unhealthy
|
|
lm.last_heartbeat_time_by_ip["172.0.0.1"] = 0
|
|
# Turn off updaters.
|
|
autoscaler.disable_node_updaters = True
|
|
# Reduce min_workers to 1
|
|
autoscaler.config["available_node_types"]["worker"]["min_workers"] = 1
|
|
fill_in_node_ids(self.provider, lm)
|
|
|
|
if disable_liveness_check:
|
|
# We've disabled the liveness check, so the unhealthy node should stick
|
|
# around until someone else takes care of it.
|
|
# Do several autoscaler updates, to reinforce the fact that the
|
|
# autoscaler will never take down the unhealthy nodes.
|
|
for _ in range(10):
|
|
autoscaler.update()
|
|
# The nodes are still there.
|
|
assert self.num_nodes(tag_filters=WORKER_FILTER) == 2
|
|
# There's no synchronization required to make the last assertion valid:
|
|
# The autoscaler's node termination is synchronous and blocking, as is
|
|
# the terminate_node method of the mock node provider used in this test.
|
|
|
|
# No events generated indicating that we are removing nodes.
|
|
for event in autoscaler.event_summarizer.summary():
|
|
assert "Removing" not in event
|
|
else:
|
|
# We expect the unhealthy node to be cleared out with a single
|
|
# autoscaler update.
|
|
autoscaler.update()
|
|
# Stopped node metric incremented.
|
|
mock_metrics.stopped_nodes.inc.assert_called_once_with()
|
|
# One node left.
|
|
self.waitForNodes(1, tag_filters=WORKER_FILTER)
|
|
|
|
# Check the node removal event is generated.
|
|
autoscaler.update()
|
|
events = autoscaler.event_summarizer.summary()
|
|
assert (
|
|
"Removing 1 nodes of type "
|
|
"worker (lost contact with raylet)." in events
|
|
), events
|
|
|
|
# No additional runner calls, since updaters were disabled.
|
|
assert len(runner.calls) == num_calls
|
|
assert mock_metrics.drain_node_exceptions.inc.call_count == 0
|
|
|
|
def testTerminateUnhealthyWorkers2(self):
|
|
"""Tests finer details of termination of unhealthy workers when
|
|
node updaters are disabled.
|
|
|
|
Specifically, test that newly up-to-date nodes which haven't sent a
|
|
heartbeat are marked active.
|
|
"""
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["provider"]["disable_node_updaters"] = True
|
|
config["available_node_types"]["worker"]["min_workers"] = 2
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
mock_metrics = Mock(spec=AutoscalerPrometheusMetrics())
|
|
lm = LoadMetrics()
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
prom_metrics=mock_metrics,
|
|
)
|
|
assert len(self.provider.non_terminated_nodes(WORKER_FILTER)) == 0
|
|
for _ in range(10):
|
|
autoscaler.update()
|
|
# Nodes stay in uninitialized state because no one has finished
|
|
# updating them.
|
|
self.waitForNodes(
|
|
2,
|
|
tag_filters={
|
|
TAG_RAY_NODE_STATUS: STATUS_UNINITIALIZED,
|
|
**WORKER_FILTER,
|
|
},
|
|
)
|
|
nodes = self.provider.non_terminated_nodes(WORKER_FILTER)
|
|
ips = [self.provider.internal_ip(node) for node in nodes]
|
|
# No heartbeats recorded yet.
|
|
assert not any(ip in lm.last_heartbeat_time_by_ip for ip in ips)
|
|
for node in nodes:
|
|
self.provider.set_node_tags(
|
|
node, {TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE, **WORKER_FILTER}
|
|
)
|
|
autoscaler.update()
|
|
# Nodes marked active after up-to-date status detected.
|
|
assert all(ip in lm.last_heartbeat_time_by_ip for ip in ips)
|
|
# Nodes are kept.
|
|
self.waitForNodes(
|
|
2, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE, **WORKER_FILTER}
|
|
)
|
|
# Mark nodes unhealthy.
|
|
for ip in ips:
|
|
lm.last_heartbeat_time_by_ip[ip] = 0
|
|
fill_in_node_ids(self.provider, lm)
|
|
autoscaler.update()
|
|
# Unhealthy nodes are gone.
|
|
self.waitForNodes(0, tag_filters=WORKER_FILTER)
|
|
autoscaler.update()
|
|
# IPs pruned
|
|
assert lm.last_heartbeat_time_by_ip == {}
|
|
assert mock_metrics.drain_node_exceptions.inc.call_count == 0
|
|
|
|
def testExternalNodeScaler(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["provider"] = {
|
|
"type": "external",
|
|
"module": "ray.autoscaler.node_provider.NodeProvider",
|
|
}
|
|
config_path = self.write_config(config)
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
LoadMetrics(),
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
update_interval_s=0,
|
|
)
|
|
assert isinstance(autoscaler.provider, NodeProvider)
|
|
|
|
def testExternalNodeScalerWrongImport(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["provider"] = {
|
|
"type": "external",
|
|
"module": "mymodule.provider_class",
|
|
}
|
|
invalid_provider = self.write_config(config)
|
|
with pytest.raises(ImportError):
|
|
MockAutoscaler(
|
|
invalid_provider, LoadMetrics(), MockGcsClient(), update_interval_s=0
|
|
)
|
|
|
|
def testExternalNodeScalerWrongModuleFormat(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["provider"] = {
|
|
"type": "external",
|
|
"module": "does-not-exist",
|
|
}
|
|
invalid_provider = self.write_config(config, call_prepare_config=False)
|
|
with pytest.raises(ValueError):
|
|
MockAutoscaler(
|
|
invalid_provider, LoadMetrics(), MockGcsClient(), update_interval_s=0
|
|
)
|
|
|
|
def testSetupCommandsWithNoNodeCaching(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 1
|
|
config["available_node_types"]["worker"]["max_workers"] = 1
|
|
config["max_workers"] = 1
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider(cache_stopped=False)
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Config.Env", ["[]" for i in range(2)])
|
|
lm = LoadMetrics()
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
)
|
|
autoscaler.update()
|
|
self.waitForNodes(1, tag_filters=WORKER_FILTER)
|
|
self.provider.finish_starting_nodes()
|
|
autoscaler.update()
|
|
self.waitForNodes(
|
|
1, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE, **WORKER_FILTER}
|
|
)
|
|
worker_ip = self.provider.non_terminated_node_ips(WORKER_FILTER)[0]
|
|
runner.assert_has_call(worker_ip, "init_cmd")
|
|
runner.assert_has_call(worker_ip, "setup_cmd")
|
|
runner.assert_has_call(worker_ip, "worker_setup_cmd")
|
|
runner.assert_has_call(worker_ip, "start_ray_worker")
|
|
|
|
# Check the node was not reused
|
|
self.provider.terminate_node("1")
|
|
autoscaler.update()
|
|
runner.clear_history()
|
|
self.waitForNodes(1, tag_filters=WORKER_FILTER)
|
|
self.provider.finish_starting_nodes()
|
|
autoscaler.update()
|
|
self.waitForNodes(
|
|
1, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE, **WORKER_FILTER}
|
|
)
|
|
new_worker_ip = self.provider.non_terminated_node_ips(WORKER_FILTER)[0]
|
|
runner.assert_has_call(new_worker_ip, "init_cmd")
|
|
runner.assert_has_call(new_worker_ip, "setup_cmd")
|
|
runner.assert_has_call(new_worker_ip, "worker_setup_cmd")
|
|
runner.assert_has_call(new_worker_ip, "start_ray_worker")
|
|
assert worker_ip != new_worker_ip
|
|
|
|
def testSetupCommandsWithStoppedNodeCachingNoDocker(self):
|
|
file_mount_dir = tempfile.mkdtemp()
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
del config["docker"]
|
|
config["file_mounts"] = {"/root/test-folder": file_mount_dir}
|
|
config["file_mounts_sync_continuously"] = True
|
|
config["available_node_types"]["worker"]["min_workers"] = 1
|
|
config["available_node_types"]["worker"]["max_workers"] = 1
|
|
config["max_workers"] = 1
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider(cache_stopped=True)
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Config.Env", ["[]" for i in range(3)])
|
|
lm = LoadMetrics()
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
)
|
|
autoscaler.update()
|
|
self.waitForNodes(1, tag_filters=WORKER_FILTER)
|
|
self.provider.finish_starting_nodes()
|
|
autoscaler.update()
|
|
self.waitForNodes(
|
|
1, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE, **WORKER_FILTER}
|
|
)
|
|
worker_ip = self.provider.non_terminated_node_ips(WORKER_FILTER)[0]
|
|
runner.assert_has_call(worker_ip, "init_cmd")
|
|
runner.assert_has_call(worker_ip, "setup_cmd")
|
|
runner.assert_has_call(worker_ip, "worker_setup_cmd")
|
|
runner.assert_has_call(worker_ip, "start_ray_worker")
|
|
|
|
# Check the node was indeed reused
|
|
self.provider.terminate_node("1")
|
|
runner.clear_history()
|
|
autoscaler.update()
|
|
self.waitForNodes(1, tag_filters=WORKER_FILTER)
|
|
self.provider.finish_starting_nodes()
|
|
autoscaler.update()
|
|
self.waitForNodes(
|
|
1, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE, **WORKER_FILTER}
|
|
)
|
|
runner.assert_not_has_call(worker_ip, "init_cmd")
|
|
runner.assert_not_has_call(worker_ip, "setup_cmd")
|
|
runner.assert_not_has_call(worker_ip, "worker_setup_cmd")
|
|
runner.assert_has_call(worker_ip, "start_ray_worker")
|
|
|
|
with open(f"{file_mount_dir}/new_file", "w") as f:
|
|
f.write("abcdefgh")
|
|
|
|
# Check that run_init happens when file_mounts have updated
|
|
self.provider.terminate_node("1")
|
|
autoscaler.update()
|
|
runner.clear_history()
|
|
self.waitForNodes(1, tag_filters=WORKER_FILTER)
|
|
self.provider.finish_starting_nodes()
|
|
autoscaler.update()
|
|
self.waitForNodes(
|
|
1, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE, **WORKER_FILTER}
|
|
)
|
|
runner.assert_not_has_call(worker_ip, "init_cmd")
|
|
runner.assert_not_has_call(worker_ip, "setup_cmd")
|
|
runner.assert_not_has_call(worker_ip, "worker_setup_cmd")
|
|
runner.assert_has_call(worker_ip, "start_ray_worker")
|
|
|
|
autoscaler.update()
|
|
runner.assert_not_has_call(worker_ip, "setup_cmd")
|
|
|
|
# We did not start any other nodes
|
|
self.waitForNodes(
|
|
1, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE, **WORKER_FILTER}
|
|
)
|
|
|
|
def testSetupCommandsWithStoppedNodeCachingDocker(self):
|
|
# NOTE(ilr) Setup & Init commands **should** run with stopped nodes
|
|
# when Docker is in use.
|
|
file_mount_dir = tempfile.mkdtemp()
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["file_mounts"] = {"/root/test-folder": file_mount_dir}
|
|
config["file_mounts_sync_continuously"] = True
|
|
config["available_node_types"]["worker"]["min_workers"] = 1
|
|
config["available_node_types"]["worker"]["max_workers"] = 1
|
|
config["max_workers"] = 1
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider(cache_stopped=True)
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Config.Env", ["[]" for i in range(3)])
|
|
lm = LoadMetrics()
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
)
|
|
autoscaler.update()
|
|
self.waitForNodes(1, tag_filters=WORKER_FILTER)
|
|
self.provider.finish_starting_nodes()
|
|
autoscaler.update()
|
|
self.waitForNodes(
|
|
1, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE, **WORKER_FILTER}
|
|
)
|
|
worker_ip = self.provider.non_terminated_node_ips(WORKER_FILTER)[0]
|
|
runner.assert_has_call(worker_ip, "init_cmd")
|
|
runner.assert_has_call(worker_ip, "setup_cmd")
|
|
runner.assert_has_call(worker_ip, "worker_setup_cmd")
|
|
runner.assert_has_call(worker_ip, "start_ray_worker")
|
|
runner.assert_has_call(worker_ip, "docker run")
|
|
|
|
# Check the node was indeed reused
|
|
self.provider.terminate_node("1")
|
|
runner.clear_history()
|
|
autoscaler.update()
|
|
self.waitForNodes(1, tag_filters=WORKER_FILTER)
|
|
self.provider.finish_starting_nodes()
|
|
autoscaler.update()
|
|
self.waitForNodes(
|
|
1, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE, **WORKER_FILTER}
|
|
)
|
|
print(runner.command_history())
|
|
# These all must happen when the node is stopped and resued
|
|
runner.assert_has_call(worker_ip, "init_cmd")
|
|
runner.assert_has_call(worker_ip, "setup_cmd")
|
|
runner.assert_has_call(worker_ip, "worker_setup_cmd")
|
|
runner.assert_has_call(worker_ip, "start_ray_worker")
|
|
runner.assert_has_call(worker_ip, "docker run")
|
|
|
|
with open(f"{file_mount_dir}/new_file", "w") as f:
|
|
f.write("abcdefgh")
|
|
|
|
# Check that run_init happens when file_mounts have updated
|
|
self.provider.terminate_node("0")
|
|
runner.clear_history()
|
|
autoscaler.update()
|
|
self.waitForNodes(1, tag_filters=WORKER_FILTER)
|
|
self.provider.finish_starting_nodes()
|
|
autoscaler.update()
|
|
self.waitForNodes(
|
|
1, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE, **WORKER_FILTER}
|
|
)
|
|
runner.assert_has_call(worker_ip, "init_cmd")
|
|
runner.assert_has_call(worker_ip, "setup_cmd")
|
|
runner.assert_has_call(worker_ip, "worker_setup_cmd")
|
|
runner.assert_has_call(worker_ip, "start_ray_worker")
|
|
runner.assert_has_call(worker_ip, "docker run")
|
|
|
|
docker_run_cmd_indx = [
|
|
i for i, cmd in enumerate(runner.command_history()) if "docker run" in cmd
|
|
][0]
|
|
mkdir_cmd_indx = [
|
|
i for i, cmd in enumerate(runner.command_history()) if "mkdir -p" in cmd
|
|
][0]
|
|
assert mkdir_cmd_indx < docker_run_cmd_indx
|
|
runner.clear_history()
|
|
autoscaler.update()
|
|
runner.assert_not_has_call(worker_ip, "setup_cmd")
|
|
|
|
# We did not start any other nodes
|
|
runner.assert_not_has_call("172.0.0.2", " ")
|
|
|
|
def testMultiNodeReuse(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
# Docker re-runs setup commands when nodes are reused.
|
|
del config["docker"]
|
|
config["available_node_types"]["worker"]["min_workers"] = 3
|
|
config["available_node_types"]["worker"]["max_workers"] = 3
|
|
config["max_workers"] = 3
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider(cache_stopped=True)
|
|
runner = MockProcessRunner()
|
|
lm = LoadMetrics()
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
)
|
|
autoscaler.update()
|
|
self.waitForNodes(3, tag_filters=WORKER_FILTER)
|
|
self.provider.finish_starting_nodes()
|
|
autoscaler.update()
|
|
self.waitForNodes(
|
|
3, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE, **WORKER_FILTER}
|
|
)
|
|
|
|
self.provider.terminate_node("1")
|
|
self.provider.terminate_node("2")
|
|
self.provider.terminate_node("3")
|
|
runner.clear_history()
|
|
|
|
# Scale up to 10 nodes, check we reuse the first 3 and add 5 more.
|
|
config["available_node_types"]["worker"]["min_workers"] = 8
|
|
config["available_node_types"]["worker"]["max_workers"] = 8
|
|
config["max_workers"] = 8
|
|
self.write_config(config)
|
|
autoscaler.update()
|
|
autoscaler.update()
|
|
self.waitForNodes(8, tag_filters=WORKER_FILTER)
|
|
self.provider.finish_starting_nodes()
|
|
autoscaler.update()
|
|
self.waitForNodes(
|
|
8, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE, **WORKER_FILTER}
|
|
)
|
|
autoscaler.update()
|
|
for i in [1, 2, 3]:
|
|
runner.assert_not_has_call("172.0.0.{}".format(i), "setup_cmd")
|
|
runner.assert_has_call("172.0.0.{}".format(i), "start_ray_worker")
|
|
for i in range(4, 9):
|
|
runner.assert_has_call("172.0.0.{}".format(i), "setup_cmd")
|
|
runner.assert_has_call("172.0.0.{}".format(i), "start_ray_worker")
|
|
|
|
def testContinuousFileMounts(self):
|
|
file_mount_dir = tempfile.mkdtemp()
|
|
|
|
self.provider = MockProvider()
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["file_mounts"] = {"/home/test-folder": file_mount_dir}
|
|
config["file_mounts_sync_continuously"] = True
|
|
config["available_node_types"]["worker"]["min_workers"] = 2
|
|
config_path = self.write_config(config)
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Config.Env", ["[]" for i in range(4)])
|
|
runner.respond_to_call("command -v docker", ["docker" for _ in range(4)])
|
|
lm = LoadMetrics()
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
)
|
|
|
|
autoscaler.update()
|
|
self.waitForNodes(3)
|
|
self.provider.finish_starting_nodes()
|
|
autoscaler.update()
|
|
self.waitForNodes(3, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE})
|
|
autoscaler.update()
|
|
docker_mount_prefix = get_docker_host_mount_location(config["cluster_name"])
|
|
for i in self.provider.non_terminated_nodes(
|
|
tag_filters={TAG_RAY_NODE_KIND: NODE_KIND_WORKER}
|
|
):
|
|
runner.assert_has_call(f"172.0.0.{i}", "setup_cmd")
|
|
runner.assert_has_call(
|
|
f"172.0.0.{i}",
|
|
f"{file_mount_dir}/ ubuntu@172.0.0.{i}:"
|
|
f"{docker_mount_prefix}/home/test-folder/",
|
|
)
|
|
|
|
runner.clear_history()
|
|
|
|
with open(os.path.join(file_mount_dir, "test.txt"), "wb") as temp_file:
|
|
temp_file.write("hello".encode())
|
|
|
|
runner.respond_to_call(".Config.Image", ["example" for _ in range(4)])
|
|
runner.respond_to_call(".State.Running", ["true" for _ in range(4)])
|
|
autoscaler.update()
|
|
self.waitForNodes(3)
|
|
self.provider.finish_starting_nodes()
|
|
autoscaler.update()
|
|
self.waitForNodes(3, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE})
|
|
autoscaler.update()
|
|
|
|
for i in self.provider.non_terminated_nodes(
|
|
tag_filters={TAG_RAY_NODE_KIND: NODE_KIND_WORKER}
|
|
):
|
|
runner.assert_not_has_call(f"172.0.0.{i}", "setup_cmd")
|
|
runner.assert_has_call(
|
|
f"172.0.0.{i}",
|
|
f"{file_mount_dir}/ ubuntu@172.0.0.{i}:"
|
|
f"{docker_mount_prefix}/home/test-folder/",
|
|
)
|
|
|
|
def testFileMountsNonContinuous(self):
|
|
file_mount_dir = tempfile.mkdtemp()
|
|
|
|
self.provider = MockProvider()
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["file_mounts"] = {"/home/test-folder": file_mount_dir}
|
|
config["available_node_types"]["worker"]["min_workers"] = 2
|
|
config_path = self.write_config(config)
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Config.Env", ["[]" for i in range(2)])
|
|
lm = LoadMetrics()
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
)
|
|
|
|
autoscaler.update()
|
|
self.waitForNodes(2, tag_filters=WORKER_FILTER)
|
|
self.provider.finish_starting_nodes()
|
|
autoscaler.update()
|
|
self.waitForNodes(
|
|
2, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE, **WORKER_FILTER}
|
|
)
|
|
autoscaler.update()
|
|
docker_mount_prefix = get_docker_host_mount_location(config["cluster_name"])
|
|
|
|
for i in self.provider.non_terminated_nodes(WORKER_FILTER):
|
|
runner.assert_has_call(f"172.0.0.{i}", "setup_cmd")
|
|
runner.assert_has_call(
|
|
f"172.0.0.{i}",
|
|
f"{file_mount_dir}/ ubuntu@172.0.0.{i}:"
|
|
f"{docker_mount_prefix}/home/test-folder/",
|
|
)
|
|
|
|
runner.clear_history()
|
|
|
|
with open(os.path.join(file_mount_dir, "test.txt"), "wb") as temp_file:
|
|
temp_file.write("hello".encode())
|
|
|
|
autoscaler.update()
|
|
self.waitForNodes(2, tag_filters=WORKER_FILTER)
|
|
self.provider.finish_starting_nodes()
|
|
self.waitForNodes(
|
|
2, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE, **WORKER_FILTER}
|
|
)
|
|
|
|
for i in self.provider.non_terminated_nodes(WORKER_FILTER):
|
|
runner.assert_not_has_call(f"172.0.0.{i}", "setup_cmd")
|
|
runner.assert_not_has_call(
|
|
f"172.0.0.{i}",
|
|
f"{file_mount_dir}/ ubuntu@172.0.0.{i}:"
|
|
f"{docker_mount_prefix}/home/test-folder/",
|
|
)
|
|
|
|
# Simulate a second `ray up` call
|
|
from ray.autoscaler._private import util
|
|
|
|
util._hash_cache = {}
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Config.Env", ["[]" for i in range(2)])
|
|
lm = LoadMetrics()
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
)
|
|
|
|
autoscaler.update()
|
|
self.waitForNodes(2, tag_filters=WORKER_FILTER)
|
|
self.provider.finish_starting_nodes()
|
|
self.waitForNodes(
|
|
2, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE, **WORKER_FILTER}
|
|
)
|
|
autoscaler.update()
|
|
|
|
for i in self.provider.non_terminated_nodes(WORKER_FILTER):
|
|
runner.assert_has_call(f"172.0.0.{i}", "setup_cmd")
|
|
runner.assert_has_call(
|
|
f"172.0.0.{i}",
|
|
f"{file_mount_dir}/ ubuntu@172.0.0.{i}:"
|
|
f"{docker_mount_prefix}/home/test-folder/",
|
|
)
|
|
|
|
def testDockerImageExistsBeforeInspect(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 1
|
|
config["available_node_types"]["worker"]["max_workers"] = 1
|
|
config["max_workers"] = 1
|
|
config["docker"]["pull_before_run"] = False
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Config.Env", ["[]" for i in range(1)])
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
LoadMetrics(),
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
)
|
|
autoscaler.update()
|
|
autoscaler.update()
|
|
self.waitForNodes(1, tag_filters={TAG_RAY_NODE_KIND: NODE_KIND_WORKER})
|
|
self.provider.finish_starting_nodes()
|
|
autoscaler.update()
|
|
self.waitForNodes(
|
|
1,
|
|
tag_filters={
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_NODE_KIND: NODE_KIND_WORKER,
|
|
},
|
|
)
|
|
first_pull = [
|
|
(i, cmd)
|
|
for i, cmd in enumerate(runner.command_history())
|
|
if "docker pull" in cmd
|
|
]
|
|
first_targeted_inspect = [
|
|
(i, cmd)
|
|
for i, cmd in enumerate(runner.command_history())
|
|
if "docker inspect -f" in cmd
|
|
]
|
|
|
|
# This checks for the bug mentioned #13128 where the image is inspected
|
|
# before the image is present.
|
|
assert min(x[0] for x in first_pull) < min(x[0] for x in first_targeted_inspect)
|
|
|
|
def testGetRunningHeadNode(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
self.provider = MockProvider()
|
|
|
|
# Node 0 is failed.
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_CLUSTER_NAME: "default",
|
|
TAG_RAY_NODE_KIND: "head",
|
|
TAG_RAY_NODE_STATUS: "update-failed",
|
|
},
|
|
1,
|
|
)
|
|
|
|
# `_allow_uninitialized_state` should return the head node
|
|
# in the `update-failed` state.
|
|
allow_failed = commands._get_running_head_node(
|
|
config,
|
|
"/fake/path",
|
|
override_cluster_name=None,
|
|
create_if_needed=False,
|
|
_provider=self.provider,
|
|
_allow_uninitialized_state=True,
|
|
)
|
|
|
|
assert allow_failed == "0"
|
|
|
|
# Node 1 is okay.
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_CLUSTER_NAME: "default",
|
|
TAG_RAY_NODE_KIND: "head",
|
|
TAG_RAY_NODE_STATUS: "up-to-date",
|
|
},
|
|
1,
|
|
)
|
|
|
|
node = commands._get_running_head_node(
|
|
config,
|
|
"/fake/path",
|
|
override_cluster_name=None,
|
|
create_if_needed=False,
|
|
_provider=self.provider,
|
|
)
|
|
|
|
assert node == "1"
|
|
|
|
# `_allow_uninitialized_state` should return the up-to-date head node
|
|
# if it is present.
|
|
optionally_failed = commands._get_running_head_node(
|
|
config,
|
|
"/fake/path",
|
|
override_cluster_name=None,
|
|
create_if_needed=False,
|
|
_provider=self.provider,
|
|
_allow_uninitialized_state=True,
|
|
)
|
|
|
|
assert optionally_failed == "1"
|
|
|
|
def testNodeTerminatedDuringUpdate(self):
|
|
"""
|
|
Tests autoscaler handling a node getting terminated during an update
|
|
triggered by the node missing a heartbeat.
|
|
|
|
Extension of testRecoverUnhealthyWorkers.
|
|
|
|
In this test, two nodes miss a heartbeat.
|
|
One of them (node 0) is terminated during its recovery update.
|
|
The other (node 1) just fails its update.
|
|
|
|
When processing completed updates, the autoscaler terminates node 1
|
|
but does not try to terminate node 0 again.
|
|
"""
|
|
cluster_config = copy.deepcopy(MOCK_DEFAULT_CONFIG)
|
|
cluster_config["available_node_types"]["ray.worker.default"]["min_workers"] = 2
|
|
cluster_config["worker_start_ray_commands"] = ["ray_start_cmd"]
|
|
|
|
# Don't need the extra node type or a docker config.
|
|
cluster_config["head_node_type"] = ["ray.worker.default"]
|
|
del cluster_config["available_node_types"]["ray.head.default"]
|
|
del cluster_config["docker"]
|
|
|
|
config_path = self.write_config(cluster_config)
|
|
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
lm = LoadMetrics()
|
|
mock_metrics = Mock(spec=AutoscalerPrometheusMetrics())
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
prom_metrics=mock_metrics,
|
|
)
|
|
|
|
# Scale up to two up-to-date workers
|
|
autoscaler.update()
|
|
self.waitForNodes(2)
|
|
self.provider.finish_starting_nodes()
|
|
autoscaler.update()
|
|
self.waitForNodes(2, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE})
|
|
|
|
# Mark both nodes as unhealthy
|
|
for _ in range(5):
|
|
if autoscaler.updaters:
|
|
time.sleep(0.05)
|
|
autoscaler.update()
|
|
|
|
lm.last_heartbeat_time_by_ip["172.0.0.0"] = 0
|
|
lm.last_heartbeat_time_by_ip["172.0.0.1"] = 0
|
|
|
|
# Expect both updates to be successful, no nodes in updating state
|
|
assert mock_metrics.successful_updates.inc.call_count == 2
|
|
assert mock_metrics.worker_update_time.observe.call_count == 2
|
|
mock_metrics.updating_nodes.set.assert_called_with(0)
|
|
assert not autoscaler.updaters
|
|
|
|
# Set up process runner to terminate worker 0 during missed heartbeat
|
|
# recovery and also cause the updater to fail.
|
|
def terminate_worker_zero():
|
|
self.provider.terminate_node("0")
|
|
|
|
autoscaler.process_runner = MockProcessRunner(
|
|
fail_cmds=["ray_start_cmd"],
|
|
cmd_to_callback={"ray_start_cmd": terminate_worker_zero},
|
|
)
|
|
# ensures that no updates are completed until after the next call
|
|
# to update()
|
|
autoscaler.process_runner.ready_to_run.clear()
|
|
num_calls = len(autoscaler.process_runner.calls)
|
|
autoscaler.update()
|
|
mock_metrics.updating_nodes.set.assert_called_with(2)
|
|
mock_metrics.recovering_nodes.set.assert_called_with(2)
|
|
autoscaler.process_runner.ready_to_run.set()
|
|
# Wait for updaters spawned by last autoscaler update to finish.
|
|
self.waitForUpdatersToFinish(autoscaler)
|
|
# Check that updaters processed some commands in the last autoscaler
|
|
# update.
|
|
assert (
|
|
len(autoscaler.process_runner.calls) > num_calls
|
|
), "Did not get additional process runner calls on last autoscaler update."
|
|
# Missed heartbeat triggered recovery for both nodes.
|
|
events = autoscaler.event_summarizer.summary()
|
|
assert (
|
|
"Restarting 2 nodes of type "
|
|
"ray.worker.default (lost contact with raylet)." in events
|
|
), events
|
|
# Node 0 was terminated during the last update.
|
|
# Node 1's updater failed, but node 1 won't be terminated until the
|
|
# next autoscaler update.
|
|
assert (
|
|
"0" not in NonTerminatedNodes(self.provider).worker_ids
|
|
), "Node zero still non-terminated."
|
|
assert not self.provider.is_terminated("1"), "Node one terminated prematurely."
|
|
|
|
fill_in_node_ids(self.provider, lm)
|
|
autoscaler.update()
|
|
# Failed updates processed are now processed.
|
|
assert (
|
|
autoscaler.num_failed_updates["0"] == 1
|
|
), "Node zero update failure not registered"
|
|
assert (
|
|
autoscaler.num_failed_updates["1"] == 1
|
|
), "Node one update failure not registered"
|
|
assert mock_metrics.failed_updates.inc.call_count == 2
|
|
assert mock_metrics.failed_recoveries.inc.call_count == 2
|
|
assert mock_metrics.successful_recoveries.inc.call_count == 0
|
|
# Completed-update-processing logic should have terminated node 1.
|
|
assert self.provider.is_terminated("1"), "Node 1 not terminated on time."
|
|
|
|
events = autoscaler.event_summarizer.summary()
|
|
# Just one node (node_id 1) terminated in the last update.
|
|
# Validates that we didn't try to double-terminate node 0.
|
|
assert (
|
|
"Removing 1 nodes of type ray.worker.default (launch failed)." in events
|
|
), events
|
|
# To be more explicit,
|
|
assert (
|
|
"Removing 2 nodes of type "
|
|
"ray.worker.default (launch failed)." not in events
|
|
), events
|
|
|
|
# Should get two new nodes after the next update.
|
|
fill_in_node_ids(self.provider, lm)
|
|
autoscaler.update()
|
|
self.waitForNodes(2)
|
|
assert set(NonTerminatedNodes(self.provider).worker_ids) == {
|
|
"2",
|
|
"3",
|
|
}, "Unexpected node_ids"
|
|
|
|
assert mock_metrics.stopped_nodes.inc.call_count == 1
|
|
assert mock_metrics.drain_node_exceptions.inc.call_count == 0
|
|
|
|
def testProviderException(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 2
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
mock_metrics = Mock(spec=AutoscalerPrometheusMetrics())
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
self.provider.creation_error = Exception(":(")
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
LoadMetrics(),
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
prom_metrics=mock_metrics,
|
|
)
|
|
autoscaler.update()
|
|
|
|
def metrics_incremented():
|
|
exceptions = mock_metrics.node_launch_exceptions.inc.call_count == 1
|
|
create_failures = mock_metrics.failed_create_nodes.inc.call_count == 1
|
|
create_arg = False
|
|
if create_failures:
|
|
# number of failed creations should be incremented by 2
|
|
create_arg = mock_metrics.failed_create_nodes.inc.call_args[0] == (2,)
|
|
return exceptions and create_failures and create_arg
|
|
|
|
self.waitFor(metrics_incremented, fail_msg="Expected metrics to update")
|
|
assert mock_metrics.drain_node_exceptions.inc.call_count == 0
|
|
|
|
def testDefaultMinMaxWorkers(self):
|
|
config = copy.deepcopy(MOCK_DEFAULT_CONFIG)
|
|
config = prepare_config(config)
|
|
node_types = config["available_node_types"]
|
|
head_node_config = node_types["ray.head.default"]
|
|
assert head_node_config["min_workers"] == 0
|
|
assert head_node_config["max_workers"] == 0
|
|
|
|
def testAutoscalerInitFailure(self):
|
|
"""Validates error handling for failed autoscaler initialization in the
|
|
Monitor.
|
|
"""
|
|
|
|
class AutoscalerInitFailException(Exception):
|
|
pass
|
|
|
|
class FaultyAutoscaler:
|
|
def __init__(self, *args, **kwargs):
|
|
raise AutoscalerInitFailException
|
|
|
|
prev_port = os.environ.get("RAY_GCS_SERVER_PORT")
|
|
os.environ["RAY_GCS_SERVER_PORT"] = "12345"
|
|
ray.init()
|
|
with patch("ray._private.utils.publish_error_to_driver") as mock_publish:
|
|
with patch.multiple(
|
|
"ray.autoscaler._private.monitor",
|
|
StandardAutoscaler=FaultyAutoscaler,
|
|
_internal_kv_initialized=Mock(return_value=False),
|
|
):
|
|
monitor = Monitor(
|
|
address="localhost:12345",
|
|
autoscaling_config="",
|
|
log_dir=self.tmpdir,
|
|
)
|
|
with pytest.raises(AutoscalerInitFailException):
|
|
monitor.run()
|
|
mock_publish.assert_called_once()
|
|
if prev_port is not None:
|
|
os.environ["RAY_GCS_SERVER_PORT"] = prev_port
|
|
else:
|
|
del os.environ["RAY_GCS_SERVER_PORT"]
|
|
|
|
def testInitializeSDKArguments(self):
|
|
# https://github.com/ray-project/ray/issues/23166
|
|
from ray.autoscaler.sdk import request_resources
|
|
|
|
with self.assertRaises(TypeError):
|
|
request_resources(num_cpus="bar")
|
|
with self.assertRaises(TypeError):
|
|
request_resources(bundles="bar")
|
|
with self.assertRaises(TypeError):
|
|
request_resources(bundles=["foo"])
|
|
# Non-numerical bundle values are rejected.
|
|
with self.assertRaises(TypeError):
|
|
request_resources(bundles=[{"foo": "bar"}])
|
|
with self.assertRaises(TypeError):
|
|
request_resources(bundles=[{"foo": 1}, {"bar": "baz"}])
|
|
# bool is a subclass of int but is not a meaningful resource value.
|
|
with self.assertRaises(TypeError):
|
|
request_resources(bundles=[{"CPU": True}])
|
|
|
|
with patch("ray.autoscaler.sdk.sdk.commands.request_resources") as mock_cmd:
|
|
request_resources(bundles=[{"CPU": 0.1}])
|
|
request_resources(bundles=[{"CPU": 1, "GPU": 0.5}])
|
|
assert mock_cmd.call_count == 2
|
|
|
|
def test_autoscaler_status_log(self):
|
|
self._test_autoscaler_status_log(status_log_enabled_env=1)
|
|
self._test_autoscaler_status_log(status_log_enabled_env=0)
|
|
|
|
def _test_autoscaler_status_log(self, status_log_enabled_env: int):
|
|
mock_logger = Mock(spec=logging.Logger(""))
|
|
with patch.multiple(
|
|
"ray.autoscaler._private.autoscaler",
|
|
logger=mock_logger,
|
|
AUTOSCALER_STATUS_LOG=status_log_enabled_env,
|
|
):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config_path = self.write_config(config)
|
|
runner = MockProcessRunner()
|
|
mock_metrics = Mock(spec=AutoscalerPrometheusMetrics())
|
|
self.provider = MockProvider()
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
LoadMetrics(),
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
prom_metrics=mock_metrics,
|
|
)
|
|
autoscaler.update()
|
|
status_log_found = False
|
|
for call in mock_logger.info.call_args_list:
|
|
args, _ = call
|
|
arg = args[0]
|
|
if " Autoscaler status: " in arg:
|
|
status_log_found = True
|
|
break
|
|
assert status_log_found is bool(status_log_enabled_env)
|
|
|
|
def testScaleDownIdleTimeOut(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["worker"]["min_workers"] = 1
|
|
config_path = self.write_config(config)
|
|
|
|
self.provider = MockProvider()
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
|
|
runner = MockProcessRunner()
|
|
lm = LoadMetrics()
|
|
mock_gcs_client = MockGcsClient()
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
mock_gcs_client,
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
)
|
|
|
|
autoscaler.update()
|
|
self.waitForNodes(1, tag_filters=WORKER_FILTER)
|
|
|
|
# Reduce cluster size to 1
|
|
new_config = copy.deepcopy(SMALL_CLUSTER)
|
|
new_config["available_node_types"]["worker"]["min_workers"] = 0
|
|
new_config["idle_timeout_minutes"] = 0.1
|
|
self.write_config(new_config)
|
|
autoscaler.update()
|
|
|
|
worker_ip = self.provider.non_terminated_node_ips(WORKER_FILTER)[0]
|
|
# Mark the node as idle
|
|
lm.update(worker_ip, mock_node_id(), {"CPU": 1}, {"CPU": 1}, 20)
|
|
autoscaler.update()
|
|
assert self.provider.internal_ip("1") == worker_ip
|
|
events = autoscaler.event_summarizer.summary()
|
|
assert "Removing 1 nodes of type worker (idle)." in events, events
|
|
autoscaler.update()
|
|
assert mock_gcs_client.drain_node_call_count == 1
|
|
|
|
def testDontScaleDownIdleTimeOutForPlacementGroups(self):
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
config["available_node_types"]["head"]["resources"][
|
|
"CPU"
|
|
] = 0 # make the head node not consume any resources.
|
|
config["available_node_types"]["worker"][
|
|
"min_workers"
|
|
] = 1 # prepare 1 worker upfront.
|
|
config["idle_timeout_minutes"] = 0.1
|
|
config_path = self.write_config(config)
|
|
|
|
self.provider = MockProvider()
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
|
|
runner = MockProcessRunner()
|
|
# Avoid the "Unable to deserialize `image_env` to Python object" error in the DockerCommandRunner.
|
|
runner.respond_to_call("json .Config.Env", ["[]" for i in range(2)])
|
|
lm = LoadMetrics()
|
|
mock_gcs_client = MockGcsClient()
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
mock_gcs_client,
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
)
|
|
|
|
autoscaler.update()
|
|
# 1 worker is ready upfront.
|
|
self.waitForNodes(1, tag_filters=WORKER_FILTER)
|
|
# clear the summary for later check.
|
|
autoscaler.event_summarizer.clear()
|
|
|
|
# Restore min_workers to allow scaling down to 0.
|
|
config["available_node_types"]["worker"]["min_workers"] = 0
|
|
self.write_config(config)
|
|
|
|
# Create a placement group with 2 bundles that require 2 workers.
|
|
placement_group_table_data = gcs_pb2.PlacementGroupTableData(
|
|
placement_group_id=b"\000",
|
|
strategy=common_pb2.PlacementStrategy.SPREAD,
|
|
)
|
|
for i in range(2):
|
|
bundle = common_pb2.Bundle()
|
|
bundle.bundle_id.placement_group_id = (
|
|
placement_group_table_data.placement_group_id
|
|
)
|
|
bundle.bundle_id.bundle_index = i
|
|
bundle.unit_resources["CPU"] = 1
|
|
placement_group_table_data.bundles.append(bundle)
|
|
|
|
# Mark the first worker as idle, but it should not be scaled down by the autoscaler because it will be used by the placement group.
|
|
worker_ip = self.provider.non_terminated_node_ips(WORKER_FILTER)[0]
|
|
lm.update(
|
|
worker_ip,
|
|
mock_node_id(),
|
|
{"CPU": 1},
|
|
{"CPU": 1},
|
|
20, # idle for 20 seconds, which is longer than the idle_timeout_minutes.
|
|
None,
|
|
None,
|
|
[placement_group_table_data],
|
|
)
|
|
autoscaler.update()
|
|
# TODO(rueian): This is a hack to avoid running into race conditions
|
|
# within v1 autoscaler. These should no longer be relevant in v2.
|
|
self.waitForNodes(2, tag_filters=WORKER_FILTER)
|
|
|
|
events = autoscaler.event_summarizer.summary()
|
|
assert "Removing 1 nodes of type worker (idle)." not in events, events
|
|
assert "Adding 1 node(s) of type worker." in events, events
|
|
|
|
autoscaler.update()
|
|
self.waitForNodes(2, tag_filters=WORKER_FILTER)
|
|
|
|
def testRecoverUnhealthyWorkersWithNodeSpecificDocker(self):
|
|
"""Test that recovery uses node-specific docker configuration.
|
|
|
|
This test verifies that when a worker node becomes unhealthy and needs
|
|
recovery, the autoscaler uses the node-specific docker configuration
|
|
rather than the global docker configuration.
|
|
"""
|
|
|
|
config = copy.deepcopy(SMALL_CLUSTER)
|
|
|
|
# Top-level global docker config (should be overridden by node-specific config)
|
|
config["docker"]["image"] = "global-image:latest"
|
|
config["docker"]["worker_image"] = "global-worker-image:latest"
|
|
|
|
# Add node-specific docker configuration
|
|
config["available_node_types"]["worker"]["docker"] = {
|
|
"worker_image": "node-specific-worker-image:latest",
|
|
"worker_run_options": ["--gpus=all"],
|
|
}
|
|
|
|
config["available_node_types"]["worker"]["min_workers"] = 1
|
|
|
|
config_path = self.write_config(config)
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
runner.respond_to_call("json .Config.Env", ["[]" for i in range(2)])
|
|
lm = LoadMetrics()
|
|
mock_metrics = Mock()
|
|
|
|
# Create head node
|
|
self.provider.create_node(
|
|
{},
|
|
{
|
|
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
|
|
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
|
|
TAG_RAY_USER_NODE_TYPE: "head",
|
|
},
|
|
1,
|
|
)
|
|
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
MockGcsClient(),
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
prom_metrics=mock_metrics,
|
|
)
|
|
autoscaler.update()
|
|
self.waitForNodes(1, tag_filters=WORKER_FILTER)
|
|
self.provider.finish_starting_nodes()
|
|
autoscaler.update()
|
|
self.waitForNodes(
|
|
1, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE, **WORKER_FILTER}
|
|
)
|
|
|
|
# Wait for initial updaters to finish
|
|
self.waitForUpdatersToFinish(autoscaler)
|
|
autoscaler.update()
|
|
|
|
# Ensure initial updaters are cleared after they finish
|
|
assert not autoscaler.updaters
|
|
|
|
# Clear command history before triggering recovery to ensure we only check
|
|
# commands from the recovery process, not the initial node creation
|
|
runner.clear_history()
|
|
|
|
# Trigger node recovery by setting the last heartbeat time to be before the timeout
|
|
worker_ip = "172.0.0.1" # Expected IP of the first worker node
|
|
lm.last_heartbeat_time_by_ip[worker_ip] = (
|
|
time.time() - AUTOSCALER_HEARTBEAT_TIMEOUT_S - 1
|
|
)
|
|
autoscaler.update()
|
|
|
|
# Wait for recovery to start and finish
|
|
self.waitFor(lambda: len(autoscaler.updaters) > 0, num_retries=150)
|
|
self.waitForUpdatersToFinish(autoscaler)
|
|
|
|
# Verify that recovery has started by checking multiple indicators:
|
|
|
|
# 1. Check that an updater was created for recovery
|
|
assert len(autoscaler.updaters) == 1
|
|
node_id = list(autoscaler.updaters.keys())[0]
|
|
updater = autoscaler.updaters[node_id]
|
|
|
|
# 2. Verify the updater is marked as a recovery updater
|
|
assert updater.for_recovery is True
|
|
|
|
# 3. Verify the recovery event was logged
|
|
events = autoscaler.event_summarizer.summary()
|
|
assert any(
|
|
"Restarting" in event and "lost contact with raylet" in event
|
|
for event in events
|
|
)
|
|
|
|
# 4. Verify that the recovery process uses the node-specific docker image
|
|
# instead of the global docker image
|
|
runner.assert_has_call(worker_ip, pattern="node-specific-worker-image:latest")
|
|
|
|
# 5. Verify that the recovery process uses the node-specific run options
|
|
runner.assert_has_call(worker_ip, pattern="--gpus=all")
|
|
|
|
# 6. Verify that the recovery updater has the correct docker config
|
|
# by checking that it uses the node-specific docker configuration
|
|
assert (
|
|
updater.docker_config.get("worker_image")
|
|
== "node-specific-worker-image:latest"
|
|
)
|
|
assert "--gpus=all" in updater.docker_config.get("worker_run_options")
|
|
|
|
def test_node_becomes_inactive_after_heartbeat_timeout(self):
|
|
cluster_config = copy.deepcopy(MOCK_DEFAULT_CONFIG)
|
|
cluster_config["available_node_types"]["ray.worker.default"]["min_workers"] = 1
|
|
cluster_config["worker_start_ray_commands"] = ["ray_start_cmd"]
|
|
|
|
cluster_config["head_node_type"] = ["ray.worker.default"]
|
|
del cluster_config["available_node_types"]["ray.head.default"]
|
|
del cluster_config["docker"]
|
|
|
|
config_path = self.write_config(cluster_config)
|
|
|
|
self.provider = MockProvider()
|
|
runner = MockProcessRunner()
|
|
lm = LoadMetrics()
|
|
mock_gcs_client = MockGcsClient()
|
|
autoscaler = MockAutoscaler(
|
|
config_path,
|
|
lm,
|
|
mock_gcs_client,
|
|
max_failures=0,
|
|
process_runner=runner,
|
|
update_interval_s=0,
|
|
)
|
|
|
|
autoscaler.update()
|
|
self.waitForNodes(1, tag_filters=WORKER_FILTER)
|
|
self.provider.finish_starting_nodes()
|
|
autoscaler.update()
|
|
self.waitForNodes(
|
|
1, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE, **WORKER_FILTER}
|
|
)
|
|
|
|
self.waitForUpdatersToFinish(autoscaler)
|
|
autoscaler.update()
|
|
|
|
assert not autoscaler.updaters
|
|
|
|
worker_ip = self.provider.non_terminated_node_ips(WORKER_FILTER)[0]
|
|
now = time.time()
|
|
past_heartbeat = now - AUTOSCALER_HEARTBEAT_TIMEOUT_S - 1
|
|
lm.last_heartbeat_time_by_ip[worker_ip] = past_heartbeat
|
|
|
|
autoscaler.update()
|
|
self.waitForNodes(
|
|
1, tag_filters={TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE, **WORKER_FILTER}
|
|
)
|
|
events = autoscaler.summary()
|
|
assert events.failed_nodes == [("172.0.0.0", "ray.worker.default")]
|
|
|
|
|
|
def test_import():
|
|
"""This test ensures that all the autoscaler imports work as expected to
|
|
prevent errors such as #19840.
|
|
"""
|
|
import ray # noqa
|
|
|
|
ray.autoscaler.sdk.request_resources # noqa
|
|
import ray.autoscaler # noqa
|
|
import ray.autoscaler.sdk # noqa
|
|
from ray.autoscaler.sdk import request_resources # noqa
|
|
|
|
|
|
def test_prom_null_metric_inc_fix():
|
|
"""Verify the bug fix https://github.com/ray-project/ray/pull/27532
|
|
for NullMetric's signature.
|
|
Check that NullMetric can be called with or without an argument.
|
|
"""
|
|
NullMetric().inc()
|
|
NullMetric().inc(5)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-sv", __file__]))
|