476 lines
17 KiB
Python
476 lines
17 KiB
Python
import asyncio
|
|
import os
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
import ray
|
|
from ray import serve
|
|
from ray._common.test_utils import wait_for_condition
|
|
from ray.serve._private.common import GANG_PG_NAME_PREFIX, DeploymentStatus
|
|
from ray.serve._private.constants import SERVE_NAMESPACE
|
|
from ray.serve._private.utils import get_all_live_placement_group_names
|
|
from ray.serve.config import GangSchedulingConfig
|
|
from ray.serve.context import _get_global_client
|
|
from ray.util.placement_group import PlacementGroup, get_current_placement_group
|
|
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
|
|
|
|
|
|
def _get_pg_strategy(pg: PlacementGroup) -> str:
|
|
return ray.util.placement_group_table(pg)["strategy"]
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Timing out on Windows.")
|
|
@pytest.mark.asyncio
|
|
async def test_basic(serve_instance):
|
|
"""Test the basic workflow: multiple replicas with their own PGs."""
|
|
|
|
@serve.deployment(
|
|
num_replicas=2,
|
|
placement_group_bundles=[{"CPU": 1}, {"CPU": 0.1}],
|
|
)
|
|
class D:
|
|
def get_pg(self) -> PlacementGroup:
|
|
return get_current_placement_group()
|
|
|
|
h = serve.run(D.bind(), name="pg_test")
|
|
|
|
# Verify that each replica has its own placement group with the correct config.
|
|
assert len(get_all_live_placement_group_names()) == 2
|
|
unique_pgs = set(await asyncio.gather(*[h.get_pg.remote() for _ in range(20)]))
|
|
assert len(unique_pgs) == 2
|
|
for pg in unique_pgs:
|
|
assert _get_pg_strategy(pg) == "PACK"
|
|
assert pg.bundle_specs == [{"CPU": 1}, {"CPU": 0.1}]
|
|
|
|
# Verify that all placement groups are deleted when the deployment is deleted.
|
|
serve.delete("pg_test")
|
|
assert len(get_all_live_placement_group_names()) == 0
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Timing out on Windows.")
|
|
def test_upgrade_and_change_pg(serve_instance):
|
|
"""Test re-deploying a deployment with different PG bundles and strategy."""
|
|
|
|
@serve.deployment(
|
|
num_replicas=1,
|
|
placement_group_bundles=[{"CPU": 1}, {"CPU": 0.1}],
|
|
placement_group_strategy="STRICT_PACK",
|
|
)
|
|
class D:
|
|
def get_pg(self) -> PlacementGroup:
|
|
return get_current_placement_group()
|
|
|
|
h = serve.run(D.bind(), name="pg_test")
|
|
|
|
# Check that the original replica is created with the expected PG config.
|
|
assert len(get_all_live_placement_group_names()) == 1
|
|
original_pg = h.get_pg.remote().result()
|
|
assert original_pg.bundle_specs == [{"CPU": 1}, {"CPU": 0.1}]
|
|
assert _get_pg_strategy(original_pg) == "STRICT_PACK"
|
|
|
|
# Re-deploy with a new PG config.
|
|
D = D.options(
|
|
placement_group_bundles=[{"CPU": 2}, {"CPU": 0.2}],
|
|
placement_group_strategy="SPREAD",
|
|
)
|
|
|
|
h = serve.run(D.bind(), name="pg_test")
|
|
assert len(get_all_live_placement_group_names()) == 1
|
|
new_pg = h.get_pg.remote().result()
|
|
assert new_pg.bundle_specs == [{"CPU": 2}, {"CPU": 0.2}]
|
|
assert _get_pg_strategy(new_pg) == "SPREAD"
|
|
|
|
# Verify that all placement groups are deleted when the deployment is deleted.
|
|
serve.delete("pg_test")
|
|
assert len(get_all_live_placement_group_names()) == 0
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Timing out on Windows.")
|
|
@pytest.mark.asyncio
|
|
async def test_pg_removed_on_replica_graceful_shutdown(serve_instance):
|
|
"""Verify that PGs are removed when a replica shuts down gracefully."""
|
|
|
|
@serve.deployment(
|
|
placement_group_bundles=[{"CPU": 1}],
|
|
)
|
|
class D:
|
|
def get_pg(self) -> PlacementGroup:
|
|
return get_current_placement_group()
|
|
|
|
h = serve.run(D.options(num_replicas=2).bind(), name="pg_test")
|
|
|
|
# Two replicas to start, each should have their own placement group.
|
|
assert len(get_all_live_placement_group_names()) == 2
|
|
original_unique_pgs = set(
|
|
await asyncio.gather(*[h.get_pg.remote() for _ in range(20)])
|
|
)
|
|
assert len(original_unique_pgs) == 2
|
|
|
|
# Re-deploy the application with a single replica.
|
|
# The existing PGs should be removed and a new one should be created for the
|
|
# new replica.
|
|
h = serve.run(D.options(num_replicas=1).bind(), name="pg_test")
|
|
assert len(get_all_live_placement_group_names()) == 1
|
|
new_unique_pgs = set(await asyncio.gather(*[h.get_pg.remote() for _ in range(20)]))
|
|
assert len(new_unique_pgs) == 1
|
|
assert not new_unique_pgs.issubset(original_unique_pgs)
|
|
|
|
# Verify that all placement groups are deleted when the deployment is deleted.
|
|
serve.delete("pg_test")
|
|
assert len(get_all_live_placement_group_names()) == 0
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Timing out on Windows.")
|
|
def test_pg_removed_on_replica_crash(serve_instance):
|
|
"""Verify that PGs are removed when a replica crashes unexpectedly."""
|
|
|
|
@serve.deployment(
|
|
placement_group_bundles=[{"CPU": 1}],
|
|
health_check_period_s=0.1,
|
|
)
|
|
class D:
|
|
def die(self):
|
|
os._exit(1)
|
|
|
|
def get_pg(self) -> PlacementGroup:
|
|
return get_current_placement_group()
|
|
|
|
h = serve.run(D.bind(), name="pg_test")
|
|
|
|
# Get the placement group for the original replica.
|
|
assert len(get_all_live_placement_group_names()) == 1
|
|
pg = h.get_pg.remote().result()
|
|
|
|
# Kill the replica forcefully.
|
|
with pytest.raises(ray.exceptions.RayActorError):
|
|
h.die.remote().result()
|
|
|
|
def new_replica_scheduled():
|
|
try:
|
|
h.get_pg.remote().result()
|
|
except ray.exceptions.RayActorError:
|
|
return False
|
|
|
|
return True
|
|
|
|
# The original placement group should be deleted and a new replica should
|
|
# be scheduled with its own new placement group.
|
|
wait_for_condition(new_replica_scheduled)
|
|
new_pg = h.get_pg.remote().result()
|
|
assert pg != new_pg
|
|
assert len(get_all_live_placement_group_names()) == 1
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Timing out on Windows.")
|
|
def test_pg_removed_after_controller_crash(serve_instance):
|
|
"""Verify that PGs are removed normally after recovering from a controller crash.
|
|
|
|
If the placement group was not properly recovered in the replica recovery process,
|
|
it would be leaked here.
|
|
"""
|
|
|
|
@serve.deployment(
|
|
placement_group_bundles=[{"CPU": 1}],
|
|
)
|
|
class D:
|
|
pass
|
|
|
|
serve.run(D.bind(), name="pg_test")
|
|
assert len(get_all_live_placement_group_names()) == 1
|
|
|
|
ray.kill(_get_global_client()._controller, no_restart=False)
|
|
|
|
serve.delete("pg_test")
|
|
assert len(get_all_live_placement_group_names()) == 0
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Timing out on Windows.")
|
|
def test_leaked_pg_removed_on_controller_recovery(serve_instance):
|
|
"""Verify that leaked PGs are removed on controller recovery.
|
|
|
|
A placement group can be "leaked" if the replica is killed while the controller is
|
|
down or the controller crashes between creating a placement group and its replica.
|
|
|
|
In these cases, the controller should detect the leak on recovery and delete the
|
|
leaked placement group(s).
|
|
"""
|
|
|
|
@serve.deployment(
|
|
placement_group_bundles=[{"CPU": 1}],
|
|
health_check_period_s=0.1,
|
|
)
|
|
class D:
|
|
def die(self):
|
|
os._exit(1)
|
|
|
|
def get_pg(self) -> PlacementGroup:
|
|
return get_current_placement_group()
|
|
|
|
h = serve.run(D.bind(), name="pg_test")
|
|
|
|
prev_pg = h.get_pg.remote().result()
|
|
assert len(get_all_live_placement_group_names()) == 1
|
|
|
|
# Kill the controller and the replica immediately after.
|
|
# This will cause the controller to *not* detect the replica on recovery, but it
|
|
# should still detect the leaked placement group and clean it up.
|
|
ray.kill(_get_global_client()._controller, no_restart=False)
|
|
with pytest.raises(ray.exceptions.RayActorError):
|
|
h.die.remote().result()
|
|
|
|
def leaked_pg_cleaned_up():
|
|
try:
|
|
new_pg = h.get_pg.remote().result()
|
|
except ray.exceptions.RayActorError:
|
|
return False
|
|
|
|
return len(get_all_live_placement_group_names()) == 1 and new_pg != prev_pg
|
|
|
|
# Verify that a new replica is placed with a new placement group and the old
|
|
# placement group has been removed.
|
|
wait_for_condition(leaked_pg_cleaned_up)
|
|
|
|
serve.delete("pg_test")
|
|
assert len(get_all_live_placement_group_names()) == 0
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Timing out on Windows.")
|
|
def test_leaked_gang_pg_removed_on_controller_recovery(serve_instance):
|
|
"""Verify that leaked gang PGs are removed while in-use gang PGs are kept.
|
|
|
|
Deploys two gang-scheduled apps (``survivor`` and ``victim``) and a
|
|
non-gang app with a per-replica placement group (``per_replica``). After
|
|
killing the controller and the victim's + per_replica's actors:
|
|
|
|
* The victim's gang PG has no alive actors -> detected as leaked -> removed.
|
|
* The per-replica PG has no alive actor -> detected as leaked -> removed.
|
|
* The survivor's gang PG still has alive actors -> preserved.
|
|
* All apps recover successfully.
|
|
"""
|
|
|
|
@serve.deployment(
|
|
num_replicas=2,
|
|
ray_actor_options={"num_cpus": 0.1},
|
|
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
|
health_check_period_s=1,
|
|
)
|
|
class Survivor:
|
|
def __call__(self):
|
|
return "survivor_ok"
|
|
|
|
@serve.deployment(
|
|
num_replicas=2,
|
|
ray_actor_options={"num_cpus": 0.1},
|
|
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
|
health_check_period_s=1,
|
|
)
|
|
class Victim:
|
|
def __call__(self):
|
|
return "victim_ok"
|
|
|
|
@serve.deployment(
|
|
placement_group_bundles=[{"CPU": 0.1}],
|
|
ray_actor_options={"num_cpus": 0.1},
|
|
health_check_period_s=1,
|
|
)
|
|
class PerReplica:
|
|
def get_pg(self) -> PlacementGroup:
|
|
return get_current_placement_group()
|
|
|
|
def __call__(self):
|
|
return "per_replica_ok"
|
|
|
|
h_surv = serve.run(Survivor.bind(), name="survivor_app", route_prefix="/surv")
|
|
h_vict = serve.run(Victim.bind(), name="victim_app", route_prefix="/vict")
|
|
h_pr = serve.run(
|
|
PerReplica.bind(), name="per_replica_app", route_prefix="/per_replica"
|
|
)
|
|
|
|
assert h_surv.remote().result() == "survivor_ok"
|
|
assert h_vict.remote().result() == "victim_ok"
|
|
assert h_pr.remote().result() == "per_replica_ok"
|
|
|
|
prev_per_replica_pg = h_pr.get_pg.remote().result()
|
|
|
|
# There should be exactly 2 gang PGs (one per gang app) + 1 per-replica PG.
|
|
all_pg_names = get_all_live_placement_group_names()
|
|
gang_pgs = [n for n in all_pg_names if n.startswith(GANG_PG_NAME_PREFIX)]
|
|
non_gang_pgs = [n for n in all_pg_names if not n.startswith(GANG_PG_NAME_PREFIX)]
|
|
assert len(gang_pgs) == 2
|
|
assert len(non_gang_pgs) == 1
|
|
|
|
survivor_pg = [n for n in gang_pgs if "survivor_app" in n]
|
|
victim_pg = [n for n in gang_pgs if "victim_app" in n]
|
|
assert len(survivor_pg) == 1
|
|
assert len(victim_pg) == 1
|
|
original_survivor_pg_name = survivor_pg[0]
|
|
original_victim_pg_name = victim_pg[0]
|
|
prev_per_replica_pg_name = non_gang_pgs[0]
|
|
|
|
# Kill the controller
|
|
ray.kill(_get_global_client()._controller, no_restart=False)
|
|
|
|
# Kill the victim actors and per-replica actors while the controller is restarting
|
|
actors_to_kill = [
|
|
a
|
|
for a in ray.util.list_named_actors(all_namespaces=True)
|
|
if a["namespace"] == SERVE_NAMESPACE
|
|
and ("victim_app" in a["name"] or "per_replica_app" in a["name"])
|
|
]
|
|
for actor_info in actors_to_kill:
|
|
try:
|
|
handle = ray.get_actor(actor_info["name"], namespace=SERVE_NAMESPACE)
|
|
ray.kill(handle, no_restart=True)
|
|
except Exception:
|
|
continue
|
|
|
|
def recovery_complete():
|
|
try:
|
|
# Survivor should still work.
|
|
result = h_surv.remote().result()
|
|
assert result == "survivor_ok"
|
|
except Exception:
|
|
return False
|
|
|
|
current_pg_names = get_all_live_placement_group_names()
|
|
current_gang_pgs = [
|
|
n for n in current_pg_names if n.startswith(GANG_PG_NAME_PREFIX)
|
|
]
|
|
current_non_gang_pgs = [
|
|
n for n in current_pg_names if not n.startswith(GANG_PG_NAME_PREFIX)
|
|
]
|
|
|
|
# The survivor's *original* PG must still exist (not removed).
|
|
survivor_pgs = [n for n in current_gang_pgs if "survivor_app" in n]
|
|
if original_survivor_pg_name not in survivor_pgs:
|
|
return False
|
|
|
|
# Victim should have recovered with a new PG (old one was removed).
|
|
victim_pgs = [n for n in current_gang_pgs if "victim_app" in n]
|
|
if len(victim_pgs) != 1:
|
|
return False
|
|
if victim_pgs[0] == original_victim_pg_name:
|
|
return False
|
|
|
|
# The old per-replica PG must be removed, not leaked alongside the new one.
|
|
if len(current_non_gang_pgs) != 1:
|
|
return False
|
|
if current_non_gang_pgs[0] == prev_per_replica_pg_name:
|
|
return False
|
|
|
|
# Per-replica app should have recovered with a new PG.
|
|
try:
|
|
new_per_replica_pg = h_pr.get_pg.remote().result()
|
|
except Exception:
|
|
return False
|
|
if new_per_replica_pg == prev_per_replica_pg:
|
|
return False
|
|
|
|
# All apps must be RUNNING with HEALTHY deployments before we
|
|
# consider recovery complete to avoid race situations where replicas
|
|
# serve requests but status is still DEPLOYING.
|
|
status = serve.status()
|
|
for app_name in ("survivor_app", "victim_app", "per_replica_app"):
|
|
app_status = status.applications.get(app_name)
|
|
if app_status is None or app_status.status != "RUNNING":
|
|
return False
|
|
for dep_status in app_status.deployments.values():
|
|
if dep_status.status != DeploymentStatus.HEALTHY:
|
|
return False
|
|
|
|
return True
|
|
|
|
wait_for_condition(recovery_complete, timeout=60)
|
|
|
|
# Verify all apps recovered and are serving.
|
|
assert h_vict.remote().result() == "victim_ok"
|
|
assert h_pr.remote().result() == "per_replica_ok"
|
|
|
|
serve.delete("survivor_app")
|
|
serve.delete("victim_app")
|
|
serve.delete("per_replica_app")
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Timing out on Windows.")
|
|
def test_replica_actor_infeasible(serve_instance):
|
|
"""Test that we get a validation error if the replica doesn't fit in the bundle."""
|
|
|
|
class Infeasible:
|
|
pass
|
|
|
|
with pytest.raises(ValueError):
|
|
serve.deployment(placement_group_bundles=[{"CPU": 0.1}])(Infeasible)
|
|
|
|
with pytest.raises(ValueError):
|
|
serve.deployment(Infeasible).options(placement_group_bundles=[{"CPU": 0.1}])
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Timing out on Windows.")
|
|
def test_coschedule_actors_and_tasks(serve_instance):
|
|
"""Test that actor/tasks are placed in the replica's placement group by default."""
|
|
|
|
@ray.remote(num_cpus=1)
|
|
class TestActor:
|
|
def get_pg(self) -> PlacementGroup:
|
|
return get_current_placement_group()
|
|
|
|
@ray.remote
|
|
def get_pg():
|
|
return get_current_placement_group()
|
|
|
|
@serve.deployment(
|
|
# Bundles have space for one additional 1-CPU actor.
|
|
placement_group_bundles=[{"CPU": 1}, {"CPU": 1}],
|
|
)
|
|
class Parent:
|
|
def run_test(self):
|
|
# First actor should be scheduled in the placement group without issue.
|
|
a1 = TestActor.remote()
|
|
assert ray.get(a1.get_pg.remote()) == get_current_placement_group()
|
|
|
|
# Second actor can't be placed because there are no more resources in the
|
|
# placement group (the first actor is occupying the second bundle).
|
|
a2 = TestActor.remote()
|
|
ready, _ = ray.wait([a2.get_pg.remote()], timeout=0.1)
|
|
assert len(ready) == 0
|
|
ray.kill(a2)
|
|
|
|
# Second actor can be successfully scheduled outside the placement group.
|
|
a3 = TestActor.options(
|
|
scheduling_strategy=PlacementGroupSchedulingStrategy(
|
|
placement_group=None
|
|
)
|
|
).remote()
|
|
assert ray.get(a3.get_pg.remote()) is None
|
|
|
|
# A zero-CPU task can be scheduled in the placement group.
|
|
assert (
|
|
ray.get(get_pg.options(num_cpus=0).remote())
|
|
== get_current_placement_group()
|
|
)
|
|
|
|
# A two-CPU task cannot fit in the placement group.
|
|
with pytest.raises(ValueError):
|
|
get_pg.options(num_cpus=2).remote()
|
|
|
|
# A two-CPU task can be scheduled outside the placement group.
|
|
assert (
|
|
ray.get(
|
|
get_pg.options(
|
|
num_cpus=2,
|
|
scheduling_strategy=PlacementGroupSchedulingStrategy(
|
|
placement_group=None
|
|
),
|
|
).remote()
|
|
)
|
|
is None
|
|
)
|
|
|
|
h = serve.run(Parent.bind())
|
|
h.run_test.remote().result()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-v", "-s", __file__]))
|