Files
ray-project--ray/python/ray/serve/tests/test_deployment_scheduler.py
2026-07-13 13:17:40 +08:00

853 lines
32 KiB
Python

import sys
import pytest
import ray
from ray import serve
from ray._common.test_utils import wait_for_condition
from ray._raylet import GcsClient
from ray.serve._private import default_impl
from ray.serve._private.common import DeploymentID, ReplicaID
from ray.serve._private.constants import RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY
from ray.serve._private.deployment_scheduler import (
ReplicaSchedulingRequest,
SpreadDeploymentSchedulingPolicy,
)
from ray.serve._private.test_utils import check_apps_running, get_node_id
from ray.serve._private.utils import get_head_node_id
from ray.tests.conftest import * # noqa
@ray.remote(num_cpus=1)
class Replica:
def get_node_id(self):
return ray.get_runtime_context().get_node_id()
def get_placement_group(self):
return ray.util.get_current_placement_group()
@pytest.mark.skipif(
RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY, reason="Need to use spread strategy"
)
class TestSpreadScheduling:
@pytest.mark.parametrize(
"placement_group_config",
[
{},
{"bundles": [{"CPU": 3}]},
{
"bundles": [{"CPU": 1}, {"CPU": 1}, {"CPU": 1}],
"strategy": "STRICT_PACK",
},
],
)
def test_spread_deployment_scheduling_policy_upscale(
self, ray_start_cluster, placement_group_config
):
"""Test to make sure replicas are spreaded."""
cluster = ray_start_cluster
cluster.add_node(num_cpus=3)
cluster.add_node(num_cpus=3)
cluster.wait_for_nodes()
ray.init(address=cluster.address)
cluster_node_info_cache = default_impl.create_cluster_node_info_cache(
GcsClient(address=ray.get_runtime_context().gcs_address)
)
cluster_node_info_cache.update()
scheduler = default_impl.create_deployment_scheduler(
cluster_node_info_cache,
get_head_node_id(),
)
dep_id = DeploymentID(name="deployment1")
r1_id = ReplicaID(unique_id="replica1", deployment_id=dep_id)
r2_id = ReplicaID(unique_id="replica2", deployment_id=dep_id)
scheduler.on_deployment_created(dep_id, SpreadDeploymentSchedulingPolicy())
replica_actor_handles = []
replica_placement_groups = []
def on_scheduled(actor_handle, placement_group):
replica_actor_handles.append(actor_handle)
replica_placement_groups.append(placement_group)
deployment_to_replicas_to_stop = scheduler.schedule(
upscales={
dep_id: [
ReplicaSchedulingRequest(
replica_id=r1_id,
actor_def=Replica,
actor_resources={"CPU": 1},
actor_options={"name": "deployment1_replica1"},
actor_init_args=(),
on_scheduled=on_scheduled,
placement_group_bundles=placement_group_config.get(
"bundles", None
),
placement_group_strategy=placement_group_config.get(
"strategy", None
),
),
ReplicaSchedulingRequest(
replica_id=r2_id,
actor_def=Replica,
actor_resources={"CPU": 1},
actor_options={"name": "deployment1_replica2"},
actor_init_args=(),
on_scheduled=on_scheduled,
placement_group_bundles=placement_group_config.get(
"bundles", None
),
placement_group_strategy=placement_group_config.get(
"strategy", None
),
),
]
},
downscales={},
)
assert not deployment_to_replicas_to_stop
assert len(replica_actor_handles) == 2
assert len(replica_placement_groups) == 2
assert not scheduler._pending_replicas[dep_id]
assert len(scheduler._launching_replicas[dep_id]) == 2
assert (
len(
{
ray.get(replica_actor_handles[0].get_node_id.remote()),
ray.get(replica_actor_handles[1].get_node_id.remote()),
}
)
== 2
)
if "bundles" in placement_group_config:
assert (
len(
{
ray.get(replica_actor_handles[0].get_placement_group.remote()),
ray.get(replica_actor_handles[1].get_placement_group.remote()),
}
)
== 2
)
scheduler.on_replica_stopping(r1_id)
scheduler.on_replica_stopping(r2_id)
scheduler.on_deployment_deleted(dep_id)
@pytest.mark.asyncio
async def test_spread_serve_strict_spread_pg(self, ray_cluster):
"""
Verifies STRICT_SPREAD PG strategy runs successfully in the Spread Scheduler
and spreads bundles across distinct nodes.
"""
cluster = ray_cluster
cluster.add_node(num_cpus=3)
cluster.add_node(num_cpus=3)
cluster.wait_for_nodes()
ray.init(address=cluster.address)
serve.start()
@ray.remote(num_cpus=0)
def get_task_node_id():
return ray.get_runtime_context().get_node_id()
@serve.deployment(
placement_group_bundles=[{"CPU": 1}, {"CPU": 1}],
placement_group_strategy="STRICT_SPREAD",
)
class StrictSpread:
async def get_bundle_node_id(self, bundle_index: int):
pg = ray.util.get_current_placement_group()
return await get_task_node_id.options(
scheduling_strategy=ray.util.scheduling_strategies.PlacementGroupSchedulingStrategy(
placement_group=pg,
placement_group_bundle_index=bundle_index,
)
).remote()
handle = serve.run(StrictSpread.bind(), name="strict_spread_app")
node_0 = await handle.get_bundle_node_id.remote(0)
node_1 = await handle.get_bundle_node_id.remote(1)
assert node_0 != node_1
serve.delete("strict_spread_app")
serve.shutdown()
@serve.deployment
def A():
return ray.get_runtime_context().get_node_id()
app_A = A.bind()
@pytest.mark.skipif(
not RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY, reason="Needs pack strategy."
)
class TestPackScheduling:
@pytest.mark.parametrize("use_pg", [True, False])
def test_e2e_basic(self, ray_cluster, use_pg: bool):
cluster = ray_cluster
cluster.add_node(num_cpus=2, resources={"head": 1})
cluster.add_node(num_cpus=3, resources={"worker1": 1})
cluster.add_node(num_cpus=4, resources={"worker2": 1})
cluster.wait_for_nodes()
ray.init(address=cluster.address)
head_node_id = ray.get(get_node_id.options(resources={"head": 1}).remote())
worker1_node_id = ray.get(
get_node_id.options(resources={"worker1": 1}).remote()
)
worker2_node_id = ray.get(
get_node_id.options(resources={"worker2": 1}).remote()
)
print("head", head_node_id)
print("worker1", worker1_node_id)
print("worker2", worker2_node_id)
# Both f replicas should be scheduled on head node to minimize
# fragmentation
if use_pg:
app1 = A.options(
num_replicas=2,
ray_actor_options={"num_cpus": 0.1},
placement_group_bundles=[{"CPU": 0.5}, {"CPU": 0.5}],
placement_group_strategy="STRICT_PACK",
).bind()
else:
app1 = A.options(num_replicas=2, ray_actor_options={"num_cpus": 1}).bind()
# Both app1 replicas should have been scheduled on head node
f_handle = serve.run(app1, name="app1", route_prefix="/app1")
refs = [f_handle.remote() for _ in range(20)]
assert {ref.result() for ref in refs} == {head_node_id}
if use_pg:
app2 = A.options(
num_replicas=1,
ray_actor_options={"num_cpus": 0.1},
placement_group_bundles=[{"CPU": 1}, {"CPU": 2}],
placement_group_strategy="STRICT_PACK",
).bind()
else:
app2 = A.options(num_replicas=1, ray_actor_options={"num_cpus": 3}).bind()
# Then there should be enough space for the g replica
# The g replica should be scheduled on worker1, not worker2, to
# minimize fragmentation
g_handle = serve.run(app2, name="app2", route_prefix="/app2")
assert g_handle.remote().result() == worker1_node_id
serve.shutdown()
@pytest.mark.parametrize("use_pg", [True, False])
@pytest.mark.parametrize(
"app_resources,expected_worker_nodes",
[
# [2, 5, 3, 3, 7, 6, 4] -> 3 nodes
({5: 1, 3: 2, 7: 1, 2: 1, 6: 1, 4: 1}, 3),
# [1, 7, 7, 3, 2] -> 2 nodes
({1: 1, 7: 2, 3: 1, 2: 1}, 2),
# [7, 3, 2, 7, 7, 2] -> 3 nodes
({7: 3, 3: 1, 2: 2}, 3),
],
)
def test_e2e_fit_replicas(
self, ray_cluster, use_pg, app_resources, expected_worker_nodes
):
for _ in range(expected_worker_nodes):
ray_cluster.add_node(num_cpus=1)
ray_cluster.wait_for_nodes()
ray.init(address=ray_cluster.address)
serve.start()
@serve.deployment
def A():
return ray.get_runtime_context().get_node_id()
@serve.deployment(ray_actor_options={"num_cpus": 0})
class Ingress:
def __init__(self, *handles):
self.handles = handles
def __call__(self):
pass
deployments = []
for n, count in app_resources.items():
num_cpus = 0.1 * n
deployments.append(
A.options(
name=f"A{n}",
num_replicas=count,
ray_actor_options={"num_cpus": 0 if use_pg else num_cpus},
placement_group_bundles=[{"CPU": num_cpus}] if use_pg else None,
placement_group_strategy="STRICT_PACK" if use_pg else None,
).bind()
)
serve.run(Ingress.bind(*deployments))
wait_for_condition(check_apps_running, apps=["default"])
print("Test passed!")
@pytest.mark.parametrize("use_pg", [True, False])
def test_e2e_custom_resources(self, ray_cluster, use_pg):
cluster = ray_cluster
cluster.add_node(num_cpus=1, resources={"head": 1})
cluster.add_node(num_cpus=3, resources={"worker1": 1, "customabcd": 1})
cluster.wait_for_nodes()
ray.init(address=cluster.address)
worker1_node_id = ray.get(
get_node_id.options(resources={"worker1": 1}).remote()
)
if use_pg:
app = A.options(
num_replicas=1,
ray_actor_options={"num_cpus": 0},
placement_group_bundles=[{"CPU": 0.5}, {"CPU": 0.5, "customabcd": 0.1}],
placement_group_strategy="STRICT_PACK",
).bind()
else:
app = A.options(
num_replicas=1,
ray_actor_options={"num_cpus": 1, "resources": {"customabcd": 0.1}},
).bind()
handle1 = serve.run(app, name="app1", route_prefix="/app1")
refs = [handle1.remote() for _ in range(20)]
assert all(ref.result() == worker1_node_id for ref in refs)
serve.shutdown()
def test_high_priority_memory_schedules_before_cpu_hogs(
self, ray_cluster, monkeypatch
):
"""Memory in RAY_SERVE_HIGH_PRIORITY_CUSTOM_RESOURCES overrides CPU priority.
Pack scheduling sorts pending replicas by ``Resources.__lt__``. By default
CPU is compared before memory, so a deployment with higher ``num_cpus``
is scheduled first when only one replica fits on the node.
Here each ``cpu_i`` requests ``replica_cpus`` CPUs while ``memory_hog``
requests ``replica_cpus - 1`` CPUs plus most of the node memory. Without
the env var, a ``cpu_i`` deployment would win; with
``RAY_SERVE_HIGH_PRIORITY_CUSTOM_RESOURCES=memory``, ``memory_hog`` must
be the only deployment that reaches RUNNING.
"""
monkeypatch.setenv("RAY_SERVE_HIGH_PRIORITY_CUSTOM_RESOURCES", "memory")
cluster = ray_cluster
cluster.add_node(num_cpus=4)
cluster.wait_for_nodes()
ray.init(address=cluster.address)
total_memory = int(ray.cluster_resources()["memory"])
high_memory = max(int(total_memory * 0.9), 1)
@serve.deployment
def replica_fn():
return "ok"
@serve.deployment(ray_actor_options={"num_cpus": 0})
class Ingress:
def __init__(self, *handles):
self.handles = handles
def __call__(self):
pass
# Reserve head/proxy CPU by measuring what Serve leaves available.
serve.start()
replica_cpus = int(ray.available_resources()["CPU"])
assert replica_cpus >= 2, "Need at least 2 CPUs for cpu vs memory_hog split"
memory_hog_cpus = replica_cpus - 1
deployments = []
for i in range(9):
deployments.append(
replica_fn.options(
name=f"cpu_{i}",
# Higher CPU than memory_hog: would sort first by default.
ray_actor_options={"num_cpus": replica_cpus},
).bind()
)
deployments.append(
replica_fn.options(
name="memory_hog",
ray_actor_options={
"num_cpus": memory_hog_cpus,
"memory": high_memory,
},
).bind()
)
serve._run(Ingress.bind(*deployments), _blocking=False)
def check_only_memory_hog_running():
app_status = serve.status().applications["default"]
if app_status.status == "DEPLOY_FAILED":
raise AssertionError(f"App failed: {app_status.message}")
deployments_status = app_status.deployments
memory_hog = deployments_status["memory_hog"]
assert memory_hog.replica_states.get("RUNNING", 0) == 1, memory_hog
for i in range(9):
cpu_dep = deployments_status[f"cpu_{i}"]
running = cpu_dep.replica_states.get("RUNNING", 0)
assert running == 0, (f"cpu_{i}", cpu_dep.replica_states)
return True
wait_for_condition(check_only_memory_hog_running, timeout=60)
app_status = serve.status().applications["default"]
assert app_status.status == "DEPLOYING"
# Add a second node: pack logs should show a new schedule-order batch
# and one cpu_* replica placed on the new node while memory_hog stays up.
cluster.add_node(num_cpus=4)
cluster.wait_for_nodes()
def check_one_cpu_replica_after_scale_out():
app_status = serve.status().applications["default"]
if app_status.status == "DEPLOY_FAILED":
raise AssertionError(f"App failed: {app_status.message}")
deployments_status = app_status.deployments
assert (
deployments_status["memory_hog"].replica_states.get("RUNNING", 0) == 1
)
cpu_running = sum(
deployments_status[f"cpu_{i}"].replica_states.get("RUNNING", 0)
for i in range(9)
)
assert cpu_running == 1, {
f"cpu_{i}": deployments_status[f"cpu_{i}"].replica_states
for i in range(9)
}
return True
wait_for_condition(check_one_cpu_replica_after_scale_out, timeout=60)
app_status = serve.status().applications["default"]
assert app_status.status == "DEPLOYING"
serve.shutdown()
@pytest.mark.asyncio
async def test_e2e_serve_strict_pack_pg_label_selector(
self, serve_instance_with_labeled_nodes
):
"""
Verifies STRICT_PACK strategy with placement_group_bundle_label_selector in Pack Scheduling Mode.
Since the strategy is STRICT_PACK, both bundles must be scheduled on the same node,
and that node must satisfy the label constraints in each selector.
"""
_, _, us_east_node_id, _ = serve_instance_with_labeled_nodes
@ray.remote(num_cpus=0)
def get_task_node_id():
return ray.get_runtime_context().get_node_id()
@serve.deployment(
placement_group_bundles=[{"CPU": 1}, {"CPU": 1}],
placement_group_strategy="STRICT_PACK",
placement_group_bundle_label_selector=[
{"gpu-type": "H100", "region": "us-east"}
],
)
class StrictPackSelector:
async def get_bundle_node_id(self, bundle_index: int):
pg = ray.util.get_current_placement_group()
return await get_task_node_id.options(
scheduling_strategy=ray.util.scheduling_strategies.PlacementGroupSchedulingStrategy(
placement_group=pg,
placement_group_bundle_index=bundle_index,
)
).remote()
handle = serve.run(StrictPackSelector.bind(), name="strict_pack_app")
# Both bundles are scheduled to the same node which matches the label constraints.
assert await handle.get_bundle_node_id.remote(0) == us_east_node_id
assert await handle.get_bundle_node_id.remote(1) == us_east_node_id
serve.delete("strict_pack_app")
@pytest.mark.asyncio
async def test_e2e_serve_pack_pg_forces_spread(
self, serve_instance_with_labeled_nodes
):
"""
Verifies that using non-strict PACK PG strategy with label selectors works.
STRICT_PACK throws NotImplementedError for selectors. However, 'PACK' is considered a
'Non-Strict' strategy which forces the scheduler to fall back to 'Spread Mode'.
"""
_, _, us_east_node_id, _ = serve_instance_with_labeled_nodes
@serve.deployment(
placement_group_bundles=[{"CPU": 1}],
placement_group_strategy="PACK",
placement_group_bundle_label_selector=[{"gpu-type": "H100"}],
)
class PackSelector:
def get_node_id(self):
return ray.get_runtime_context().get_node_id()
# If this stayed in the Pack Scheduler, it would raise NotImplementedError.
# Because it forces Spread Mode, it succeeds.
handle = serve.run(PackSelector.bind(), name="pack_selector_app")
assert await handle.get_node_id.remote() == us_east_node_id
serve.delete("pack_selector_app")
@pytest.mark.asyncio
async def test_e2e_serve_multiple_bundles_selector(
self, serve_instance_with_labeled_nodes
):
"""Verifies multiple bundles with bundle_label_selector are applied correctly."""
_, us_west_node_id, us_east_node_id, _ = serve_instance_with_labeled_nodes
# Helper task to return the node ID it's running on
@ray.remote(num_cpus=0)
def get_task_node_id():
return ray.get_runtime_context().get_node_id()
@serve.deployment(
placement_group_bundles=[{"CPU": 1}, {"CPU": 1}],
placement_group_strategy="SPREAD",
placement_group_bundle_label_selector=[
{"gpu-type": "H100"}, # matches us-east node
{"gpu-type": "A100"}, # matches us-west node
],
)
class MultiBundleSelector:
async def get_bundle_node_id(self, bundle_index: int):
pg = ray.util.get_current_placement_group()
return await get_task_node_id.options(
scheduling_strategy=ray.util.scheduling_strategies.PlacementGroupSchedulingStrategy(
placement_group=pg,
placement_group_bundle_index=bundle_index,
)
).remote()
handle = serve.run(MultiBundleSelector.bind(), name="multi_bundle_app")
# Verify bundles are scheduled to expected nodes based on label selectors.
assert await handle.get_bundle_node_id.remote(0) == us_east_node_id
assert await handle.get_bundle_node_id.remote(1) == us_west_node_id
serve.delete("multi_bundle_app")
@pytest.mark.asyncio
async def test_e2e_serve_multiple_bundles_single_bundle_label_selector(
self, serve_instance_with_labeled_nodes
):
"""
Verifies that when only one bundle_label_selector is provided for multiple bundles,
the label_selector is applied to each bundle uniformly.
"""
_, _, us_east_node_id, _ = serve_instance_with_labeled_nodes
@ray.remote(num_cpus=0)
def get_task_node_id():
return ray.get_runtime_context().get_node_id()
@serve.deployment(
placement_group_bundles=[{"CPU": 1}, {"CPU": 1}],
# Use SPREAD to verify the label constraint forces them to same node.
placement_group_strategy="SPREAD",
placement_group_bundle_label_selector=[
{"gpu-type": "H100"},
],
)
class MultiBundleSelector:
async def get_bundle_node_id(self, bundle_index: int):
pg = ray.util.get_current_placement_group()
return await get_task_node_id.options(
scheduling_strategy=ray.util.scheduling_strategies.PlacementGroupSchedulingStrategy(
placement_group=pg,
placement_group_bundle_index=bundle_index,
)
).remote()
handle = serve.run(MultiBundleSelector.bind(), name="multi_bundle_app")
assert await handle.get_bundle_node_id.remote(0) == us_east_node_id
assert await handle.get_bundle_node_id.remote(1) == us_east_node_id
serve.delete("multi_bundle_app")
@pytest.mark.asyncio
async def test_e2e_serve_actor_multiple_fallbacks(
self, serve_instance_with_labeled_nodes
):
"""
Verifies that the scheduler can iterate through a label selector and multiple fallback options.
"""
_, us_west_node_id, _, _ = serve_instance_with_labeled_nodes
@serve.deployment(
ray_actor_options={
"label_selector": {"region": "invalid-label-1"},
"fallback_strategy": [
{"label_selector": {"region": "invalid-label-2"}},
{"label_selector": {"region": "us-west"}}, # Should match
],
}
)
class MultiFallbackActor:
def get_node_id(self):
return ray.get_runtime_context().get_node_id()
handle = serve.run(MultiFallbackActor.bind(), name="multi_fallback_app")
assert await handle.get_node_id.remote() == us_west_node_id
serve.delete("multi_fallback_app")
@pytest.mark.asyncio
async def test_e2e_serve_label_selector(serve_instance_with_labeled_nodes):
"""
Verifies that label selectors work correctly for both Actors and Placement Groups.
This test also verifies that label selectors are respected when scheduling with a
preferred node ID for resource compaction. This test verifies both the Pack and
Spread scheduler paths.
"""
_, us_west_node_id, us_east_node_id, _ = serve_instance_with_labeled_nodes
# Validate a Serve deplyoment utilizes a label_selector when passed to the Ray Actor options.
@serve.deployment(ray_actor_options={"label_selector": {"region": "us-west"}})
class DeploymentActor:
def get_node_id(self):
return ray.get_runtime_context().get_node_id()
handle = serve.run(DeploymentActor.bind(), name="actor_app")
assert await handle.get_node_id.remote() == us_west_node_id
serve.delete("actor_app")
# Validate placement_group scheduling strategy with placement_group_bundle_label_selector
# and PACK strategy.
@serve.deployment(
placement_group_bundles=[{"CPU": 1}],
placement_group_strategy="PACK",
placement_group_bundle_label_selector=[{"gpu-type": "H100"}],
)
class DeploymentPGPack:
def get_node_id(self):
return ray.get_runtime_context().get_node_id()
handle_pack = serve.run(DeploymentPGPack.bind(), name="pg_pack_app")
assert await handle_pack.get_node_id.remote() == us_east_node_id
serve.delete("pg_pack_app")
# Validate placement_group scheduling strategy with placement_group_bundle_label_selector
# and SPREAD strategy.
@serve.deployment(
placement_group_bundles=[{"CPU": 1}],
placement_group_strategy="SPREAD",
placement_group_bundle_label_selector=[{"gpu-type": "H100"}],
)
class DeploymentPGSpread:
def get_node_id(self):
return ray.get_runtime_context().get_node_id()
handle_spread = serve.run(DeploymentPGSpread.bind(), name="pg_spread_app")
assert await handle_spread.get_node_id.remote() == us_east_node_id
serve.delete("pg_spread_app")
@pytest.mark.asyncio
async def test_e2e_serve_fallback_strategy(serve_instance_with_labeled_nodes):
"""
Verifies that fallback strategies allow scheduling on alternative nodes when
primary constraints fail.
"""
_, _, h100_node_id, _ = serve_instance_with_labeled_nodes
# Fallback strategy specified for Ray Actor in Serve deployment.
@serve.deployment(
ray_actor_options={
"label_selector": {"region": "unavailable"},
"fallback_strategy": [{"label_selector": {"gpu-type": "H100"}}],
}
)
class FallbackDeployment:
def get_node_id(self):
return ray.get_runtime_context().get_node_id()
# TODO (ryanaoleary@): Add a test for fallback_strategy in placement group options
# when support is added.
handle = serve.run(FallbackDeployment.bind(), name="fallback_app")
assert await handle.get_node_id.remote() == h100_node_id
serve.delete("fallback_app")
@pytest.mark.asyncio
@pytest.mark.parametrize(
"use_pg,strategy",
[
(False, None), # Actor-level label_selector.
(True, "PACK"), # PG bundle_label_selector with PACK strategy.
(True, "STRICT_PACK"), # PG bundle_label_selector with STRICT_PACK strategy.
(True, "SPREAD"), # PG bundle_label_selector with SPREAD strategy.
(
True,
"STRICT_SPREAD",
), # PG bundle_label_selector with STRICT_SPREAD strategy.
],
)
async def test_e2e_serve_label_selector_unschedulable(
serve_instance_with_labeled_nodes, use_pg, strategy
):
"""
Verifies the interaction between unschedulable a placement_group_bundle_label_selector
and different scheduling strategies in the Pack and Spread Serve scheduler.
"""
_, _, _, cluster = serve_instance_with_labeled_nodes
@serve.deployment
def A():
return ray.get_runtime_context().get_node_id()
# Cluster in fixture only contains us-west and us-east.
target_label = {"region": "eu-central"}
if use_pg:
app = A.options(
num_replicas=1,
placement_group_bundles=[{"CPU": 1}],
placement_group_strategy=strategy,
placement_group_bundle_label_selector=[target_label],
).bind()
else:
app = A.options(
num_replicas=1,
ray_actor_options={"label_selector": target_label},
).bind()
handle = serve._run(app, name="unschedulable_label_app", _blocking=False)
def check_status(expected_status):
try:
status_info = serve.status().applications["unschedulable_label_app"]
return status_info.status == expected_status
except KeyError:
return False
def verify_resource_request_stuck():
"""Verifies that the underlying resource request is pending."""
# Serve deployment should be stuck DEPLOYING.
if not check_status("DEPLOYING"):
return False
# Check PG/Actor is actually pending.
if use_pg:
pgs = ray.util.state.list_placement_groups()
return any(pg["state"] == "PENDING" for pg in pgs)
else:
actors = ray.util.state.list_actors()
return any(a["state"] == "PENDING_CREATION" for a in actors)
# Serve deployment should remain stuck in deploying because Actor/PG can't be scheduled.
wait_for_condition(verify_resource_request_stuck, timeout=30)
assert not check_status("RUNNING"), (
"Test setup failed: The deployment became RUNNING before the required "
"node was added. The label selector constraint was ignored."
)
# Add a suitable node to the cluster.
new_node = cluster.add_node(
num_cpus=2, labels=target_label, resources={"target_node": 1}
)
cluster.wait_for_nodes()
expected_node_id = ray.get(
get_node_id.options(resources={"target_node": 1}).remote()
)
# Validate deployment can now be scheduled since label selector is satisfied.
wait_for_condition(lambda: check_status("RUNNING"), timeout=30)
assert await handle.remote() == expected_node_id
serve.delete("unschedulable_label_app")
cluster.remove_node(new_node)
@pytest.mark.asyncio
async def test_e2e_serve_fallback_strategy_unschedulable(
serve_instance_with_labeled_nodes,
):
"""
Verifies that an unschedulable fallback_strategy causes the Serve deployment to wait
until a suitable node is added to the cluster.
"""
_, _, _, cluster = serve_instance_with_labeled_nodes
@serve.deployment
def A():
return ray.get_runtime_context().get_node_id()
fallback_label = {"region": "me-central2"}
app = A.options(
num_replicas=1,
ray_actor_options={
"label_selector": {"region": "non-existant"},
"fallback_strategy": [{"label_selector": fallback_label}],
},
).bind()
handle = serve._run(app, name="unschedulable_fallback_app", _blocking=False)
def check_status(expected_status):
try:
status_info = serve.status().applications["unschedulable_fallback_app"]
return status_info.status == expected_status
except KeyError:
return False
def verify_resource_request_stuck():
"""Verifies that the underlying resource request is pending."""
# Serve deployment should be stuck DEPLOYING.
if not check_status("DEPLOYING"):
return False
actors = ray.util.state.list_actors()
return any(a["state"] == "PENDING_CREATION" for a in actors)
# Serve deployment should remain stuck in deploying because Actor/PG can't be scheduled.
wait_for_condition(verify_resource_request_stuck, timeout=30)
assert not check_status("RUNNING"), (
"Test setup failed: The deployment became RUNNING before the required "
"node was added. The label selector constraint was ignored."
)
# Add a node that matches the fallback.
new_node = cluster.add_node(
num_cpus=2, labels=fallback_label, resources={"fallback_node": 1}
)
cluster.wait_for_nodes()
expected_node_id = ray.get(
get_node_id.options(resources={"fallback_node": 1}).remote()
)
# The serve deployment should recover and start running on the fallback node.
wait_for_condition(lambda: check_status("RUNNING"), timeout=30)
assert await handle.remote() == expected_node_id
serve.delete("unschedulable_fallback_app")
cluster.remove_node(new_node)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))