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

1166 lines
41 KiB
Python

import asyncio
import sys
from copy import deepcopy
from typing import Dict, Optional
import pytest
from pydantic import BaseModel
import ray
from ray import serve
from ray._common.test_utils import SignalActor, wait_for_condition
from ray.exceptions import RayActorError
from ray.serve import Application
from ray.serve._private.client import ServeControllerClient
from ray.serve._private.common import (
DeploymentID,
DeploymentStatus,
DeploymentStatusTrigger,
ReplicaState,
TargetCapacityDirection,
)
from ray.serve._private.constants import SERVE_DEFAULT_APP_NAME, SERVE_NAMESPACE
from ray.serve.config import AutoscalingConfig
from ray.serve.context import _get_global_client
from ray.serve.schema import (
ApplicationStatus,
ServeApplicationSchema,
ServeDeploySchema,
)
INGRESS_DEPLOYMENT_NAME = "ingress"
INGRESS_DEPLOYMENT_NUM_REPLICAS = 6
DOWNSTREAM_DEPLOYMENT_NAME = "downstream"
DOWNSTREAM_DEPLOYMENT_NUM_REPLICAS = 2
SCALE_TO_ZERO_DEPLOYMENT_NAME = "zero"
SCALE_TO_ZERO_DEPLOYMENT_MAX_REPLICAS = 4
START_AT_10_DEPLOYMENT_NAME = "start_at_ten"
START_AT_10_DEPLOYMENT_INITIAL_REPLICAS = 10
START_AT_10_DEPLOYMENT_MIN_REPLICAS = 0
START_AT_10_DEPLOYMENT_MAX_REPLICAS = 20
@pytest.fixture
def shutdown_ray_and_serve():
serve.shutdown()
if ray.is_initialized():
# wait_for_processes=True blocks until the raylet/GCS/etc. subprocesses
# have fully exited, so the next test's serve.start() (which calls
# ray.init()) doesn't race a still-terminating raylet.
ray.shutdown(wait_for_processes=True)
yield
serve.shutdown()
if ray.is_initialized():
ray.shutdown(wait_for_processes=True)
@serve.deployment(ray_actor_options={"num_cpus": 0})
class DummyDeployment:
def __init__(self, *args):
pass
def __call__(self, *args):
return "hi"
test_app = DummyDeployment.options(
name=INGRESS_DEPLOYMENT_NAME,
num_replicas=INGRESS_DEPLOYMENT_NUM_REPLICAS,
).bind(
DummyDeployment.options(
name=DOWNSTREAM_DEPLOYMENT_NAME,
num_replicas=DOWNSTREAM_DEPLOYMENT_NUM_REPLICAS,
).bind()
)
def check_expected_num_replicas(
deployment_to_num_replicas: Dict[str, int], app_name: str = SERVE_DEFAULT_APP_NAME
) -> bool:
"""Checks that the expected number of replicas for each deployment is running.
Looks for the deployments in the app named app_name.
"""
status = serve.status()
assert app_name in status.applications
application = status.applications[app_name]
assert application.status == ApplicationStatus.RUNNING
for name, num_replicas in deployment_to_num_replicas.items():
assert name in application.deployments
assert (
sum(application.deployments[name].replica_states.values()) == num_replicas
)
return True
@pytest.fixture
def client() -> ServeControllerClient:
serve.start()
yield _get_global_client()
def test_incremental_scale_up(shutdown_ray_and_serve, client: ServeControllerClient):
config = ServeDeploySchema(
applications=[
ServeApplicationSchema(
import_path="ray.serve.tests.test_target_capacity:test_app"
)
]
)
# Initially deploy at target_capacity 0, should have no replicas.
config.target_capacity = 0.0
client.deploy_apps(config)
wait_for_condition(lambda: serve.status().target_capacity == 0.0)
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={
INGRESS_DEPLOYMENT_NAME: 0,
DOWNSTREAM_DEPLOYMENT_NAME: 0,
},
timeout=30,
)
# Initially deploy at target_capacity 1, should have 1 replica of each.
config.target_capacity = 1.0
client.deploy_apps(config)
wait_for_condition(lambda: serve.status().target_capacity == 1.0)
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={
INGRESS_DEPLOYMENT_NAME: 1,
DOWNSTREAM_DEPLOYMENT_NAME: 1,
},
timeout=30,
)
# Increase target_capacity to 50, ingress deployment should scale up.
config.target_capacity = 50.0
client.deploy_apps(config)
wait_for_condition(lambda: serve.status().target_capacity == 50.0)
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={
INGRESS_DEPLOYMENT_NAME: INGRESS_DEPLOYMENT_NUM_REPLICAS / 2,
DOWNSTREAM_DEPLOYMENT_NAME: DOWNSTREAM_DEPLOYMENT_NUM_REPLICAS / 2,
},
timeout=30,
)
# Increase target_capacity to 100, both should fully scale up.
config.target_capacity = 100.0
client.deploy_apps(config)
wait_for_condition(lambda: serve.status().target_capacity == 100.0)
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={
INGRESS_DEPLOYMENT_NAME: INGRESS_DEPLOYMENT_NUM_REPLICAS,
DOWNSTREAM_DEPLOYMENT_NAME: DOWNSTREAM_DEPLOYMENT_NUM_REPLICAS,
},
timeout=30,
)
# Finish rollout (remove target_capacity), should have no effect.
config.target_capacity = None
client.deploy_apps(config)
wait_for_condition(lambda: serve.status().target_capacity is None)
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={
INGRESS_DEPLOYMENT_NAME: INGRESS_DEPLOYMENT_NUM_REPLICAS,
DOWNSTREAM_DEPLOYMENT_NAME: DOWNSTREAM_DEPLOYMENT_NUM_REPLICAS,
},
timeout=30,
)
def test_incremental_scale_down(shutdown_ray_and_serve, client: ServeControllerClient):
config = ServeDeploySchema(
applications=[
ServeApplicationSchema(
import_path="ray.serve.tests.test_target_capacity:test_app"
)
]
)
# Initially deploy with no target_capacity (full scale).
client.deploy_apps(config)
wait_for_condition(lambda: serve.status().target_capacity is None)
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={
INGRESS_DEPLOYMENT_NAME: INGRESS_DEPLOYMENT_NUM_REPLICAS,
DOWNSTREAM_DEPLOYMENT_NAME: DOWNSTREAM_DEPLOYMENT_NUM_REPLICAS,
},
timeout=30,
)
# Decrease target_capacity to 50, both deployments should scale down.
config.target_capacity = 50.0
client.deploy_apps(config)
wait_for_condition(lambda: serve.status().target_capacity == 50.0)
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={
INGRESS_DEPLOYMENT_NAME: INGRESS_DEPLOYMENT_NUM_REPLICAS / 2,
DOWNSTREAM_DEPLOYMENT_NAME: DOWNSTREAM_DEPLOYMENT_NUM_REPLICAS / 2,
},
timeout=30,
)
# Decrease target_capacity to 1, both should fully scale down.
config.target_capacity = 1.0
client.deploy_apps(config)
wait_for_condition(lambda: serve.status().target_capacity == 1.0)
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={
INGRESS_DEPLOYMENT_NAME: 1,
DOWNSTREAM_DEPLOYMENT_NAME: 1,
},
timeout=30,
)
# Decrease target_capacity to 0, both should fully scale down to zero.
config.target_capacity = 0.0
client.deploy_apps(config)
wait_for_condition(lambda: serve.status().target_capacity == 0.0)
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={
INGRESS_DEPLOYMENT_NAME: 0,
DOWNSTREAM_DEPLOYMENT_NAME: 0,
},
timeout=30,
)
def test_controller_recover_target_capacity(
shutdown_ray_and_serve, client: ServeControllerClient
):
config = ServeDeploySchema(
target_capacity=100,
applications=[
ServeApplicationSchema(
import_path="ray.serve.tests.test_target_capacity:test_app"
)
],
)
client.deploy_apps(config)
wait_for_condition(lambda: serve.status().target_capacity == 100.0)
assert (
ray.get(client._controller._get_target_capacity_direction.remote())
== TargetCapacityDirection.UP
)
# Scale down to target_capacity 50, both deployments should be at half scale.
config.target_capacity = 50.0
client.deploy_apps(config)
wait_for_condition(lambda: serve.status().target_capacity == 50.0)
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={
INGRESS_DEPLOYMENT_NAME: INGRESS_DEPLOYMENT_NUM_REPLICAS / 2,
DOWNSTREAM_DEPLOYMENT_NAME: DOWNSTREAM_DEPLOYMENT_NUM_REPLICAS / 2,
},
timeout=30,
)
assert (
ray.get(client._controller._get_target_capacity_direction.remote())
== TargetCapacityDirection.DOWN
)
ray.kill(client._controller, no_restart=False)
# Verify that the target_capacity is recovered after the controller comes back.
wait_for_condition(lambda: serve.status().target_capacity == 50.0)
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={
INGRESS_DEPLOYMENT_NAME: INGRESS_DEPLOYMENT_NUM_REPLICAS / 2,
DOWNSTREAM_DEPLOYMENT_NAME: DOWNSTREAM_DEPLOYMENT_NUM_REPLICAS / 2,
},
timeout=30,
)
assert (
ray.get(client._controller._get_target_capacity_direction.remote())
== TargetCapacityDirection.DOWN
)
@serve.deployment(
ray_actor_options={"num_cpus": 0},
autoscaling_config={
"min_replicas": 0,
"max_replicas": SCALE_TO_ZERO_DEPLOYMENT_MAX_REPLICAS,
"target_ongoing_requests": 1,
"upscale_delay_s": 1,
"downscale_delay_s": 1,
"upscaling_factor": 4,
"downscaling_factor": 4,
"metrics_interval_s": 1,
# The default look_back_period_s is 30, which means the test assertions will be
# slow to respond to changes in metrics. Setting it to 2 makes the test assertions
# more responsive to changes in metrics, hence reducing flakiness.
"look_back_period_s": 2,
},
max_ongoing_requests=2,
graceful_shutdown_timeout_s=0,
)
class ScaleToZeroDeployment:
async def __call__(self, *args):
await asyncio.sleep(10000)
scale_to_zero_app = ScaleToZeroDeployment.options(
name=SCALE_TO_ZERO_DEPLOYMENT_NAME,
).bind()
def test_autoscaling_scale_to_zero(
shutdown_ray_and_serve, client: ServeControllerClient
):
config = ServeDeploySchema(
applications=[
ServeApplicationSchema(
import_path="ray.serve.tests.test_target_capacity:scale_to_zero_app"
)
]
)
# Initially deploy at target_capacity 0, should have 0 replicas.
config.target_capacity = 0.0
client.deploy_apps(config)
wait_for_condition(lambda: serve.status().target_capacity == 0.0)
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={
SCALE_TO_ZERO_DEPLOYMENT_NAME: 0,
},
)
# Increase to target_capacity 1, should remain at 0 replicas initially.
config.target_capacity = 1.0
client.deploy_apps(config)
wait_for_condition(lambda: serve.status().target_capacity == 1.0)
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={
SCALE_TO_ZERO_DEPLOYMENT_NAME: 0,
},
)
# Send a bunch of requests. Autoscaler will want to scale it up to max replicas,
# but it should stay at one due to the target_capacity.
handle = serve.get_app_handle(SERVE_DEFAULT_APP_NAME)
responses = [
handle.remote() for _ in range(5 * SCALE_TO_ZERO_DEPLOYMENT_MAX_REPLICAS)
]
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={
SCALE_TO_ZERO_DEPLOYMENT_NAME: 1,
},
timeout=30,
)
# Increase to target_capacity 100, should scale all the way up.
config.target_capacity = 100.0
client.deploy_apps(config)
wait_for_condition(lambda: serve.status().target_capacity == 100.0)
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={
SCALE_TO_ZERO_DEPLOYMENT_NAME: SCALE_TO_ZERO_DEPLOYMENT_MAX_REPLICAS,
},
timeout=30,
)
# Decrease to target_capacity 50, should scale down.
config.target_capacity = 50.0
client.deploy_apps(config)
wait_for_condition(lambda: serve.status().target_capacity == 50.0)
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={
SCALE_TO_ZERO_DEPLOYMENT_NAME: SCALE_TO_ZERO_DEPLOYMENT_MAX_REPLICAS / 2,
},
timeout=30,
)
# Cancel all of the requests, should scale down to zero.
[r.cancel() for r in responses]
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={
SCALE_TO_ZERO_DEPLOYMENT_NAME: 0,
},
timeout=30,
)
@serve.deployment(ray_actor_options={"num_cpus": 0})
class ControlledLifecycleDeployment:
async def __init__(self):
self.lifecycle_signal = ray.get_actor(
name="lifecycle_signal", namespace=SERVE_NAMESPACE
)
await self.lifecycle_signal.wait.remote()
try:
self.request_signal = ray.get_actor(
name="request_signal", namespace=SERVE_NAMESPACE
)
except ValueError:
# No request signal actor was launched.
self.request_signal = None
async def __call__(self) -> str:
if self.request_signal is not None:
try:
await self.request_signal.wait.remote()
except RayActorError:
print("Request signal actor already dead. Running request.")
return "Hello world!"
async def __del__(self):
# Use try-except here to avoid cluttering logs if the SignalActor
# gets torn down before the replicas during clean up.
try:
await self.lifecycle_signal.wait.remote()
except RayActorError:
print("Lifecycle signal actor already dead. Replica exiting.")
class ControllerAppConfig(BaseModel):
num_replicas: int
def create_controlled_app(config: ControllerAppConfig) -> Application:
num_replicas = config.num_replicas
return ControlledLifecycleDeployment.options(
name="controlled",
num_replicas=num_replicas,
).bind()
class AutoscalingControllerAppConfig(BaseModel):
min_replicas: int
initial_replicas: Optional[int] = None
max_replicas: int
def create_autoscaling_controlled_app(
config: AutoscalingControllerAppConfig,
) -> Application:
min_replicas = config.min_replicas
initial_replicas = config.initial_replicas
max_replicas = config.max_replicas
return ControlledLifecycleDeployment.options(
name="controlled",
autoscaling_config=AutoscalingConfig(
min_replicas=min_replicas,
initial_replicas=initial_replicas,
max_replicas=max_replicas,
target_ongoing_requests=1,
metrics_interval_s=0.1,
look_back_period_s=0.2,
upscale_delay_s=0.1,
downscale_delay_s=0.1,
),
graceful_shutdown_timeout_s=0,
).bind()
class TestTargetCapacityUpdateAndServeStatus:
def check_num_replicas(
self,
expected_num_replicas: int,
app_name: str,
deployment_name: str,
replica_state: ReplicaState = ReplicaState.RUNNING,
controller_handle: Optional[ray.actor.ActorHandle] = None,
) -> bool:
"""Checks that the number of replicas are as expected.
Args:
expected_num_replicas: the expected number of replicas.
app_name: the deployment's application name.
deployment_name: the deployment's name.
replica_state: only replicas in this state are counted.
controller_handle: this is an optional argument. If provided, the
controller handle is used to get the current autoscaling
metrics and print them if the assertion fails.
Returns:
True when the replica count matches (raises ``AssertionError`` otherwise).
"""
deployment = serve.status().applications[app_name].deployments[deployment_name]
num_running_replicas = deployment.replica_states.get(replica_state, 0)
if controller_handle is None:
assert num_running_replicas == expected_num_replicas, f"{deployment}"
else:
deployment_id = DeploymentID(name=deployment_name, app_name=app_name)
autoscaling_metrics = ray.get(
controller_handle._get_metrics_for_deployment_for_testing.remote(
deployment_id
)
)
assert num_running_replicas == expected_num_replicas, (
f"Status: {deployment}" f"\nAutoscaling metrics: {autoscaling_metrics}"
)
return True
def apply_config_and_check_status(
self,
client: ServeControllerClient,
target_capacity: Optional[float],
config: ServeDeploySchema,
app_name: str,
deployment_name: str,
expected_app_status: Optional[ApplicationStatus] = None,
expected_deployment_status: Optional[DeploymentStatus] = None,
expected_deployment_status_trigger: Optional[DeploymentStatusTrigger] = None,
timeout=10,
):
"""Applies config with specified target_capacity."""
config = deepcopy(config)
config.target_capacity = target_capacity
client.deploy_apps(config)
def check():
status = serve.status()
assert status.target_capacity == target_capacity
if expected_app_status is not None:
assert status.applications[app_name].status == expected_app_status
dep_status = status.applications[app_name].deployments[deployment_name]
if expected_deployment_status is not None:
assert dep_status.status == expected_deployment_status
if expected_deployment_status_trigger is not None:
assert dep_status.status_trigger == expected_deployment_status_trigger
return True
wait_for_condition(check, timeout=timeout)
def unblock_replica_creation_and_deletion(self, lifecycle_signal, app_name: str):
"""Unblocks creating and deleting ControlledLifecycleDeployment replicas.
These replicas can't initialize or be deleted until the
"lifecycle_signal" actor runs send, so this method runs send, waits
until the replicas start or stop running, and then resets the signal.
"""
def check_running():
app_status_data = serve.status().applications[app_name]
app_status = app_status_data.status
assert app_status == ApplicationStatus.RUNNING, f"{app_status_data}"
return True
ray.get(lifecycle_signal.send.remote())
wait_for_condition(check_running, timeout=30, retry_interval_ms=500)
ray.get(lifecycle_signal.send.remote(clear=True))
def test_static_num_replicas_target_capacity_update(
self, shutdown_ray_and_serve, client: ServeControllerClient
):
"""Check how Serve's status updates when target_capacity changes."""
app_name = "controlled_app"
deployment_name = "controlled"
num_replicas = 20
signal = SignalActor.options(
name="lifecycle_signal", namespace=SERVE_NAMESPACE
).remote()
config = ServeDeploySchema(
applications=[
ServeApplicationSchema(
name=app_name,
import_path=(
"ray.serve.tests.test_target_capacity:create_controlled_app"
),
args={"num_replicas": num_replicas},
)
]
)
# Initially deploy at target_capacity 0, and check status.
self.apply_config_and_check_status(
client,
target_capacity=0.0,
config=config,
app_name=app_name,
deployment_name=deployment_name,
expected_app_status=ApplicationStatus.RUNNING,
expected_deployment_status=DeploymentStatus.HEALTHY,
expected_deployment_status_trigger=(
DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
),
)
self.check_num_replicas(0, app_name, deployment_name)
# Increase the target_capacity, and check again.
self.apply_config_and_check_status(
client,
target_capacity=50.0,
config=config,
app_name=app_name,
deployment_name=deployment_name,
expected_app_status=ApplicationStatus.DEPLOYING,
expected_deployment_status=DeploymentStatus.UPSCALING,
expected_deployment_status_trigger=(
DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
),
)
self.check_num_replicas(0, app_name, deployment_name)
self.unblock_replica_creation_and_deletion(signal, app_name)
self.check_num_replicas(int(0.5 * num_replicas), app_name, deployment_name)
# Decrease the target_capacity, and check again.
self.apply_config_and_check_status(
client,
target_capacity=10.0,
config=config,
app_name=app_name,
deployment_name=deployment_name,
expected_app_status=ApplicationStatus.DEPLOYING,
expected_deployment_status=DeploymentStatus.DOWNSCALING,
expected_deployment_status_trigger=(
DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
),
)
# DeploymentStateManager marks replicas as STOPPING once the deployment
# starts downscaling.
wait_for_condition(
self.check_num_replicas,
expected_num_replicas=int(0.1 * num_replicas),
app_name=app_name,
deployment_name=deployment_name,
replica_state=ReplicaState.RUNNING,
timeout=20,
)
self.check_num_replicas(
int(0.4 * num_replicas),
app_name,
deployment_name,
replica_state=ReplicaState.STOPPING,
)
self.unblock_replica_creation_and_deletion(signal, app_name)
self.check_num_replicas(int(0.1 * num_replicas), app_name, deployment_name)
self.check_num_replicas(
0, app_name, deployment_name, replica_state=ReplicaState.STOPPING
)
# Send a signal so all replicas shut down quickly when the test finishes.
ray.get(signal.send.remote())
@pytest.mark.skipif(sys.platform == "win32", reason="Autoscaling flaky on Windows.")
def test_autoscaling_target_capacity_update(
self, shutdown_ray_and_serve, client: ServeControllerClient
):
"""Check Serve's status when target_capacity changes while autoscaling."""
# TODO(landscapepainter): This test fails locally due to the stall for replica initialization
# during upscaling and delayed response from serve.status(). It does not fail from
# buildkite, but need to investigate why it fails locally.
app_name = "controlled_app"
deployment_name = "controlled"
min_replicas = 10
initial_replicas = 12
max_replicas = 20
lifecycle_signal = SignalActor.options(
name="lifecycle_signal", namespace=SERVE_NAMESPACE
).remote()
request_signal = SignalActor.options(
name="request_signal", namespace=SERVE_NAMESPACE
).remote()
config = ServeDeploySchema(
applications=[
ServeApplicationSchema(
name=app_name,
import_path=(
"ray.serve.tests.test_target_capacity:"
"create_autoscaling_controlled_app"
),
args=dict(
min_replicas=min_replicas,
initial_replicas=initial_replicas,
max_replicas=max_replicas,
),
)
]
)
# Initially deploy at target_capacity 0, and check status.
self.apply_config_and_check_status(
client,
target_capacity=0.0,
config=config,
app_name=app_name,
deployment_name=deployment_name,
timeout=20,
expected_app_status=ApplicationStatus.RUNNING,
expected_deployment_status=DeploymentStatus.HEALTHY,
expected_deployment_status_trigger=(
DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
),
)
self.check_num_replicas(0, app_name, deployment_name)
# Increase the target_capacity, and check again.
self.apply_config_and_check_status(
client,
target_capacity=50.0,
config=config,
app_name=app_name,
deployment_name=deployment_name,
expected_app_status=ApplicationStatus.DEPLOYING,
expected_deployment_status=DeploymentStatus.UPSCALING,
expected_deployment_status_trigger=(
DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
),
)
self.check_num_replicas(0, app_name, deployment_name)
self.unblock_replica_creation_and_deletion(lifecycle_signal, app_name)
self.check_num_replicas(int(0.5 * initial_replicas), app_name, deployment_name)
# Send requests and check that the application scales up.
requests = []
handle = serve.get_app_handle(app_name)
for _ in range(max_replicas):
requests.append(handle.remote())
ray.get(lifecycle_signal.send.remote())
wait_for_condition(
self.check_num_replicas,
expected_num_replicas=int(0.5 * max_replicas),
app_name=app_name,
deployment_name=deployment_name,
timeout=30,
)
# Clear requests and check that application scales down.
ray.get(request_signal.send.remote())
results = [request.result() for request in requests]
assert results == ["Hello world!"] * (max_replicas)
wait_for_condition(
self.check_num_replicas,
expected_num_replicas=int(0.5 * initial_replicas),
app_name=app_name,
deployment_name=deployment_name,
replica_state=ReplicaState.RUNNING,
timeout=20,
retry_interval_ms=1000,
)
wait_for_condition(
self.check_num_replicas,
expected_num_replicas=0,
app_name=app_name,
deployment_name=deployment_name,
replica_state=ReplicaState.STOPPING,
timeout=20,
retry_interval_ms=1000,
)
ray.get(lifecycle_signal.send.remote(clear=True))
ray.get(request_signal.send.remote(clear=True))
# Decrease the target_capacity, and check that min_replicas is used
# to create the lower bound.
self.apply_config_and_check_status(
client,
target_capacity=10.0,
config=config,
app_name=app_name,
deployment_name=deployment_name,
expected_app_status=ApplicationStatus.DEPLOYING,
expected_deployment_status=DeploymentStatus.DOWNSCALING,
expected_deployment_status_trigger=(
DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
),
)
self.unblock_replica_creation_and_deletion(lifecycle_signal, app_name)
self.check_num_replicas(
int(0.1 * min_replicas),
app_name,
deployment_name,
replica_state=ReplicaState.RUNNING,
)
self.check_num_replicas(
0, app_name, deployment_name, replica_state=ReplicaState.STOPPING
)
# Check that target_capacity * max_replicas is still the upper bound.
requests = []
handle = serve.get_app_handle(app_name)
for _ in range(max_replicas):
requests.append(handle.remote())
ray.get(lifecycle_signal.send.remote())
wait_for_condition(
self.check_num_replicas,
expected_num_replicas=int(0.1 * max_replicas),
app_name=app_name,
deployment_name=deployment_name,
controller_handle=client._controller,
timeout=25,
retry_interval_ms=2000,
)
# Clear requests and check that application scales down to
# target_capacity * min_replicas.
ray.get(request_signal.send.remote())
results = [request.result() for request in requests]
assert results == ["Hello world!"] * (max_replicas)
wait_for_condition(
self.check_num_replicas,
expected_num_replicas=int(0.1 * min_replicas),
app_name=app_name,
deployment_name=deployment_name,
controller_handle=client._controller,
timeout=25,
retry_interval_ms=2000,
)
ray.get(lifecycle_signal.send.remote(clear=True))
ray.get(request_signal.send.remote(clear=True))
# Scaling up to 100% target_capacity should make Serve use
# initial_replicas as lower bound.
self.apply_config_and_check_status(
client,
target_capacity=100.0,
config=config,
app_name=app_name,
deployment_name=deployment_name,
expected_app_status=ApplicationStatus.DEPLOYING,
expected_deployment_status=DeploymentStatus.UPSCALING,
expected_deployment_status_trigger=(
DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
),
)
self.check_num_replicas(int(0.1 * min_replicas), app_name, deployment_name)
self.unblock_replica_creation_and_deletion(lifecycle_signal, app_name)
self.check_num_replicas(initial_replicas, app_name, deployment_name)
# Unsetting target_capacity should make Serve use
# min_replicas as lower bound. The current number of
# replicas is already a valid number, so the application stays
# RUNNING.
self.apply_config_and_check_status(
client,
target_capacity=None,
config=config,
app_name=app_name,
deployment_name=deployment_name,
expected_app_status=ApplicationStatus.RUNNING,
expected_deployment_status=DeploymentStatus.DOWNSCALING,
expected_deployment_status_trigger=(DeploymentStatusTrigger.AUTOSCALING),
)
self.unblock_replica_creation_and_deletion(lifecycle_signal, app_name)
wait_for_condition(
self.check_num_replicas,
expected_num_replicas=min_replicas,
app_name=app_name,
deployment_name=deployment_name,
)
# Send a signal so all replicas shut down quickly when the test finishes.
ray.get(request_signal.send.remote())
ray.get(lifecycle_signal.send.remote())
@serve.deployment(
ray_actor_options={"num_cpus": 0},
max_ongoing_requests=2,
graceful_shutdown_timeout_s=0,
)
class HangDeployment:
async def __call__(self, *args):
await asyncio.sleep(10000)
def create_hang_app(config: Dict) -> Application:
name: str = config["name"]
min_replicas: int = config["min_replicas"]
initial_replicas: int = config["initial_replicas"]
max_replicas: int = config["max_replicas"]
return HangDeployment.options(
name=name,
autoscaling_config={
"min_replicas": min_replicas,
"initial_replicas": initial_replicas,
"max_replicas": max_replicas,
"target_ongoing_requests": 1,
"metrics_interval_s": 0.01,
"downscale_delay_s": 0.01,
},
graceful_shutdown_timeout_s=0.01,
).bind()
class TestInitialReplicasHandling:
def deploy_config_and_wait_for_target_capacity(
self,
client: ServeControllerClient,
config: ServeDeploySchema,
target_capacity: float,
):
config.target_capacity = target_capacity
client.deploy_apps(config)
wait_for_condition(lambda: serve.status().target_capacity == target_capacity)
def test_initial_replicas_scales_down(
self, shutdown_ray_and_serve, client: ServeControllerClient
):
# TODO(landscapepainter): This test fails locally due to the stall for replica initialization
# during upscaling and delayed response from serve.status(). It does not fail from
# buildkite, but need to investigate why it fails locally.
deployment_name = "start_at_ten"
min_replicas = 5
initial_replicas = 10
config = ServeDeploySchema(
applications=[
ServeApplicationSchema(
import_path="ray.serve.tests.test_target_capacity:create_hang_app",
args={
"name": deployment_name,
"min_replicas": min_replicas,
"initial_replicas": initial_replicas,
"max_replicas": 15,
},
),
],
)
client.deploy_apps(config)
wait_for_condition(lambda: len(serve.status().applications) == 1)
assert serve.status().target_capacity is None
# Wait for initial deployment to reach RUNNING before starting the
# target_capacity loop. Without this, the first loop iteration must
# handle both initial replica startup AND target_capacity convergence
# within its timeout, which is marginal under CI resource contention.
wait_for_condition(
lambda: (
SERVE_DEFAULT_APP_NAME in serve.status().applications
and serve.status().applications[SERVE_DEFAULT_APP_NAME].status
== ApplicationStatus.RUNNING
),
timeout=30,
)
# Kick off downscaling pattern.
test_target_capacities = [100, 60, 40, 0]
expected_num_replicas = [
int(min_replicas * capacity / 100) for capacity in test_target_capacities
]
for target_capacity, num_replicas in zip(
test_target_capacities, expected_num_replicas
):
print(f"target_capacity: {target_capacity}, num_replicas: {num_replicas}")
config.target_capacity = target_capacity
client.deploy_apps(config)
wait_for_condition(
lambda: serve.status().target_capacity == target_capacity
)
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={deployment_name: num_replicas},
timeout=30,
)
def test_initial_replicas_scales_up_and_down(
self, shutdown_ray_and_serve, client: ServeControllerClient
):
# TODO(landscapepainter): This test fails locally due to the stall for replica initialization
# during upscaling and delayed response from serve.status(). It does not fail from
# buildkite, but need to investigate why it fails locally.
deployment_name = "start_at_five"
min_replicas = 0
initial_replicas = 5
config = ServeDeploySchema(
target_capacity=0,
applications=[
ServeApplicationSchema(
import_path="ray.serve.tests.test_target_capacity:create_hang_app",
args={
"name": deployment_name,
"min_replicas": min_replicas,
"initial_replicas": initial_replicas,
"max_replicas": 2 * initial_replicas,
},
),
],
)
test_target_capacities = [0, 1, 20, 60, 40, 30, 100, None]
expected_num_replicas = [
0,
1,
1,
3,
min_replicas,
min_replicas,
initial_replicas,
min_replicas,
]
for target_capacity, num_replicas in zip(
test_target_capacities, expected_num_replicas
):
print(f"target_capacity: {target_capacity}, num_replicas: {num_replicas}")
config.target_capacity = target_capacity
client.deploy_apps(config)
wait_for_condition(
lambda: serve.status().target_capacity == target_capacity
)
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={deployment_name: num_replicas},
timeout=30,
)
def test_initial_replicas_zero(
self, shutdown_ray_and_serve, client: ServeControllerClient
):
deployment_name = "start_at_zero"
min_replicas = 0
initial_replicas = 0
config = ServeDeploySchema(
target_capacity=0,
applications=[
ServeApplicationSchema(
import_path="ray.serve.tests.test_target_capacity:create_hang_app",
args={
"name": deployment_name,
"min_replicas": min_replicas,
"initial_replicas": initial_replicas,
"max_replicas": 20,
},
),
],
)
test_target_capacities = [0, 1, 20, 60, 40, 30, 100, None]
expected_num_replicas = [0] * len(test_target_capacities)
for target_capacity, num_replicas in zip(
test_target_capacities, expected_num_replicas
):
config.target_capacity = target_capacity
client.deploy_apps(config)
wait_for_condition(
lambda: serve.status().target_capacity == target_capacity
)
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={deployment_name: num_replicas},
)
def test_initial_replicas_new_configs(
self, shutdown_ray_and_serve, client: ServeControllerClient
):
# TODO(landscapepainter): This test fails locally due to the stall for replica initialization
# during upscaling and delayed response from serve.status(). It does not fail from
# buildkite, but need to investigate why it fails locally.
deployment_name = "start_at_ten"
min_replicas = 0
initial_replicas = 10
config_target_capacity = 40
config = ServeDeploySchema(
target_capacity=config_target_capacity,
applications=[
ServeApplicationSchema(
name="app1",
import_path="ray.serve.tests.test_target_capacity:create_hang_app",
args={
"name": deployment_name,
"min_replicas": min_replicas,
"initial_replicas": initial_replicas,
"max_replicas": initial_replicas * 2,
},
),
],
)
# When deploying first config, initial_replicas * target_capacity
# should be treated as floor
client.deploy_apps(config)
wait_for_condition(
lambda: serve.status().target_capacity == config_target_capacity
)
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={
deployment_name: int(initial_replicas * config_target_capacity / 100)
},
app_name="app1",
timeout=30,
)
# When deploying a new config, initial_replicas * target_capacity
# should be treated as floor for all deployments, even if
# target_capacity is lower
new_deployment_name = f"new_{deployment_name}"
new_config_target_capacity = config_target_capacity / 2
new_config = deepcopy(config)
new_config.applications.append(
ServeApplicationSchema(
name="app2",
import_path="ray.serve.tests.test_target_capacity:create_hang_app",
args={
"name": new_deployment_name,
"min_replicas": min_replicas,
"initial_replicas": initial_replicas,
"max_replicas": initial_replicas * 2,
},
),
)
new_config.target_capacity = new_config_target_capacity
client.deploy_apps(new_config)
wait_for_condition(
lambda: serve.status().target_capacity == new_config_target_capacity
)
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={
deployment_name: int(
initial_replicas * new_config_target_capacity / 100
)
},
app_name="app1",
timeout=30,
)
wait_for_condition(
check_expected_num_replicas,
deployment_to_num_replicas={
new_deployment_name: int(
initial_replicas * new_config_target_capacity / 100
)
},
app_name="app2",
timeout=30,
)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))