Files
2026-07-13 13:17:40 +08:00

1025 lines
32 KiB
Python

import os
import subprocess
import sys
import time
from typing import Dict
import pytest
import ray
from ray._common.constants import HEAD_NODE_RESOURCE_NAME
from ray._common.test_utils import wait_for_condition
from ray._common.usage.usage_lib import get_extra_usage_tags_to_report
from ray._private.test_utils import run_string_as_driver_nonblocking
from ray._raylet import GcsClient
from ray.autoscaler.v2.sdk import get_cluster_status
from ray.cluster_utils import AutoscalingCluster
from ray.core.generated.usage_pb2 import TagKey
from ray.util.placement_group import (
placement_group,
remove_placement_group,
)
from ray.util.state.api import (
list_actors,
list_placement_groups,
list_tasks,
)
def is_head_node_from_resource_usage(usage: Dict[str, float]) -> bool:
if HEAD_NODE_RESOURCE_NAME in usage:
return True
return False
@pytest.mark.parametrize("autoscaler_v2", [False, True], ids=["v1", "v2"])
def test_autoscaler_no_churn(autoscaler_v2):
num_cpus_per_node = 4
expected_nodes = 6
cluster = AutoscalingCluster(
head_resources={"CPU": num_cpus_per_node},
worker_node_types={
"type-1": {
"resources": {"CPU": num_cpus_per_node},
"node_config": {},
"min_workers": 0,
"max_workers": 2 * expected_nodes,
},
},
autoscaler_v2=autoscaler_v2,
)
driver_script = f"""
import time
import ray
@ray.remote(num_cpus=1)
def foo():
time.sleep(60)
return True
ray.init("auto")
print("start")
assert(ray.get([foo.remote() for _ in range({num_cpus_per_node * expected_nodes})]))
print("end")
"""
try:
cluster.start()
ray.init("auto")
gcs_address = ray.get_runtime_context().gcs_address
def tasks_run():
tasks = list_tasks()
# Waiting til the driver in the run_string_as_driver_nonblocking is running
assert len(tasks) > 0
return True
run_string_as_driver_nonblocking(driver_script)
wait_for_condition(tasks_run)
reached_threshold = False
for _ in range(30):
# verify no pending task + with resource used.
status = get_cluster_status(gcs_address)
has_task_demand = len(status.resource_demands.ray_task_actor_demand) > 0
# Autoscaler can briefly launch extra workers while demand and
# in-flight instance views catch up; it is then idle-terminated.
# Check that we don't overscale (allow one transient extra node).
assert len(status.active_nodes) <= expected_nodes + 1
# Check there's no demand if we've reached the expected number of nodes
if reached_threshold:
assert not has_task_demand
# Load disappears in the next cycle after we've fully scaled up.
if len(status.active_nodes) >= expected_nodes:
reached_threshold = True
time.sleep(1)
assert reached_threshold
finally:
# TODO(rickyx): refactor into a fixture for autoscaling cluster.
ray.shutdown()
cluster.shutdown()
# TODO(rickyx): We are NOT able to counter multi-node inconsistency yet. The problem is
# right now, when node A (head node) has an infeasible task,
# node B just finished running previous task.
# the actual cluster view will be:
# node A: 1 pending task (infeasible)
# node B: 0 pending task, CPU used = 0
#
# However, when node B's state is not updated on node A, the cluster view will be:
# node A: 1 pending task (infeasible)
# node B: 0 pending task, but **CPU used = 1**
#
@pytest.mark.parametrize("mode", (["single_node", "multi_node"]))
@pytest.mark.parametrize("autoscaler_v2", [False, True], ids=["v1", "v2"])
def test_scheduled_task_no_pending_demand(mode, autoscaler_v2):
# So that head node will need to dispatch tasks to worker node.
num_head_cpu = 0 if mode == "multi_node" else 1
cluster = AutoscalingCluster(
head_resources={"CPU": num_head_cpu},
worker_node_types={
"type-1": {
"resources": {"CPU": 1},
"node_config": {},
"min_workers": 0,
"max_workers": 1,
},
},
autoscaler_v2=autoscaler_v2,
)
driver_script = """
import time
import ray
@ray.remote(num_cpus=1)
def foo():
return True
ray.init("auto")
while True:
assert(ray.get(foo.remote()))
"""
try:
cluster.start()
ray.init("auto")
gcs_address = ray.get_runtime_context().gcs_address
run_string_as_driver_nonblocking(driver_script)
def tasks_run():
tasks = list_tasks()
# Waiting til the driver in the run_string_as_driver_nonblocking is running
assert len(tasks) > 0
return True
wait_for_condition(tasks_run)
for _ in range(30):
# verify no pending task + with resource used.
status = get_cluster_status(gcs_address)
has_task_demand = len(status.resource_demands.ray_task_actor_demand) > 0
has_task_usage = False
for usage in status.cluster_resource_usage:
if usage.resource_name == "CPU":
has_task_usage = usage.used > 0
print(status.cluster_resource_usage)
print(status.resource_demands.ray_task_actor_demand)
assert not (has_task_demand and has_task_usage), status
time.sleep(0.1)
finally:
# TODO(rickyx): refactor into a fixture for autoscaling cluster.
ray.shutdown()
cluster.shutdown()
@pytest.mark.parametrize("autoscaler_v2", [False, True], ids=["v1", "v2"])
def test_placement_group_consistent(autoscaler_v2):
# Test that continuously creating and removing placement groups
# does not leak pending resource requests.
import time
cluster = AutoscalingCluster(
head_resources={"CPU": 0},
worker_node_types={
"type-1": {
"resources": {"CPU": 1},
"node_config": {},
"min_workers": 0,
"max_workers": 2,
},
},
autoscaler_v2=autoscaler_v2,
)
driver_script = """
import ray
import time
# Import placement group APIs.
from ray.util.placement_group import (
placement_group,
placement_group_table,
remove_placement_group,
)
ray.init("auto")
# Reserve all the CPUs of nodes, X= num of cpus, N = num of nodes
while True:
pg = placement_group([{"CPU": 1}])
ray.get(pg.ready())
time.sleep(0.5)
remove_placement_group(pg)
time.sleep(0.5)
"""
try:
cluster.start()
ray.init("auto")
gcs_address = ray.get_runtime_context().gcs_address
run_string_as_driver_nonblocking(driver_script)
def pg_created():
pgs = list_placement_groups()
assert len(pgs) > 0
return True
wait_for_condition(pg_created)
for _ in range(30):
# verify no pending request + resource used.
status = get_cluster_status(gcs_address)
has_pg_demand = len(status.resource_demands.placement_group_demand) > 0
has_pg_usage = False
for usage in status.cluster_resource_usage:
has_pg_usage = has_pg_usage or "bundle" in usage.resource_name
print(has_pg_demand, has_pg_usage)
assert not (has_pg_demand and has_pg_usage), status
time.sleep(0.1)
finally:
ray.shutdown()
cluster.shutdown()
def test_autoscaler_v2_usage_report():
# Test that nodes become idle after placement group removal.
cluster = AutoscalingCluster(
head_resources={"CPU": 2},
worker_node_types={
"type-1": {
"resources": {"CPU": 2},
"node_config": {},
"min_workers": 0,
"max_workers": 2,
},
},
autoscaler_v2=True,
)
try:
cluster.start()
ray.init("auto")
gcs_client = GcsClient(ray.get_runtime_context().gcs_address)
def verify():
tags = get_extra_usage_tags_to_report(gcs_client)
print(tags)
assert tags[TagKey.Name(TagKey.AUTOSCALER_VERSION).lower()] == "v2", tags
return True
wait_for_condition(verify)
finally:
ray.shutdown()
cluster.shutdown()
@pytest.mark.parametrize("autoscaler_v2", [False, True], ids=["v1", "v2"])
def test_placement_group_removal_idle_node(autoscaler_v2):
# Test that nodes become idle after placement group removal.
cluster = AutoscalingCluster(
head_resources={"CPU": 2},
worker_node_types={
"type-1": {
"resources": {"CPU": 2},
"node_config": {},
"min_workers": 0,
"max_workers": 2,
},
},
autoscaler_v2=autoscaler_v2,
)
try:
cluster.start()
ray.init("auto")
gcs_address = ray.get_runtime_context().gcs_address
# Schedule a pg on nodes
pg = placement_group([{"CPU": 2}] * 3, strategy="STRICT_SPREAD")
ray.get(pg.ready())
time.sleep(2)
remove_placement_group(pg)
from ray.autoscaler.v2.sdk import get_cluster_status
def verify():
cluster_state = get_cluster_status(gcs_address)
# Verify that nodes are idle.
assert len((cluster_state.idle_nodes)) == 3
for node in cluster_state.idle_nodes:
assert node.node_status == "IDLE"
assert node.resource_usage.idle_time_ms >= 1000
return True
wait_for_condition(verify)
finally:
ray.shutdown()
cluster.shutdown()
@pytest.mark.parametrize("autoscaler_v2", [False, True], ids=["v1", "v2"])
def test_placement_group_reschedule_node_dead(autoscaler_v2):
# Test autoscaler reschedules placement group when node dies.
# Note that it should only provision nodes for the bundles that haven't been placed.
cluster = AutoscalingCluster(
head_resources={"CPU": 0},
worker_node_types={
"type-1": {
"resources": {"R1": 1},
"node_config": {},
"min_workers": 0,
"max_workers": 2,
},
"type-2": {
"resources": {"R2": 1},
"node_config": {},
"min_workers": 0,
"max_workers": 2,
},
"type-3": {
"resources": {"R3": 1},
"node_config": {},
"min_workers": 0,
"max_workers": 2,
},
},
autoscaler_v2=autoscaler_v2,
)
try:
cluster.start()
ray.init("auto")
gcs_address = ray.get_runtime_context().gcs_address
pg = placement_group([{"R1": 1}, {"R2": 1}, {"R3": 1}])
ray.get(pg.ready())
def verify_nodes(active, idle):
cluster_state = get_cluster_status(gcs_address)
assert len(cluster_state.active_nodes) == active
assert len(cluster_state.idle_nodes) == idle
return True
# 3 worker nodes, 1 head node (idle)
wait_for_condition(lambda: verify_nodes(3, 1))
def kill_node(node_id):
cmd = f"ps auxww | grep {node_id} | grep -v grep | awk '{{print $2}}'"
pids = subprocess.check_output(cmd, shell=True).decode("utf-8").strip()
print(f"Killing pids {pids}")
# kill the pids (handle multiple PIDs separated by newlines)
for pid in pids.split("\n"):
if pid:
cmd = f"kill -9 {pid}"
subprocess.run(cmd, shell=True)
# Kill a worker node with 'R1' in resources
for n in ray.nodes():
if "R1" in n["Resources"]:
node = n
break
# TODO(mimi): kill_raylet won't trigger reschedule in autoscaler v1
kill_node(node["NodeID"])
# Wait for the node to be removed
wait_for_condition(lambda: verify_nodes(2, 1), 30)
# Only provision nodes for unplaced bundles;
# avoid rescheduling the whole placement group.
wait_for_condition(lambda: verify_nodes(3, 1))
# Verify that the R1 node is recreated and has a different NodeID.
assert any(
[
"R1" in n["Resources"] and node["NodeID"] != n["NodeID"]
for n in ray.nodes()
]
), "R1 node is not recreated."
finally:
ray.shutdown()
cluster.shutdown()
def test_object_store_memory_idle_node(shutdown_only):
ray.init()
obj = ray.put("hello")
gcs_address = ray.get_runtime_context().gcs_address
def verify():
state = get_cluster_status(gcs_address)
for node in state.active_nodes:
assert node.node_status == "RUNNING"
assert node.used_resources()["object_store_memory"] > 0
assert len(state.idle_nodes) == 0
return True
wait_for_condition(verify)
del obj
import time
time.sleep(1)
def verify():
state = get_cluster_status(gcs_address)
for node in state.idle_nodes:
assert node.node_status == "IDLE"
assert node.used_resources()["object_store_memory"] == 0
assert node.resource_usage.idle_time_ms >= 1000
assert len(state.active_nodes) == 0
return True
wait_for_condition(verify)
@pytest.mark.parametrize("autoscaler_v2", [False, True], ids=["v1", "v2"])
def test_non_corrupted_resources(autoscaler_v2):
"""
Test that when node's local gc happens due to object store pressure,
the message doesn't corrupt the resource view on the gcs.
See issue https://github.com/ray-project/ray/issues/39644
"""
num_worker_nodes = 5
cluster = AutoscalingCluster(
head_resources={"CPU": 2, "object_store_memory": 100 * 1024 * 1024},
worker_node_types={
"type-1": {
"resources": {"CPU": 2},
"node_config": {},
"min_workers": num_worker_nodes,
"max_workers": num_worker_nodes,
},
},
idle_timeout_minutes=999,
autoscaler_v2=autoscaler_v2,
)
driver_script = """
import ray
import time
ray.init("auto")
@ray.remote(num_cpus=1)
def foo():
ray.put(bytearray(1024*1024* 50))
while True:
ray.get([foo.remote() for _ in range(50)])
"""
try:
# This should trigger many COMMANDS messages from NodeManager.
cluster.start(
_system_config={
"debug_dump_period_milliseconds": 10,
"raylet_report_resources_period_milliseconds": 10000,
"global_gc_min_interval_s": 1,
"local_gc_interval_s": 1,
"plasma_store_usage_trigger_gc_threshold": 0.2,
"raylet_check_gc_period_milliseconds": 10,
},
)
ctx = ray.init("auto")
gcs_address = ctx.address_info["gcs_address"]
from ray.autoscaler.v2.sdk import get_cluster_status
def nodes_up():
cluster_state = get_cluster_status(gcs_address)
assert len(cluster_state.idle_nodes) == num_worker_nodes + 1
return True
wait_for_condition(nodes_up, timeout=20)
# Schedule tasks
run_string_as_driver_nonblocking(driver_script)
start = time.time()
# Check the cluster state for 10 seconds
while time.time() - start < 10:
cluster_state = get_cluster_status(gcs_address)
# Verify total cluster resources never change
assert (
len(cluster_state.idle_nodes) + len(cluster_state.active_nodes)
) == num_worker_nodes + 1
assert cluster_state.total_resources()["CPU"] == 2 * (num_worker_nodes + 1)
finally:
ray.shutdown()
cluster.shutdown()
# Helper function to vaidate that a node's labels satisfy a `label_selector`.
def _verify_node_labels_for_selector(
node_labels: Dict[str, str], selector: Dict[str, str]
) -> bool:
for key, value in selector.items():
node_val = node_labels.get(key)
if "!in(" in value:
options_str = value.replace("!in(", "").replace(")", "")
options = {opt.strip() for opt in options_str.split(",")}
if node_val in options:
return False
elif "in(" in value:
options_str = value.replace("in(", "").replace(")", "")
options = {opt.strip() for opt in options_str.split(",")}
if node_val not in options:
return False
elif value.startswith("!"):
if node_val == value[1:]:
return False
else:
if node_val != value:
return False
# If all checks pass for all key-value pairs in the selector, return True.
return True
@pytest.mark.parametrize("autoscaler_v2", [True])
def test_task_scheduled_on_node_with_label_selector(autoscaler_v2):
cluster = AutoscalingCluster(
head_resources={"CPU": 0},
worker_node_types={
"node1": {
"resources": {"CPU": 1},
"node_config": {},
"labels": {"accelerator-type": "A100", "market-type": "spot"},
"min_workers": 0,
"max_workers": 1,
},
"node2": {
"resources": {"CPU": 1},
"node_config": {},
"labels": {
"region": "us-east1",
"accelerator-type": "TPU",
"market-type": "spot",
},
"min_workers": 0,
"max_workers": 1,
},
"node3": {
"resources": {"CPU": 1},
"node_config": {},
"labels": {"accelerator-type": "B200", "market-type": "spot"},
"min_workers": 0,
"max_workers": 1,
},
"node4": {
"resources": {"CPU": 1},
"node_config": {},
"labels": {"market-type": "on-demand", "accelerator-type": "TPU"},
"min_workers": 0,
"max_workers": 1,
},
},
idle_timeout_minutes=999,
autoscaler_v2=autoscaler_v2,
)
driver_script = """
import ray
import time
@ray.remote(num_cpus=1)
def labels_task():
time.sleep(20)
return True
ray.init("auto")
label_selectors = [
{"accelerator-type": "A100"},
{"region": "in(us-east1,me-central1)"},
{"accelerator-type": "!in(A100,TPU)"},
{"market-type": "!spot"},
]
results = [
labels_task.options(name=f"task_{i}", label_selector=sel).remote()
for i, sel in enumerate(label_selectors)
]
assert all(ray.get(results))
"""
try:
cluster.start()
ray.init("auto")
gcs_address = ray.get_runtime_context().gcs_address
expected_nodes = 4
def all_tasks_submitted():
return len(list_tasks()) == expected_nodes
proc = run_string_as_driver_nonblocking(driver_script)
wait_for_condition(all_tasks_submitted)
def all_nodes_launched():
status = get_cluster_status(gcs_address)
return len(status.active_nodes) == expected_nodes
wait_for_condition(all_nodes_launched, timeout=30)
proc.wait(timeout=30)
assert proc.returncode == 0, "The driver script failed."
# Validate Tasks are scheduled on nodes with required labels.
tasks_by_name = {
task.name: task for task in list_tasks(detail=True) if hasattr(task, "name")
}
nodes = {node["NodeID"]: node["Labels"] for node in ray.nodes()}
task_selectors = {
"task_0": {"accelerator-type": "A100"},
"task_1": {"region": "in(me-central1,us-east1)"},
"task_2": {"accelerator-type": "!in(A100,TPU)"},
"task_3": {"market-type": "!spot"},
}
for task_name, expected_selector in task_selectors.items():
assert (
task_name in tasks_by_name
), f"Task with name '{task_name}' was not found."
task = tasks_by_name[task_name]
# Verify actual label selector from the Task matches the expected.
actual_selector = task.get("label_selector")
assert (
actual_selector is not None
), f"Task '{task_name}' did not have a 'label_selector' field."
assert actual_selector == expected_selector, (
f"Task '{task_name}' has an incorrect label selector. "
f"Expected: {expected_selector}, Got: {actual_selector}"
)
# Verify Ray node created for Task.
node_id = task["node_id"]
assert (
node_id in nodes
), f"Node with ID '{node_id}' for task '{task_name}' was not found."
# Validate node labels satisfy `label_selector` for Task.
node_labels = nodes[node_id]
assert _verify_node_labels_for_selector(
node_labels, actual_selector
), f"Verification failed for task '{task_name}' on node '{node_id}'"
finally:
ray.shutdown()
cluster.shutdown()
@pytest.mark.parametrize("autoscaler_v2", [True])
def test_actor_scheduled_on_node_with_label_selector(autoscaler_v2):
cluster = AutoscalingCluster(
head_resources={"CPU": 0},
worker_node_types={
"node1": {
"resources": {"CPU": 1},
"node_config": {},
"labels": {"accelerator-type": "A100", "market-type": "spot"},
"min_workers": 0,
"max_workers": 1,
},
"node2": {
"resources": {"CPU": 1},
"node_config": {},
"labels": {
"region": "us-east1",
"accelerator-type": "TPU",
"market-type": "spot",
},
"min_workers": 0,
"max_workers": 1,
},
"node3": {
"resources": {"CPU": 1},
"node_config": {},
"labels": {"accelerator-type": "B200", "market-type": "spot"},
"min_workers": 0,
"max_workers": 1,
},
"node4": {
"resources": {"CPU": 1},
"node_config": {},
"labels": {"market-type": "on-demand", "accelerator-type": "TPU"},
"min_workers": 0,
"max_workers": 1,
},
},
idle_timeout_minutes=999,
autoscaler_v2=autoscaler_v2,
)
driver_script = """
import ray
@ray.remote(num_cpus=1)
class Actor:
def ready(self):
return True
ray.init("auto")
label_selectors = [
{"accelerator-type": "A100"},
{"region": "in(us-east1,me-central1)"},
{"accelerator-type": "!in(A100,TPU)"},
{"market-type": "!spot"},
]
actors = [
Actor.options(name=f"actor_{i}", label_selector=sel).remote()
for i, sel in enumerate(label_selectors)
]
ray.get([a.ready.remote() for a in actors])
"""
try:
cluster.start()
ray.init("auto")
gcs_address = ray.get_runtime_context().gcs_address
expected_nodes = 4
def all_actors_submitted():
return len(list_actors()) == expected_nodes
proc = run_string_as_driver_nonblocking(driver_script)
wait_for_condition(all_actors_submitted)
def all_actors_scheduled():
# Verify the nodes launched for the Actors are as expected.
status = get_cluster_status(gcs_address)
if len(status.active_nodes) != expected_nodes:
return False
active_node_types = {
node.ray_node_type_name for node in status.active_nodes
}
expected_node_types = {"node1", "node2", "node3", "node4"}
return active_node_types == expected_node_types
# All Actors with label selectors should be scheduled, scaling
# 4 nodes with the required labels.
wait_for_condition(all_actors_scheduled, timeout=30)
proc.wait(timeout=30)
assert proc.returncode == 0, "The driver script failed to submit actors."
# Finally, validate the Actors are scheduled on the node with matching labels.
actors_by_name = {
actor.name: actor
for actor in list_actors(detail=True)
if hasattr(actor, "name")
}
nodes = {node["NodeID"]: node["Labels"] for node in ray.nodes()}
actor_selectors = {
"actor_0": {"accelerator-type": "A100"},
"actor_1": {"region": "in(me-central1,us-east1)"},
"actor_2": {"accelerator-type": "!in(A100,TPU)"},
"actor_3": {"market-type": "!spot"},
}
for actor_name, expected_selector in actor_selectors.items():
assert (
actor_name in actors_by_name
), f"Actor with name '{actor_name}' was not found."
actor = actors_by_name[actor_name]
# Verify actual label selector from the Actor matches the expected.
actual_selector = actor.get("label_selector")
assert (
actual_selector is not None
), f"Actor '{actor_name}' did not have a 'label_selector' field."
assert actual_selector == expected_selector, (
f"Actor '{actor_name}' has an incorrect label selector. "
f"Expected: {expected_selector}, Got: {actual_selector}"
)
# Verify Ray node created for Actor.
node_id = actor["node_id"]
assert (
node_id in nodes
), f"Node with ID '{node_id}' for Actor '{actor_name}' was not found."
# Validate node labels satisfy `label_selector` for Actor.
node_labels = nodes[node_id]
assert _verify_node_labels_for_selector(
node_labels, actual_selector
), f"Verification failed for Actor '{actor_name}' on node '{node_id}'"
finally:
ray.shutdown()
cluster.shutdown()
@pytest.mark.parametrize("autoscaler_v2", [True])
def test_pg_scheduled_on_node_with_bundle_label_selector(autoscaler_v2):
cluster = AutoscalingCluster(
head_resources={"CPU": 0},
worker_node_types={
"unlabelled_node": {
"resources": {"CPU": 1, "GPU": 1, "TPU": 1},
"node_config": {},
"min_workers": 0,
"max_workers": 1,
},
"not_matching_labels": {
"resources": {"CPU": 1},
"labels": {"unrelated": "labels"},
"node_config": {},
"min_workers": 0,
"max_workers": 1,
},
"a100_node": {
"resources": {"CPU": 1, "GPU": 1},
"node_config": {},
"labels": {"accelerator-type": "A100"},
"min_workers": 0,
"max_workers": 1,
},
"tpu_node": {
"resources": {"CPU": 1, "TPU": 1},
"node_config": {},
"labels": {"accelerator-type": "TPU_V6E"},
"min_workers": 0,
"max_workers": 1,
},
},
idle_timeout_minutes=999,
autoscaler_v2=autoscaler_v2,
)
try:
cluster.start()
ray.init("auto")
gcs_address = ray.get_runtime_context().gcs_address
# We expect one GPU and one TPU node to scale.
expected_nodes = 2
# Define a placement group where each bundle should scale a node of a different type.
pg = placement_group(
name="label_selector_pg",
bundles=[
{"CPU": 1},
{"CPU": 1},
],
bundle_label_selector=[
{"accelerator-type": "A100"}, # a100_node
{"accelerator-type": "TPU_V6E"}, # tpu_node
],
strategy="SPREAD",
)
# Wait for the placement group to be ready.
ray.get(pg.ready())
# Validate the number and types of the auto-scaled nodes are as expected.
# Add a wait here to avoid flaky test behavior.
def check_nodes_active():
status = get_cluster_status(gcs_address)
return len(status.active_nodes) == expected_nodes
try:
wait_for_condition(check_nodes_active, timeout=30, retry_interval_ms=500)
except Exception as e:
latest_status = get_cluster_status(gcs_address)
raise AssertionError(
f"Timed out waiting for {expected_nodes} active nodes. "
f"Got: {len(latest_status.active_nodes)}. "
f"Full status: {latest_status}"
) from e
status = get_cluster_status(gcs_address)
actual_node_types = {node.ray_node_type_name for node in status.active_nodes}
expected_node_types = {"a100_node", "tpu_node"}
assert actual_node_types == expected_node_types
# Validate the placement group is scheduled to nodes with the required labels.
pgs = list_placement_groups(detail=True)
assert len(pgs) == 1
pg_state = pgs[0]
bundles_list = pg_state.bundles
assert (
bundles_list is not None
), "PlacementGroupState did not have a 'bundles' field."
actual_bundle_selectors = []
for bundle in bundles_list:
actual_bundle_selectors.append(bundle["label_selector"])
expected_bundle_selectors = [
{"accelerator-type": "A100"},
{"accelerator-type": "TPU_V6E"},
]
assert actual_bundle_selectors == expected_bundle_selectors, (
f"Placement group has incorrect bundle selectors. "
f"Expected: {expected_bundle_selectors}, Got: {actual_bundle_selectors}"
)
nodes = {node["NodeID"]: node["Labels"] for node in ray.nodes()}
for bundle_index, bundle in enumerate(bundles_list):
# Verify bundle placed on expected node.
bundle_node_id = bundle.get("node_id")
assert (
bundle_node_id in nodes
), f"Node with ID '{bundle_node_id}' for bundle {bundle_index} was not found."
# Verify node's labels satisfy the bundle's label_selector.
bundle_selector = actual_bundle_selectors[bundle_index]
node_labels = nodes[bundle_node_id]
assert _verify_node_labels_for_selector(node_labels, bundle_selector)
finally:
ray.shutdown()
cluster.shutdown()
def test_priority_selection_e2e():
cluster = AutoscalingCluster(
head_resources={"CPU": 0},
worker_node_types={
"high-priority": {
"resources": {"CPU": 1},
"node_config": {},
"priority": 10,
"min_workers": 0,
"max_workers": 1,
},
"low-priority": {
"resources": {"CPU": 1},
"node_config": {},
"priority": 0,
"min_workers": 0,
"max_workers": 1,
},
},
autoscaler_v2=True,
)
try:
cluster.start()
ray.init("auto")
gcs_address = ray.get_runtime_context().gcs_address
@ray.remote(num_cpus=1)
def foo():
import time
time.sleep(5)
return True
# Submit a task
foo.remote()
def high_priority_node_launched():
status = get_cluster_status(gcs_address)
active_node_types = {
node.ray_node_type_name for node in status.active_nodes
}
assert "low-priority" not in active_node_types
return "high-priority" in active_node_types
# Wait for the high priority node to be launched
wait_for_condition(high_priority_node_launched, timeout=30)
finally:
ray.shutdown()
cluster.shutdown()
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__]))