chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1 @@
from ray.tests.conftest import * # noqa
@@ -0,0 +1,544 @@
# coding: utf-8
"""
Test recovery behavior for the ALLOCATION_TIMEOUT scenario.
Verifies that when an instance reaches ALLOCATION_TIMEOUT, the autoscaler can:
1. Terminate the timed-out old instance.
2. Launch a replacement instance.
3. Avoid a QUEUED->REQUESTED->QUEUED loop.
Test design principles:
- Pure Python, mocking only the k8s client.
- Validate instance state rather than log output.
- Run reconcile multiple times to verify the state does not get stuck.
"""
import time
from typing import Any, Dict, List
import pytest
from ray.autoscaler.v2.instance_manager.config import InstanceReconcileConfig, Provider
from ray.autoscaler.v2.instance_manager.instance_manager import InstanceManager
from ray.autoscaler.v2.instance_manager.instance_storage import InstanceStorage
from ray.autoscaler.v2.instance_manager.node_provider import (
CloudInstance,
ICloudInstanceProvider,
)
from ray.autoscaler.v2.instance_manager.reconciler import Reconciler
from ray.autoscaler.v2.instance_manager.storage import InMemoryStorage
from ray.autoscaler.v2.instance_manager.subscribers.cloud_instance_updater import (
CloudInstanceUpdater,
)
from ray.autoscaler.v2.instance_manager.subscribers.cloud_resource_monitor import (
CloudResourceMonitor,
)
from ray.autoscaler.v2.scheduler import (
ResourceDemandScheduler,
)
from ray.autoscaler.v2.tests.util import create_instance
from ray.core.generated.autoscaler_pb2 import (
ClusterResourceState,
NodeState,
NodeStatus,
)
from ray.core.generated.instance_manager_pb2 import Instance, NodeKind
s_to_ns = 1 * 1_000_000_000
class MockAutoscalingConfig:
"""Mock autoscaling config for testing"""
def __init__(self, configs=None):
if configs is None:
configs = {}
self._configs = configs
def get_node_type_configs(self):
return self._configs.get("node_type_configs", {})
def get_max_num_worker_nodes(self):
return self._configs.get("max_num_worker_nodes")
def get_max_num_nodes(self):
n = self._configs.get("max_num_worker_nodes")
return n + 1 if n is not None else None
def get_upscaling_speed(self):
return self._configs.get("upscaling_speed", 0.0)
def get_max_concurrent_launches(self):
return self._configs.get("max_concurrent_launches", 100)
def get_instance_reconcile_config(self):
return self._configs.get("instance_reconcile_config", InstanceReconcileConfig())
def disable_node_updaters(self):
return self._configs.get("disable_node_updaters", True)
def disable_launch_config_check(self):
return self._configs.get("disable_launch_config_check", False)
def get_idle_timeout_s(self):
return self._configs.get("idle_timeout_s", 999)
def get_provider_instance_type(self, ray_node_type):
return ""
@property
def provider(self):
return Provider.UNKNOWN
class EventCapturingSubscriber:
"""
Subscriber that captures events for verification and delegates to CloudInstanceUpdater.
This wraps the real CloudInstanceUpdater to capture events for test assertions
while using the actual launch/terminate logic.
"""
def __init__(self, cloud_provider: ICloudInstanceProvider):
self.cloud_provider = cloud_provider
self.updater = CloudInstanceUpdater(cloud_provider=cloud_provider)
self.events = []
def notify(self, events):
self.events.extend(events)
# Delegate to real updater which calls cloud_provider.launch/terminate
self.updater.notify(events)
def clear(self):
self.events.clear()
def events_by_id(self, instance_id):
return [e for e in self.events if e.instance_id == instance_id]
class MockK8sClient:
"""
Mock Kubernetes API client that simulates RayCluster behavior.
Tracks:
- replicas: current replica count
- workers_to_delete: list of worker pod names to delete
- patch_history: history of all patches for verification
"""
def __init__(self, max_replicas=3, initial_replicas=3, worker_pod_names=None):
self.max_replicas = max_replicas
self.replicas = initial_replicas
self.workers_to_delete: List[str] = []
self.patch_history: List[Dict] = []
self._resource_version = 100
# Worker pod names - defaults to worker-001, worker-002, worker-003
self.worker_pod_names = worker_pod_names or [
"worker-001",
"worker-002",
"worker-003",
]
def get(self, path: str) -> Dict[str, Any]:
"""Handle GET requests"""
if "rayclusters" in path:
return {
"metadata": {
"name": "test-ray-cluster",
"namespace": "default",
"resourceVersion": str(self._resource_version),
},
"spec": {
"workerGroupSpecs": [
{
"groupName": "default-worker-group",
"replicas": self.replicas,
"minReplicas": 0,
"maxReplicas": self.max_replicas,
"scaleStrategy": {
"workersToDelete": list(self.workers_to_delete)
},
"numOfHosts": 1,
}
]
},
}
elif "pods" in path:
# Return pods based on current replicas (excluding workers_to_delete)
# Use worker_pod_names to match the cloud_instances in the test
items = []
for i in range(min(self.replicas, len(self.worker_pod_names))):
pod_name = self.worker_pod_names[i]
if pod_name not in self.workers_to_delete:
# worker-003 is in ALLOCATION_TIMEOUT state, so it should not be running
if pod_name == "worker-003":
# Pod is pending/failed - not running
container_state = {"waiting": {"reason": "ContainerCreating"}}
else:
container_state = {"running": {}}
items.append(
{
"metadata": {
"name": pod_name,
"labels": {
"ray.io/cluster": "test-ray-cluster",
"ray.io/node-type": "worker",
"ray.io/group": "default-worker-group",
},
},
"status": {
"containerStatuses": [{"state": container_state}]
},
}
)
return {
"metadata": {"resourceVersion": str(self._resource_version)},
"items": items,
}
return {}
def patch(self, path: str, payload: List[Dict]) -> Dict[str, Any]:
"""Handle PATCH requests and update internal state"""
self.patch_history.append({"path": path, "payload": payload})
self._resource_version += 1
for op in payload:
if op["op"] == "replace" and "replicas" in op["path"]:
self.replicas = op["value"]
elif op["op"] == "replace" and "scaleStrategy" in op["path"]:
self.workers_to_delete = op["value"].get("workersToDelete", [])
return {}
class TestAllocationTimeoutRecovery:
"""Test ALLOCATION_TIMEOUT instance recovery"""
@staticmethod
def _add_instances(instance_storage, instances):
for instance in instances:
ok, _ = instance_storage.upsert_instance(instance)
assert ok
def test_no_queued_requested_loop(self):
"""
Minimal reproduction path:
Preconditions:
- maxReplicas = 3
- 3 worker instances: 2 ALLOCATED (healthy), 1 ALLOCATION_TIMEOUT
- idle_worker_nodes = 1
Steps:
1. Run Reconciler.reconcile() multiple times to simulate repeated
reconciler cycles.
Expected results:
1. The new instance does not enter a QUEUED->REQUESTED->QUEUED loop.
2. The new instance can eventually transition into REQUESTED.
3. Terminate is submitted to Kubernetes before launch.
"""
# ===== Preconditions =====
instance_storage = InstanceStorage(
cluster_id="test_cluster_id",
storage=InMemoryStorage(),
)
cloud_resource_monitor = CloudResourceMonitor()
# Mock K8s client: maxReplicas=3, initial_replicas=3
# Worker pod names match the cloud_instance_id in cloud_instances
mock_k8s = MockK8sClient(
max_replicas=3,
initial_replicas=3,
worker_pod_names=["worker-001", "worker-002", "worker-003"],
)
# Create real cloud provider with mock k8s client
from unittest.mock import MagicMock
from ray.autoscaler.v2.instance_manager.cloud_providers.kuberay.cloud_provider import (
KubeRayProvider,
)
cloud_provider = KubeRayProvider(
cluster_name="test-ray-cluster",
provider_config={"namespace": "default"},
gcs_client=MagicMock(),
k8s_api_client=mock_k8s,
)
# Use EventCapturingSubscriber that wraps real CloudInstanceUpdater
# and captures events for test verification
mock_subscriber = EventCapturingSubscriber(cloud_provider=cloud_provider)
instance_manager = InstanceManager(
instance_storage=instance_storage,
instance_status_update_subscribers=[mock_subscriber],
)
# Create 2 ALLOCATED instances and 1 ALLOCATION_TIMEOUT instance.
current_time = time.time_ns()
timeout_time = (
current_time - 200 * s_to_ns
) # 200s ago, beyond the timeout threshold.
from ray._common.utils import binary_to_hex
instances = [
# Head node
create_instance(
"head",
status=Instance.RAY_RUNNING,
cloud_instance_id="head-001",
node_kind=NodeKind.HEAD,
instance_type="head",
ray_node_id=binary_to_hex(b"head"),
),
# Worker 1: healthy
create_instance(
"worker-1",
status=Instance.ALLOCATED,
instance_type="default-worker-group",
cloud_instance_id="worker-001",
node_kind=NodeKind.WORKER,
ray_node_id=binary_to_hex(b"wkr1"),
),
# Worker 2: healthy
create_instance(
"worker-2",
status=Instance.ALLOCATED,
instance_type="default-worker-group",
cloud_instance_id="worker-002",
node_kind=NodeKind.WORKER,
ray_node_id=binary_to_hex(b"wkr2"),
),
# Worker 3: ALLOCATION_TIMEOUT (startup timed out)
create_instance(
"worker-3",
status=Instance.ALLOCATION_TIMEOUT,
instance_type="default-worker-group",
cloud_instance_id="worker-003",
node_kind=NodeKind.WORKER,
status_times=[(Instance.ALLOCATION_TIMEOUT, timeout_time)],
),
]
TestAllocationTimeoutRecovery._add_instances(instance_storage, instances)
# Mock ray nodes: head + 2 healthy workers.
# ray_node_id must match the instance node_id.
# available_resources=0 means resources are fully consumed, so scale-up is needed.
ray_nodes = [
NodeState(
node_id=b"head",
status=NodeStatus.RUNNING,
instance_id="head-001",
total_resources={"CPU": 0},
available_resources={"CPU": 0},
),
NodeState(
node_id=b"wkr1",
status=NodeStatus.RUNNING,
instance_id="worker-001",
total_resources={"CPU": 4},
available_resources={"CPU": 0}, # Resources are fully consumed.
),
NodeState(
node_id=b"wkr2",
status=NodeStatus.RUNNING,
instance_id="worker-002",
total_resources={"CPU": 4},
available_resources={"CPU": 0}, # Resources are fully consumed.
),
]
# Mock cloud instances
# worker-003 must be included because the cloud instance still exists
# while the instance is in ALLOCATION_TIMEOUT.
# is_running=False means the pod is not running normally.
cloud_instances = {
"head-001": CloudInstance("head-001", "head", True, NodeKind.HEAD),
"worker-001": CloudInstance(
"worker-001", "default-worker-group", True, NodeKind.WORKER
),
"worker-002": CloudInstance(
"worker-002", "default-worker-group", True, NodeKind.WORKER
),
"worker-003": CloudInstance(
"worker-003",
"default-worker-group",
False,
NodeKind.WORKER, # is_running=False
),
}
# Use the real ResourceDemandScheduler.
scheduler = ResourceDemandScheduler()
# Add pending_resource_requests to simulate resource demand.
# Request 4 CPU while current available CPU is 0, so one more worker is needed.
from ray.core.generated.autoscaler_pb2 import (
ResourceRequest,
ResourceRequestByCount,
)
ray_cluster_resource_state = ClusterResourceState(
node_states=ray_nodes,
pending_resource_requests=[
ResourceRequestByCount(
request=ResourceRequest(resources_bundle={"CPU": 4}), count=1
),
],
)
# Mock autoscaling config. Use MockAutoscalingConfig to avoid schema validation.
# Build real NodeTypeConfig objects.
from ray.autoscaler.v2.instance_manager.config import NodeTypeConfig
node_type_configs = {
"default-worker-group": NodeTypeConfig(
name="default-worker-group",
min_worker_nodes=0,
max_worker_nodes=3,
resources={"CPU": 4},
labels={},
launch_config_hash="hash1",
idle_timeout_s=None,
),
"head": NodeTypeConfig(
name="head",
min_worker_nodes=0,
max_worker_nodes=1,
resources={"CPU": 0},
labels={},
launch_config_hash="hash1",
idle_timeout_s=None,
),
}
autoscaling_config = MockAutoscalingConfig(
configs={
"node_type_configs": node_type_configs,
"max_num_worker_nodes": 3,
"upscaling_speed": 1.0,
"max_concurrent_launches": 100,
"instance_reconcile_config": InstanceReconcileConfig(
request_status_timeout_s=10,
allocate_status_timeout_s=300,
),
"disable_node_updaters": True,
"disable_launch_config_check": True,
"idle_timeout_s": 999,
}
)
# ===== Steps: run reconcile multiple times =====
# Run 3 reconcile cycles to simulate repeated reconciler execution.
for cycle in range(3):
# Clear subscriber events so each iteration is checked independently.
mock_subscriber.events.clear()
Reconciler.reconcile(
instance_manager=instance_manager,
scheduler=scheduler,
cloud_provider=cloud_provider,
cloud_resource_monitor=cloud_resource_monitor,
ray_cluster_resource_state=ray_cluster_resource_state,
non_terminated_cloud_instances=cloud_instances,
cloud_provider_errors=[],
ray_install_errors=[],
autoscaling_config=autoscaling_config,
)
# Fetch current instance state to ensure repeated reconcile calls do not fail.
instance_storage.get_instances()
# ===== Expected result checks =====
# Get final instance states.
all_instances, _ = instance_storage.get_instances()
# 1. Verify the ALLOCATION_TIMEOUT instance transitions to TERMINATING.
worker_3 = all_instances.get("worker-3")
assert worker_3 is not None, "Expected worker-3 instance to exist"
assert worker_3.status == Instance.TERMINATING, (
f"Expected worker-3 status to be TERMINATING, "
f"got {Instance.InstanceStatus.Name(worker_3.status)}"
)
# 2. Verify at least one new instance is created (QUEUED or REQUESTED).
new_instances = [
i
for i in all_instances.values()
if i.status in (Instance.QUEUED, Instance.REQUESTED)
]
assert len(new_instances) >= 1, (
f"Expected at least 1 new instance (QUEUED/REQUESTED), "
f"got {len(new_instances)}"
)
# 3. Verify K8s patch history shows terminate before launch.
# Each patch may contain both replicas and scaleStrategy updates.
# Key checks:
# a) There should be two patches (terminate + launch).
# b) The first patch should contain workersToDelete.
# c) The second patch should increase replicas.
# Verify there are two patches (terminate + launch).
assert len(mock_k8s.patch_history) == 2, (
f"Expected 2 patches (terminate + launch), got {len(mock_k8s.patch_history)}. "
"If only 1 patch, launch may have failed due to maxReplicas bug."
)
# Verify the first patch contains workersToDelete.
first_patch = mock_k8s.patch_history[0]
first_patch_payload_str = str(first_patch.get("payload", []))
assert "workersToDelete" in first_patch_payload_str, (
f"Expected first patch to contain workersToDelete (terminate before launch). "
f"First patch: {first_patch}"
)
# Verify workersToDelete contains the timed-out instance.
for op in first_patch["payload"]:
if "scaleStrategy" in op.get("path", ""):
workers_to_delete = op["value"].get("workersToDelete", [])
assert (
"worker-003" in workers_to_delete
), f"Expected worker-003 in workersToDelete, got {workers_to_delete}"
break
# Verify the second patch increases replicas.
second_patch = mock_k8s.patch_history[1]
for op in second_patch["payload"]:
if "replicas" in op.get("path", ""):
assert (
op["value"] == 3
), f"Expected replicas=3 after launch, got {op['value']}"
break
# 4. Verify final replicas do not exceed maxReplicas.
assert (
mock_k8s.replicas <= 3
), f"Expected replicas <= 3, got {mock_k8s.replicas}"
# 5. Verify no instance enters a REQUESTED->QUEUED->REQUESTED loop.
# Check whether status_history contains a repeated transition pattern.
for instance in all_instances.values():
status_sequence = [h.instance_status for h in instance.status_history]
# Check for a REQUESTED->QUEUED->REQUESTED pattern.
for i in range(len(status_sequence) - 2):
assert not (
status_sequence[i] == Instance.REQUESTED
and status_sequence[i + 1] == Instance.QUEUED
and status_sequence[i + 2] == Instance.REQUESTED
), (
f"Instance {instance.instance_id} entered REQUESTED->QUEUED->REQUESTED loop. "
f"Status sequence: {[Instance.InstanceStatus.Name(s) for s in status_sequence]}"
)
if __name__ == "__main__":
pytest.main([__file__, "-v", "-s"])
@@ -0,0 +1,262 @@
import logging
import os
import subprocess
import sys
import time
from unittest.mock import MagicMock, patch
import pytest
import ray
from ray._common.test_utils import wait_for_condition
from ray._raylet import GcsClient
from ray.autoscaler._private.fake_multi_node.node_provider import FAKE_HEAD_NODE_ID
from ray.autoscaler.v2.autoscaler import Autoscaler
from ray.autoscaler.v2.event_logger import AutoscalerEventLogger
from ray.autoscaler.v2.instance_manager.config import AutoscalingConfig
from ray.autoscaler.v2.monitor import AutoscalerMonitor
from ray.autoscaler.v2.sdk import get_cluster_status, request_cluster_resources
from ray.autoscaler.v2.tests.util import MockEventLogger
from ray.cluster_utils import Cluster
logger = logging.getLogger(__name__)
DEFAULT_AUTOSCALING_CONFIG = {
"cluster_name": "fake_multinode",
"max_workers": 8,
"provider": {
"type": "fake_multinode",
},
"available_node_types": {
"ray.head.default": {
"resources": {
"CPU": 0,
},
"max_workers": 0,
"node_config": {},
},
"ray.worker.cpu": {
"resources": {
"CPU": 1,
},
"min_workers": 0,
"max_workers": 10,
"node_config": {},
},
"ray.worker.gpu": {
"resources": {
"GPU": 1,
},
"min_workers": 0,
"max_workers": 10,
"node_config": {},
},
},
"head_node_type": "ray.head.default",
"upscaling_speed": 0,
"idle_timeout_minutes": 0.08, # ~5 seconds
}
@pytest.fixture(scope="function")
def make_autoscaler():
ctx = {}
def _make_autoscaler(config):
head_node_kwargs = {
"env_vars": {
"RAY_CLOUD_INSTANCE_ID": FAKE_HEAD_NODE_ID,
"RAY_OVERRIDE_NODE_ID_FOR_TESTING": FAKE_HEAD_NODE_ID,
"RAY_NODE_TYPE_NAME": "ray.head.default",
},
"num_cpus": config["available_node_types"]["ray.head.default"]["resources"][
"CPU"
],
}
cluster = Cluster(
initialize_head=True, head_node_args=head_node_kwargs, connect=True
)
ctx["cluster"] = cluster
mock_config_reader = MagicMock()
gcs_address = cluster.address
# Configs for the node provider
config["provider"]["gcs_address"] = gcs_address
config["provider"]["head_node_id"] = FAKE_HEAD_NODE_ID
config["provider"]["launch_multiple"] = True
os.environ["RAY_FAKE_CLUSTER"] = "1"
mock_config_reader.get_cached_autoscaling_config.return_value = (
AutoscalingConfig(configs=config, skip_content_hash=True)
)
gcs_address = gcs_address
gcs_client = GcsClient(gcs_address)
event_logger = AutoscalerEventLogger(MockEventLogger(logger))
autoscaler = Autoscaler(
session_name="test",
config_reader=mock_config_reader,
gcs_client=gcs_client,
event_logger=event_logger,
)
return autoscaler
yield _make_autoscaler
try:
ray.shutdown()
ctx["cluster"].shutdown()
except Exception:
logger.exception("Error during teardown")
# Run ray stop to clean up everything
subprocess.run(
["ray", "stop", "--force"], stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
def test_basic_scaling(make_autoscaler):
config = DEFAULT_AUTOSCALING_CONFIG
autoscaler = make_autoscaler(DEFAULT_AUTOSCALING_CONFIG)
gcs_address = autoscaler._gcs_client.address
# Resource requests
print("=================== Test scaling up constraint 1/2====================")
request_cluster_resources(
gcs_address, [{"resources": {"CPU": 1}}, {"resources": {"GPU": 1}}]
)
def verify():
autoscaler.update_autoscaling_state()
cluster_state = get_cluster_status(gcs_address)
assert len(cluster_state.active_nodes + cluster_state.idle_nodes) == 3
return True
wait_for_condition(verify, retry_interval_ms=2000)
# Test scaling down shouldn't happen
print("=================== Test scaling down constraint 2/2 ====================")
idle_timeout_s = config["idle_timeout_minutes"] * 60
time.sleep(idle_timeout_s + 2)
wait_for_condition(verify, retry_interval_ms=2000)
# Test scaling down.
print("=================== Test scaling down idle ====================")
request_cluster_resources(gcs_address, [])
def verify():
autoscaler.update_autoscaling_state()
cluster_state = get_cluster_status(gcs_address)
assert len(cluster_state.active_nodes + cluster_state.idle_nodes) == 1
return True
wait_for_condition(verify, retry_interval_ms=2000)
# Test scaling up again with tasks
print("=================== Test scaling up with tasks ====================")
@ray.remote
def task():
time.sleep(999)
task.options(num_cpus=1).remote()
task.options(num_cpus=0, num_gpus=1).remote()
def verify():
autoscaler.update_autoscaling_state()
cluster_state = get_cluster_status(gcs_address)
assert len(cluster_state.active_nodes + cluster_state.idle_nodes) == 3
return True
wait_for_condition(verify, retry_interval_ms=2000)
# Test with placement groups
print(
"=================== Test scaling up with placement groups ===================="
)
# Spread to create another 2 nodes.
ray.util.placement_group(
name="pg", strategy="STRICT_SPREAD", bundles=[{"CPU": 0.5}, {"CPU": 0.5}]
)
def verify():
autoscaler.update_autoscaling_state()
cluster_state = get_cluster_status(gcs_address)
assert len(cluster_state.active_nodes + cluster_state.idle_nodes) == 5
return True
wait_for_condition(verify, retry_interval_ms=2000)
# Pack with feasible ones
ray.util.placement_group(
name="pg_feasible", strategy="STRICT_PACK", bundles=[{"CPU": 0.5}, {"CPU": 0.5}]
)
def verify():
autoscaler.update_autoscaling_state()
cluster_state = get_cluster_status(gcs_address)
assert len(cluster_state.active_nodes + cluster_state.idle_nodes) == 6
return True
wait_for_condition(verify, retry_interval_ms=2000)
# Pack with infeasible request
ray.util.placement_group(
name="pg_infeasible", strategy="STRICT_PACK", bundles=[{"CPU": 1}, {"CPU": 1}]
)
def verify():
autoscaling_state = autoscaler.update_autoscaling_state()
cluster_state = get_cluster_status(gcs_address)
assert len(cluster_state.active_nodes + cluster_state.idle_nodes) == 6
assert len(autoscaling_state.infeasible_gang_resource_requests) == 1
return True
wait_for_condition(verify, retry_interval_ms=2000)
# Test report autoscaling state
def verify():
autoscaling_state = autoscaler.update_autoscaling_state()
assert len(autoscaling_state.infeasible_gang_resource_requests) == 1
# For now, we just track that it's called ok.
autoscaler._gcs_client.report_autoscaling_state(
autoscaling_state.SerializeToString()
)
return True
wait_for_condition(verify, retry_interval_ms=2000)
class TestAutoscalerMonitor(AutoscalerMonitor):
"""Lightweight wrapper for testing _run() without full init."""
def __init__(self, gcs_address, gcs_client, autoscaler):
self.gcs_address = gcs_address
self.gcs_client = gcs_client
self.autoscaler = autoscaler
self._session_name = "test"
def test_raise_AuthenticationError_v2(make_autoscaler):
autoscaler = make_autoscaler(DEFAULT_AUTOSCALING_CONFIG)
gcs_client = autoscaler._gcs_client
gcs_address = gcs_client.address
monitor = TestAutoscalerMonitor(gcs_address, gcs_client, autoscaler)
def flaky():
raise ray.exceptions.AuthenticationError("WrongClusterID")
with patch.object(autoscaler, "update_autoscaling_state", side_effect=flaky):
with pytest.raises(ray.exceptions.AuthenticationError):
monitor._run()
if __name__ == "__main__":
if os.environ.get("PARALLEL_CI"):
sys.exit(pytest.main(["-n", "auto", "--boxed", "-vs", __file__]))
else:
sys.exit(pytest.main(["-sv", __file__]))
@@ -0,0 +1,251 @@
# coding: utf-8
import os
import sys
import tempfile
import pytest # noqa
from ray._common.utils import binary_to_hex
from ray._private.test_utils import get_test_config_path
from ray.autoscaler import AUTOSCALER_DIR_PATH
from ray.autoscaler._private.util import format_readonly_node_type
from ray.autoscaler.v2.instance_manager import config as config_mod
from ray.autoscaler.v2.instance_manager.config import (
FileConfigReader,
Provider,
ReadOnlyProviderConfigReader,
)
@pytest.mark.parametrize(
"skip_hash",
[True, False],
)
def test_simple(skip_hash):
config = FileConfigReader(
get_test_config_path("test_multi_node.yaml"), skip_content_hash=skip_hash
).get_cached_autoscaling_config()
assert config.get_cloud_node_config("head_node") == {"InstanceType": "m5.large"}
assert config.get_docker_config("head_node") == {
"image": "anyscale/ray-ml:latest",
"container_name": "ray_container",
"pull_before_run": True,
}
assert config.get_worker_start_ray_commands
def test_complex():
config = FileConfigReader(
get_test_config_path("test_ray_complex.yaml")
).get_cached_autoscaling_config()
assert config.get_head_setup_commands() == [
"echo a",
"echo b",
"echo ${echo hi}",
"echo head",
]
assert config.get_head_start_ray_commands() == [
"ray stop",
"ray start --head --autoscaling-config=~/ray_bootstrap_config.yaml",
]
assert config.get_worker_setup_commands("worker_nodes") == [
"echo a",
"echo b",
"echo ${echo hi}",
"echo worker",
]
assert config.get_worker_start_ray_commands() == [
"ray stop",
"ray start --address=$RAY_HEAD_IP",
]
assert config.get_worker_setup_commands("worker_nodes1") == [
"echo worker1",
]
assert config.get_docker_config("head_node") == {
"image": "anyscale/ray-ml:head-default",
"container_name": "ray_container",
"pull_before_run": True,
}
assert config.get_docker_config("default") == {
"image": "anyscale/ray-ml:worker-default",
"container_name": "ray_container",
"pull_before_run": True,
}
assert config.get_docker_config("worker_nodes") == {
"image": "anyscale/ray-ml:worker-default",
"container_name": "ray_container",
"pull_before_run": True,
}
assert config.get_docker_config("worker_nodes1") == {
"image": "anyscale/ray-ml:worker_nodes1",
"container_name": "ray_container",
"pull_before_run": True,
}
assert config.get_initialization_commands("worker_nodes") == ["echo what"]
assert config.get_initialization_commands("worker_nodes1") == ["echo init"]
assert config.get_node_resources("worker_nodes1") == {"CPU": 2}
assert config.get_node_resources("worker_nodes") == {}
assert config.get_node_labels("worker_nodes1") == {"foo": "bar"}
assert config.get_config("cluster_name") == "test-cli"
assert config.get_config("non-existing", "default") == "default"
assert config.get_config("non-existing") is None
def test_multi_provider_instance_type():
def load_config(file):
path = os.path.join(AUTOSCALER_DIR_PATH, file)
return FileConfigReader(path).get_cached_autoscaling_config()
aws_config = load_config("aws/defaults.yaml")
assert aws_config.get_provider_instance_type("ray.head.default") == "m5.large"
gcp_config = load_config("gcp/defaults.yaml")
# NOTE: Why is this underscore....
assert gcp_config.get_provider_instance_type("ray_head_default") == "n1-standard-2"
aliyun_config = load_config("aliyun/defaults.yaml")
assert (
aliyun_config.get_provider_instance_type("ray.head.default") == "ecs.n4.large"
)
azure_config = load_config("azure/defaults.yaml")
assert (
azure_config.get_provider_instance_type("ray.head.default") == "Standard_D2s_v3"
)
# TODO(rickyx):
# We don't have kuberay and local config yet.
def test_node_type_configs():
config = FileConfigReader(
get_test_config_path("test_ray_complex.yaml")
).get_cached_autoscaling_config()
node_type_configs = config.get_node_type_configs()
assert config.get_max_num_worker_nodes() == 10
assert len(node_type_configs) == 4
assert node_type_configs["head_node"].max_worker_nodes == 1
assert node_type_configs["head_node"].min_worker_nodes == 0
assert node_type_configs["head_node"].resources == {}
assert node_type_configs["head_node"].labels == {}
assert node_type_configs["default"].max_worker_nodes == 2
assert node_type_configs["default"].min_worker_nodes == 0
assert node_type_configs["default"].resources == {}
assert node_type_configs["default"].labels == {}
assert node_type_configs["worker_nodes"].max_worker_nodes == 2
assert node_type_configs["worker_nodes"].min_worker_nodes == 1
assert node_type_configs["worker_nodes"].resources == {}
assert node_type_configs["worker_nodes"].labels == {}
assert node_type_configs["worker_nodes1"].max_worker_nodes == 2
assert node_type_configs["worker_nodes1"].min_worker_nodes == 1
assert node_type_configs["worker_nodes1"].resources == {"CPU": 2}
assert node_type_configs["worker_nodes1"].labels == {"foo": "bar"}
def test_read_config():
# Make a temp config file from aws/defaults.yaml
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
# Write "aws/defaults.yaml" to the temp file
with open(
os.path.join(AUTOSCALER_DIR_PATH, "aws/defaults.yaml"), "r"
) as default_file:
f.write(default_file.read())
config_reader = FileConfigReader(f.name)
# Check that the config is read correctly
assert config_reader.get_cached_autoscaling_config().provider == Provider.AWS
# Now override the file with a different provider
with open(f.name, "w") as f:
# Replace the file with "gcp/defaults.yaml"
with open(
os.path.join(AUTOSCALER_DIR_PATH, "gcp/defaults.yaml"), "r"
) as default_file:
f.write(default_file.read())
# Still the same.
assert config_reader.get_cached_autoscaling_config().provider == Provider.AWS
# Reload
config_reader.refresh_cached_autoscaling_config()
assert config_reader.get_cached_autoscaling_config().provider == Provider.GCP
def test_readonly_node_type_name_and_fallback(monkeypatch):
class _DummyNodeState:
def __init__(self, ray_node_type_name, node_id, total_resources):
self.ray_node_type_name = ray_node_type_name
self.node_id = node_id
self.total_resources = total_resources
class _DummyClusterState:
def __init__(self, node_states):
self.node_states = node_states
# Avoid real GCS usage.
monkeypatch.setattr(config_mod, "GcsClient", lambda address: object())
# Build a cluster with:
# - 1 named head type
# - 2 named worker types of the same type (aggregation check)
# - 1 worker type without name (fallback to node_id-based type)
unnamed_worker_id = b"\xab"
fallback_name = format_readonly_node_type(binary_to_hex(unnamed_worker_id))
nodes = [
_DummyNodeState(
"ray.head.default", b"\x01", {"CPU": 1, "node:__internal_head__": 1}
),
_DummyNodeState("worker.custom", b"\x02", {"CPU": 2}),
_DummyNodeState("worker.custom", b"\x03", {"CPU": 2}),
_DummyNodeState("worker.custom", b"\x04", {"CPU": 2}),
_DummyNodeState("", unnamed_worker_id, {"CPU": 3}),
]
monkeypatch.setattr(
config_mod,
"get_cluster_resource_state",
lambda _gc: _DummyClusterState(nodes),
)
reader = ReadOnlyProviderConfigReader("dummy:0")
reader.refresh_cached_autoscaling_config()
cfg = reader.get_cached_autoscaling_config()
node_types = cfg.get_config("available_node_types")
# Head assertions
assert "ray.head.default" in node_types
assert node_types["ray.head.default"]["max_workers"] == 0
assert cfg.get_head_node_type() == "ray.head.default"
# Preferred name aggregation
assert "worker.custom" in node_types
assert node_types["worker.custom"]["max_workers"] == 3
# Fallback for unnamed worker
assert fallback_name in node_types
assert node_types[fallback_name]["max_workers"] == 1
# Global max_workers should be the sum of all worker-type max_workers,
# NOT the count of node type names.
# Here: 3 distinct types (head, worker.custom, fallback), but
# 4 actual workers (3 x worker.custom + 1 x fallback).
# The old buggy `len(available_node_types)` returned 3 instead of 4.
assert cfg.get_config("max_workers") == 4
if __name__ == "__main__":
if os.environ.get("PARALLEL_CI"):
sys.exit(pytest.main(["-n", "auto", "--boxed", "-vs", __file__]))
else:
sys.exit(pytest.main(["-sv", __file__]))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,131 @@
import logging
import os
import sys
import pytest
from ray.autoscaler.v2.event_logger import AutoscalerEventLogger
from ray.autoscaler.v2.tests.util import MockEventLogger
from ray.autoscaler.v2.utils import ResourceRequestUtil
from ray.core.generated.autoscaler_pb2 import (
ClusterResourceConstraint,
GangResourceRequest,
)
from ray.core.generated.instance_manager_pb2 import LaunchRequest, TerminationRequest
# coding: utf-8
OUTDATED = TerminationRequest.Cause.OUTDATED
IDLE = TerminationRequest.Cause.IDLE
MAX_NUM_NODE_PER_TYPE = TerminationRequest.Cause.MAX_NUM_NODE_PER_TYPE
MAX_NUM_NODES = TerminationRequest.Cause.MAX_NUM_NODES
def launch_request(instance_type: str, count: int) -> LaunchRequest:
return LaunchRequest(
instance_type=instance_type,
count=count,
)
def termination_request(
instance_type: str, cause: TerminationRequest.Cause
) -> TerminationRequest:
return TerminationRequest(
instance_id="",
instance_type=instance_type,
cause=cause,
)
logger = logging.getLogger(__name__)
def test_log_scheduling_updates():
mock_logger = MockEventLogger(logger)
event_logger = AutoscalerEventLogger(mock_logger)
launch_requests = [
launch_request("m4.large", 2),
launch_request("m4.xlarge", 2),
]
terminate_requests = [
termination_request("m4.large", IDLE),
termination_request("m4.xlarge", OUTDATED),
]
infeasible_requests = [
ResourceRequestUtil.make({"CPU": 4, "GPU": 1}),
] * 100 + [ResourceRequestUtil.make({"CPU": 4})]
gang_resource_requests = [
[
ResourceRequestUtil.make({"CPU": 4, "GPU": 1}),
ResourceRequestUtil.make({"CPU": 4, "GPU": 1}),
]
]
cluster_resource_constraints = [
ResourceRequestUtil.make({"CPU": 1, "GPU": 1}),
] * 100
event_logger.log_cluster_scheduling_update(
launch_requests=launch_requests,
terminate_requests=terminate_requests,
infeasible_requests=infeasible_requests,
infeasible_gang_requests=[
GangResourceRequest(requests=reqs) for reqs in gang_resource_requests
],
infeasible_cluster_resource_constraints=[
ClusterResourceConstraint(
resource_requests=ResourceRequestUtil.group_by_count(
cluster_resource_constraints
)
)
],
cluster_resources={"CPU": 5, "GPU": 5, "TPU": 2},
)
assert mock_logger.get_logs("info") == [
"Adding 2 node(s) of type m4.large.",
"Adding 2 node(s) of type m4.xlarge.",
"Removing 1 nodes of type m4.large (idle).",
"Removing 1 nodes of type m4.xlarge (outdated).",
"Resized to 5 CPUs, 5 GPUs, 2 TPUs.",
]
expect_lines = [
"No available node types can fulfill resource requests", # noqa
"No available node types can fulfill placement group requests", # noqa
"No available node types can fulfill cluster constraint", # noqa
]
for expect_line, actual_line in zip(expect_lines, mock_logger.get_logs("error")):
assert expect_line in actual_line
assert mock_logger.get_logs("error") == []
assert mock_logger.get_logs("debug") == [
"Current cluster resources: {'CPU': 5, 'GPU': 5, 'TPU': 2}."
]
def test_log_scheduling_updates_without_cluster_shape():
mock_logger = MockEventLogger(logger)
event_logger = AutoscalerEventLogger(mock_logger, log_cluster_shape=False)
event_logger.log_cluster_scheduling_update(
launch_requests=[launch_request("m4.large", 1)],
terminate_requests=[termination_request("m4.xlarge", OUTDATED)],
infeasible_requests=[ResourceRequestUtil.make({"CPU": 4})],
cluster_resources={"CPU": 5},
)
assert mock_logger.get_logs("info") == []
assert mock_logger.get_logs("warning") == [
"No available node types can fulfill resource requests {'CPU': 4.0}*1. Add suitable node types to this cluster to resolve this issue."
]
assert mock_logger.get_logs("debug") == []
if __name__ == "__main__":
if os.environ.get("PARALLEL_CI"):
sys.exit(pytest.main(["-n", "auto", "--boxed", "-vs", __file__]))
else:
sys.exit(pytest.main(["-sv", __file__]))
@@ -0,0 +1,414 @@
import os
import sys
import unittest
from collections import defaultdict
from unittest.mock import MagicMock
# coding: utf-8
import pytest
from ray.autoscaler.v2.instance_manager.instance_manager import InstanceManager
from ray.autoscaler.v2.instance_manager.instance_storage import InstanceStorage
from ray.autoscaler.v2.instance_manager.storage import InMemoryStorage, StoreStatus
from ray.autoscaler.v2.tests.util import MockSubscriber
from ray.core.generated.instance_manager_pb2 import (
GetInstanceManagerStateRequest,
Instance,
InstanceUpdateEvent,
NodeKind,
StatusCode,
UpdateInstanceManagerStateRequest,
)
class InstanceManagerTest(unittest.TestCase):
def test_instances_version_mismatch(self):
ins_storage = MagicMock()
subscriber = MockSubscriber()
im = InstanceManager(
ins_storage, instance_status_update_subscribers=[subscriber]
)
# Version mismatch on reading from the storage.
ins_storage.get_instances.return_value = ({}, 1)
update = InstanceUpdateEvent(
instance_id="id-1",
new_instance_status=Instance.QUEUED,
instance_type="type-1",
upsert=True,
)
reply = im.update_instance_manager_state(
UpdateInstanceManagerStateRequest(
expected_version=0,
updates=[update],
)
)
assert reply.status.code == StatusCode.VERSION_MISMATCH
assert len(subscriber.events) == 0
# Version OK.
reply = im.update_instance_manager_state(
UpdateInstanceManagerStateRequest(
expected_version=1,
updates=[update],
)
)
assert reply.status.code == StatusCode.OK
assert len(subscriber.events) == 1
assert subscriber.events[0].new_instance_status == Instance.QUEUED
# Version mismatch when writing to the storage (race happens)
ins_storage.batch_upsert_instances.return_value = StoreStatus(
False, 2 # No longer 1
)
subscriber.clear()
reply = im.update_instance_manager_state(
UpdateInstanceManagerStateRequest(
expected_version=1,
updates=[update],
)
)
assert reply.status.code == StatusCode.VERSION_MISMATCH
assert len(subscriber.events) == 0
# Non-version mismatch error.
ins_storage.batch_upsert_instances.return_value = StoreStatus(
False, 1 # Still 1
)
reply = im.update_instance_manager_state(
UpdateInstanceManagerStateRequest(
expected_version=1,
updates=[update],
)
)
assert reply.status.code == StatusCode.UNKNOWN_ERRORS
assert len(subscriber.events) == 0
def test_get_and_updates(self):
ins_storage = InstanceStorage(
"cluster-id",
InMemoryStorage(),
)
subscriber = MockSubscriber()
im = InstanceManager(
ins_storage, instance_status_update_subscribers=[subscriber]
)
# Empty storage.
reply = im.get_instance_manager_state(GetInstanceManagerStateRequest())
assert reply.status.code == StatusCode.OK
assert list(reply.state.instances) == []
# Launch nodes.
reply = im.update_instance_manager_state(
UpdateInstanceManagerStateRequest(
expected_version=0,
updates=[
InstanceUpdateEvent(
instance_type="type-1",
instance_id="id-1",
new_instance_status=Instance.QUEUED,
upsert=True,
),
InstanceUpdateEvent(
instance_type="type-2",
instance_id="id-2",
new_instance_status=Instance.QUEUED,
upsert=True,
),
InstanceUpdateEvent(
instance_type="type-2",
instance_id="id-3",
new_instance_status=Instance.QUEUED,
upsert=True,
),
],
)
)
assert reply.status.code == StatusCode.OK
assert len(subscriber.events) == 3
for e in subscriber.events:
assert e.new_instance_status == Instance.QUEUED
# Get launched nodes.
reply = im.get_instance_manager_state(GetInstanceManagerStateRequest())
assert reply.status.code == StatusCode.OK
assert len(reply.state.instances) == 3
instance_ids = [ins.instance_id for ins in reply.state.instances]
types_count = defaultdict(int)
for ins in reply.state.instances:
types_count[ins.instance_type] += 1
assert ins.status == Instance.QUEUED
assert types_count["type-1"] == 1
assert types_count["type-2"] == 2
# Update node status.
subscriber.clear()
reply = im.update_instance_manager_state(
UpdateInstanceManagerStateRequest(
expected_version=1,
updates=[
InstanceUpdateEvent(
instance_id=instance_ids[0],
new_instance_status=Instance.REQUESTED,
instance_type="type-1",
launch_request_id="l1",
),
InstanceUpdateEvent(
instance_id=instance_ids[1],
new_instance_status=Instance.REQUESTED,
launch_request_id="l1",
instance_type="type-1",
),
],
)
)
assert reply.status.code == StatusCode.OK
assert len(subscriber.events) == 2
for e in subscriber.events:
assert e.new_instance_status == Instance.REQUESTED
# Get updated nodes.
reply = im.get_instance_manager_state(GetInstanceManagerStateRequest())
assert reply.status.code == StatusCode.OK
assert len(reply.state.instances) == 3
types_count = defaultdict(int)
for ins in reply.state.instances:
types_count[ins.instance_type] += 1
if ins.instance_id in [instance_ids[0], instance_ids[1]]:
assert ins.status == Instance.REQUESTED
else:
assert ins.status == Instance.QUEUED
# Invalid instances status update.
subscriber.clear()
with pytest.raises(AssertionError):
reply = im.update_instance_manager_state(
UpdateInstanceManagerStateRequest(
expected_version=2,
updates=[
InstanceUpdateEvent(
instance_id=instance_ids[2],
# Not requested yet.
new_instance_status=Instance.RAY_RUNNING,
),
],
)
)
assert len(subscriber.events) == 0
# Invalid versions.
reply = im.update_instance_manager_state(
UpdateInstanceManagerStateRequest(
expected_version=0, # Invalid version, outdated.
updates=[
InstanceUpdateEvent(
instance_id=instance_ids[2],
new_instance_status=Instance.REQUESTED,
instance_type="type-2",
),
],
)
)
assert reply.status.code == StatusCode.VERSION_MISMATCH
assert len(subscriber.events) == 0
def test_insert(self):
ins_storage = InstanceStorage(
"cluster-id",
InMemoryStorage(),
)
subscriber = MockSubscriber()
im = InstanceManager(
ins_storage, instance_status_update_subscribers=[subscriber]
)
im.update_instance_manager_state(
UpdateInstanceManagerStateRequest(
expected_version=0,
updates=[
InstanceUpdateEvent(
instance_type="type-1",
instance_id="id-1",
new_instance_status=Instance.QUEUED,
upsert=True,
),
InstanceUpdateEvent(
instance_id="id-2",
new_instance_status=Instance.TERMINATING,
cloud_instance_id="cloud-id-2",
upsert=True,
),
InstanceUpdateEvent(
instance_id="id-3",
new_instance_status=Instance.ALLOCATED,
cloud_instance_id="cloud-id-3",
node_kind=NodeKind.WORKER,
instance_type="type-3",
upsert=True,
),
],
)
)
reply = im.get_instance_manager_state(GetInstanceManagerStateRequest())
assert len(reply.state.instances) == 3
instance_by_ids = {ins.instance_id: ins for ins in reply.state.instances}
assert instance_by_ids["id-1"].status == Instance.QUEUED
assert instance_by_ids["id-1"].instance_type == "type-1"
assert instance_by_ids["id-2"].status == Instance.TERMINATING
assert instance_by_ids["id-3"].status == Instance.ALLOCATED
assert instance_by_ids["id-3"].cloud_instance_id == "cloud-id-3"
version = reply.state.version
# With non-upsert flags.
with pytest.raises(AssertionError):
reply = im.update_instance_manager_state(
UpdateInstanceManagerStateRequest(
expected_version=version,
updates=[
InstanceUpdateEvent(
instance_type="type-1",
instance_id="id-999",
new_instance_status=Instance.QUEUED,
),
],
)
)
# With invalid statuses
all_statuses = set(Instance.InstanceStatus.values())
non_insertable_statuses = all_statuses - {
Instance.QUEUED,
Instance.TERMINATING,
Instance.ALLOCATED,
}
for status in non_insertable_statuses:
subscriber.clear()
with pytest.raises(AssertionError):
reply = im.update_instance_manager_state(
UpdateInstanceManagerStateRequest(
expected_version=version,
updates=[
InstanceUpdateEvent(
instance_id="id-999",
new_instance_status=status,
),
],
)
)
assert len(subscriber.events) == 0
def test_apply_update(self):
ins_storage = InstanceStorage(
"cluster-id",
InMemoryStorage(),
)
subscriber = MockSubscriber()
im = InstanceManager(
ins_storage, instance_status_update_subscribers=[subscriber]
)
# Insert a new instance.
im.update_instance_manager_state(
UpdateInstanceManagerStateRequest(
expected_version=0,
updates=[
InstanceUpdateEvent(
instance_type="type-1",
instance_id="id-1",
new_instance_status=Instance.QUEUED,
upsert=True,
),
],
)
)
reply = im.get_instance_manager_state(GetInstanceManagerStateRequest())
assert len(reply.state.instances) == 1
assert reply.state.instances[0].status == Instance.QUEUED
assert reply.state.instances[0].instance_type == "type-1"
# Request
im.update_instance_manager_state(
UpdateInstanceManagerStateRequest(
expected_version=1,
updates=[
InstanceUpdateEvent(
instance_id="id-1",
new_instance_status=Instance.REQUESTED,
launch_request_id="l1",
instance_type="type-1",
),
],
)
)
reply = im.get_instance_manager_state(GetInstanceManagerStateRequest())
assert len(reply.state.instances) == 1
assert reply.state.instances[0].status == Instance.REQUESTED
assert reply.state.instances[0].launch_request_id == "l1"
# ALLOCATED
im.update_instance_manager_state(
UpdateInstanceManagerStateRequest(
expected_version=2,
updates=[
InstanceUpdateEvent(
instance_id="id-1",
new_instance_status=Instance.ALLOCATED,
cloud_instance_id="cloud-id-1",
node_kind=NodeKind.WORKER,
instance_type="type-1",
),
],
)
)
reply = im.get_instance_manager_state(GetInstanceManagerStateRequest())
assert len(reply.state.instances) == 1
assert reply.state.instances[0].status == Instance.ALLOCATED
assert reply.state.instances[0].cloud_instance_id == "cloud-id-1"
# RAY_RUNNING
im.update_instance_manager_state(
UpdateInstanceManagerStateRequest(
expected_version=3,
updates=[
InstanceUpdateEvent(
instance_id="id-1",
new_instance_status=Instance.RAY_RUNNING,
ray_node_id="ray-node-1",
),
],
)
)
reply = im.get_instance_manager_state(GetInstanceManagerStateRequest())
assert len(reply.state.instances) == 1
assert reply.state.instances[0].status == Instance.RAY_RUNNING
assert reply.state.instances[0].node_id == "ray-node-1"
# TERMINATED
im.update_instance_manager_state(
UpdateInstanceManagerStateRequest(
expected_version=4,
updates=[
InstanceUpdateEvent(
instance_id="id-1",
new_instance_status=Instance.TERMINATED,
),
],
)
)
reply = im.get_instance_manager_state(GetInstanceManagerStateRequest())
assert len(reply.state.instances) == 1
assert reply.state.instances[0].status == Instance.TERMINATED
if __name__ == "__main__":
if os.environ.get("PARALLEL_CI"):
sys.exit(pytest.main(["-n", "auto", "--boxed", "-vs", __file__]))
else:
sys.exit(pytest.main(["-sv", __file__]))
@@ -0,0 +1,220 @@
# coding: utf-8
import copy
import os
import sys
from unittest import mock
import pytest # noqa
from ray.autoscaler.v2.instance_manager.instance_storage import InstanceStorage
from ray.autoscaler.v2.instance_manager.storage import InMemoryStorage
from ray.core.generated.instance_manager_pb2 import Instance
def create_instance(instance_id, status=Instance.QUEUED, version=0):
return Instance(
instance_id=instance_id,
status=status,
version=version,
)
@mock.patch("time.time", mock.MagicMock(return_value=1))
def test_upsert():
storage = InstanceStorage(
cluster_id="test_cluster",
storage=InMemoryStorage(),
)
instance1 = create_instance("instance1")
instance2 = create_instance("instance2")
instance3 = create_instance("instance3")
assert (True, 1) == storage.batch_upsert_instances(
[instance1, instance2],
expected_storage_version=None,
)
instance1.version = 1
instance2.version = 1
entries, storage_version = storage.get_instances()
assert storage_version == 1
assert entries == {
"instance1": instance1,
"instance2": instance2,
}
assert (False, 1) == storage.batch_upsert_instances(
[create_instance("instance1"), create_instance("instance2")],
expected_storage_version=0,
)
instance2.status = Instance.ALLOCATED
assert (True, 2) == storage.batch_upsert_instances(
[instance3, instance2],
expected_storage_version=1,
)
instance1.version = 1
instance2.version = 2
instance3.version = 2
entries, storage_version = storage.get_instances()
assert storage_version == 2
assert entries == {
"instance1": instance1,
"instance2": instance2,
"instance3": instance3,
}
@mock.patch("time.time", mock.MagicMock(return_value=1))
def test_update():
storage = InstanceStorage(
cluster_id="test_cluster",
storage=InMemoryStorage(),
)
instance1 = create_instance("instance1")
instance2 = create_instance("instance2")
assert (True, 1) == storage.upsert_instance(instance=instance1)
assert (True, 2) == storage.upsert_instance(instance=instance2)
assert (
{
"instance1": create_instance("instance1", version=1),
"instance2": create_instance("instance2", version=2),
},
2,
) == storage.get_instances()
# failed because instance version is not correct
assert (False, 2) == storage.upsert_instance(
instance=instance1,
expected_instance_version=0,
)
# failed because storage version is not correct
assert (False, 2) == storage.upsert_instance(
instance=instance1,
expected_storage_version=0,
)
assert (True, 3) == storage.upsert_instance(
instance=instance2,
expected_storage_version=2,
)
assert (
{
"instance1": create_instance("instance1", version=1),
"instance2": create_instance("instance2", version=3),
},
3,
) == storage.get_instances()
assert (True, 4) == storage.upsert_instance(
instance=instance1,
expected_instance_version=1,
)
assert (
{
"instance1": create_instance("instance1", version=4),
"instance2": create_instance("instance2", version=3),
},
4,
) == storage.get_instances()
@mock.patch("time.time", mock.MagicMock(return_value=1))
def test_delete():
storage = InstanceStorage(
cluster_id="test_cluster",
storage=InMemoryStorage(),
)
instance1 = create_instance("instance1")
instance2 = create_instance("instance2")
instance3 = create_instance("instance3")
assert (True, 1) == storage.batch_upsert_instances(
[instance1, instance2, instance3],
expected_storage_version=None,
)
assert (False, 1) == storage.batch_delete_instances(
instance_ids=["instance1"], expected_storage_version=0
)
assert (True, 2) == storage.batch_delete_instances(instance_ids=["instance1"])
assert (
{
"instance2": create_instance("instance2", version=1),
"instance3": create_instance("instance3", version=1),
},
2,
) == storage.get_instances()
assert (True, 3) == storage.batch_delete_instances(
instance_ids=["instance2"], expected_storage_version=2
)
assert (
{
"instance3": create_instance("instance3", version=1),
},
3,
) == storage.get_instances()
@mock.patch("time.time", mock.MagicMock(return_value=1))
def test_get_instances():
storage = InstanceStorage(
cluster_id="test_cluster",
storage=InMemoryStorage(),
)
instance1 = create_instance("instance1", version=1)
instance2 = create_instance("instance2", status=Instance.ALLOCATED, version=1)
instance3 = create_instance("instance3", status=Instance.TERMINATING, version=1)
assert (True, 1) == storage.batch_upsert_instances(
[copy.deepcopy(instance1), copy.deepcopy(instance2), copy.deepcopy(instance3)],
expected_storage_version=None,
)
assert (
{
"instance1": instance1,
"instance2": instance2,
"instance3": instance3,
},
1,
) == storage.get_instances()
assert (
{
"instance1": instance1,
"instance2": instance2,
},
1,
) == storage.get_instances(instance_ids=["instance1", "instance2"])
assert ({"instance2": instance2}, 1) == storage.get_instances(
instance_ids=["instance1", "instance2"], status_filter={Instance.ALLOCATED}
)
assert (
{
"instance2": instance2,
},
1,
) == storage.get_instances(status_filter={Instance.ALLOCATED})
if __name__ == "__main__":
if os.environ.get("PARALLEL_CI"):
sys.exit(pytest.main(["-n", "auto", "--boxed", "-vs", __file__]))
else:
sys.exit(pytest.main(["-sv", __file__]))
@@ -0,0 +1,336 @@
import os
import sys
import unittest
# coding: utf-8
from unittest.mock import patch
import pytest
from ray.autoscaler.v2.instance_manager.common import InstanceUtil
from ray.core.generated.instance_manager_pb2 import Instance
class InstanceUtilTest(unittest.TestCase):
def test_basic(self):
# New instance.
instance = InstanceUtil.new_instance("i-123", "type_1", Instance.QUEUED)
assert instance.instance_id == "i-123"
assert instance.instance_type == "type_1"
assert instance.status == Instance.QUEUED
# Set status.
assert InstanceUtil.set_status(instance, Instance.REQUESTED)
assert instance.status == Instance.REQUESTED
# Set status with invalid status.
assert not InstanceUtil.set_status(instance, Instance.RAY_RUNNING)
assert not InstanceUtil.set_status(instance, Instance.UNKNOWN)
def test_transition_graph(self):
# Assert on each edge in the graph.
all_status = set(Instance.InstanceStatus.values())
g = InstanceUtil.get_valid_transitions()
assert g[Instance.QUEUED] == {Instance.REQUESTED, Instance.TERMINATED}
all_status.remove(Instance.QUEUED)
assert g[Instance.REQUESTED] == {
Instance.ALLOCATED,
Instance.QUEUED,
Instance.ALLOCATION_FAILED,
}
all_status.remove(Instance.REQUESTED)
assert g[Instance.ALLOCATED] == {
Instance.RAY_INSTALLING,
Instance.RAY_RUNNING,
Instance.RAY_STOPPING,
Instance.RAY_STOPPED,
Instance.TERMINATING,
Instance.TERMINATED,
Instance.ALLOCATION_TIMEOUT,
}
all_status.remove(Instance.ALLOCATED)
assert g[Instance.RAY_INSTALLING] == {
Instance.RAY_RUNNING,
Instance.RAY_INSTALL_FAILED,
Instance.RAY_STOPPED,
Instance.TERMINATING,
Instance.TERMINATED,
}
all_status.remove(Instance.RAY_INSTALLING)
assert g[Instance.RAY_RUNNING] == {
Instance.RAY_STOP_REQUESTED,
Instance.RAY_STOPPING,
Instance.RAY_STOPPED,
Instance.TERMINATING,
Instance.TERMINATED,
}
all_status.remove(Instance.RAY_RUNNING)
assert g[Instance.ALLOCATION_TIMEOUT] == {
Instance.TERMINATING,
Instance.TERMINATED,
}
all_status.remove(Instance.ALLOCATION_TIMEOUT)
assert g[Instance.RAY_STOP_REQUESTED] == {
Instance.RAY_STOPPING,
Instance.RAY_STOPPED,
Instance.TERMINATED,
Instance.RAY_RUNNING,
}
all_status.remove(Instance.RAY_STOP_REQUESTED)
assert g[Instance.RAY_STOPPING] == {
Instance.RAY_STOPPED,
Instance.TERMINATING,
Instance.TERMINATED,
}
all_status.remove(Instance.RAY_STOPPING)
assert g[Instance.RAY_STOPPED] == {Instance.TERMINATED, Instance.TERMINATING}
all_status.remove(Instance.RAY_STOPPED)
assert g[Instance.TERMINATING] == {
Instance.TERMINATED,
Instance.TERMINATION_FAILED,
}
all_status.remove(Instance.TERMINATING)
assert g[Instance.TERMINATION_FAILED] == {
Instance.TERMINATING,
Instance.TERMINATED,
}
all_status.remove(Instance.TERMINATION_FAILED)
assert g[Instance.TERMINATED] == set()
all_status.remove(Instance.TERMINATED)
assert g[Instance.ALLOCATION_FAILED] == set()
all_status.remove(Instance.ALLOCATION_FAILED)
assert g[Instance.RAY_INSTALL_FAILED] == {
Instance.TERMINATED,
Instance.TERMINATING,
}
all_status.remove(Instance.RAY_INSTALL_FAILED)
assert g[Instance.UNKNOWN] == set()
all_status.remove(Instance.UNKNOWN)
assert len(all_status) == 0
@patch("time.time_ns")
def test_status_time(self, mock_time):
mock_time.return_value = 1
instance = InstanceUtil.new_instance("i-123", "type_1", Instance.QUEUED)
# OK
assert (
InstanceUtil.get_status_transition_times_ns(instance, Instance.QUEUED)[0]
== 1
)
# No filter.
assert InstanceUtil.get_status_transition_times_ns(
instance,
) == [1]
# Missing status returns empty list
assert (
InstanceUtil.get_status_transition_times_ns(instance, Instance.REQUESTED)
== []
)
# Multiple status.
mock_time.return_value = 2
InstanceUtil.set_status(instance, Instance.REQUESTED)
mock_time.return_value = 3
InstanceUtil.set_status(instance, Instance.QUEUED)
mock_time.return_value = 4
InstanceUtil.set_status(instance, Instance.REQUESTED)
assert InstanceUtil.get_status_transition_times_ns(
instance, Instance.QUEUED
) == [1, 3]
@patch("time.time_ns")
def test_get_last_status_transition(self, mock_time):
mock_time.return_value = 1
instance = InstanceUtil.new_instance("i-123", "type_1", Instance.QUEUED)
assert (
InstanceUtil.get_last_status_transition(instance).instance_status
== Instance.QUEUED
)
assert InstanceUtil.get_last_status_transition(instance).timestamp_ns == 1
mock_time.return_value = 2
InstanceUtil.set_status(instance, Instance.REQUESTED)
assert (
InstanceUtil.get_last_status_transition(instance).instance_status
== Instance.REQUESTED
)
assert InstanceUtil.get_last_status_transition(instance).timestamp_ns == 2
mock_time.return_value = 3
InstanceUtil.set_status(instance, Instance.QUEUED)
assert (
InstanceUtil.get_last_status_transition(instance).instance_status
== Instance.QUEUED
)
assert InstanceUtil.get_last_status_transition(instance).timestamp_ns == 3
assert (
InstanceUtil.get_last_status_transition(
instance, select_instance_status=Instance.REQUESTED
).instance_status
== Instance.REQUESTED
)
assert (
InstanceUtil.get_last_status_transition(
instance, select_instance_status=Instance.REQUESTED
).timestamp_ns
== 2
)
assert (
InstanceUtil.get_last_status_transition(
instance, select_instance_status=Instance.RAY_RUNNING
)
is None
)
def test_is_cloud_instance_allocated(self):
all_status = set(Instance.InstanceStatus.values())
instance = InstanceUtil.new_instance("i-123", "type_1", Instance.QUEUED)
positive_status = {
Instance.ALLOCATED,
Instance.RAY_INSTALLING,
Instance.RAY_INSTALL_FAILED,
Instance.RAY_RUNNING,
Instance.RAY_STOP_REQUESTED,
Instance.RAY_STOPPING,
Instance.RAY_STOPPED,
Instance.TERMINATING,
Instance.TERMINATION_FAILED,
Instance.ALLOCATION_TIMEOUT,
}
for s in positive_status:
instance.status = s
assert InstanceUtil.is_cloud_instance_allocated(instance.status)
all_status.remove(s)
# Unknown not possible.
all_status.remove(Instance.UNKNOWN)
for s in all_status:
instance.status = s
assert not InstanceUtil.is_cloud_instance_allocated(instance.status)
def test_is_ray_running(self):
all_statuses = set(Instance.InstanceStatus.values())
positive_statuses = {
Instance.RAY_RUNNING,
Instance.RAY_STOP_REQUESTED,
Instance.RAY_STOPPING,
}
all_statuses.remove(Instance.UNKNOWN)
for s in positive_statuses:
assert InstanceUtil.is_ray_running(s)
all_statuses.remove(s)
for s in all_statuses:
assert not InstanceUtil.is_ray_running(s)
def test_is_ray_pending(self):
all_statuses = set(Instance.InstanceStatus.values())
all_statuses.remove(Instance.UNKNOWN)
positive_statuses = {
Instance.QUEUED,
Instance.REQUESTED,
Instance.RAY_INSTALLING,
Instance.ALLOCATED,
}
for s in positive_statuses:
assert InstanceUtil.is_ray_pending(s), Instance.InstanceStatus.Name(s)
all_statuses.remove(s)
for s in all_statuses:
assert not InstanceUtil.is_ray_pending(s), Instance.InstanceStatus.Name(s)
def test_is_ray_running_reachable(self):
all_status = set(Instance.InstanceStatus.values())
positive_status = {
Instance.QUEUED,
Instance.REQUESTED,
Instance.ALLOCATED,
Instance.RAY_INSTALLING,
Instance.RAY_RUNNING,
Instance.RAY_STOP_REQUESTED,
}
for s in positive_status:
assert InstanceUtil.is_ray_running_reachable(
s
), Instance.InstanceStatus.Name(s)
all_status.remove(s)
# Unknown not possible.
all_status.remove(Instance.UNKNOWN)
for s in all_status:
assert not InstanceUtil.is_ray_running_reachable(
s
), Instance.InstanceStatus.Name(s)
def test_reachable_from(self):
def add_reachable_from(reachable, src, transitions):
reachable[src] = set()
for dst in transitions[src]:
reachable[src].add(dst)
reachable[src] |= (
reachable[dst] if reachable[dst] is not None else set()
)
expected_reachable = {s: None for s in Instance.InstanceStatus.values()}
# Error status and terminal status.
expected_reachable[Instance.ALLOCATION_FAILED] = set()
expected_reachable[Instance.UNKNOWN] = set()
expected_reachable[Instance.TERMINATED] = set()
transitions = InstanceUtil.get_valid_transitions()
# Recursively build the reachable set from terminal statuses.
add_reachable_from(expected_reachable, Instance.TERMINATION_FAILED, transitions)
add_reachable_from(expected_reachable, Instance.TERMINATING, transitions)
# Add TERMINATION_FAILED again since it's also reachable from TERMINATING.
add_reachable_from(expected_reachable, Instance.TERMINATION_FAILED, transitions)
add_reachable_from(expected_reachable, Instance.RAY_STOPPED, transitions)
add_reachable_from(expected_reachable, Instance.RAY_STOPPING, transitions)
add_reachable_from(expected_reachable, Instance.RAY_STOP_REQUESTED, transitions)
add_reachable_from(expected_reachable, Instance.RAY_RUNNING, transitions)
# Add RAY_STOP_REQUESTED again since it's also reachable from RAY_RUNNING.
add_reachable_from(expected_reachable, Instance.RAY_STOP_REQUESTED, transitions)
add_reachable_from(expected_reachable, Instance.RAY_INSTALL_FAILED, transitions)
add_reachable_from(expected_reachable, Instance.RAY_INSTALLING, transitions)
add_reachable_from(expected_reachable, Instance.ALLOCATED, transitions)
add_reachable_from(expected_reachable, Instance.REQUESTED, transitions)
add_reachable_from(expected_reachable, Instance.QUEUED, transitions)
# Add REQUESTED again since it's also reachable from QUEUED.
add_reachable_from(expected_reachable, Instance.REQUESTED, transitions)
add_reachable_from(expected_reachable, Instance.ALLOCATION_TIMEOUT, transitions)
for s, expected in expected_reachable.items():
assert InstanceUtil.get_reachable_statuses(s) == expected, (
f"reachable_from({s}) = {InstanceUtil.get_reachable_statuses(s)} "
f"!= {expected}"
)
if __name__ == "__main__":
if os.environ.get("PARALLEL_CI"):
sys.exit(pytest.main(["-n", "auto", "--boxed", "-vs", __file__]))
else:
sys.exit(pytest.main(["-sv", __file__]))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,219 @@
import os
import sys
from typing import List
import pytest
from ray.autoscaler._private.prom_metrics import AutoscalerPrometheusMetrics
from ray.autoscaler.v2.instance_manager.config import NodeTypeConfig
from ray.autoscaler.v2.metrics_reporter import AutoscalerMetricsReporter
from ray.autoscaler.v2.tests.util import create_instance
from ray.core.generated.instance_manager_pb2 import Instance
def _get_metrics(metrics, name) -> List[float]:
sample_values = []
for x in metrics:
for sample in x.samples:
if sample.name == name:
sample_values.append(sample.value)
return sample_values
def test_report_nodes_resources():
"""
Test that the metrics reporter reports the correct number of nodes and resources
"""
reporter = AutoscalerMetricsReporter(
AutoscalerPrometheusMetrics(session_name="test")
)
node_type_configs = {
"type_1": NodeTypeConfig(
name="type_1",
max_worker_nodes=10,
min_worker_nodes=1,
resources={"CPU": 1},
),
"type_2": NodeTypeConfig(
name="type_2",
max_worker_nodes=10,
min_worker_nodes=1,
resources={"GPU": 1},
),
}
_i = 0
def id():
nonlocal _i
_i += 1
return f"i-{_i}"
terminating_type_1 = create_instance(
id(), status=Instance.TERMINATING, instance_type="type_1"
)
terminating_type_2 = create_instance(
id(), status=Instance.TERMINATING, instance_type="type_2"
)
instances = [
# Active = 3
create_instance(id(), status=Instance.RAY_RUNNING, instance_type="type_1"),
create_instance(
id(), status=Instance.RAY_STOP_REQUESTED, instance_type="type_1"
),
create_instance(id(), status=Instance.RAY_STOPPING, instance_type="type_1"),
create_instance(id(), status=Instance.RAY_RUNNING, instance_type="type_2"),
# Pending
create_instance(id(), status=Instance.QUEUED, instance_type="type_1"),
create_instance(id(), status=Instance.REQUESTED, instance_type="type_1"),
create_instance(id(), status=Instance.RAY_INSTALLING, instance_type="type_1"),
create_instance(id(), status=Instance.ALLOCATED, instance_type="type_1"),
create_instance(id(), status=Instance.RAY_INSTALLING, instance_type="type_2"),
create_instance(id(), status=Instance.ALLOCATED, instance_type="type_2"),
# Terminating
terminating_type_1,
terminating_type_2,
]
reporter.report_instances(instances, node_type_configs)
assert _get_metrics(
reporter._prom_metrics.active_nodes.labels(
SessionName="test", NodeType="type_1"
).collect(),
"autoscaler_active_nodes",
) == [3]
assert _get_metrics(
reporter._prom_metrics.pending_nodes.labels(
SessionName="test", NodeType="type_1"
).collect(),
"autoscaler_pending_nodes",
) == [4]
assert _get_metrics(
reporter._prom_metrics.recently_failed_nodes.labels(
SessionName="test", NodeType="type_1"
).collect(),
"autoscaler_recently_failed_nodes",
) == [1]
# Test that resources are reported correctly
reporter.report_resources(instances, node_type_configs)
assert _get_metrics(
reporter._prom_metrics.cluster_resources.labels(
SessionName="test", resource="CPU"
).collect(),
"autoscaler_cluster_resources",
) == [3]
assert _get_metrics(
reporter._prom_metrics.cluster_resources.labels(
SessionName="test", resource="GPU"
).collect(),
"autoscaler_cluster_resources",
) == [1]
assert _get_metrics(
reporter._prom_metrics.pending_resources.labels(
SessionName="test", resource="CPU"
).collect(),
"autoscaler_pending_resources",
) == [4]
assert _get_metrics(
reporter._prom_metrics.pending_resources.labels(
SessionName="test", resource="GPU"
).collect(),
"autoscaler_pending_resources",
) == [2]
def test_report_nodes_resources_handles_deleted_node_type():
reporter = AutoscalerMetricsReporter(
AutoscalerPrometheusMetrics(session_name="test_deleted")
)
node_type_configs = {
"current_type": NodeTypeConfig(
name="current_type",
max_worker_nodes=10,
min_worker_nodes=1,
resources={"CPU": 2},
),
}
instances = [
create_instance(
"current-running",
status=Instance.RAY_RUNNING,
instance_type="current_type",
),
create_instance(
"deleted-terminated",
status=Instance.TERMINATED,
instance_type="deleted_type",
),
create_instance(
"deleted-terminating",
status=Instance.TERMINATING,
instance_type="deleted_type",
),
create_instance(
"deleted-pending",
status=Instance.QUEUED,
instance_type="deleted_type",
),
]
reporter.report_instances(instances, node_type_configs)
reporter.report_resources(instances, node_type_configs)
assert _get_metrics(
reporter._prom_metrics.active_nodes.labels(
SessionName="test_deleted", NodeType="current_type"
).collect(),
"autoscaler_active_nodes",
) == [1]
assert _get_metrics(
reporter._prom_metrics.pending_nodes.labels(
SessionName="test_deleted", NodeType="current_type"
).collect(),
"autoscaler_pending_nodes",
) == [0]
assert _get_metrics(
reporter._prom_metrics.recently_failed_nodes.labels(
SessionName="test_deleted", NodeType="current_type"
).collect(),
"autoscaler_recently_failed_nodes",
) == [0]
assert _get_metrics(
reporter._prom_metrics.active_nodes.labels(
SessionName="test_deleted", NodeType="deleted_type"
).collect(),
"autoscaler_active_nodes",
) == [0]
assert _get_metrics(
reporter._prom_metrics.pending_nodes.labels(
SessionName="test_deleted", NodeType="deleted_type"
).collect(),
"autoscaler_pending_nodes",
) == [1]
assert _get_metrics(
reporter._prom_metrics.recently_failed_nodes.labels(
SessionName="test_deleted", NodeType="deleted_type"
).collect(),
"autoscaler_recently_failed_nodes",
) == [1]
assert _get_metrics(
reporter._prom_metrics.cluster_resources.labels(
SessionName="test_deleted", resource="CPU"
).collect(),
"autoscaler_cluster_resources",
) == [2]
if __name__ == "__main__":
if os.environ.get("PARALLEL_CI"):
sys.exit(pytest.main(["-n", "auto", "--boxed", "-vs", __file__]))
else:
sys.exit(pytest.main(["-sv", __file__]))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,328 @@
import os
import sys
import time
import pytest
from ray.autoscaler.v2.instance_manager.config import NodeTypeConfig
from ray.autoscaler.v2.instance_manager.subscribers.cloud_resource_monitor import (
CloudResourceMonitor,
)
from ray.autoscaler.v2.scheduler import (
ResourceDemandScheduler,
ResourceRequestSource,
SchedulingNode,
SchedulingNodeStatus,
SchedulingRequest,
)
from ray.core.generated.autoscaler_pb2 import ResourceRequest as PBResourceRequest
from ray.core.generated.instance_manager_pb2 import (
Instance,
InstanceUpdateEvent,
NodeKind,
)
def test_recovery_scoring():
monitor = CloudResourceMonitor()
node_type = "gpu-node"
# Mock failure
event = InstanceUpdateEvent(
instance_type=node_type, new_instance_status=Instance.ALLOCATION_TIMEOUT
)
monitor.notify([event])
# Immediately after failure, score should be 0.0
scores = monitor.get_recoverable_resource_availabilities()
assert scores[node_type] == 0.0
# After safety floor (e.g., 11s, default safety floor is 10s)
monitor._last_unavailable_timestamp[node_type] = time.time() - 11
scores = monitor.get_recoverable_resource_availabilities()
assert 0.0 < scores[node_type] < 0.1
# Halfway through recovery window (600s / 2 = 300s)
monitor._last_unavailable_timestamp[node_type] = time.time() - 300
scores = monitor.get_recoverable_resource_availabilities()
# 300 / 600 = 0.5
assert pytest.approx(scores[node_type], 0.01) == 0.5
# After recovery window
monitor._last_unavailable_timestamp[node_type] = time.time() - 601
scores = monitor.get_recoverable_resource_availabilities()
assert scores[node_type] == 1.0
def test_scheduler_priority_tie_breaking():
# Two node types with identical resources
resources = {"CPU": 4}
node_type_1 = "high-priority"
node_type_2 = "low-priority"
config_1 = NodeTypeConfig(
name=node_type_1,
min_worker_nodes=0,
max_worker_nodes=10,
resources=resources,
priority=10,
)
config_2 = NodeTypeConfig(
name=node_type_2,
min_worker_nodes=0,
max_worker_nodes=10,
resources=resources,
priority=0,
)
node_1 = SchedulingNode.from_node_config(
config_1, status=SchedulingNodeStatus.TO_LAUNCH, node_kind=NodeKind.WORKER
)
node_2 = SchedulingNode.from_node_config(
config_2, status=SchedulingNodeStatus.TO_LAUNCH, node_kind=NodeKind.WORKER
)
request = PBResourceRequest(resources_bundle={"CPU": 1})
# Utilization and Availability are equal (all perfect)
# Priority should break the tie
best_node, infeasible, remaining_nodes = ResourceDemandScheduler._sched_best_node(
[request], [node_2, node_1], ResourceRequestSource.PENDING_DEMAND, {}, {}
)
assert best_node.node_type == node_type_1
def test_schedule_context_propagation():
resources = {"CPU": 4}
node_type = "gpu-node"
config = NodeTypeConfig(
name=node_type,
min_worker_nodes=0,
max_worker_nodes=10,
resources=resources,
priority=7,
)
# Mock cloud availabilities
cloud_availabilities = {node_type: 0.1} # failure recency
recoverable_availabilities = {node_type: 0.5}
req = SchedulingRequest(
node_type_configs={node_type: config},
cloud_resource_availabilities=cloud_availabilities,
recoverable_resource_availabilities=recoverable_availabilities,
disable_launch_config_check=True,
)
ctx = ResourceDemandScheduler.ScheduleContext.from_schedule_request(req)
# Check if a new node created from this context has the correct priority
node_pools = [
SchedulingNode.from_node_config(
ctx.get_node_type_configs()[nt],
status=SchedulingNodeStatus.TO_LAUNCH,
node_kind=NodeKind.WORKER,
)
for nt, num_available in ctx.get_node_type_available().items()
]
assert len(node_pools) == 1
node = node_pools[0]
assert node.priority == 7
# Dynamic scores are in the context, not the node
assert ctx.get_recoverable_resource_availabilities()[node_type] == 0.5
assert ctx.get_cloud_resource_availabilities()[node_type] == 0.1
def test_scheduler_availability_over_priority():
# High priority node is recovering (score 0.5)
# Low priority node is available (score 1.0)
resources = {"CPU": 4}
node_type_1 = "high-priority"
node_type_2 = "low-priority"
config_1 = NodeTypeConfig(
name=node_type_1,
min_worker_nodes=0,
max_worker_nodes=10,
resources=resources,
priority=10,
)
config_2 = NodeTypeConfig(
name=node_type_2,
min_worker_nodes=0,
max_worker_nodes=10,
resources=resources,
priority=0,
)
node_1 = SchedulingNode.from_node_config(
config_1, status=SchedulingNodeStatus.TO_LAUNCH, node_kind=NodeKind.WORKER
)
node_2 = SchedulingNode.from_node_config(
config_2, status=SchedulingNodeStatus.TO_LAUNCH, node_kind=NodeKind.WORKER
)
request = PBResourceRequest(resources_bundle={"CPU": 1})
# Recoverable Availability is higher for node_2, so it should be chosen despite lower priority
best_node, infeasible, remaining_nodes = ResourceDemandScheduler._sched_best_node(
[request],
[node_1, node_2],
ResourceRequestSource.PENDING_DEMAND,
cloud_resource_availabilities={},
recoverable_resource_availabilities={node_type_1: 0.5, node_type_2: 1.0},
)
assert best_node.node_type == node_type_2
def test_scheduler_failure_recency_tie_breaking():
# Same priority, same recoverable availability (1.0)
# One has an older failure than the other.
resources = {"CPU": 4}
node_type_1 = "older-failure"
node_type_2 = "newer-failure"
config_1 = NodeTypeConfig(
name=node_type_1,
min_worker_nodes=0,
max_worker_nodes=10,
resources=resources,
priority=5,
)
config_2 = NodeTypeConfig(
name=node_type_2,
min_worker_nodes=0,
max_worker_nodes=10,
resources=resources,
priority=5,
)
node_1 = SchedulingNode.from_node_config(
config_1, status=SchedulingNodeStatus.TO_LAUNCH, node_kind=NodeKind.WORKER
)
node_2 = SchedulingNode.from_node_config(
config_2, status=SchedulingNodeStatus.TO_LAUNCH, node_kind=NodeKind.WORKER
)
request = PBResourceRequest(resources_bundle={"CPU": 1})
best_node, infeasible, remaining_nodes = ResourceDemandScheduler._sched_best_node(
[request],
[node_1, node_2],
ResourceRequestSource.PENDING_DEMAND,
cloud_resource_availabilities={node_type_1: 0.9, node_type_2: 0.1},
recoverable_resource_availabilities={node_type_1: 1.0, node_type_2: 1.0},
)
assert best_node.node_type == node_type_1
def test_recovery_integration():
monitor = CloudResourceMonitor()
node_type_1 = "high-priority"
node_type_2 = "low-priority"
# Mock failure for node_1 (high priority)
event = InstanceUpdateEvent(
instance_type=node_type_1, new_instance_status=Instance.ALLOCATION_TIMEOUT
)
monitor.notify([event])
# Score should be 0.0 for node_1 immediately
scores = monitor.get_recoverable_resource_availabilities()
assert scores[node_type_1] == 0.0
# Setup scheduler structures
resources = {"CPU": 4}
config_1 = NodeTypeConfig(
name=node_type_1,
min_worker_nodes=0,
max_worker_nodes=10,
resources=resources,
priority=10,
)
config_2 = NodeTypeConfig(
name=node_type_2,
min_worker_nodes=0,
max_worker_nodes=10,
resources=resources,
priority=0,
)
node_1 = SchedulingNode.from_node_config(
config_1, status=SchedulingNodeStatus.TO_LAUNCH, node_kind=NodeKind.WORKER
)
node_2 = SchedulingNode.from_node_config(
config_2, status=SchedulingNodeStatus.TO_LAUNCH, node_kind=NodeKind.WORKER
)
request = PBResourceRequest(resources_bundle={"CPU": 1})
# Pass scores from monitor to scheduler
best_node, infeasible, remaining_nodes = ResourceDemandScheduler._sched_best_node(
[request],
[node_1, node_2],
ResourceRequestSource.PENDING_DEMAND,
cloud_resource_availabilities={},
recoverable_resource_availabilities=scores,
)
# Node 2 should be chosen because Node 1 is recovering, despite Node 1 having higher priority
assert best_node.node_type == node_type_2
def test_scheduler_utilization_over_priority():
# Node 1: 4 CPUs, priority 10
# Node 2: 2 CPUs, priority 0
# Request: 2 CPUs
# Node 2 should be selected because it fits perfectly (utilization score is higher),
# even though Node 1 has higher priority.
resources_1 = {"CPU": 4}
resources_2 = {"CPU": 2}
node_type_1 = "large-node"
node_type_2 = "small-node"
config_1 = NodeTypeConfig(
name=node_type_1,
min_worker_nodes=0,
max_worker_nodes=10,
resources=resources_1,
priority=10,
)
config_2 = NodeTypeConfig(
name=node_type_2,
min_worker_nodes=0,
max_worker_nodes=10,
resources=resources_2,
priority=0,
)
node_1 = SchedulingNode.from_node_config(
config_1, status=SchedulingNodeStatus.TO_LAUNCH, node_kind=NodeKind.WORKER
)
node_2 = SchedulingNode.from_node_config(
config_2, status=SchedulingNodeStatus.TO_LAUNCH, node_kind=NodeKind.WORKER
)
request = PBResourceRequest(resources_bundle={"CPU": 2})
best_node, infeasible, remaining_nodes = ResourceDemandScheduler._sched_best_node(
[request],
[node_1, node_2],
ResourceRequestSource.PENDING_DEMAND,
cloud_resource_availabilities={},
recoverable_resource_availabilities={},
)
assert best_node.node_type == node_type_2
if __name__ == "__main__":
if os.environ.get("PARALLEL_CI"):
sys.exit(pytest.main(["-n", "auto", "--boxed", "-vs", __file__]))
else:
sys.exit(pytest.main(["-sv", __file__]))
@@ -0,0 +1,68 @@
# coding: utf-8
import os
import sys
import unittest
import pytest # noqa
from ray._private.test_utils import load_test_config
from ray.autoscaler.tags import TAG_RAY_NODE_KIND
from ray.autoscaler.v2.instance_manager.config import AutoscalingConfig
from ray.autoscaler.v2.instance_manager.ray_installer import RayInstaller
from ray.core.generated.instance_manager_pb2 import Instance
from ray.tests.autoscaler_test_utils import MockProcessRunner, MockProvider
class RayInstallerTest(unittest.TestCase):
def setUp(self):
self.base_provider = MockProvider()
self.config = AutoscalingConfig(load_test_config("test_ray_complex.yaml"))
self.runner = MockProcessRunner()
self.ray_installer = RayInstaller(self.base_provider, self.config, self.runner)
def test_install_succeeded(self):
self.base_provider.create_node({}, {TAG_RAY_NODE_KIND: "worker_nodes1"}, 1)
self.runner.respond_to_call("json .Config.Env", ["[]" for i in range(1)])
self.ray_installer.install_ray(
Instance(
instance_id="0", instance_type="worker_nodes1", cloud_instance_id="0"
),
head_node_ip="1.2.3.4",
)
def test_install_failed(self):
# creation failed because no such node.
with self.assertRaisesRegex(KeyError, "0"):
assert not self.ray_installer.install_ray(
Instance(
instance_id="0",
instance_type="worker_nodes1",
cloud_instance_id="0",
),
head_node_ip="1.2.3.4",
)
self.base_provider.create_node({}, {TAG_RAY_NODE_KIND: "worker_nodes1"}, 1)
self.runner.fail_cmds = [
"echo" # this is the command used in the test_ray_complex.yaml
]
self.runner.respond_to_call("json .Config.Env", ["[]" for i in range(1)])
# creation failed because setup command failed.
with self.assertRaisesRegex(Exception, "unexpected status"):
self.ray_installer.install_ray(
Instance(
instance_id="0",
instance_type="worker_nodes1",
cloud_instance_id="0",
),
head_node_ip="1.2.3.4",
)
if __name__ == "__main__":
if os.environ.get("PARALLEL_CI"):
sys.exit(pytest.main(["-n", "auto", "--boxed", "-vs", __file__]))
else:
sys.exit(pytest.main(["-sv", __file__]))
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,232 @@
import os
import sys
import time
import pytest
from ray.autoscaler.v2.schema import (
AutoscalerInstance,
ClusterStatus,
IPPRGroupSpec,
IPPRStatus,
)
from ray.core.generated.autoscaler_pb2 import NodeState, NodeStatus
from ray.core.generated.instance_manager_pb2 import Instance
def test_cluster_status_default_stats():
status = ClusterStatus()
assert status.active_nodes == []
assert status.idle_nodes == []
assert status.pending_launches == []
assert status.failed_launches == []
assert status.pending_nodes == []
assert status.failed_nodes == []
assert status.cluster_resource_usage == []
assert status.stats.gcs_request_time_s == 0.0
assert status.stats.request_ts_s is None
def _make_ippr_status() -> IPPRStatus:
return IPPRStatus(
cloud_instance_id="ray-worker-1",
spec=IPPRGroupSpec(
min_cpu=1.0,
max_cpu=4.0,
min_memory=2,
max_memory=8,
resize_timeout=10,
),
current_cpu=1.0,
current_memory=2,
desired_cpu=1.0,
desired_memory=2,
)
def test_autoscaler_instance():
i = AutoscalerInstance()
assert not i.validate()[0], "Empty instance should be invalid"
i = AutoscalerInstance(im_instance=Instance(status=Instance.QUEUED))
assert i.validate()[0], "Instance with only im_instance should be valid"
i = AutoscalerInstance(
ray_node=NodeState(status=NodeStatus.RUNNING, instance_id="i-123"),
cloud_instance_id="i-123",
)
assert i.validate()[0], i.validate()[1]
i = AutoscalerInstance(
ray_node=NodeState(status=NodeStatus.RUNNING),
)
assert not i.validate()[0], "Missing cloud node id."
i = AutoscalerInstance(
im_instance=Instance(status=Instance.QUEUED),
ray_node=NodeState(status=NodeStatus.RUNNING),
)
assert not i.validate()[
0
], "cloud node id is required to link the ray node with im state"
i = AutoscalerInstance(
cloud_instance_id="i-123",
)
assert not i.validate()[
0
], "cloud instance id is not possible without im or ray node"
i = AutoscalerInstance(
im_instance=Instance(status=Instance.ALLOCATED, cloud_instance_id="i-123"),
cloud_instance_id="i-123",
)
assert i.validate()[0]
i = AutoscalerInstance(
im_instance=Instance(status=Instance.ALLOCATED, cloud_instance_id="i-123"),
cloud_instance_id="i-124", # mismatch.
)
assert not i.validate()[0], "cloud instance id should match"
i = AutoscalerInstance(
im_instance=Instance(status=Instance.QUEUED, cloud_instance_id="i-123"),
cloud_instance_id="i-123",
)
assert not i.validate()[0], "cloud instance id is not possible with queued state"
i = AutoscalerInstance(
im_instance=Instance(status=Instance.ALLOCATED, cloud_instance_id="i-123"),
)
assert not i.validate()[0], "cloud instance id should also be set"
i = AutoscalerInstance(
ray_node=NodeState(status=NodeStatus.RUNNING, instance_id="i-123"),
cloud_instance_id="i-123",
)
assert i.validate()[0]
i = AutoscalerInstance(
ray_node=NodeState(status=NodeStatus.RUNNING, instance_id="i-123"),
cloud_instance_id="i-124", # mismatch.
)
assert not i.validate()[0]
i = AutoscalerInstance(
im_instance=Instance(status=Instance.RAY_RUNNING, cloud_instance_id="i-123"),
ray_node=NodeState(status=NodeStatus.RUNNING, instance_id="i-123"),
cloud_instance_id="i-123",
)
assert i.validate()[0]
i = AutoscalerInstance(
im_instance=Instance(status=Instance.RAY_RUNNING, cloud_instance_id="i-123"),
ray_node=NodeState(status=NodeStatus.RUNNING, instance_id="i-123"),
cloud_instance_id="i-124", # mismatch.
)
assert not i.validate()[0]
def test_ippr_status_queue_resize_request():
status = _make_ippr_status()
status.resizing_at = 123
status.k8s_resize_status = "deferred"
status.k8s_resize_message = "pending"
status.raylet_id = "abc"
assert status.queue_resize_request(desired_cpu=2.0, desired_memory=4)
assert status.desired_cpu == 2.0
assert status.desired_memory == 4
assert status.resizing_at is None
assert status.k8s_resize_status == "new"
assert status.k8s_resize_message is None
assert not status.queue_resize_request(desired_cpu=2.0, desired_memory=4)
def test_ippr_status_request_and_progress_helpers():
status = _make_ippr_status()
assert status.has_resize_request_to_send() is False
assert status.is_in_progress() is False
status.raylet_id = "abc"
assert status.is_k8s_resize_finished()
assert not status.has_resize_request_to_send()
assert not status.is_in_progress()
status.queue_resize_request(desired_cpu=2.0)
assert status.has_resize_request_to_send()
assert status.is_in_progress()
assert not status.is_k8s_resize_finished()
status.resizing_at = int(time.time())
status.k8s_resize_status = None
assert status.is_in_progress()
assert status.is_k8s_resize_finished()
def test_ippr_status_need_sync_with_raylet():
status = _make_ippr_status()
assert status.need_sync_with_raylet() is False
status.raylet_id = "abc"
status.resizing_at = int(time.time())
status.k8s_resize_status = None
assert status.need_sync_with_raylet()
status.desired_cpu = 2.0
assert not status.need_sync_with_raylet()
def test_ippr_status_limits_and_can_resize_up():
status = _make_ippr_status()
assert status.can_resize_up()
status.raylet_id = "abc"
assert status.max_cpu() == 4.0
assert status.max_memory() == 8
assert status.can_resize_up()
status.suggested_max_cpu = 1.5
status.suggested_max_memory = 3
assert status.max_cpu() == 1.5
assert status.max_memory() == 3
status.current_cpu = 1.5
status.current_memory = 3
assert not status.can_resize_up()
status.current_cpu = 1.0
status.last_failed_at = 1
assert not status.can_resize_up()
def test_ippr_status_failure_and_timeout_helpers():
status = _make_ippr_status()
status.raylet_id = "abc"
status.desired_cpu = 2.0
status.desired_memory = 4
status.resizing_at = int(time.time()) - 20
assert status.is_timeout()
status.k8s_resize_status = "error"
assert status.is_errored()
status.record_failure("resize failed", failed_at=123)
assert status.last_failed_at == 123
assert status.last_failed_reason == "resize failed"
# K8s resize finished and resources match desired, but raylet sync is still
# pending: do not treat as timeout (provider will call GCS to sync).
status2 = _make_ippr_status()
status2.raylet_id = "abc"
status2.resizing_at = int(time.time()) - 20
status2.k8s_resize_status = None
assert status2.need_sync_with_raylet()
assert not status2.is_timeout()
if __name__ == "__main__":
if os.environ.get("PARALLEL_CI"):
sys.exit(pytest.main(["-n", "auto", "--boxed", "-vs", __file__]))
else:
sys.exit(pytest.main(["-sv", __file__]))
+995
View File
@@ -0,0 +1,995 @@
import os
import sys
import time
# coding: utf-8
from dataclasses import dataclass
from typing import Callable, List, Optional, Tuple
import pytest
import ray
import ray._private.ray_constants as ray_constants
from ray._common.test_utils import wait_for_condition
from ray._private import authentication_test_utils
from ray.autoscaler.v2.schema import (
ClusterStatus,
LaunchRequest,
NodeInfo,
ResourceRequestByCount,
)
from ray.autoscaler.v2.sdk import (
get_cluster_status,
request_cluster_resources,
)
from ray.autoscaler.v2.tests.util import (
get_available_resources,
get_cluster_resource_state,
get_total_resources,
report_autoscaling_state,
)
from ray.core.generated import autoscaler_pb2, autoscaler_pb2_grpc
from ray.core.generated.autoscaler_pb2 import ClusterResourceState, NodeStatus
from ray.core.generated.common_pb2 import LabelSelectorOperator
from ray.util.state.api import list_nodes
def _autoscaler_state_service_stub():
"""Get the grpc stub for the autoscaler state service"""
from ray._private.grpc_utils import init_grpc_channel
gcs_address = ray.get_runtime_context().gcs_address
gcs_channel = init_grpc_channel(gcs_address, ray_constants.GLOBAL_GRPC_OPTIONS)
return autoscaler_pb2_grpc.AutoscalerStateServiceStub(gcs_channel)
def get_node_ids() -> Tuple[str, List[str]]:
"""Get the node ids of the head node and a worker node"""
head_node_id = None
nodes = list_nodes()
worker_node_ids = []
for node in nodes:
if node.is_head_node:
head_node_id = node.node_id
else:
worker_node_ids += [node.node_id]
return head_node_id, worker_node_ids
def assert_cluster_resource_constraints(
state: ClusterResourceState, expected_bundles: List[dict], expected_count: List[int]
):
"""
Assert a GetClusterResourceStateReply has cluster_resource_constraints that
matches with the expected resources.
"""
# We only have 1 constraint for now.
assert len(state.cluster_resource_constraints) == 1
resource_requests = state.cluster_resource_constraints[0].resource_requests
assert len(resource_requests) == len(expected_bundles) == len(expected_count)
# Sort all the bundles by bundle's resource names
resource_requests = sorted(
resource_requests,
key=lambda bundle_by_count: "".join(
bundle_by_count.request.resources_bundle.keys()
),
)
expected = zip(expected_bundles, expected_count)
expected = sorted(
expected, key=lambda bundle_count: "".join(bundle_count[0].keys())
)
for actual_bundle_count, expected_bundle_count in zip(resource_requests, expected):
assert (
dict(actual_bundle_count.request.resources_bundle)
== expected_bundle_count[0]
)
assert actual_bundle_count.count == expected_bundle_count[1]
@dataclass
class ExpectedNodeState:
node_id: str
node_status: NodeStatus
idle_time_check_cb: Optional[Callable] = None
labels: Optional[dict] = None
def assert_node_states(
state: ClusterResourceState, expected_nodes: List[ExpectedNodeState]
):
"""
Assert a GetClusterResourceStateReply has node states that
matches with the expected nodes.
"""
assert len(state.node_states) == len(expected_nodes)
# Sort all the nodes by node's node_id
node_states = sorted(state.node_states, key=lambda node: node.node_id)
expected_nodes = sorted(expected_nodes, key=lambda node: node.node_id)
for actual_node, expected_node in zip(node_states, expected_nodes):
assert actual_node.status == expected_node.node_status
if expected_node.idle_time_check_cb:
assert expected_node.idle_time_check_cb(actual_node.idle_duration_ms)
if expected_node.labels:
assert sorted(actual_node.dynamic_labels) == sorted(expected_node.labels)
@dataclass
class ExpectedNodeInfo:
node_id: Optional[str] = None
node_status: Optional[str] = None
idle_time_check_cb: Optional[Callable] = None
instance_id: Optional[str] = None
ray_node_type_name: Optional[str] = None
instance_type_name: Optional[str] = None
ip_address: Optional[str] = None
details: Optional[str] = None
# Check those resources are included in the actual node info.
total_resources: Optional[dict] = None
available_resources: Optional[dict] = None
def assert_nodes(actual_nodes: List[NodeInfo], expected_nodes: List[ExpectedNodeInfo]):
assert len(actual_nodes) == len(expected_nodes)
# Sort the nodes by id.
actual_nodes = sorted(actual_nodes, key=lambda node: node.node_id)
expected_nodes = sorted(expected_nodes, key=lambda node: node.node_id)
for actual_node, expected_node in zip(actual_nodes, expected_nodes):
if expected_node.node_id is not None:
assert actual_node.node_id == expected_node.node_id
if expected_node.node_status is not None:
assert actual_node.node_status == expected_node.node_status
if expected_node.instance_id is not None:
assert actual_node.instance_id == expected_node.instance_id
if expected_node.ray_node_type_name is not None:
assert actual_node.ray_node_type_name == expected_node.ray_node_type_name
if expected_node.instance_type_name is not None:
assert actual_node.instance_type_name == expected_node.instance_type_name
if expected_node.ip_address is not None:
assert actual_node.ip_address == expected_node.ip_address
if expected_node.details is not None:
assert expected_node.details in actual_node.details
if expected_node.idle_time_check_cb:
assert expected_node.idle_time_check_cb(
actual_node.resource_usage.idle_time_ms
)
if expected_node.total_resources:
for resource_name, total in expected_node.total_resources.items():
assert (
total
== get_total_resources(actual_node.resource_usage.usage)[
resource_name
]
)
if expected_node.available_resources:
for resource_name, available in expected_node.available_resources.items():
assert (
available
== get_available_resources(actual_node.resource_usage.usage)[
resource_name
]
)
def assert_launches(
cluster_status: ClusterStatus,
expected_pending_launches: List[LaunchRequest],
expected_failed_launches: List[LaunchRequest],
):
def assert_launches(actuals, expects):
for actual, expect in zip(actuals, expects):
assert actual.instance_type_name == expect.instance_type_name
assert actual.ray_node_type_name == expect.ray_node_type_name
assert actual.count == expect.count
assert actual.state == expect.state
assert actual.request_ts_s == expect.request_ts_s
assert len(cluster_status.pending_launches) == len(expected_pending_launches)
assert len(cluster_status.failed_launches) == len(expected_failed_launches)
actual_pending = sorted(
cluster_status.pending_launches, key=lambda launch: launch.ray_node_type_name
)
expected_pending = sorted(
expected_pending_launches, key=lambda launch: launch.ray_node_type_name
)
assert_launches(actual_pending, expected_pending)
actual_failed = sorted(
cluster_status.failed_launches, key=lambda launch: launch.ray_node_type_name
)
expected_failed = sorted(
expected_failed_launches, key=lambda launch: launch.ray_node_type_name
)
assert_launches(actual_failed, expected_failed)
@dataclass
class GangResourceRequest:
# Resource bundles.
bundles: List[dict]
# List of detail information about the request
details: List[str]
def assert_gang_requests(
state: ClusterResourceState, expected: List[GangResourceRequest]
):
"""
Assert a GetClusterResourceStateReply has gang requests that
matches with the expected requests.
"""
assert len(state.pending_gang_resource_requests) == len(expected)
# Sort all the requests by request's details
requests = sorted(
state.pending_gang_resource_requests, key=lambda request: request.details
)
expected = sorted(expected, key=lambda request: "".join(request.details))
for actual_request, expected_request in zip(requests, expected):
# Assert the detail contains the expected details
for detail_str in expected_request.details:
assert detail_str in actual_request.details
def test_request_cluster_resources_basic(shutdown_only):
ctx = ray.init(num_cpus=1)
stub = _autoscaler_state_service_stub()
gcs_address = ctx.address_info["gcs_address"]
# Request one
request_cluster_resources(gcs_address, [{"resources": {"CPU": 1}}])
def verify():
state = get_cluster_resource_state(stub)
assert_cluster_resource_constraints(state, [{"CPU": 1}], [1])
return True
wait_for_condition(verify)
# Request another overrides the previous request
request_cluster_resources(
gcs_address, [{"resources": {"CPU": 2, "GPU": 1}}, {"resources": {"CPU": 1}}]
)
def verify():
state = get_cluster_resource_state(stub)
assert_cluster_resource_constraints(
state, [{"CPU": 2, "GPU": 1}, {"CPU": 1}], [1, 1]
)
return True
# Request multiple is aggregated by shape.
request_cluster_resources(gcs_address, [{"resources": {"CPU": 1}}] * 100)
def verify():
state = get_cluster_resource_state(stub)
assert_cluster_resource_constraints(state, [{"CPU": 1}], [100])
return True
wait_for_condition(verify)
def test_request_cluster_resources_with_label_selectors(shutdown_only):
ctx = ray.init(num_cpus=1)
stub = _autoscaler_state_service_stub()
gcs_address = ctx.address_info["gcs_address"]
# Define two bundles, each with its own label_selector, to request.
bundles = [
{"CPU": 1},
{"GPU": 1, "CPU": 2},
]
bundle_label_selectors = [
{"region": "us-west1"},
{"accelerator-type": "!in(A100)"},
]
to_request = [
{"resources": b, "label_selector": s}
for b, s in zip(bundles, bundle_label_selectors)
]
# Send the request for these resource bundles
request_cluster_resources(gcs_address, to_request)
def verify():
state = get_cluster_resource_state(stub)
# Validate shape and resource request count
assert_cluster_resource_constraints(state, bundles, [1, 1])
# Check that requests carry expected label selectors
requests = state.cluster_resource_constraints[0].resource_requests
# First resource request
label_selectors_0 = requests[0].request.label_selectors
selector_0 = label_selectors_0[0]
constraints_0 = {
c.label_key: list(c.label_values) for c in selector_0.label_constraints
}
assert constraints_0 == {"region": ["us-west1"]}
assert (
selector_0.label_constraints[0].operator
== LabelSelectorOperator.LABEL_OPERATOR_IN
)
# Second resource request
label_selectors_1 = requests[1].request.label_selectors
selector_1 = label_selectors_1[0]
constraints_1 = {
c.label_key: list(c.label_values) for c in selector_1.label_constraints
}
assert constraints_1 == {"accelerator-type": ["A100"]}
assert (
selector_1.label_constraints[0].operator
== LabelSelectorOperator.LABEL_OPERATOR_NOT_IN
)
return True
wait_for_condition(verify)
def test_node_info_basic(shutdown_only, monkeypatch):
with monkeypatch.context() as m:
m.setenv("RAY_CLOUD_INSTANCE_ID", "instance-id")
m.setenv("RAY_NODE_TYPE_NAME", "node-type-name")
m.setenv("RAY_CLOUD_INSTANCE_TYPE_NAME", "instance-type-name")
ctx = ray.init(num_cpus=1)
ip = ctx.address_info["node_ip_address"]
stub = _autoscaler_state_service_stub()
def verify():
state = get_cluster_resource_state(stub)
assert len(state.node_states) == 1
node = state.node_states[0]
assert node.instance_id == "instance-id"
assert node.ray_node_type_name == "node-type-name"
assert node.node_ip_address == ip
assert node.instance_type_name == "instance-type-name"
assert (
state.cluster_session_name
== ray._private.worker.global_worker.node.session_name
)
return True
wait_for_condition(verify)
def test_pg_pending_gang_requests_basic(shutdown_only):
ray.init(num_cpus=1)
# Create a pg that's pending.
pg = ray.util.placement_group([{"CPU": 1}] * 3, strategy="STRICT_SPREAD")
try:
ray.get(pg.ready(), timeout=2)
except TimeoutError:
pass
pg_id = pg.id.hex()
stub = _autoscaler_state_service_stub()
def verify():
state = get_cluster_resource_state(stub)
assert_gang_requests(
state,
[
GangResourceRequest(
[{"CPU": 1}] * 3, details=[pg_id, "STRICT_SPREAD", "PENDING"]
)
],
)
return True
wait_for_condition(verify)
def test_pg_usage_labels(shutdown_only):
ray.init(num_cpus=1)
# Create a pg
pg = ray.util.placement_group([{"CPU": 1}])
ray.get(pg.ready())
# Check the labels
stub = _autoscaler_state_service_stub()
head_node_id, _ = get_node_ids()
pg_id = pg.id.hex()
def verify():
state = get_cluster_resource_state(stub)
assert_node_states(
state,
[
ExpectedNodeState(
head_node_id,
NodeStatus.RUNNING,
labels={f"_PG_{pg_id}": ""},
),
],
)
return True
wait_for_condition(verify)
def test_node_state_lifecycle_basic(ray_start_cluster):
start_s = time.perf_counter()
cluster = ray_start_cluster
cluster.add_node(num_cpus=0)
ray.init(address=cluster.address)
node = cluster.add_node(num_cpus=1)
stub = _autoscaler_state_service_stub()
# We don't have node id from `add_node` unfortunately.
def nodes_up():
nodes = list_nodes()
assert len(nodes) == 2
return True
wait_for_condition(nodes_up)
head_node_id, worker_node_ids = get_node_ids()
node_id = worker_node_ids[0]
def verify_cluster_idle():
state = get_cluster_resource_state(stub)
assert_node_states(
state,
[
ExpectedNodeState(
node_id, NodeStatus.IDLE, lambda idle_ms: idle_ms > 0
),
ExpectedNodeState(
head_node_id, NodeStatus.IDLE, lambda idle_ms: idle_ms > 0
),
],
)
return True
wait_for_condition(verify_cluster_idle)
# Schedule a task running
@ray.remote(num_cpus=0.1)
def f():
while True:
pass
t = f.remote()
def verify_cluster_busy():
state = get_cluster_resource_state(stub)
assert_node_states(
state,
[
ExpectedNodeState(
node_id, NodeStatus.RUNNING, lambda idle_ms: idle_ms == 0
),
ExpectedNodeState(
head_node_id, NodeStatus.IDLE, lambda idle_ms: idle_ms > 0
),
],
)
return True
wait_for_condition(verify_cluster_busy)
# Kill the task
ray.cancel(t, force=True)
wait_for_condition(verify_cluster_idle)
# Kill the node.
cluster.remove_node(node)
# Sleep for a bit so head node should be idle longer than this.
time.sleep(3)
def verify_cluster_no_node():
state = get_cluster_resource_state(stub)
now_s = time.perf_counter()
test_dur_ms = (now_s - start_s) * 1000
assert_node_states(
state,
[
ExpectedNodeState(node_id, NodeStatus.DEAD),
ExpectedNodeState(
head_node_id,
NodeStatus.IDLE,
lambda idle_ms: idle_ms > 3 * 1000 and idle_ms < test_dur_ms,
),
],
)
return True
wait_for_condition(verify_cluster_no_node)
# We test that a node with only workers blocked on get
# is considered idle.
def test_idle_node_blocked(ray_start_cluster):
cluster = ray_start_cluster
cluster.add_node(num_cpus=1)
ray.init(address=cluster.address)
stub = _autoscaler_state_service_stub()
# We don't have node id from `add_node` unfortunately.
def nodes_up():
nodes = list_nodes()
assert len(nodes) == 1
return True
wait_for_condition(nodes_up)
head_node_id = get_node_ids()
def verify_cluster_idle():
state = get_cluster_resource_state(stub)
assert_node_states(
state,
[
ExpectedNodeState(
head_node_id, NodeStatus.IDLE, lambda idle_ms: idle_ms > 0
),
],
)
return True
wait_for_condition(verify_cluster_idle)
# Unschedulable
@ray.remote(num_cpus=10000)
def f():
pass
# Schedule a task running
@ray.remote(num_cpus=1)
def g():
ray.get(f.remote())
t = g.remote()
def verify_cluster_busy():
state = get_cluster_resource_state(stub)
assert_node_states(
state,
[
ExpectedNodeState(
head_node_id, NodeStatus.RUNNING, lambda idle_ms: idle_ms == 0
),
],
)
return True
wait_for_condition(verify_cluster_busy)
for _ in range(10):
time.sleep(0.5)
verify_cluster_busy()
# Kill the task
ray.cancel(t, force=True)
wait_for_condition(verify_cluster_idle)
def test_idle_node_no_resource(ray_start_cluster):
cluster = ray_start_cluster
cluster.add_node(num_cpus=1)
ray.init(address=cluster.address)
stub = _autoscaler_state_service_stub()
# We don't have node id from `add_node` unfortunately.
def nodes_up():
nodes = list_nodes()
assert len(nodes) == 1
return True
wait_for_condition(nodes_up)
head_node_id = get_node_ids()
def verify_cluster_idle():
state = get_cluster_resource_state(stub)
assert_node_states(
state,
[
ExpectedNodeState(
head_node_id, NodeStatus.IDLE, lambda idle_ms: idle_ms > 0
),
],
)
return True
wait_for_condition(verify_cluster_idle)
# Schedule a task running
@ray.remote(num_cpus=0)
def f():
while True:
pass
t = f.remote()
def verify_cluster_busy():
state = get_cluster_resource_state(stub)
assert_node_states(
state,
[
ExpectedNodeState(
head_node_id, NodeStatus.RUNNING, lambda idle_ms: idle_ms == 0
),
],
)
return True
wait_for_condition(verify_cluster_busy)
# Kill the task
ray.cancel(t, force=True)
wait_for_condition(verify_cluster_idle)
def test_get_cluster_status_resources(ray_start_cluster):
cluster = ray_start_cluster
# Head node
cluster.add_node(num_cpus=1, _system_config={"enable_autoscaler_v2": True})
ray.init(address=cluster.address)
# Worker node
cluster.add_node(num_cpus=2)
@ray.remote(num_cpus=1)
class Actor:
def loop(self):
while True:
pass
# Schedule tasks to use all resources.
@ray.remote(num_cpus=1)
def loop():
while True:
pass
[loop.remote() for _ in range(2)]
actor = Actor.remote()
actor.loop.remote()
def verify_cpu_resources_all_used():
cluster_status = get_cluster_status(cluster.address)
total_cluster_resources = get_total_resources(
cluster_status.cluster_resource_usage
)
assert total_cluster_resources["CPU"] == 3.0
available_cluster_resources = get_available_resources(
cluster_status.cluster_resource_usage
)
assert available_cluster_resources["CPU"] == 0.0
return True
wait_for_condition(verify_cpu_resources_all_used)
# Schedule more tasks should show up as task demands
[loop.remote() for _ in range(2)]
def verify_task_demands():
resource_demands = get_cluster_status(cluster.address).resource_demands
assert len(resource_demands.ray_task_actor_demand) == 1
assert resource_demands.ray_task_actor_demand[0].bundles_by_count == [
ResourceRequestByCount(
bundle={"CPU": 1.0},
count=2,
)
]
return True
wait_for_condition(verify_task_demands)
# Request resources through SDK
request_cluster_resources(
gcs_address=cluster.address, to_request=[{"resources": {"GPU": 1, "CPU": 2}}]
)
def verify_cluster_constraint_demand():
resource_demands = get_cluster_status(cluster.address).resource_demands
assert len(resource_demands.cluster_constraint_demand) == 1
assert resource_demands.cluster_constraint_demand[0].bundles_by_count == [
ResourceRequestByCount(
bundle={"GPU": 1.0, "CPU": 2.0},
count=1,
)
]
return True
wait_for_condition(verify_cluster_constraint_demand)
# Try to schedule some PGs
pg1 = ray.util.placement_group([{"CPU": 1}] * 3)
def verify_pg_demands():
resource_demands = get_cluster_status(cluster.address).resource_demands
assert len(resource_demands.placement_group_demand) == 1
assert resource_demands.placement_group_demand[0].bundles_by_count == [
ResourceRequestByCount(
bundle={"CPU": 1.0},
count=3,
)
]
assert resource_demands.placement_group_demand[0].pg_id == pg1.id.hex()
assert resource_demands.placement_group_demand[0].strategy == "PACK"
assert resource_demands.placement_group_demand[0].state == "PENDING"
return True
wait_for_condition(verify_pg_demands)
def test_get_cluster_status(ray_start_cluster):
# This test is to make sure the grpc stub is working.
# TODO(rickyx): Add e2e tests for the autoscaler state service in a separate PR
# to validate the data content.
cluster = ray_start_cluster
# Head node
cluster.add_node(num_cpus=1, _system_config={"enable_autoscaler_v2": True})
ray.init(address=cluster.address)
# Worker node
cluster.add_node(num_cpus=2)
head_node_id, worker_node_ids = get_node_ids()
def verify_nodes():
cluster_status = get_cluster_status(cluster.address)
assert_nodes(
cluster_status.idle_nodes,
[
ExpectedNodeInfo(
worker_node_ids[0],
"IDLE",
lambda idle_ms: idle_ms > 0,
total_resources={"CPU": 2.0},
available_resources={"CPU": 2.0},
),
ExpectedNodeInfo(
head_node_id,
"IDLE",
lambda idle_ms: idle_ms > 0,
total_resources={"CPU": 1.0},
available_resources={"CPU": 1.0},
),
],
)
return True
wait_for_condition(verify_nodes)
# Schedule a task running
@ray.remote(num_cpus=2)
def f():
while True:
pass
f.remote()
def verify_nodes_busy():
cluster_status = get_cluster_status(cluster.address)
assert_nodes(
cluster_status.idle_nodes,
[
ExpectedNodeInfo(head_node_id, "IDLE", lambda idle_ms: idle_ms > 0),
],
)
assert_nodes(
cluster_status.active_nodes,
[
ExpectedNodeInfo(
worker_node_ids[0],
"RUNNING",
lambda idle_ms: idle_ms == 0,
total_resources={"CPU": 2.0},
available_resources={"CPU": 0.0},
),
],
)
return True
wait_for_condition(verify_nodes_busy)
stub = _autoscaler_state_service_stub()
state = autoscaler_pb2.AutoscalingState(
last_seen_cluster_resource_state_version=0,
# since the autoscaler will also update the autoscaler_state_version periodically,
# we need to use a large number here, such as 10, to override it to avoid flaky test.
autoscaler_state_version=10,
pending_instance_requests=[
autoscaler_pb2.PendingInstanceRequest(
instance_type_name="m5.large",
ray_node_type_name="worker",
count=2,
request_ts=1000,
)
],
failed_instance_requests=[
autoscaler_pb2.FailedInstanceRequest(
instance_type_name="m5.large",
ray_node_type_name="worker",
count=2,
start_ts=1000,
failed_ts=2000,
reason="insufficient quota",
)
],
pending_instances=[
autoscaler_pb2.PendingInstance(
instance_id="instance-id",
instance_type_name="m5.large",
ray_node_type_name="worker",
ip_address="10.10.10.10",
details="launching",
)
],
)
report_autoscaling_state(stub, autoscaling_state=state)
def verify_autoscaler_state():
# TODO(rickyx): Add infeasible asserts.
cluster_status = get_cluster_status(cluster.address)
assert len(cluster_status.pending_launches) == 1
assert_launches(
cluster_status,
expected_pending_launches=[
LaunchRequest(
instance_type_name="m5.large",
ray_node_type_name="worker",
count=2,
state=LaunchRequest.Status.PENDING,
request_ts_s=1000,
)
],
expected_failed_launches=[
LaunchRequest(
instance_type_name="m5.large",
ray_node_type_name="worker",
count=2,
state=LaunchRequest.Status.FAILED,
request_ts_s=1000,
failed_ts_s=2000,
details="insufficient quota",
)
],
)
assert_nodes(
cluster_status.pending_nodes,
[
ExpectedNodeInfo(
instance_id="instance-id",
ray_node_type_name="worker",
details="launching",
ip_address="10.10.10.10",
)
],
)
return True
wait_for_condition(verify_autoscaler_state)
@pytest.mark.parametrize(
"env_val,enabled",
[
("1", True),
("0", False),
("", False),
],
)
def test_is_autoscaler_v2_enabled(shutdown_only, monkeypatch, env_val, enabled):
def reset_autoscaler_v2_enabled_cache():
import ray.autoscaler.v2.utils as u
u.cached_is_autoscaler_v2 = None
reset_autoscaler_v2_enabled_cache()
with monkeypatch.context() as m:
m.setenv("RAY_enable_autoscaler_v2", env_val)
ray.init()
def verify():
assert ray.autoscaler.v2.utils.is_autoscaler_v2() == enabled
return True
wait_for_condition(verify)
@pytest.mark.parametrize(
"token_state,setup_token,should_fail",
[
("valid", lambda: None, False),
("invalid", lambda: _setup_invalid_token(), True),
],
)
def test_autoscaler_api_with_token_auth(
setup_cluster_with_token_auth,
cleanup_auth_token_env,
token_state,
setup_token,
should_fail,
):
"""Parametrized test for autoscaler API with different token states.
Tests request_cluster_resources with valid, invalid, and missing tokens.
"""
# Setup token state (this changes the client-side token)
setup_token()
if should_fail:
# API call should fail with invalid token
with pytest.raises(Exception) as exc_info:
request_cluster_resources(
ray.get_runtime_context().gcs_address,
[{"resources": {"CPU": 1}, "label_selector": {}}],
)
# Verify it's an authentication error
error_str = str(exc_info.value).lower()
assert (
"unauthenticated" in error_str or "invalidauthtoken" in error_str
), f"request_cluster_resources with {token_state} token should return auth error, got: {exc_info.value}"
else:
# API call should succeed with valid token
request_cluster_resources(
ray.get_runtime_context().gcs_address,
[{"resources": {"CPU": 1}, "label_selector": {}}],
)
# Verify the request was successful using the autoscaler state service stub
stub = _autoscaler_state_service_stub()
state = get_cluster_resource_state(stub)
assert (
len(state.cluster_resource_constraints) > 0
), f"request_cluster_resources with {token_state} token should succeed"
def _setup_invalid_token():
"""Helper to set up an invalid authentication token."""
invalid_token = "invalid_token_value"
authentication_test_utils.set_env_auth_token(invalid_token)
authentication_test_utils.reset_auth_token_state()
def _clear_token():
"""Helper to clear authentication token sources."""
authentication_test_utils.clear_auth_token_sources()
authentication_test_utils.reset_auth_token_state()
if __name__ == "__main__":
if os.environ.get("PARALLEL_CI"):
sys.exit(pytest.main(["-n", "auto", "--boxed", "-vs", __file__]))
else:
sys.exit(pytest.main(["-sv", __file__]))
@@ -0,0 +1,87 @@
# coding: utf-8
import os
import sys
import pytest # noqa
from ray.autoscaler.v2.instance_manager.storage import (
InMemoryStorage,
StoreStatus,
VersionedValue,
)
@pytest.mark.parametrize("storage", [InMemoryStorage()])
def test_storage(storage):
assert storage.get_version() == 0
assert storage.get_all(table="test_table") == ({}, 0)
assert storage.get(table="test_table", keys=[]) == ({}, 0)
assert storage.get(table="test_table", keys=["key1"]) == ({}, 0)
assert storage.batch_update(
table="test_table", mutation={"key1": "value1"}
) == StoreStatus(
True,
1,
)
assert storage.get_version() == 1
assert storage.get_all(table="test_table") == (
{"key1": VersionedValue("value1", 1)},
1,
)
assert storage.get(table="test_table", keys=[]) == (
{"key1": VersionedValue("value1", 1)},
1,
)
assert storage.batch_update(
table="test_table", mutation={"key1": "value2"}, expected_version=0
) == StoreStatus(False, 1)
assert storage.batch_update(
table="test_table", mutation={"key1": "value2"}, expected_version=1
) == StoreStatus(True, 2)
assert storage.get_all(table="test_table") == (
{"key1": VersionedValue("value2", 2)},
2,
)
assert storage.batch_update(
table="test_table",
mutation={"key2": "value3", "key3": "value4"},
deletion=["key1"],
expected_version=2,
) == StoreStatus(True, 3)
assert storage.get_all(table="test_table") == (
{"key2": VersionedValue("value3", 3), "key3": VersionedValue("value4", 3)},
3,
)
assert storage.get(table="test_table", keys=["key2", "key1"]) == (
{"key2": VersionedValue("value3", 3)},
3,
)
assert storage.update(
table="test_table", key="key2", value="value5"
) == StoreStatus(True, 4)
assert storage.update(
table="test_table", key="key2", value="value5", insert_only=True
) == StoreStatus(False, 4)
assert storage.update(
table="test_table", key="key2", value="value5", expected_entry_version=3
) == StoreStatus(False, 4)
assert storage.update(
table="test_table", key="key2", value="value6", expected_entry_version=4
) == StoreStatus(True, 5)
if __name__ == "__main__":
if os.environ.get("PARALLEL_CI"):
sys.exit(pytest.main(["-n", "auto", "--boxed", "-vs", __file__]))
else:
sys.exit(pytest.main(["-sv", __file__]))
@@ -0,0 +1,392 @@
# coding: utf-8
import os
import sys
from queue import Queue
from unittest import mock
import pytest
from ray._common.test_utils import wait_for_condition
from ray._common.utils import binary_to_hex, hex_to_binary
from ray.autoscaler._private.prom_metrics import AutoscalerPrometheusMetrics
from ray.autoscaler.v2.instance_manager.cloud_providers.read_only.cloud_provider import (
ReadOnlyProvider,
)
from ray.autoscaler.v2.instance_manager.subscribers.cloud_instance_updater import (
CloudInstanceUpdater,
)
from ray.autoscaler.v2.instance_manager.subscribers.ray_stopper import ( # noqa
RayStopper,
)
from ray.autoscaler.v2.metrics_reporter import AutoscalerMetricsReporter
from ray.core.generated.autoscaler_pb2 import DrainNodeReason
from ray.core.generated.instance_manager_pb2 import (
Instance,
InstanceUpdateEvent,
TerminationRequest,
)
def _get_stopped_nodes_total(metrics_reporter: AutoscalerMetricsReporter) -> float:
total_samples = [
sample.value
for metric in metrics_reporter._prom_metrics.stopped_nodes.collect()
for sample in metric.samples
if sample.name == "autoscaler_stopped_nodes_total"
]
assert len(total_samples) == 1
return total_samples[0]
class TestRayStopper:
def test_no_op(self):
mock_gcs_client = mock.MagicMock()
ray_stopper = RayStopper(gcs_client=mock_gcs_client, error_queue=Queue())
ray_stopper.notify(
[
InstanceUpdateEvent(
instance_id="test_id",
new_instance_status=Instance.REQUESTED,
)
]
)
assert mock_gcs_client.drain_node.call_count == 0
@pytest.mark.parametrize(
"drain_accepted",
[True, False, None],
ids=["drain_accepted", "drain_rejected", "drain_error"],
)
def test_idle_termination(self, drain_accepted):
mock_gcs_client = mock.MagicMock()
if drain_accepted is None:
mock_gcs_client.drain_node.side_effect = Exception("error")
else:
mock_gcs_client.drain_node.return_value = (
drain_accepted,
f"accepted={str(drain_accepted)}",
)
error_queue = Queue()
ray_stopper = RayStopper(gcs_client=mock_gcs_client, error_queue=error_queue)
ray_stopper.notify(
[
InstanceUpdateEvent(
instance_id="test_id",
new_instance_status=Instance.RAY_STOP_REQUESTED,
termination_request=TerminationRequest(
cause=TerminationRequest.Cause.IDLE,
idle_duration_ms=1000,
ray_node_id="0000",
),
)
]
)
def verify():
mock_gcs_client.drain_node.assert_has_calls(
[
mock.call(
node_id="0000",
reason=DrainNodeReason.DRAIN_NODE_REASON_IDLE_TERMINATION,
reason_message=(
"Termination of node that's idle for 1.0 seconds."
),
deadline_timestamp_ms=0,
)
]
)
if drain_accepted:
assert error_queue.empty()
else:
error = error_queue.get_nowait()
assert error.im_instance_id == "test_id"
return True
wait_for_condition(verify)
@pytest.mark.parametrize(
"stop_accepted",
[True, False],
ids=["stop_accepted", "stop_rejected"],
)
def test_preemption(self, stop_accepted):
mock_gcs_client = mock.MagicMock()
mock_gcs_client.drain_nodes.return_value = [0] if stop_accepted else []
error_queue = Queue()
ray_stopper = RayStopper(gcs_client=mock_gcs_client, error_queue=error_queue)
ray_stopper.notify(
[
InstanceUpdateEvent(
instance_id="i-1",
new_instance_status=Instance.RAY_STOP_REQUESTED,
termination_request=TerminationRequest(
cause=TerminationRequest.Cause.MAX_NUM_NODE_PER_TYPE,
max_num_nodes_per_type=10,
ray_node_id=binary_to_hex(hex_to_binary(b"1111")),
),
),
InstanceUpdateEvent(
instance_id="i-2",
new_instance_status=Instance.RAY_STOP_REQUESTED,
termination_request=TerminationRequest(
cause=TerminationRequest.Cause.MAX_NUM_NODES,
max_num_nodes=100,
ray_node_id=binary_to_hex(hex_to_binary(b"2222")),
),
),
]
)
def verify():
mock_gcs_client.drain_nodes.assert_has_calls(
[
mock.call(
node_ids=[hex_to_binary(b"1111")],
),
mock.call(
node_ids=[hex_to_binary(b"2222")],
),
]
)
if stop_accepted:
assert error_queue.empty()
else:
error_in_ids = set()
while not error_queue.empty():
error = error_queue.get_nowait()
error_in_ids.add(error.im_instance_id)
assert error_in_ids == {"i-1", "i-2"}
return True
wait_for_condition(verify)
class TestCloudInstanceUpdater:
def test_launch_no_op(self):
mock_provider = mock.MagicMock()
launcher = CloudInstanceUpdater(mock_provider)
launcher.notify(
[
InstanceUpdateEvent(
new_instance_status=Instance.RAY_RUNNING,
launch_request_id="1",
instance_type="type-1",
),
]
)
mock_provider.launch.assert_not_called()
def test_launch_new_instances(self):
mock_provider = mock.MagicMock()
launcher = CloudInstanceUpdater(mock_provider)
launcher.notify(
[
InstanceUpdateEvent(
new_instance_status=Instance.REQUESTED,
launch_request_id="1",
instance_type="type-1",
),
InstanceUpdateEvent(
new_instance_status=Instance.REQUESTED,
launch_request_id="1",
instance_type="type-1",
),
InstanceUpdateEvent(
new_instance_status=Instance.REQUESTED,
launch_request_id="2",
instance_type="type-1",
),
InstanceUpdateEvent(
new_instance_status=Instance.REQUESTED,
launch_request_id="2",
instance_type="type-2",
),
]
)
def verify():
mock_provider.launch.assert_has_calls(
[
mock.call(shape={"type-1": 2}, request_id="1"),
mock.call(shape={"type-1": 1, "type-2": 1}, request_id="2"),
]
)
return True
wait_for_condition(verify)
def test_multi_notify(self):
mock_provider = mock.MagicMock()
launcher = CloudInstanceUpdater(mock_provider)
launcher.notify(
[
InstanceUpdateEvent(
new_instance_status=Instance.REQUESTED,
launch_request_id="1",
instance_type="type-1",
),
]
)
launcher.notify(
[
InstanceUpdateEvent(
new_instance_status=Instance.REQUESTED,
launch_request_id="2",
instance_type="type-1",
),
]
)
def verify():
assert mock_provider.launch.call_count == 2
mock_provider.launch.assert_has_calls(
[
mock.call(shape={"type-1": 1}, request_id="1"),
mock.call(shape={"type-1": 1}, request_id="2"),
]
)
return True
wait_for_condition(verify)
def test_terminate_no_op(self):
mock_provider = mock.MagicMock()
launcher = CloudInstanceUpdater(mock_provider)
launcher.notify(
[
InstanceUpdateEvent(
new_instance_status=Instance.RAY_RUNNING,
instance_id="1",
cloud_instance_id="c1",
),
]
)
def verify():
mock_provider.terminate.assert_not_called()
return True
wait_for_condition(verify)
def test_terminate_instances(self):
mock_provider = mock.MagicMock()
metrics_reporter = AutoscalerMetricsReporter(
AutoscalerPrometheusMetrics(session_name="test")
)
launcher = CloudInstanceUpdater(mock_provider, metrics_reporter)
launcher.notify(
[
InstanceUpdateEvent(
new_instance_status=Instance.TERMINATING,
instance_id="1",
cloud_instance_id="c1",
),
InstanceUpdateEvent(
new_instance_status=Instance.TERMINATING,
instance_id="2",
cloud_instance_id="c2",
),
InstanceUpdateEvent(
new_instance_status=Instance.TERMINATING,
instance_id="3",
cloud_instance_id="c3",
),
]
)
def verify():
mock_provider.terminate.assert_called_once_with(
ids=["c1", "c2", "c3"], request_id=mock.ANY
)
assert _get_stopped_nodes_total(metrics_reporter) == 0
return True
wait_for_condition(verify)
def test_count_stopped_instances_on_terminated(self):
mock_provider = mock.MagicMock()
metrics_reporter = AutoscalerMetricsReporter(
AutoscalerPrometheusMetrics(session_name="test")
)
launcher = CloudInstanceUpdater(mock_provider, metrics_reporter)
launcher.notify(
[
InstanceUpdateEvent(
new_instance_status=Instance.TERMINATED,
instance_id="1",
cloud_instance_id="c1",
),
InstanceUpdateEvent(
new_instance_status=Instance.TERMINATED,
instance_id="2",
cloud_instance_id="c2",
),
InstanceUpdateEvent(
new_instance_status=Instance.TERMINATED,
instance_id="3",
),
]
)
def verify():
mock_provider.terminate.assert_not_called()
assert _get_stopped_nodes_total(metrics_reporter) == 2
return True
wait_for_condition(verify)
class TestReadOnlyProvider:
def test_terminate_raises_not_implemented_with_correct_interface(self):
"""ReadOnlyProvider.terminate() must accept ids and request_id kwargs.
Regression test for:
TypeError: ReadOnlyProvider.terminate() got an unexpected keyword
argument 'ids'
CloudInstanceUpdater calls provider.terminate(ids=..., request_id=...)
which matches the ICloudInstanceProvider interface. ReadOnlyProvider
must accept the same signature even though it raises NotImplementedError.
"""
provider = object.__new__(ReadOnlyProvider) # skip __init__ (needs GCS)
with pytest.raises(NotImplementedError):
provider.terminate(ids=["node-1", "node-2"], request_id="req-1")
def test_terminate_via_cloud_instance_updater_raises_not_implemented(self):
"""Verify the full call path: CloudInstanceUpdater -> ReadOnlyProvider.
When a TERMINATING event arrives, CloudInstanceUpdater calls
provider.terminate(ids=..., request_id=...). With ReadOnlyProvider this
should surface as NotImplementedError, not TypeError.
"""
provider = object.__new__(ReadOnlyProvider)
updater = CloudInstanceUpdater(cloud_provider=provider)
with pytest.raises(NotImplementedError):
updater.notify(
[
InstanceUpdateEvent(
new_instance_status=Instance.TERMINATING,
instance_id="i-1",
cloud_instance_id="cloud-node-1",
),
]
)
if __name__ == "__main__":
if os.environ.get("PARALLEL_CI"):
sys.exit(pytest.main(["-n", "auto", "--boxed", "-vs", __file__]))
else:
sys.exit(pytest.main(["-sv", __file__]))
@@ -0,0 +1,119 @@
# coding: utf-8
import os
import sys
import unittest
from queue import Queue
from unittest.mock import patch
import pytest # noqa
from ray._private.test_utils import load_test_config
from ray.autoscaler.tags import TAG_RAY_NODE_KIND
from ray.autoscaler.v2.instance_manager.config import AutoscalingConfig
from ray.autoscaler.v2.instance_manager.instance_storage import InstanceStorage
from ray.autoscaler.v2.instance_manager.ray_installer import RayInstaller
from ray.autoscaler.v2.instance_manager.storage import InMemoryStorage
from ray.autoscaler.v2.instance_manager.subscribers.threaded_ray_installer import (
ThreadedRayInstaller,
)
from ray.core.generated.instance_manager_pb2 import Instance, NodeKind
from ray.tests.autoscaler_test_utils import MockProcessRunner, MockProvider
class ThreadedRayInstallerTest(unittest.TestCase):
def setUp(self):
self.base_provider = MockProvider()
self.config = AutoscalingConfig(load_test_config("test_ray_complex.yaml"))
self.runner = MockProcessRunner()
self.ray_installer = RayInstaller(self.base_provider, self.config, self.runner)
self.instance_storage = InstanceStorage(
cluster_id="test_cluster_id",
storage=InMemoryStorage(),
)
self.error_queue = Queue()
self.threaded_ray_installer = ThreadedRayInstaller(
head_node_ip="127.0.0.1",
instance_storage=self.instance_storage,
ray_installer=self.ray_installer,
error_queue=self.error_queue,
)
def test_install_ray_on_new_node_version_mismatch(self):
self.base_provider.create_node({}, {TAG_RAY_NODE_KIND: "worker_nodes1"}, 1)
instance = Instance(
instance_id="0",
instance_type="worker_nodes1",
cloud_instance_id="0",
status=Instance.RAY_INSTALLING,
node_kind=NodeKind.WORKER,
)
success, verison = self.instance_storage.upsert_instance(instance)
assert success
self.runner.respond_to_call("json .Config.Env", ["[]" for i in range(1)])
self.threaded_ray_installer._install_ray_on_single_node(instance)
instances, _ = self.instance_storage.get_instances(
instance_ids={instance.instance_id}
)
assert instances[instance.instance_id].status == Instance.RAY_INSTALLING
assert instances[instance.instance_id].version == verison
@patch.object(RayInstaller, "install_ray")
def test_install_ray_on_new_node_install_failed(self, mock_method):
self.base_provider.create_node({}, {TAG_RAY_NODE_KIND: "worker_nodes1"}, 1)
instance = Instance(
instance_id="0",
instance_type="worker_nodes1",
cloud_instance_id="0",
status=Instance.RAY_INSTALLING,
node_kind=NodeKind.WORKER,
)
success, verison = self.instance_storage.upsert_instance(instance)
assert success
instance.version = verison
mock_method.side_effect = RuntimeError("Installation failed")
self.threaded_ray_installer._install_retry_interval = 0
self.threaded_ray_installer._max_install_attempts = 1
self.threaded_ray_installer._install_ray_on_single_node(instance)
instances, _ = self.instance_storage.get_instances(
instance_ids={instance.instance_id}
)
# Make sure the instance status is not updated by the ThreadedRayInstaller
# since it should be updated by the Reconciler.
assert instances[instance.instance_id].status == Instance.RAY_INSTALLING
# Make sure the error is added to the error queue.
error = self.error_queue.get()
assert error.im_instance_id == instance.instance_id
assert "Installation failed" in error.details
def test_install_ray_on_new_nodes(self):
self.base_provider.create_node({}, {TAG_RAY_NODE_KIND: "worker_nodes1"}, 1)
instance = Instance(
instance_id="0",
instance_type="worker_nodes1",
cloud_instance_id="0",
status=Instance.RAY_INSTALLING,
node_kind=NodeKind.WORKER,
)
success, verison = self.instance_storage.upsert_instance(instance)
assert success
instance.version = verison
self.runner.respond_to_call("json .Config.Env", ["[]" for i in range(1)])
self.threaded_ray_installer._install_ray_on_new_nodes(instance.instance_id)
self.threaded_ray_installer._ray_installation_executor.shutdown(wait=True)
instances, _ = self.instance_storage.get_instances(
instance_ids={instance.instance_id}
)
# Make sure the instance status is not updated by the ThreadedRayInstaller
# since it should be updated by the Reconciler.
assert instances[instance.instance_id].status == Instance.RAY_INSTALLING
if __name__ == "__main__":
if os.environ.get("PARALLEL_CI"):
sys.exit(pytest.main(["-n", "auto", "--boxed", "-vs", __file__]))
else:
sys.exit(pytest.main(["-sv", __file__]))
@@ -0,0 +1,607 @@
# coding: utf-8
import os
import sys
from typing import Dict
import pytest # noqa
from google.protobuf.json_format import ParseDict
from ray.autoscaler.v2.schema import (
ClusterConstraintDemand,
ClusterStatus,
LaunchRequest,
NodeInfo,
NodeUsage,
PlacementGroupResourceDemand,
RayTaskActorDemand,
ResourceDemandSummary,
ResourceRequestByCount,
ResourceUsage,
Stats,
)
from ray.autoscaler.v2.utils import (
ClusterStatusFormatter,
ClusterStatusParser,
ResourceRequestUtil,
)
from ray.core.generated.autoscaler_pb2 import GetClusterStatusReply
def _gen_cluster_status_reply(data: Dict):
return ParseDict(data, GetClusterStatusReply())
class TestResourceRequestUtil:
@staticmethod
def test_combine_requests_with_affinity():
AFFINITY = ResourceRequestUtil.PlacementConstraintType.AFFINITY
ANTI_AFFINITY = ResourceRequestUtil.PlacementConstraintType.ANTI_AFFINITY
rqs = [
ResourceRequestUtil.make({"CPU": 1}, [(AFFINITY, "1", "1")]), # 1
ResourceRequestUtil.make({"CPU": 2}, [(AFFINITY, "1", "1")]), # 1
ResourceRequestUtil.make({"CPU": 1}, [(AFFINITY, "2", "2")]), # 2
ResourceRequestUtil.make({"CPU": 1}, [(AFFINITY, "2", "2")]), # 2
ResourceRequestUtil.make({"CPU": 1}, [(ANTI_AFFINITY, "2", "2")]), # 3
ResourceRequestUtil.make({"CPU": 1}, [(ANTI_AFFINITY, "2", "2")]), # 4
ResourceRequestUtil.make({"CPU": 1}), # 5
]
rq_result = ResourceRequestUtil.combine_requests_with_affinity(rqs)
assert len(rq_result) == 5
actual = ResourceRequestUtil.to_dict_list(rq_result)
expected = [
ResourceRequestUtil.to_dict(
ResourceRequestUtil.make(
{"CPU": 3}, # Combined
[
(AFFINITY, "1", "1"),
],
)
),
ResourceRequestUtil.to_dict(
ResourceRequestUtil.make(
{"CPU": 2}, # Combined
[
(AFFINITY, "2", "2"),
],
)
),
ResourceRequestUtil.to_dict(
ResourceRequestUtil.make(
{"CPU": 1},
[(ANTI_AFFINITY, "2", "2")],
)
),
ResourceRequestUtil.to_dict(
ResourceRequestUtil.make(
{"CPU": 1},
[(ANTI_AFFINITY, "2", "2")],
)
),
ResourceRequestUtil.to_dict(
ResourceRequestUtil.make(
{"CPU": 1},
)
),
]
actual_str_serialized = [str(x) for x in actual]
expected_str_serialized = [str(x) for x in expected]
assert sorted(actual_str_serialized) == sorted(expected_str_serialized)
def test_cluster_status_parser_cluster_resource_state():
test_data = {
"cluster_resource_state": {
"node_states": [
{
"node_id": b"1" * 4,
"instance_id": "instance1",
"ray_node_type_name": "head_node",
"available_resources": {
"CPU": 0.5,
"GPU": 2.0,
},
"total_resources": {
"CPU": 1,
"GPU": 2.0,
},
"status": "RUNNING",
"node_ip_address": "10.10.10.10",
"instance_type_name": "m5.large",
},
{
"node_id": b"2" * 4,
"instance_id": "instance2",
"ray_node_type_name": "worker_node",
"available_resources": {},
"total_resources": {
"CPU": 1,
"GPU": 2.0,
},
"status": "DEAD",
"node_ip_address": "22.22.22.22",
"instance_type_name": "m5.large",
},
{
"node_id": b"3" * 4,
"instance_id": "instance3",
"ray_node_type_name": "worker_node",
"available_resources": {
"CPU": 1.0,
"GPU": 2.0,
},
"total_resources": {
"CPU": 1,
"GPU": 2.0,
},
"idle_duration_ms": 100,
"status": "IDLE",
"node_ip_address": "22.22.22.22",
"instance_type_name": "m5.large",
},
],
"pending_gang_resource_requests": [
{
"requests": [
{
"resources_bundle": {"CPU": 1, "GPU": 1},
"placement_constraints": [
{
"anti_affinity": {
"label_name": "_PG_1x1x",
"label_value": "",
}
}
],
},
],
"details": "1x1x:STRICT_SPREAD|PENDING",
},
{
"requests": [
{
"resources_bundle": {"GPU": 2},
"placement_constraints": [
{
"affinity": {
"label_name": "_PG_2x2x",
"label_value": "",
}
}
],
},
],
"details": "2x2x:STRICT_PACK|PENDING",
},
],
"pending_resource_requests": [
{
"request": {
"resources_bundle": {"CPU": 1, "GPU": 1},
"placement_constraints": [],
},
"count": 1,
},
],
"cluster_resource_constraints": [
{
"resource_requests": [
{
"request": {
"resources_bundle": {"GPU": 2, "CPU": 100},
"placement_constraints": [],
},
"count": 1,
},
]
}
],
"cluster_resource_state_version": 10,
},
"autoscaling_state": {},
}
reply = _gen_cluster_status_reply(test_data)
stats = Stats(gcs_request_time_s=0.1)
cluster_status = ClusterStatusParser.from_get_cluster_status_reply(reply, stats)
# Assert on health nodes
assert len(cluster_status.idle_nodes) + len(cluster_status.active_nodes) == 2
assert cluster_status.active_nodes[0].instance_id == "instance1"
assert cluster_status.active_nodes[0].ray_node_type_name == "head_node"
cluster_status.active_nodes[0].resource_usage.usage.sort(
key=lambda x: x.resource_name
)
assert cluster_status.active_nodes[0].resource_usage == NodeUsage(
usage=[
ResourceUsage(resource_name="CPU", total=1.0, used=0.5),
ResourceUsage(resource_name="GPU", total=2.0, used=0.0),
],
idle_time_ms=0,
)
assert cluster_status.idle_nodes[0].instance_id == "instance3"
assert cluster_status.idle_nodes[0].ray_node_type_name == "worker_node"
cluster_status.idle_nodes[0].resource_usage.usage.sort(
key=lambda x: x.resource_name
)
assert cluster_status.idle_nodes[0].resource_usage == NodeUsage(
usage=[
ResourceUsage(resource_name="CPU", total=1.0, used=0.0),
ResourceUsage(resource_name="GPU", total=2.0, used=0.0),
],
idle_time_ms=100,
)
# Assert on dead nodes
assert len(cluster_status.failed_nodes) == 1
assert cluster_status.failed_nodes[0].instance_id == "instance2"
assert cluster_status.failed_nodes[0].ray_node_type_name == "worker_node"
assert cluster_status.failed_nodes[0].resource_usage is None
# Assert on resource demands from tasks
assert len(cluster_status.resource_demands.ray_task_actor_demand) == 1
assert cluster_status.resource_demands.ray_task_actor_demand[
0
].bundles_by_count == [
ResourceRequestByCount(
bundle={"CPU": 1, "GPU": 1},
count=1,
)
]
# Assert on resource demands from placement groups
assert len(cluster_status.resource_demands.placement_group_demand) == 2
assert sorted(
cluster_status.resource_demands.placement_group_demand, key=lambda x: x.pg_id
) == [
PlacementGroupResourceDemand(
bundles_by_count=[
ResourceRequestByCount(bundle={"CPU": 1, "GPU": 1}, count=1)
],
strategy="STRICT_SPREAD",
pg_id="1x1x",
state="PENDING",
details="1x1x:STRICT_SPREAD|PENDING",
),
PlacementGroupResourceDemand(
bundles_by_count=[ResourceRequestByCount(bundle={"GPU": 2}, count=1)],
strategy="STRICT_PACK",
pg_id="2x2x",
state="PENDING",
details="2x2x:STRICT_PACK|PENDING",
),
]
# Assert on resource constraints
assert len(cluster_status.resource_demands.cluster_constraint_demand) == 1
assert cluster_status.resource_demands.cluster_constraint_demand[
0
].bundles_by_count == [
ResourceRequestByCount(bundle={"GPU": 2, "CPU": 100}, count=1)
]
# Assert on the cluster_resource_usage
assert sorted(
cluster_status.cluster_resource_usage, key=lambda x: x.resource_name
) == [
ResourceUsage(resource_name="CPU", total=2.0, used=0.5),
ResourceUsage(resource_name="GPU", total=4.0, used=0.0),
]
# Assert on the node stats
assert cluster_status.stats.cluster_resource_state_version == "10"
assert cluster_status.stats.gcs_request_time_s == 0.1
def test_cluster_status_parser_autoscaler_state():
test_data = {
"cluster_resource_state": {},
"autoscaling_state": {
"pending_instance_requests": [
{
"instance_type_name": "m5.large",
"ray_node_type_name": "head_node",
"count": 1,
"request_ts": 29999,
},
{
"instance_type_name": "m5.large",
"ray_node_type_name": "worker_node",
"count": 2,
"request_ts": 19999,
},
],
"pending_instances": [
{
"instance_type_name": "m5.large",
"ray_node_type_name": "head_node",
"instance_id": "instance1",
"ip_address": "10.10.10.10",
"details": "Starting Ray",
},
],
"failed_instance_requests": [
{
"instance_type_name": "m5.large",
"ray_node_type_name": "worker_node",
"count": 2,
"reason": "Insufficient capacity",
"start_ts": 10000,
"failed_ts": 20000,
}
],
"autoscaler_state_version": 10,
},
}
reply = _gen_cluster_status_reply(test_data)
stats = Stats(gcs_request_time_s=0.1)
cluster_status = ClusterStatusParser.from_get_cluster_status_reply(reply, stats)
# Assert on the pending requests
assert len(cluster_status.pending_launches) == 2
assert cluster_status.pending_launches[0].instance_type_name == "m5.large"
assert cluster_status.pending_launches[0].ray_node_type_name == "head_node"
assert cluster_status.pending_launches[0].count == 1
assert cluster_status.pending_launches[0].request_ts_s == 29999
assert cluster_status.pending_launches[1].instance_type_name == "m5.large"
assert cluster_status.pending_launches[1].ray_node_type_name == "worker_node"
assert cluster_status.pending_launches[1].count == 2
assert cluster_status.pending_launches[1].request_ts_s == 19999
# Assert on the failed requests
assert len(cluster_status.failed_launches) == 1
assert cluster_status.failed_launches[0].instance_type_name == "m5.large"
assert cluster_status.failed_launches[0].ray_node_type_name == "worker_node"
assert cluster_status.failed_launches[0].count == 2
assert cluster_status.failed_launches[0].details == "Insufficient capacity"
assert cluster_status.failed_launches[0].request_ts_s == 10000
assert cluster_status.failed_launches[0].failed_ts_s == 20000
# Assert on the pending nodes
assert len(cluster_status.pending_nodes) == 1
assert cluster_status.pending_nodes[0].instance_type_name == "m5.large"
assert cluster_status.pending_nodes[0].ray_node_type_name == "head_node"
assert cluster_status.pending_nodes[0].instance_id == "instance1"
assert cluster_status.pending_nodes[0].ip_address == "10.10.10.10"
assert cluster_status.pending_nodes[0].details == "Starting Ray"
# Assert on stats
assert cluster_status.stats.autoscaler_version == "10"
assert cluster_status.stats.gcs_request_time_s == 0.1
def test_cluster_status_formatter():
state = ClusterStatus(
idle_nodes=[
NodeInfo(
instance_id="instance1",
instance_type_name="m5.large",
ray_node_type_name="head_node",
ip_address="127.0.0.1",
node_status="RUNNING",
node_id="fffffffffffffffffffffffffffffffffffffffffffffffffff00001",
resource_usage=NodeUsage(
usage=[
ResourceUsage(resource_name="CPU", total=1.0, used=0.5),
ResourceUsage(resource_name="GPU", total=2.0, used=0.0),
ResourceUsage(
resource_name="object_store_memory",
total=10282.0,
used=5555.0,
),
],
idle_time_ms=0,
),
),
NodeInfo(
instance_id="instance2",
instance_type_name="m5.large",
ray_node_type_name="worker_node",
ip_address="127.0.0.2",
node_status="RUNNING",
node_id="fffffffffffffffffffffffffffffffffffffffffffffffffff00002",
resource_usage=NodeUsage(
usage=[
ResourceUsage(resource_name="CPU", total=1.0, used=0),
ResourceUsage(resource_name="GPU", total=2.0, used=0),
],
idle_time_ms=0,
),
),
NodeInfo(
instance_id="instance3",
instance_type_name="m5.large",
ray_node_type_name="worker_node",
ip_address="127.0.0.2",
node_status="RUNNING",
node_id="fffffffffffffffffffffffffffffffffffffffffffffffffff00003",
resource_usage=NodeUsage(
usage=[
ResourceUsage(resource_name="CPU", total=1.0, used=0.0),
],
idle_time_ms=0,
),
),
],
pending_launches=[
LaunchRequest(
instance_type_name="m5.large",
count=2,
ray_node_type_name="worker_node",
state=LaunchRequest.Status.PENDING,
request_ts_s=10000,
),
LaunchRequest(
instance_type_name="g5n.large",
count=1,
ray_node_type_name="worker_node_gpu",
state=LaunchRequest.Status.PENDING,
request_ts_s=20000,
),
],
failed_launches=[
LaunchRequest(
instance_type_name="m5.large",
count=2,
ray_node_type_name="worker_node",
state=LaunchRequest.Status.FAILED,
details="Insufficient capacity",
request_ts_s=10000,
failed_ts_s=20000,
),
],
pending_nodes=[
NodeInfo(
instance_id="instance4",
instance_type_name="m5.large",
ray_node_type_name="worker_node",
ip_address="127.0.0.3",
details="Starting Ray",
),
],
failed_nodes=[
NodeInfo(
instance_id="instance5",
instance_type_name="m5.large",
ray_node_type_name="worker_node",
ip_address="127.0.0.5",
node_status="DEAD",
),
],
cluster_resource_usage=[
ResourceUsage(resource_name="CPU", total=3.0, used=0.5),
ResourceUsage(resource_name="GPU", total=4.0, used=0.0),
ResourceUsage(
resource_name="object_store_memory", total=10282.0, used=5555.0
),
],
resource_demands=ResourceDemandSummary(
placement_group_demand=[
PlacementGroupResourceDemand(
pg_id="1x1x",
strategy="STRICT_SPREAD",
state="PENDING",
details="1x1x:STRICT_SPREAD|PENDING",
bundles_by_count=[
ResourceRequestByCount(bundle={"CPU": 1, "GPU": 1}, count=1)
],
),
PlacementGroupResourceDemand(
pg_id="2x2x",
strategy="STRICT_PACK",
state="PENDING",
details="2x2x:STRICT_PACK|PENDING",
bundles_by_count=[
ResourceRequestByCount(bundle={"GPU": 2}, count=1)
],
),
PlacementGroupResourceDemand(
pg_id="3x3x",
strategy="STRICT_PACK",
state="PENDING",
details="3x3x:STRICT_PACK|PENDING",
bundles_by_count=[
ResourceRequestByCount(bundle={"GPU": 2}, count=1)
],
),
],
ray_task_actor_demand=[
RayTaskActorDemand(
bundles_by_count=[
ResourceRequestByCount(bundle={"CPU": 1, "GPU": 1}, count=1)
]
),
RayTaskActorDemand(
bundles_by_count=[
ResourceRequestByCount(bundle={"CPU": 1, "GPU": 1}, count=10)
]
),
],
cluster_constraint_demand=[
ClusterConstraintDemand(
bundles_by_count=[
ResourceRequestByCount(bundle={"GPU": 2, "CPU": 100}, count=2)
]
),
],
),
stats=Stats(
gcs_request_time_s=0.1,
none_terminated_node_request_time_s=0.2,
autoscaler_iteration_time_s=0.3,
autoscaler_version="10",
cluster_resource_state_version="20",
request_ts_s=775303535,
),
)
actual = ClusterStatusFormatter.format(state, verbose=True)
expected = """======== Autoscaler status: 1994-07-27 10:05:35 ========
GCS request time: 0.100000s
Node Provider non_terminated_nodes time: 0.200000s
Autoscaler iteration time: 0.300000s
Node status
--------------------------------------------------------
Active:
(no active nodes)
Idle:
1 head_node
2 worker_node
Pending:
worker_node, 1 launching
worker_node_gpu, 1 launching
instance4: worker_node, starting ray
Recent failures:
worker_node: LaunchFailed (latest_attempt: 02:46:40) - Insufficient capacity
worker_node: NodeTerminated (instance_id: instance5)
Resources
--------------------------------------------------------
Total Usage:
0.5/3.0 CPU
0.0/4.0 GPU
5.42KiB/10.04KiB object_store_memory
From request_resources:
{'GPU': 2, 'CPU': 100}: 2 from request_resources()
Pending Demands:
{'CPU': 1, 'GPU': 1}: 11+ pending tasks/actors
{'CPU': 1, 'GPU': 1} * 1 (STRICT_SPREAD): 1+ pending placement groups
{'GPU': 2} * 1 (STRICT_PACK): 2+ pending placement groups
Node: instance1 (head_node)
Id: fffffffffffffffffffffffffffffffffffffffffffffffffff00001
Usage:
0.5/1.0 CPU
0.0/2.0 GPU
5.42KiB/10.04KiB object_store_memory
Activity:
(no activity)
Node: instance2 (worker_node)
Id: fffffffffffffffffffffffffffffffffffffffffffffffffff00002
Usage:
0/1.0 CPU
0/2.0 GPU
Activity:
(no activity)
Node: instance3 (worker_node)
Id: fffffffffffffffffffffffffffffffffffffffffffffffffff00003
Usage:
0.0/1.0 CPU
Activity:
(no activity)"""
assert actual == expected
if __name__ == "__main__":
if os.environ.get("PARALLEL_CI"):
sys.exit(pytest.main(["-n", "auto", "--boxed", "-vs", __file__]))
else:
sys.exit(pytest.main(["-sv", __file__]))
+206
View File
@@ -0,0 +1,206 @@
import abc
import operator
import time
from abc import abstractmethod
from collections import defaultdict
from typing import Dict, List, Optional, Tuple
import ray
from ray.autoscaler.v2.schema import AutoscalerInstance, ClusterStatus, ResourceUsage
from ray.autoscaler.v2.sdk import get_cluster_status
from ray.core.generated import autoscaler_pb2
from ray.core.generated.instance_manager_pb2 import Instance, NodeKind
class MockEventLogger:
def __init__(self, logger) -> None:
self._logs = defaultdict(list)
self._logger = logger
def info(self, s):
self._logger.info(s)
self._logs["info"].append(s)
def warning(self, s):
self._logger.warning(s)
self._logs["warning"].append(s)
def error(self, s):
self._logger.error(s)
self._logs["error"].append(s)
def debug(self, s):
self._logger.debug(s)
self._logs["debug"].append(s)
def get_logs(self, level: str) -> List[str]:
return self._logs[level]
class MockSubscriber:
def __init__(self):
self.events = []
def notify(self, events):
self.events.extend(events)
def clear(self):
self.events.clear()
def events_by_id(self, instance_id):
return [e for e in self.events if e.instance_id == instance_id]
def make_autoscaler_instance(
im_instance: Optional[Instance] = None,
ray_node: Optional[autoscaler_pb2.NodeState] = None,
cloud_instance_id: Optional[str] = None,
) -> AutoscalerInstance:
if cloud_instance_id:
if im_instance:
im_instance.cloud_instance_id = cloud_instance_id
if ray_node:
ray_node.instance_id = cloud_instance_id
return AutoscalerInstance(
im_instance=im_instance,
ray_node=ray_node,
cloud_instance_id=cloud_instance_id,
)
def get_cluster_resource_state(stub) -> autoscaler_pb2.ClusterResourceState:
request = autoscaler_pb2.GetClusterResourceStateRequest(
last_seen_cluster_resource_state_version=0
)
return stub.GetClusterResourceState(request).cluster_resource_state
class FakeCounter:
def dec(self, *args, **kwargs):
pass
def create_instance(
instance_id,
status=Instance.UNKNOWN,
instance_type="worker_nodes1",
status_times: List[Tuple["Instance.InstanceStatus", int]] = None,
launch_request_id="",
version=0,
cloud_instance_id="",
ray_node_id="",
node_kind=NodeKind.WORKER,
):
if not status_times:
status_times = [(status, time.time_ns())]
return Instance(
instance_id=instance_id,
status=status,
version=version,
instance_type=instance_type,
launch_request_id=launch_request_id,
status_history=[
Instance.StatusHistory(instance_status=status, timestamp_ns=ts)
for status, ts in status_times
],
cloud_instance_id=cloud_instance_id,
node_id=ray_node_id,
node_kind=node_kind,
)
def report_autoscaling_state(stub, autoscaling_state: autoscaler_pb2.AutoscalingState):
request = autoscaler_pb2.ReportAutoscalingStateRequest(
autoscaling_state=autoscaling_state
)
stub.ReportAutoscalingState(request)
def get_total_resources(usages: List[ResourceUsage]) -> Dict[str, float]:
"""Returns a map of resource name to total resource."""
return {r.resource_name: r.total for r in usages}
def get_available_resources(usages: List[ResourceUsage]) -> Dict[str, float]:
"""Returns a map of resource name to available resource."""
return {r.resource_name: r.total - r.used for r in usages}
def get_used_resources(usages: List[ResourceUsage]) -> Dict[str, float]:
"""Returns a map of resource name to used resource."""
return {r.resource_name: r.used for r in usages}
"""
Test utils for e2e autoscaling states checking.
"""
class Check(abc.ABC):
@abstractmethod
def check(self, status: ClusterStatus):
pass
def __repr__(self) -> str:
return self.__str__()
class CheckFailure(RuntimeError):
pass
class NodeCountCheck(Check):
def __init__(self, count: int):
self.count = count
def check(self, status: ClusterStatus):
healthy_nodes = len(status.active_nodes) + len(status.idle_nodes)
if healthy_nodes != self.count:
raise CheckFailure(f"Expected {self.count} nodes, got {healthy_nodes}")
def __str__(self) -> str:
return f"NodeCountCheck: {self.count}"
class TotalResourceCheck(Check):
def __init__(
self, resources: Dict[str, float], op: operator = operator.eq, enforce_all=False
):
self.resources = resources
self.op = op
self.enforce_all = enforce_all
def check(self, status: ClusterStatus):
actual = status.total_resources()
if self.enforce_all and len(actual) != len(self.resources):
raise CheckFailure(
f"Expected {len(self.resources)} resources, got {len(actual)}"
)
for k, v in self.resources.items():
if k not in actual and v:
raise CheckFailure(f"Expected resource {k} not found")
if not self.op(v, actual.get(k, 0)):
raise CheckFailure(
f"Expected resource {k} {self.op} {v}, got {actual.get(k, 0)}"
)
def __str__(self) -> str:
return f"TotalResourceCheck({self.op}): {self.resources}"
def check_cluster(
targets: List[Check],
) -> bool:
gcs_address = ray.get_runtime_context().gcs_address
cluster_status = get_cluster_status(gcs_address)
for target in targets:
target.check(cluster_status)
return True