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

2561 lines
92 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import logging
import os
import sys
import tempfile
import threading
import time
import zipfile
from typing import Dict, Iterable, List
from unittest import mock
import aiohttp
import httpx
import pytest
from starlette.requests import Request
from starlette.responses import StreamingResponse
import ray
from ray import serve
from ray._common.test_utils import SignalActor, wait_for_condition
from ray.serve._private.common import (
DeploymentID,
DeploymentStatus,
DeploymentStatusTrigger,
ReplicaID,
ReplicaState,
)
from ray.serve._private.constants import (
RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE,
SERVE_DEFAULT_APP_NAME,
SERVE_NAMESPACE,
)
from ray.serve._private.controller import ServeController
from ray.serve._private.test_utils import (
check_deployment_status,
check_num_replicas_eq,
check_num_replicas_gte,
check_num_replicas_lte,
check_running,
get_num_alive_replicas,
tlog,
)
from ray.serve.config import AutoscalingConfig, AutoscalingContext, AutoscalingPolicy
from ray.serve.handle import DeploymentHandle
from ray.serve.schema import ApplicationStatus, ServeDeploySchema
from ray.util.state import list_actors
def get_running_replica_ids(name: str, controller: ServeController) -> List[ReplicaID]:
"""Get the replica tags of running replicas for given deployment"""
replicas = ray.get(
controller._dump_replica_states_for_testing.remote(DeploymentID(name=name))
)
running_replicas = replicas.get([ReplicaState.RUNNING])
return [replica.replica_id for replica in running_replicas]
def get_deployment_start_time(controller: ServeController, name: str):
"""Return start time for given deployment"""
deployments = ray.get(controller.list_deployments_internal.remote())
deployment_info, _ = deployments[DeploymentID(name=name)]
return deployment_info.start_time_ms
def check_num_queued_requests_eq(handle: DeploymentHandle, expected: int):
assert (
handle._router._asyncio_router._metrics_manager.num_queued_requests == expected
)
return True
def assert_no_replicas_deprovisioned(
replica_ids_1: Iterable[ReplicaID], replica_ids_2: Iterable[ReplicaID]
) -> None:
"""
Checks whether any replica ids from replica_ids_1 are absent from
replica_ids_2. Assumes that this indicates replicas were de-provisioned.
replica_ids_1: Replica ids of running replicas at the first timestep
replica_ids_2: Replica ids of running replicas at the second timestep
"""
replica_ids_1, replica_ids_2 = set(replica_ids_1), set(replica_ids_2)
num_matching_replicas = len(replica_ids_1.intersection(replica_ids_2))
print(
f"{num_matching_replicas} replica(s) stayed provisioned between "
f"both deployments. All {len(replica_ids_1)} replica(s) were "
f"expected to stay provisioned. "
f"{len(replica_ids_1) - num_matching_replicas} replica(s) were "
f"de-provisioned."
)
assert len(replica_ids_1) == num_matching_replicas
def test_assert_no_replicas_deprovisioned():
deployment_id = DeploymentID(name="hi")
replica_ids_1 = [
ReplicaID("a", deployment_id=deployment_id),
ReplicaID("b", deployment_id=deployment_id),
ReplicaID("c", deployment_id=deployment_id),
]
replica_ids_2 = [
ReplicaID("a", deployment_id=deployment_id),
ReplicaID("b", deployment_id=deployment_id),
ReplicaID("c", deployment_id=deployment_id),
ReplicaID("d", deployment_id=deployment_id),
ReplicaID("e", deployment_id=deployment_id),
]
assert_no_replicas_deprovisioned(replica_ids_1, replica_ids_2)
with pytest.raises(AssertionError):
assert_no_replicas_deprovisioned(replica_ids_2, replica_ids_1)
def get_num_requests(client, dep_id: DeploymentID):
ref = client._controller._get_total_num_requests_for_deployment_for_testing.remote(
dep_id
)
return ray.get(ref)
def check_num_requests_eq(client, id: DeploymentID, expected: int):
assert get_num_requests(client, id) == expected
return True
def check_num_requests_ge(client, id: DeploymentID, expected: int):
assert get_num_requests(client, id) >= expected
return True
class TestAutoscalingMetrics:
@pytest.mark.parametrize("aggregation_function", ["mean", "max"])
def test_basic(self, serve_instance, aggregation_function):
"""Test that request metrics are sent correctly to the controller."""
client = serve_instance
signal = SignalActor.remote()
@serve.deployment(
autoscaling_config={
"metrics_interval_s": 0.1,
"min_replicas": 1,
"max_replicas": 10,
"target_ongoing_requests": 10,
"upscale_delay_s": 0,
"downscale_delay_s": 5,
"look_back_period_s": 1,
"aggregation_function": aggregation_function,
},
max_ongoing_requests=25,
# To make the test run faster, we set the graceful_shutdown_timeout_s to 0.1
graceful_shutdown_timeout_s=0.1,
)
class A:
async def __call__(self):
await signal.wait.remote()
handle = serve.run(A.bind())
dep_id = DeploymentID(name="A")
[handle.remote() for _ in range(50)]
# Wait for metrics to propagate
wait_for_condition(check_num_requests_ge, client=client, id=dep_id, expected=1)
tlog("Autoscaling metrics started recording on controller.")
# Many queries should be inflight.
wait_for_condition(check_num_requests_ge, client=client, id=dep_id, expected=45)
tlog("Confirmed many queries are inflight.")
wait_for_condition(check_num_queued_requests_eq, handle=handle, expected=0)
tlog("Confirmed all requests are assigned to replicas.")
wait_for_condition(check_num_replicas_eq, name="A", target=5)
tlog("Confirmed deployment scaled to 5 replicas.")
tlog("Releasing signal.")
signal.send.remote()
# After traffic stops, num replica should drop to 1
wait_for_condition(check_num_replicas_eq, name="A", target=1, timeout=15)
tlog("Num replicas dropped to 1.")
# Request metrics should drop to 0
wait_for_condition(check_num_requests_eq, client=client, id=dep_id, expected=0)
tlog("Queued and ongoing requests dropped to 0.")
@pytest.mark.skipif(
not RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE,
reason="Needs metric collection at handle.",
)
@pytest.mark.parametrize("use_generator", [True, False])
def test_replicas_die(self, serve_instance_with_signal, use_generator):
"""If replicas die while requests are still executing, that
should be tracked correctly."""
client, signal = serve_instance_with_signal
config = {
"autoscaling_config": {
"target_ongoing_requests": 10,
"metrics_interval_s": 0.1,
"min_replicas": 1,
"max_replicas": 10,
"upscale_delay_s": 0,
"downscale_delay_s": 0,
"look_back_period_s": 1,
},
"graceful_shutdown_timeout_s": 0.1,
"max_ongoing_requests": 25,
}
if use_generator:
@serve.deployment(**config)
class A:
async def __call__(self):
await signal.wait.remote()
async for i in range(3):
yield i
else:
@serve.deployment(**config)
class A:
async def __call__(self):
await signal.wait.remote()
handle = serve.run(A.bind(), name="app1").options(stream=use_generator)
dep_id = DeploymentID(name="A", app_name="app1")
[handle.remote() for _ in range(50)]
# Many queries should be inflight.
wait_for_condition(check_num_requests_ge, client=client, id=dep_id, expected=45)
print("Confirmed many queries are inflight.")
wait_for_condition(check_num_replicas_eq, name="A", target=5, app_name="app1")
print("Confirmed deployment scaled to 5 replicas.")
# Wait for all requests to be scheduled to replicas so they'll be failed
# when the replicas are removed.
wait_for_condition(lambda: ray.get(signal.cur_num_waiters.remote()) == 50)
# Remove all replicas before they can finish the requests.
serve.delete("app1")
# Num requests should still drop to 0 despite all requests failing.
def check_handle_metrics(handle):
metrics_manager = handle._router._asyncio_router._metrics_manager
num_requests = metrics_manager.num_requests_sent_to_replicas
for replica_id, num in num_requests.items():
assert (
num == 0
), f"Replica {replica_id} still has {num} ongoing requests"
return True
wait_for_condition(check_handle_metrics, handle=handle)
@pytest.mark.skipif(
not RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE,
reason="Needs metric collection at handle.",
)
@pytest.mark.parametrize("use_get_handle_api", [True, False])
def test_handle_deleted_on_crashed_replica(
self, serve_instance_with_signal, use_get_handle_api
):
"""If a Serve replica crashes, the metrics from handles living on that replica
should be dropped.
"""
client, signal = serve_instance_with_signal
dep_id = DeploymentID(name="A")
@serve.deployment(
autoscaling_config={
"target_ongoing_requests": 4,
"metrics_interval_s": 0.1,
"min_replicas": 0,
"max_replicas": 10,
"upscale_delay_s": 1,
"downscale_delay_s": 1,
# Keep this value smaller than the wait_for_condition timeout to ensure the
# autoscaler remains responsive to metric changes. If its larger, the test
# may become flaky because the autoscaler might not have stabilized within
# the wait window.
"look_back_period_s": 5,
},
graceful_shutdown_timeout_s=0.1,
health_check_period_s=1,
max_ongoing_requests=10,
)
class A:
async def __call__(self):
await signal.wait.remote()
return "sup"
@serve.deployment(graceful_shutdown_timeout_s=1, max_ongoing_requests=50)
class Router:
def __init__(self, handle: DeploymentHandle):
if use_get_handle_api:
self._handle = serve.get_deployment_handle("A")
else:
self._handle = handle
async def __call__(self):
return await self._handle.remote()
app = Router.bind(A.bind())
handle = serve.run(app)
[handle.remote() for _ in range(20)]
wait_for_condition(check_num_requests_eq, client=client, id=dep_id, expected=20)
# Wait for deployment A to scale up
wait_for_condition(check_num_replicas_eq, name="A", target=5)
print("Confirmed deployment scaled to 5 replicas.")
router_info = [
actor
for actor in list_actors(filters=[("state", "=", "ALIVE")])
if actor["class_name"] == "ServeReplica:default:Router"
][0]
router = ray.get_actor(router_info["name"], namespace=SERVE_NAMESPACE)
# Kill Router replica
print(f"Killing Router ({router_info['actor_id']}) at", time.time())
ray.kill(router)
wait_for_condition(check_num_replicas_eq, name="A", target=0)
wait_for_condition(check_num_requests_eq, client=client, id=dep_id, expected=0)
# Wait for new Router replica to start, so we avoid potential
# race conditions during test shutdown.
# (Ex: controller starts a new Router replica, before the replica
# initializes the test shutdown procedure deletes the Router
# deployment, replica initializes and tries to get deployment
# handle to `A` and fails.)
wait_for_condition(check_num_replicas_eq, name="Router", target=1)
@pytest.mark.skipif(
not RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE,
reason="Needs metric collection at handle.",
)
def test_handle_deleted_on_non_serve_actor(self, serve_instance_with_signal):
"""If handles are deleted while requests are still inflight, the
metrics should be invalidated after a certain time so the info
doesn't become stale. This is the fallback for handles that don't
live on serve actors.
"""
client, signal = serve_instance_with_signal
dep_id = DeploymentID(name="A")
@serve.deployment(
autoscaling_config={
"target_ongoing_requests": 4,
"metrics_interval_s": 0.1,
"min_replicas": 0,
"max_replicas": 10,
"upscale_delay_s": 1,
"downscale_delay_s": 1,
# Keep this value smaller than the wait_for_condition timeout to ensure the
# autoscaler remains responsive to metric changes. If its larger, the test
# may become flaky because the autoscaler might not have stabilized within
# the wait window.
"look_back_period_s": 5,
},
graceful_shutdown_timeout_s=0.1,
health_check_period_s=1,
max_ongoing_requests=10,
)
class A:
async def __call__(self):
await signal.wait.remote()
return "sup"
@ray.remote
class CallActor:
def __init__(self):
self._handle = DeploymentHandle("A", "default")
async def call(self):
return await self._handle.remote()
serve.run(A.bind())
caller = CallActor.options(name="caller", namespace="abc").remote()
[caller.call.remote() for _ in range(20)]
# Wait for deployment A to scale up
wait_for_condition(check_num_requests_eq, client=client, id=dep_id, expected=20)
wait_for_condition(check_num_replicas_eq, name="A", target=5)
print("Confirmed deployment scaled to 5 replicas.")
# Kill CallerActor
print("Killing CallerActor at", time.time())
ray.kill(ray.get_actor("caller", namespace="abc"))
wait_for_condition(check_num_replicas_eq, name="A", target=0, timeout=20)
wait_for_condition(
check_num_requests_eq, client=client, id=dep_id, expected=0, timeout=20
)
@pytest.mark.skipif(
not RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE,
reason="Needs metric collection at handle.",
)
def test_downstream_does_not_overscale_waiting_for_upstream_args(
self, serve_instance_with_signal
):
client, signal = serve_instance_with_signal
@serve.deployment(max_ongoing_requests=100)
class SlowUpstream:
async def __call__(self):
await signal.wait.remote()
return "result"
@serve.deployment(
max_ongoing_requests=5,
autoscaling_config={
"target_ongoing_requests": 1,
"metrics_interval_s": 0.1,
"min_replicas": 1,
"max_replicas": 10,
"upscale_delay_s": 0.2,
"downscale_delay_s": 0.5,
"look_back_period_s": 0.5,
},
)
class FastDownstream:
async def __call__(self, data: str):
# Instant processing - just return
return f"processed: {data}"
@serve.deployment(max_ongoing_requests=100)
class Router:
def __init__(self, up: DeploymentHandle, down: DeploymentHandle):
self._up, self._down = up, down
async def __call__(self):
# Pass upstream response directly to downstream as an argument
return await self._down.remote(self._up.remote())
handle = serve.run(Router.bind(SlowUpstream.bind(), FastDownstream.bind()))
wait_for_condition(check_num_replicas_eq, name="FastDownstream", target=1)
wait_for_condition(check_num_replicas_eq, name="SlowUpstream", target=1)
# Send 5 requests - they will be blocked at SlowUpstream
responses = [handle.remote() for _ in range(5)]
# Wait for all 5 requests to be blocked at SlowUpstream (waiting on signal)
wait_for_condition(lambda: ray.get(signal.cur_num_waiters.remote()) == 5)
# Key assertion: FastDownstream should NOT scale up while waiting
# for upstream arguments. It should stay at 1 replica because
# num_queued_requests should only be incremented AFTER arguments
# are resolved.
num_downstream_replicas = get_num_alive_replicas("FastDownstream")
assert num_downstream_replicas == 1, (
f"FastDownstream over-provisioned to {num_downstream_replicas} replicas "
f"while waiting for upstream arguments. Expected 1 replica."
)
# Also verify the controller doesn't see inflated request count for downstream
downstream_dep_id = DeploymentID(name="FastDownstream")
downstream_requests = get_num_requests(client, downstream_dep_id)
assert downstream_requests == 0, (
f"Controller sees {downstream_requests} requests for FastDownstream "
f"while they're still blocked at SlowUpstream. Expected 0."
)
# Release the signal to complete requests
ray.get(signal.send.remote())
for r in responses:
assert r.result() == "processed: result"
@pytest.mark.parametrize("min_replicas", [1, 2])
@pytest.mark.parametrize("aggregation_function", ["mean", "max", "min"])
def test_e2e_scale_up_down_basic(
min_replicas, serve_instance_with_signal, aggregation_function
):
"""Send 100 requests and check that we autoscale up, and then back down."""
client, signal = serve_instance_with_signal
@serve.deployment(
autoscaling_config={
"metrics_interval_s": 0.1,
"min_replicas": min_replicas,
"max_replicas": 3,
"look_back_period_s": 0.2,
"downscale_delay_s": 0.5,
"upscale_delay_s": 0,
"aggregation_function": aggregation_function,
},
# We will send over a lot of queries. This will make sure replicas are
# killed quickly during cleanup.
graceful_shutdown_timeout_s=1,
max_ongoing_requests=1000,
)
class A:
async def __call__(self):
await signal.wait.remote()
handle = serve.run(A.bind())
wait_for_condition(
check_deployment_status, name="A", expected_status=DeploymentStatus.HEALTHY
)
start_time = get_deployment_start_time(client._controller, "A")
[handle.remote() for _ in range(100)]
# scale up one more replica from min_replicas
wait_for_condition(check_num_replicas_gte, name="A", target=min_replicas + 1)
# check_deployment_status(controller, "A", DeploymentStatus.UPSCALING)
signal.send.remote()
# As the queue is drained, we should scale back down.
wait_for_condition(
check_num_replicas_lte, name="A", target=min_replicas, timeout=20
)
# Make sure start time did not change for the deployment
assert get_deployment_start_time(client._controller, "A") == start_time
@pytest.mark.skipif(sys.platform == "win32", reason="Failing on Windows.")
@pytest.mark.parametrize("scaling_factor", [1, 0.2])
@pytest.mark.parametrize("use_upscale_downscale_config", [True, False])
def test_e2e_scale_up_down_with_0_replica(
serve_instance_with_signal,
scaling_factor,
use_upscale_downscale_config,
):
"""Send 100 requests and check that we autoscale up, and then back down."""
client, signal = serve_instance_with_signal
controller = client._controller
autoscaling_config = {
"metrics_interval_s": 0.1,
"min_replicas": 0,
"max_replicas": 2,
"look_back_period_s": 0.2,
"downscale_delay_s": 0.5,
"upscale_delay_s": 0,
}
if use_upscale_downscale_config:
autoscaling_config["upscaling_factor"] = scaling_factor
autoscaling_config["downscaling_factor"] = scaling_factor
else:
autoscaling_config["smoothing_factor"] = scaling_factor
@serve.deployment(
autoscaling_config=autoscaling_config,
# We will send over a lot of queries. This will make sure replicas are
# killed quickly during cleanup.
graceful_shutdown_timeout_s=1,
max_ongoing_requests=1000,
)
class A:
def __call__(self):
ray.get(signal.wait.remote())
handle = serve.run(A.bind())
wait_for_condition(
check_deployment_status, name="A", expected_status=DeploymentStatus.HEALTHY
)
start_time = get_deployment_start_time(controller, "A")
results = [handle.remote() for _ in range(100)]
# After the blocking requests are sent, the number of replicas
# should increase.
wait_for_condition(check_num_replicas_gte, name="A", target=1)
# Release the signal, which should unblock all requests.
print("Number of replicas reached at least 1, releasing signal.")
signal.send.remote()
# As the queue is drained, we should scale back down.
wait_for_condition(check_num_replicas_eq, name="A", target=0)
# Make sure no requests were dropped.
# If the deployment (unexpectedly) scaled down before the
# blocking signal was released, chances are some requests failed b/c
# they were assigned to a replica that died. Therefore, this for
# loop is intended to help make sure that didn't happen.
for res in results:
res.result()
# Make sure start time did not change for the deployment
assert get_deployment_start_time(controller, "A") == start_time
@mock.patch.object(ServeController, "run_control_loop")
def test_initial_num_replicas(mock, serve_instance):
"""assert that the inital amount of replicas a deployment is launched with
respects the bounds set by autoscaling_config.
For this test we mock out the run event loop, make sure the number of
replicas is set correctly before we hit the autoscaling procedure.
"""
@serve.deployment(
autoscaling_config={
"min_replicas": 2,
"max_replicas": 4,
},
)
class A:
def __call__(self):
return "ok!"
serve.run(A.bind())
check_num_replicas_eq("A", 2)
def test_cold_start_time(serve_instance):
"""Test a request is served quickly by a deployment that's scaled to zero"""
@serve.deployment(
autoscaling_config={
"min_replicas": 0,
"max_replicas": 1,
"metrics_interval_s": 0.1,
"look_back_period_s": 0.2,
},
)
class A:
def __call__(self):
return "hello"
handle = serve.run(A.bind())
def check_running():
assert serve.status().applications["default"].status == "RUNNING"
return True
wait_for_condition(check_running)
assert httpx.post("http://localhost:8000/-/healthz").status_code == 200
assert httpx.post("http://localhost:8000/-/routes").status_code == 200
start = time.time()
result = handle.remote().result()
cold_start_time = time.time() - start
if sys.platform == "win32":
timeout = 10 # Windows has a longer tail.
else:
timeout = 3
assert cold_start_time < timeout
print(
"Time taken for deployment at 0 replicas to serve first request:",
cold_start_time,
)
assert result == "hello"
@pytest.mark.skipif(sys.platform == "win32", reason="Failing on Windows.")
@pytest.mark.parametrize("aggregation_function", ["mean", "max", "min"])
def test_e2e_bursty(serve_instance_with_signal, aggregation_function):
"""
Sends 100 requests in bursts. Uses delays for smooth provisioning.
"""
client, signal = serve_instance_with_signal
controller = client._controller
@serve.deployment(
autoscaling_config={
"metrics_interval_s": 0.1,
"min_replicas": 1,
"max_replicas": 2,
"look_back_period_s": 0.5,
"downscale_delay_s": 0.5,
"upscale_delay_s": 0.5,
"aggregation_function": aggregation_function,
},
# We will send over a lot of queries. This will make sure replicas are
# killed quickly during cleanup.
graceful_shutdown_timeout_s=1,
max_ongoing_requests=1000,
)
class A:
def __init__(self):
logging.getLogger("ray.serve").setLevel(logging.ERROR)
async def __call__(self):
await signal.wait.remote()
handle = serve.run(A.bind())
wait_for_condition(
check_deployment_status, name="A", expected_status=DeploymentStatus.HEALTHY
)
start_time = get_deployment_start_time(controller, "A")
[handle.remote() for _ in range(100)]
wait_for_condition(check_num_replicas_gte, name="A", target=2)
num_replicas = get_num_alive_replicas("A")
signal.send.remote()
# Execute a bursty workload that issues 100 requests every 0.05 seconds
# The SignalActor allows all requests in a burst to be queued before they
# are all executed, which increases the
# target_in_flight_requests_per_replica. Then the send method will bring
# it back to 0. This bursty behavior should be smoothed by the delay
# parameters.
for _ in range(5):
ray.get(signal.send.remote(clear=True))
check_num_replicas_eq("A", num_replicas)
responses = [handle.remote() for _ in range(100)]
signal.send.remote()
[r.result() for r in responses]
time.sleep(0.05)
# As the queue is drained, we should scale back down.
wait_for_condition(check_num_replicas_lte, name="A", target=1)
# Make sure start time did not change for the deployment
assert get_deployment_start_time(controller, "A") == start_time
@pytest.mark.skipif(sys.platform == "win32", reason="Failing on Windows.")
def test_e2e_intermediate_downscaling(serve_instance_with_signal):
"""
Scales up, then down, and up again.
"""
client, signal = serve_instance_with_signal
controller = client._controller
@serve.deployment(
autoscaling_config={
"metrics_interval_s": 0.1,
"min_replicas": 0,
"max_replicas": 20,
"look_back_period_s": 0.2,
"downscale_delay_s": 0.2,
"upscale_delay_s": 0.2,
},
# We will send over a lot of queries. This will make sure replicas are
# killed quickly during cleanup.
graceful_shutdown_timeout_s=1,
max_ongoing_requests=1000,
)
class A:
async def __call__(self):
await signal.wait.remote()
handle = serve.run(A.bind())
wait_for_condition(
check_deployment_status, name="A", expected_status=DeploymentStatus.HEALTHY
)
start_time = get_deployment_start_time(controller, "A")
[handle.remote() for _ in range(50)]
wait_for_condition(check_num_replicas_gte, name="A", target=20, timeout=30)
signal.send.remote()
wait_for_condition(check_num_replicas_lte, name="A", target=1, timeout=30)
signal.send.remote(clear=True)
[handle.remote() for _ in range(50)]
wait_for_condition(check_num_replicas_gte, name="A", target=20, timeout=30)
signal.send.remote()
# As the queue is drained, we should scale back down.
wait_for_condition(check_num_replicas_eq, name="A", target=0, timeout=30)
# Make sure start time did not change for the deployment
assert get_deployment_start_time(controller, "A") == start_time
@pytest.mark.parametrize("initial_replicas", [2, 3])
@pytest.mark.parametrize("use_deprecated_smoothing_factor", [True, False])
def test_downscaling_with_fractional_scaling_factor(
serve_instance_with_signal,
initial_replicas: int,
use_deprecated_smoothing_factor: bool,
):
client, signal = serve_instance_with_signal
signal.send.remote(clear=True)
app_config = {
"import_path": "ray.serve.tests.test_config_files.get_signal.app",
"deployments": [
{
"name": "A",
"autoscaling_config": {
"metrics_interval_s": 0.1,
"min_replicas": 0,
"max_replicas": 5,
"initial_replicas": initial_replicas,
"look_back_period_s": 0.2,
"downscale_delay_s": 5,
},
"graceful_shutdown_timeout_s": 1,
"max_ongoing_requests": 1000,
}
],
}
if use_deprecated_smoothing_factor:
app_config["deployments"][0]["autoscaling_config"][
"downscale_smoothing_factor"
] = 0.5
else:
app_config["deployments"][0]["autoscaling_config"]["downscaling_factor"] = 0.5
# Deploy with initial replicas = 2+, smoothing factor = 0.5
client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]}))
wait_for_condition(
check_deployment_status, name="A", expected_status=DeploymentStatus.HEALTHY
)
# Send a blocked request to one of two replicas.
# Deployment should still have the initial number of replicas since
# downscale delay = 5
h = serve.get_app_handle(SERVE_DEFAULT_APP_NAME)
h.remote()
check_num_replicas_eq("A", initial_replicas)
# There is 1 ongoing (blocked) request and 2+ replicas. The
# deployment should autoscale down to 1 replica despite the
# smoothing factor
current_num_replicas = initial_replicas
while current_num_replicas > 1:
wait_for_condition(
check_num_replicas_eq, name="A", target=current_num_replicas - 1
)
current_num_replicas -= 1
print(f"Deployment has downscaled to {current_num_replicas} replicas.")
# Release signal so we don't get an ugly error message from the
# replica when the signal actor goes out of scope and gets killed
ray.get(signal.send.remote())
@pytest.mark.skipif(sys.platform == "win32", reason="Failing on Windows.")
@pytest.mark.skip(reason="Currently failing with undefined behavior")
def test_e2e_update_autoscaling_deployment(serve_instance_with_signal):
# See https://github.com/ray-project/ray/issues/21017 for details
client, signal = serve_instance_with_signal
controller = client._controller
app_config = {
"import_path": "ray.serve.tests.test_config_files.get_signal.app",
"deployments": [
{
"name": "A",
"autoscaling_config": {
"metrics_interval_s": 0.1,
"min_replicas": 0,
"max_replicas": 10,
"look_back_period_s": 0.2,
"downscale_delay_s": 0.2,
"upscale_delay_s": 0.2,
},
"graceful_shutdown_timeout_s": 1,
"max_ongoing_requests": 1000,
}
],
}
client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]}))
print("Deployed A with min_replicas 1 and max_replicas 10.")
wait_for_condition(
check_deployment_status, name="A", expected_status=DeploymentStatus.HEALTHY
)
handle = serve.get_deployment_handle("A", "default")
start_time = get_deployment_start_time(controller, "A")
check_num_replicas_eq("A", 0)
[handle.remote() for _ in range(400)]
print("Issued 400 requests.")
wait_for_condition(check_num_replicas_gte, name="A", target=10)
print("Scaled to 10 replicas.")
first_deployment_replicas = get_running_replica_ids("A", controller)
check_num_replicas_lte("A", 20)
[handle.remote() for _ in range(458)]
time.sleep(3)
print("Issued 458 requests. Request routing in-progress.")
app_config["deployments"][0]["autoscaling_config"]["min_replicas"] = 2
app_config["deployments"][0]["autoscaling_config"]["max_replicas"] = 20
client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]}))
print("Redeployed A.")
wait_for_condition(check_num_replicas_gte, name="A", target=20)
print("Scaled up to 20 requests.")
second_deployment_replicas = get_running_replica_ids("A", controller)
# Confirm that none of the original replicas were de-provisioned
assert_no_replicas_deprovisioned(
first_deployment_replicas, second_deployment_replicas
)
signal.send.remote()
# As the queue is drained, we should scale back down.
wait_for_condition(check_num_replicas_lte, name="A", target=2)
check_num_replicas_gte("A", 2)
# Make sure start time did not change for the deployment
assert get_deployment_start_time(controller, "A") == start_time
# scale down to 0
app_config["deployments"][0]["autoscaling_config"]["min_replicas"] = 0
client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]}))
print("Redeployed A.")
wait_for_condition(
check_deployment_status, name="A", expected_status=DeploymentStatus.HEALTHY
)
wait_for_condition(check_num_replicas_eq, name="A", target=0)
check_num_replicas_eq("A", 0)
# scale up
[handle.remote() for _ in range(400)]
wait_for_condition(check_num_replicas_gte, name="A", target=0)
signal.send.remote()
wait_for_condition(check_num_replicas_eq, name="A", target=0)
@pytest.mark.skipif(sys.platform == "win32", reason="Failing on Windows.")
def test_e2e_raise_min_replicas(serve_instance_with_signal):
"""Raise min replicas from 0 to 2."""
client, signal = serve_instance_with_signal
controller = client._controller
app_config = {
"import_path": "ray.serve.tests.test_config_files.get_signal.app",
"deployments": [
{
"name": "A",
"autoscaling_config": {
"metrics_interval_s": 0.1,
"min_replicas": 0,
"max_replicas": 10,
"look_back_period_s": 0.2,
"downscale_delay_s": 0.2,
"upscale_delay_s": 0.2,
},
"graceful_shutdown_timeout_s": 1,
"max_ongoing_requests": 1000,
}
],
}
client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]}))
tlog("Deployed A.")
wait_for_condition(
check_deployment_status, name="A", expected_status=DeploymentStatus.HEALTHY
)
start_time = get_deployment_start_time(controller, "A")
tlog(f"Deployment A is healthy, {start_time=}")
check_num_replicas_eq("A", 0)
handle = serve.get_deployment_handle("A", "default")
handle.remote()
tlog("Issued one request.")
wait_for_condition(check_num_replicas_eq, name="A", target=1, timeout=5)
tlog("Scaled up to 1 replica.")
first_deployment_replicas = get_running_replica_ids("A", controller)
app_config["deployments"][0]["autoscaling_config"]["min_replicas"] = 2
client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]}))
tlog("Redeployed A with min_replicas set to 2.")
wait_for_condition(
check_deployment_status, name="A", expected_status=DeploymentStatus.HEALTHY
)
# Confirm that autoscaler doesn't scale above 2 even after waiting
with pytest.raises(RuntimeError, match="timeout"):
wait_for_condition(check_num_replicas_gte, name="A", target=3, timeout=5)
tlog("Autoscaled to 2 without issuing any new requests.")
second_deployment_replicas = get_running_replica_ids("A", controller)
# Confirm that none of the original replicas were de-provisioned
assert_no_replicas_deprovisioned(
first_deployment_replicas, second_deployment_replicas
)
signal.send.remote()
time.sleep(1)
tlog("Completed request.")
# As the queue is drained, we should scale back down.
wait_for_condition(check_num_replicas_lte, name="A", target=2)
check_num_replicas_gte("A", 2)
tlog("Stayed at 2 replicas.")
# Make sure start time did not change for the deployment
assert get_deployment_start_time(controller, "A") == start_time
@pytest.mark.skipif(sys.platform == "win32", reason="Failing on Windows.")
def test_e2e_initial_replicas(serve_instance):
@serve.deployment(
autoscaling_config=AutoscalingConfig(
min_replicas=1,
initial_replicas=2,
max_replicas=5,
downscale_delay_s=3,
),
)
def f():
return os.getpid()
serve.run(f.bind())
check_num_replicas_eq("f", target=2)
# f should scale down to min_replicas (1) deployments
wait_for_condition(check_num_replicas_eq, name="f", target=1, timeout=20)
@pytest.mark.skipif(sys.platform == "win32", reason="Failing on Windows.")
def test_e2e_preserve_prev_replicas(serve_instance_with_signal):
_, signal = serve_instance_with_signal
@serve.deployment(
max_ongoing_requests=5,
# The config makes the deployment scale up really quickly and then
# wait nearly forever to downscale.
autoscaling_config=AutoscalingConfig(
min_replicas=1,
max_replicas=2,
downscale_delay_s=600,
upscale_delay_s=0,
metrics_interval_s=1,
look_back_period_s=2,
),
)
def scaler():
ray.get(signal.wait.remote())
time.sleep(0.2)
return os.getpid()
handle = serve.run(scaler.bind())
dep_id = DeploymentID(name="scaler")
responses = [handle.remote() for _ in range(20)]
wait_for_condition(
check_num_replicas_eq,
name="scaler",
target=2,
use_controller=True,
retry_interval_ms=1000,
timeout=20,
)
ray.get(signal.send.remote())
pids = {r.result() for r in responses}
assert len(pids) == 2
# Now re-deploy the application, make sure it is still 2 replicas and it shouldn't
# be scaled down.
handle = serve.run(scaler.bind())
responses = [handle.remote() for _ in range(10)]
pids = {r.result() for r in responses}
assert len(pids) == 2
def check_num_replicas(live: int, dead: int):
live_actors = list_actors(
filters=[
("class_name", "=", dep_id.to_replica_actor_class_name()),
("state", "=", "ALIVE"),
]
)
dead_actors = list_actors(
filters=[
("class_name", "=", dep_id.to_replica_actor_class_name()),
("state", "=", "DEAD"),
]
)
return len(live_actors) == live and len(dead_actors) == dead
wait_for_condition(
check_num_replicas, retry_interval_ms=1000, timeout=20, live=2, dead=2
)
ray.get(signal.send.remote())
# re-deploy the application with initial_replicas. This should override the
# previous number of replicas.
scaler = scaler.options(
autoscaling_config=AutoscalingConfig(
min_replicas=1,
initial_replicas=3,
max_replicas=5,
downscale_delay_s=600,
upscale_delay_s=600,
metrics_interval_s=1,
look_back_period_s=2,
)
)
handle = serve.run(scaler.bind())
responses = [handle.remote() for _ in range(15)]
pids = {r.result() for r in responses}
assert len(pids) == 3
wait_for_condition(
check_num_replicas, retry_interval_ms=1000, timeout=20, live=3, dead=4
)
@pytest.mark.skipif(sys.platform == "win32", reason="Failing on Windows.")
def test_e2e_preserve_prev_replicas_rest_api(serve_instance_with_signal):
client, signal = serve_instance_with_signal
# Step 1: Prepare the script in a zip file so it can be submitted via REST API.
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp_path:
with zipfile.ZipFile(tmp_path, "w") as zip_obj:
with zip_obj.open("app.py", "w") as f:
f.write(
"""
from ray import serve
import ray
import os
@serve.deployment
async def g():
signal = ray.get_actor("signal123")
await signal.wait.remote()
return os.getpid()
app = g.bind()
""".encode()
)
# Step 2: Deploy it with max_replicas=1
app_config = {
"import_path": "app:app",
"runtime_env": {"working_dir": f"file://{tmp_path.name}"},
"deployments": [
{
"name": "g",
"autoscaling_config": {
"min_replicas": 0,
"max_replicas": 1,
"downscale_delay_s": 600,
"upscale_delay_s": 0,
"metrics_interval_s": 1,
"look_back_period_s": 2,
},
}
],
}
client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]}))
wait_for_condition(
lambda: serve.status().applications[SERVE_DEFAULT_APP_NAME].status == "RUNNING"
)
# Step 3: Verify that it can scale from 0 to 1.
@ray.remote
def send_request():
return httpx.get("http://localhost:8000/").text
ref = send_request.remote()
wait_for_condition(
check_num_replicas_eq, name="g", target=1, retry_interval_ms=1000, timeout=20
)
signal.send.remote()
existing_pid = int(ray.get(ref))
# Step 4: Change the max replicas to 2
app_config["deployments"][0]["autoscaling_config"]["max_replicas"] = 2
client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]}))
wait_for_condition(
lambda: serve.status().applications[SERVE_DEFAULT_APP_NAME].status == "RUNNING"
)
wait_for_condition(
check_num_replicas_eq, name="g", target=1, retry_interval_ms=1000, timeout=20
)
# Step 5: Make sure it is the same replica (lightweight change).
for _ in range(10):
other_pid = int(ray.get(send_request.remote()))
assert other_pid == existing_pid
# Step 6: Make sure initial_replicas overrides previous replicas
app_config["deployments"][0]["autoscaling_config"]["max_replicas"] = 5
app_config["deployments"][0]["autoscaling_config"]["initial_replicas"] = 3
app_config["deployments"][0]["autoscaling_config"]["upscale_delay"] = 600
client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]}))
wait_for_condition(
lambda: serve.status().applications[SERVE_DEFAULT_APP_NAME].status == "RUNNING"
)
wait_for_condition(
check_num_replicas_eq, name="g", target=3, retry_interval_ms=1000, timeout=20
)
# Step 7: Make sure original replica is still running (lightweight change)
pids = {int(pid) for pid in ray.get([send_request.remote() for _ in range(20)])}
assert existing_pid in pids
@pytest.mark.skipif(sys.platform == "win32", reason="Failing on Windows.")
@pytest.mark.skipif(
not RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE,
reason="Only works when collecting request metrics at handle.",
)
def test_max_ongoing_requests_set_to_one(serve_instance_with_signal):
assert RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE
_, signal = serve_instance_with_signal
@serve.deployment(
autoscaling_config=AutoscalingConfig(
target_ongoing_requests=1.0,
min_replicas=1,
max_replicas=3,
upscale_delay_s=0.5,
downscale_delay_s=0.5,
metrics_interval_s=0.5,
look_back_period_s=2,
),
max_ongoing_requests=1,
graceful_shutdown_timeout_s=1,
ray_actor_options={"num_cpus": 0},
)
async def f():
await signal.wait.remote()
return os.getpid()
h = serve.run(f.bind())
check_num_replicas_eq("f", 1)
# Repeatedly (5 times):
# 1. Send a new request.
# 2. Wait for the number of waiters on signal to increase by 1.
# 3. Assert the number of replicas has increased by 1.
refs = []
for i in range(3):
refs.append(h.remote())
def check_num_waiters(target: int):
num_waiters = ray.get(signal.cur_num_waiters.remote())
assert num_waiters == target
return True
wait_for_condition(check_num_waiters, target=i + 1)
print(time.time(), f"Number of waiters on signal reached {i+1}.")
check_num_replicas_eq("f", i + 1)
print(time.time(), f"Confirmed number of replicas are at {i+1}.")
print(time.time(), "Releasing signal.")
signal.send.remote()
# Check that pids returned are unique
# This implies that each replica only served one request, so the
# number of "running" requests per replica was at most 1 at any time;
# meaning the "queued" requests were taken into consideration for
# autoscaling.
pids = [ref.result() for ref in refs]
assert len(pids) == len(set(pids)), f"Pids {pids} are not unique."
print("Confirmed each replica only served one request.")
@pytest.mark.skipif(sys.platform == "win32", reason="Failing on Windows.")
def test_autoscaling_status_changes(serve_instance):
"""Test status changes when autoscaling deployments are deployed.
This test runs an autoscaling deployment and an actor called the
EventManager. During initialization, each replica creates an asyncio.Event
in the EventManager, and it waits on the event. Once the event is set, the
replica can finish initializing. The test uses this EventManager to control
the number of replicas that should be running at a given time.
The test does the following:
1. Starts an EventManager.
2. Deploys an autoscaling deployment with min_replicas 3.
3. Releases 2 replicas via the EventManager.
4. Checks that the deployment remains in the UPDATING status.
5. Redeploys the deployment with min_replicas 4.
6. Releases 1 more replica via the EventManager.
7. Checks that the deployment remains in the UPDATING status.
8. Releases 1 more replica.
9. Checks that the deployment enters HEALTHY status.
10. Redeploys the deployment with min_replicas 5.
11. Checks that the deployment re-enters and remains in the UPDATING status.
12. Releases 1 more replica.
13 Checks that the deployment enters HEALTHY status.
"""
@ray.remote
class EventManager:
"""Manages events for each deployment replica.
This actor uses a goal-state architecture. The test sets a max number
of replicas to run. Whenever this manager creates or removes an event,
it checks how many replicas are running and attempts to match the goal
state.
"""
def __init__(self):
self._max_replicas_to_run = 0
# This dictionary maps replica names -> asyncio.Event.
self._events: Dict[str, asyncio.Event] = dict()
def get_num_running_replicas(self):
running_replicas = [
actor_name
for actor_name, event in self._events.items()
if event.is_set()
]
return len(running_replicas)
def release_replicas(self):
"""Releases replicas until self._max_replicas_to_run are released."""
num_replicas_released = 0
for _, event in self._events.items():
if self.get_num_running_replicas() < self._max_replicas_to_run:
if not event.is_set():
event.set()
num_replicas_released += 1
else:
break
if num_replicas_released > 0:
print(
f"Started running {num_replicas_released} replicas. "
f"{self.get_waiter_statuses()}"
)
async def wait(self, actor_name):
print(f"Replica {actor_name} started waiting...")
event = asyncio.Event()
self._events[actor_name] = event
self.release_replicas()
await event.wait()
print(f"Replica {actor_name} finished waiting.")
async def set_max_replicas_to_run(self, max_num_replicas: int = 1):
print(f"Setting _max_replicas_to_run to {max_num_replicas}.")
self._max_replicas_to_run = max_num_replicas
self.release_replicas()
async def get_max_replicas_to_run(self) -> int:
return self._max_replicas_to_run
async def num_active_replicas(self) -> int:
"""The number of replicas that are waiting or running."""
return len(self._events)
def get_waiter_statuses(self) -> Dict[str, bool]:
return {
actor_name: event.is_set() for actor_name, event in self._events.items()
}
async def clear_dead_replicas(self):
"""Clears dead replicas from internal _events dictionary."""
actor_names = list(self._events.keys())
for name in actor_names:
try:
ray.get_actor(name=name, namespace=SERVE_NAMESPACE)
except ValueError:
print(f"Actor {name} has died. Removing event.")
self._events.pop(name)
self.release_replicas()
print("Starting EventManager actor...")
event_manager_actor_name = "event_manager_actor"
event_manager = EventManager.options(
name=event_manager_actor_name, namespace=SERVE_NAMESPACE
).remote()
print("Starting Serve app...")
deployment_name = "autoscaling_app"
min_replicas = 3
max_replicas = 15
@serve.deployment(
name=deployment_name,
autoscaling_config=AutoscalingConfig(
min_replicas=min_replicas,
max_replicas=max_replicas,
),
ray_actor_options=dict(num_cpus=0),
graceful_shutdown_timeout_s=0,
)
class AutoscalingDeployment:
"""Deployment that autoscales."""
async def __init__(self):
self.name = ray.get_runtime_context().get_actor_name()
print(f"Replica {self.name} initializing...")
event_manager = ray.get_actor(
name=event_manager_actor_name, namespace=SERVE_NAMESPACE
)
await event_manager.wait.remote(self.name)
print(f"Replica {self.name} has initialized.")
app_name = "autoscaling_app"
app = AutoscalingDeployment.bind()
# Start the AutoscalingDeployment.
serve._run(app, name=app_name, _blocking=False)
# Active replicas are replicas that are waiting or running.
expected_num_active_replicas: int = min_replicas
def check_num_active_replicas(expected: int) -> bool:
ray.get(event_manager.clear_dead_replicas.remote())
assert ray.get(event_manager.num_active_replicas.remote()) == expected
return True
wait_for_condition(check_num_active_replicas, expected=expected_num_active_replicas)
print("Replicas have started waiting. Releasing some replicas...")
ray.get(event_manager.set_max_replicas_to_run.remote(min_replicas - 1))
# Wait for replicas to start.
print("Waiting for replicas to run.")
def replicas_running(expected_num_running_replicas: int) -> bool:
ray.get(event_manager.clear_dead_replicas.remote())
status = serve.status()
app_status = status.applications[app_name]
deployment_status = app_status.deployments[deployment_name]
num_running_replicas = deployment_status.replica_states.get(
ReplicaState.RUNNING, 0
)
assert num_running_replicas == expected_num_running_replicas, (
f"{app_status}, {ray.available_resources()}, "
f"{ray.get(event_manager.get_waiter_statuses.remote())}, "
f"{ray.get(event_manager.get_max_replicas_to_run.remote())}"
)
return True
wait_for_condition(
replicas_running,
expected_num_running_replicas=(min_replicas - 1),
timeout=15,
)
def check_expected_statuses(
expected_app_status: ApplicationStatus,
expected_deployment_status: DeploymentStatus,
expected_deployment_status_trigger: DeploymentStatusTrigger,
) -> bool:
status = serve.status()
app_status = status.applications[app_name]
assert app_status.status == expected_app_status, f"{app_status}"
deployment_status = app_status.deployments[deployment_name]
assert (
deployment_status.status == expected_deployment_status
), f"{deployment_status}"
assert (
deployment_status.status_trigger == expected_deployment_status_trigger
), f"{deployment_status}"
return True
check_expected_statuses(
ApplicationStatus.DEPLOYING,
DeploymentStatus.UPDATING,
DeploymentStatusTrigger.CONFIG_UPDATE_STARTED,
)
# Check that these statuses don't change over time.
print("Statuses are as expected. Sleeping briefly and checking again...")
time.sleep(1.5)
check_expected_statuses(
ApplicationStatus.DEPLOYING,
DeploymentStatus.UPDATING,
DeploymentStatusTrigger.CONFIG_UPDATE_STARTED,
)
print("Statuses are still as expected. Redeploying...")
# Check the status after redeploying the deployment.
min_replicas += 1
app = AutoscalingDeployment.options(
autoscaling_config=AutoscalingConfig(
min_replicas=min_replicas,
max_replicas=max_replicas,
)
).bind()
serve._run(app, name=app_name, _blocking=False)
expected_num_active_replicas = min_replicas
wait_for_condition(check_num_active_replicas, expected=expected_num_active_replicas)
print("Replicas have started waiting. Releasing some replicas...")
ray.get(event_manager.set_max_replicas_to_run.remote(min_replicas - 1))
wait_for_condition(
replicas_running,
expected_num_running_replicas=(min_replicas - 1),
timeout=20,
)
check_expected_statuses(
ApplicationStatus.DEPLOYING,
DeploymentStatus.UPDATING,
DeploymentStatusTrigger.CONFIG_UPDATE_STARTED,
)
print("Statuses are as expected. Sleeping briefly and checking again...")
time.sleep(1.5)
check_expected_statuses(
ApplicationStatus.DEPLOYING,
DeploymentStatus.UPDATING,
DeploymentStatusTrigger.CONFIG_UPDATE_STARTED,
)
print(
"Statuses are still as expected. "
"Releasing some replicas and checking again..."
)
wait_for_condition(check_num_active_replicas, expected=expected_num_active_replicas)
# Release enough replicas for deployment to enter autoscaling bounds.
ray.get(event_manager.set_max_replicas_to_run.remote(min_replicas))
wait_for_condition(
replicas_running,
expected_num_running_replicas=min_replicas,
timeout=20,
)
check_expected_statuses(
ApplicationStatus.RUNNING,
DeploymentStatus.HEALTHY,
DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED,
)
print("Statuses are as expected. Redeploying with higher min_replicas...")
min_replicas += 1
app = AutoscalingDeployment.options(
autoscaling_config=AutoscalingConfig(
min_replicas=min_replicas,
max_replicas=max_replicas,
)
).bind()
serve._run(app, name=app_name, _blocking=False)
expected_num_active_replicas = min_replicas
wait_for_condition(check_num_active_replicas, expected=expected_num_active_replicas)
print("Replicas have started waiting. Checking statuses...")
# DeploymentStatus should return to UPDATING because the
# autoscaling_config changed.
wait_for_condition(
check_expected_statuses,
expected_app_status=ApplicationStatus.DEPLOYING,
expected_deployment_status=DeploymentStatus.UPDATING,
expected_deployment_status_trigger=(
DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
),
)
print("Statuses are as expected. Sleeping briefly and checking again...")
time.sleep(1.5)
check_expected_statuses(
ApplicationStatus.DEPLOYING,
DeploymentStatus.UPDATING,
DeploymentStatusTrigger.CONFIG_UPDATE_STARTED,
)
print(
"Statuses are still as expected. Releasing some replicas and checking again..."
)
ray.get(event_manager.set_max_replicas_to_run.remote(min_replicas))
wait_for_condition(
replicas_running,
expected_num_running_replicas=min_replicas,
timeout=20,
)
check_expected_statuses(
ApplicationStatus.RUNNING,
DeploymentStatus.HEALTHY,
DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED,
)
print("Statuses are as expected.")
# Serve applies autoscaling config to custom policies at registration time.
def custom_autoscaling_policy(ctx: AutoscalingContext):
if ctx.total_num_requests > 50:
return 3, {}
else:
return 2, {}
@pytest.mark.parametrize(
"policy",
[
{
"policy_function": "ray.serve.tests.test_autoscaling_policy.custom_autoscaling_policy",
},
AutoscalingPolicy(
policy_function="ray.serve.tests.test_autoscaling_policy.custom_autoscaling_policy"
),
AutoscalingPolicy(policy_function=custom_autoscaling_policy),
],
)
def test_e2e_scale_up_down_basic_with_custom_policy(serve_instance_with_signal, policy):
"""Send 100 requests and check that we autoscale up, and then back down."""
_, signal = serve_instance_with_signal
@serve.deployment(
autoscaling_config={
"min_replicas": 1,
"max_replicas": 4,
"downscale_delay_s": 0.5,
"upscale_delay_s": 0,
"policy": policy,
"metrics_interval_s": 0.1,
"look_back_period_s": 1,
},
# We will send over a lot of queries. This will make sure replicas are
# killed quickly during cleanup.
graceful_shutdown_timeout_s=1,
max_ongoing_requests=1000,
)
class A:
async def __call__(self):
await signal.wait.remote()
handle = serve.run(A.bind())
wait_for_condition(
check_deployment_status, name="A", expected_status=DeploymentStatus.HEALTHY
)
[handle.remote() for _ in range(40)]
# scale up one more replica from min_replicas
wait_for_condition(check_num_replicas_eq, name="A", target=2)
print("Scaled up to 2 replicas.")
ray.get(signal.send.remote())
wait_for_condition(lambda: ray.get(signal.cur_num_waiters.remote()) == 0)
ray.get(signal.send.remote(clear=True))
[handle.remote() for _ in range(70)]
wait_for_condition(check_num_replicas_eq, name="A", target=3)
ray.get(signal.send.remote())
wait_for_condition(lambda: ray.get(signal.cur_num_waiters.remote()) == 0)
def app_level_custom_autoscaling_policy(ctxs: Dict[DeploymentID, AutoscalingContext]):
decisions: Dict[DeploymentID, int] = {}
for deployment_id, ctx in ctxs.items():
if deployment_id.name == "A":
if ctx.total_num_requests > 50:
decisions[deployment_id] = 4
else:
decisions[deployment_id] = 2
elif deployment_id.name == "B":
if ctx.total_num_requests > 60:
decisions[deployment_id] = 5
else:
decisions[deployment_id] = 3
else:
raise RuntimeWarning(f"Unknown deployment: {deployment_id}")
return decisions, {}
class TestAppLevelAutoscalingPolicy:
@pytest.fixture
def serve_instance_with_two_signal(self, serve_instance):
client = serve_instance
signal_a = SignalActor.options(name="signal_A").remote()
signal_b = SignalActor.options(name="signal_B").remote()
yield client, signal_a, signal_b
# Delete signal actors so there is no conflict between tests
ray.kill(signal_a)
ray.kill(signal_b)
def verify_scaling_decisions(self, signal_A, signal_B):
hA = serve.get_deployment_handle("A", app_name=SERVE_DEFAULT_APP_NAME)
hB = serve.get_deployment_handle("B", app_name=SERVE_DEFAULT_APP_NAME)
# ---- Deployment A ----
ray.get(signal_A.send.remote(clear=True))
results = [hA.remote() for _ in range(40)]
wait_for_condition(lambda: ray.get(signal_A.cur_num_waiters.remote()) == 40)
wait_for_condition(check_num_replicas_eq, name="A", target=2)
ray.get(signal_A.send.remote(clear=True))
assert all(result.result(timeout_s=10) for result in results)
results = [hA.remote() for _ in range(70)]
wait_for_condition(lambda: ray.get(signal_A.cur_num_waiters.remote()) == 70)
wait_for_condition(check_num_replicas_eq, name="A", target=4)
ray.get(signal_A.send.remote())
assert all(result.result(timeout_s=10) for result in results)
# ---- Deployment B ----
ray.get(signal_B.send.remote(clear=True))
results = [hB.remote() for _ in range(50)]
wait_for_condition(lambda: ray.get(signal_B.cur_num_waiters.remote()) == 50)
wait_for_condition(check_num_replicas_eq, name="B", target=3)
ray.get(signal_B.send.remote(clear=True))
assert all(result.result(timeout_s=10) for result in results)
results = [hB.remote() for _ in range(120)]
wait_for_condition(lambda: ray.get(signal_B.cur_num_waiters.remote()) == 120)
wait_for_condition(check_num_replicas_eq, name="B", target=5)
ray.get(signal_B.send.remote())
assert all(result.result(timeout_s=10) for result in results)
@pytest.mark.parametrize(
"policy",
[
{
"policy_function": "ray.serve.tests.test_autoscaling_policy.app_level_custom_autoscaling_policy"
},
AutoscalingPolicy(
policy_function="ray.serve.tests.test_autoscaling_policy.app_level_custom_autoscaling_policy"
),
AutoscalingPolicy(policy_function=app_level_custom_autoscaling_policy),
],
)
def test_application_autoscaling_policy(
self, serve_instance_with_two_signal, policy
):
client, signal_A, signal_B = serve_instance_with_two_signal
config_template = {
"import_path": "ray.serve.tests.test_config_files.get_multi_deployment_signal_app.app",
"autoscaling_policy": policy,
"deployments": [
{
"name": "A",
"max_ongoing_requests": 1000,
"autoscaling_config": {
"min_replicas": 1,
"max_replicas": 10,
"metrics_interval_s": 0.1,
"upscale_delay_s": 0.1,
"downscale_delay_s": 0.5,
"look_back_period_s": 1,
},
"graceful_shutdown_timeout_s": 0.1,
},
{
"name": "B",
"max_ongoing_requests": 1000,
"autoscaling_config": {
"min_replicas": 1,
"max_replicas": 10,
"metrics_interval_s": 0.1,
"upscale_delay_s": 0.1,
"downscale_delay_s": 0.5,
"look_back_period_s": 1,
},
"graceful_shutdown_timeout_s": 0.1,
},
],
}
print(time.ctime(), "Deploying application with deployments A and B.")
client.deploy_apps(
ServeDeploySchema.model_validate({"applications": [config_template]})
)
wait_for_condition(check_running, timeout=15)
print(time.ctime(), "Application is RUNNING.")
self.verify_scaling_decisions(signal_A, signal_B)
def test_autoscaling_policy_switchback(self, serve_instance_with_two_signal):
client, signal_A, signal_B = serve_instance_with_two_signal
config_template = {
"import_path": "ray.serve.tests.test_config_files.get_multi_deployment_signal_app.app",
"deployments": [
{
"name": "A",
"max_ongoing_requests": 1000,
"autoscaling_config": {
"min_replicas": 1,
"max_replicas": 10,
"metrics_interval_s": 0.1,
"upscale_delay_s": 0.1,
"downscale_delay_s": 0.5,
"look_back_period_s": 1,
"policy": {
"policy_function": "ray.serve.tests.test_autoscaling_policy.custom_autoscaling_policy"
},
},
"graceful_shutdown_timeout_s": 0.1,
},
],
}
client.deploy_apps(
ServeDeploySchema.model_validate({"applications": [config_template]})
)
wait_for_condition(check_running, timeout=15)
hA = serve.get_deployment_handle("A", app_name=SERVE_DEFAULT_APP_NAME)
results = [hA.remote() for _ in range(60)]
wait_for_condition(lambda: ray.get(signal_A.cur_num_waiters.remote()) == 60)
wait_for_condition(check_num_replicas_eq, name="A", target=3)
ray.get(signal_A.send.remote())
assert all(result.result(timeout_s=10) for result in results)
ray.get(signal_A.send.remote(clear=True))
# Switch to app-level policy
config_template = {
"import_path": "ray.serve.tests.test_config_files.get_multi_deployment_signal_app.app",
"autoscaling_policy": {
"policy_function": "ray.serve.tests.test_autoscaling_policy.app_level_custom_autoscaling_policy"
},
"deployments": [
{
"name": "A",
"max_ongoing_requests": 1000,
"autoscaling_config": {
"min_replicas": 1,
"max_replicas": 10,
"metrics_interval_s": 0.1,
"upscale_delay_s": 0.1,
"downscale_delay_s": 0.5,
"look_back_period_s": 1,
},
"graceful_shutdown_timeout_s": 0.1,
},
{
"name": "B",
"max_ongoing_requests": 1000,
"autoscaling_config": {
"min_replicas": 1,
"max_replicas": 10,
"metrics_interval_s": 0.1,
"upscale_delay_s": 0.1,
"downscale_delay_s": 0.5,
"look_back_period_s": 1,
},
"graceful_shutdown_timeout_s": 0.1,
},
],
}
client.deploy_apps(
ServeDeploySchema.model_validate({"applications": [config_template]})
)
wait_for_condition(check_running, timeout=15)
hA = serve.get_deployment_handle("A", app_name=SERVE_DEFAULT_APP_NAME)
results = [hA.remote() for _ in range(120)]
wait_for_condition(lambda: ray.get(signal_A.cur_num_waiters.remote()) == 120)
wait_for_condition(check_num_replicas_eq, name="A", target=4)
ray.get(signal_A.send.remote())
assert all(result.result(timeout_s=10) for result in results)
ray.get(signal_A.send.remote(clear=True))
hB = serve.get_deployment_handle("B", app_name=SERVE_DEFAULT_APP_NAME)
results = [hB.remote() for _ in range(120)]
wait_for_condition(lambda: ray.get(signal_B.cur_num_waiters.remote()) == 120)
wait_for_condition(check_num_replicas_eq, name="B", target=5)
ray.get(signal_B.send.remote())
assert all(result.result(timeout_s=10) for result in results)
ray.get(signal_B.send.remote(clear=True))
# switch back to deployment-level policy
config_template = {
"import_path": "ray.serve.tests.test_config_files.get_multi_deployment_signal_app.app",
"deployments": [
{
"name": "A",
"max_ongoing_requests": 1000,
"autoscaling_config": {
"min_replicas": 1,
"max_replicas": 10,
"metrics_interval_s": 0.1,
"upscale_delay_s": 0.1,
"downscale_delay_s": 0.5,
"look_back_period_s": 1,
"policy": {
"policy_function": "ray.serve.tests.test_autoscaling_policy.custom_autoscaling_policy"
},
},
"graceful_shutdown_timeout_s": 0.1,
},
],
}
print(time.ctime(), "Deploying application with deployments A and B.")
client.deploy_apps(
ServeDeploySchema.model_validate({"applications": [config_template]})
)
wait_for_condition(check_running, timeout=15)
hA = serve.get_deployment_handle("A", app_name=SERVE_DEFAULT_APP_NAME)
results = [hA.remote() for _ in range(120)]
wait_for_condition(lambda: ray.get(signal_A.cur_num_waiters.remote()) == 120)
wait_for_condition(check_num_replicas_eq, name="A", target=3)
ray.get(signal_A.send.remote())
assert all(result.result(timeout_s=10) for result in results)
def test_autoscaling_policy_enable_disable(self, serve_instance_with_two_signal):
client, signal_A, _ = serve_instance_with_two_signal
config_template = {
"import_path": "ray.serve.tests.test_config_files.get_multi_deployment_signal_app.app",
"deployments": [
{
"name": "A",
"max_ongoing_requests": 1000,
"num_replicas": 1,
},
],
}
client.deploy_apps(
ServeDeploySchema.model_validate({"applications": [config_template]})
)
wait_for_condition(check_running, timeout=15)
hA = serve.get_deployment_handle("A", app_name=SERVE_DEFAULT_APP_NAME)
results = [hA.remote() for _ in range(120)]
wait_for_condition(lambda: ray.get(signal_A.cur_num_waiters.remote()) == 120)
wait_for_condition(check_num_replicas_eq, name="A", target=1)
ray.get(signal_A.send.remote(clear=True))
assert all(result.result(timeout_s=10) for result in results)
config_template = {
"import_path": "ray.serve.tests.test_config_files.get_multi_deployment_signal_app.app",
"autoscaling_policy": {
"policy_function": "ray.serve.tests.test_autoscaling_policy.app_level_custom_autoscaling_policy"
},
"deployments": [
{
"name": "A",
"max_ongoing_requests": 1000,
"num_replicas": "auto",
"autoscaling_config": {
"min_replicas": 1,
"max_replicas": 10,
"metrics_interval_s": 0.1,
"upscale_delay_s": 0.1,
"downscale_delay_s": 0.5,
"look_back_period_s": 1,
},
},
],
}
client.deploy_apps(
ServeDeploySchema.model_validate({"applications": [config_template]})
)
wait_for_condition(check_running, timeout=15)
hA = serve.get_deployment_handle("A", app_name=SERVE_DEFAULT_APP_NAME)
results = [hA.remote() for _ in range(120)]
wait_for_condition(lambda: ray.get(signal_A.cur_num_waiters.remote()) == 120)
wait_for_condition(check_num_replicas_eq, name="A", target=4)
ray.get(signal_A.send.remote(clear=True))
assert all(result.result(timeout_s=10) for result in results)
# turn off app-level autoscaling policy
config_template = {
"import_path": "ray.serve.tests.test_config_files.get_multi_deployment_signal_app.app",
"deployments": [
{
"name": "A",
"max_ongoing_requests": 1000,
"num_replicas": 1,
},
],
}
client.deploy_apps(
ServeDeploySchema.model_validate({"applications": [config_template]})
)
wait_for_condition(check_running, timeout=15)
wait_for_condition(check_num_replicas_eq, name="A", target=1)
hA = serve.get_deployment_handle("A", app_name=SERVE_DEFAULT_APP_NAME)
results = [hA.remote() for _ in range(120)]
wait_for_condition(lambda: ray.get(signal_A.cur_num_waiters.remote()) == 120)
wait_for_condition(check_num_replicas_eq, name="A", target=1)
ray.get(signal_A.send.remote(clear=True))
assert all(result.result(timeout_s=10) for result in results)
class AppLevelClassCallableAutoscalingPolicy:
"""App-level autoscaling policy using the class-callable pattern.
Receives ``Dict[DeploymentID, AutoscalingContext]`` and returns per-
deployment replica decisions. Constructor kwargs configure the target
replica counts per deployment name.
"""
def __init__(self, targets_low: Dict[str, int], targets_high: Dict[str, int]):
self._targets_low = targets_low
self._targets_high = targets_high
def __call__(self, ctxs: Dict[DeploymentID, AutoscalingContext]):
decisions: Dict[DeploymentID, int] = {}
for deployment_id, ctx in ctxs.items():
high_threshold = 50 if deployment_id.name == "A" else 60
if ctx.total_num_requests > high_threshold:
decisions[deployment_id] = self._targets_high[deployment_id.name]
else:
decisions[deployment_id] = self._targets_low[deployment_id.name]
return decisions, {}
APP_LEVEL_CLASS_CALLABLE_POLICY_KWARGS = {
"targets_low": {"A": 2, "B": 3},
"targets_high": {"A": 4, "B": 5},
}
class TestAppLevelClassCallablePolicy:
@pytest.fixture
def serve_instance_with_two_signal(self, serve_instance):
client = serve_instance
signal_a = SignalActor.options(name="signal_A").remote()
signal_b = SignalActor.options(name="signal_B").remote()
yield client, signal_a, signal_b
ray.kill(signal_a)
ray.kill(signal_b)
@pytest.mark.parametrize(
"policy",
[
{
"policy_function": "ray.serve.tests.test_autoscaling_policy.AppLevelClassCallableAutoscalingPolicy",
"policy_kwargs": APP_LEVEL_CLASS_CALLABLE_POLICY_KWARGS,
},
AutoscalingPolicy(
policy_function="ray.serve.tests.test_autoscaling_policy.AppLevelClassCallableAutoscalingPolicy",
policy_kwargs=APP_LEVEL_CLASS_CALLABLE_POLICY_KWARGS,
),
AutoscalingPolicy(
policy_function=AppLevelClassCallableAutoscalingPolicy,
policy_kwargs=APP_LEVEL_CLASS_CALLABLE_POLICY_KWARGS,
),
],
)
def test_app_level_class_callable_policy(
self, serve_instance_with_two_signal, policy
):
"""Test app-level autoscaling with a class-callable policy and policy_kwargs.
Uses the same multi-deployment app and verification logic as the
existing ``TestAppLevelAutoscalingPolicy`` but with a class-based
policy whose thresholds are supplied via ``policy_kwargs``.
"""
client, signal_A, signal_B = serve_instance_with_two_signal
config_template = {
"import_path": "ray.serve.tests.test_config_files.get_multi_deployment_signal_app.app",
"autoscaling_policy": policy,
"deployments": [
{
"name": "A",
"max_ongoing_requests": 1000,
"autoscaling_config": {
"min_replicas": 1,
"max_replicas": 10,
"metrics_interval_s": 0.1,
"upscale_delay_s": 0.1,
"downscale_delay_s": 0.5,
"look_back_period_s": 1,
},
"graceful_shutdown_timeout_s": 0.1,
},
{
"name": "B",
"max_ongoing_requests": 1000,
"autoscaling_config": {
"min_replicas": 1,
"max_replicas": 10,
"metrics_interval_s": 0.1,
"upscale_delay_s": 0.1,
"downscale_delay_s": 0.5,
"look_back_period_s": 1,
},
"graceful_shutdown_timeout_s": 0.1,
},
],
}
print(time.ctime(), "Deploying app with class-callable app-level policy.")
client.deploy_apps(
ServeDeploySchema.model_validate({"applications": [config_template]})
)
wait_for_condition(check_running, timeout=15)
print(time.ctime(), "Application is RUNNING.")
hA = serve.get_deployment_handle("A", app_name=SERVE_DEFAULT_APP_NAME)
hB = serve.get_deployment_handle("B", app_name=SERVE_DEFAULT_APP_NAME)
# ---- Deployment A: low load → targets_low["A"] = 2 ----
ray.get(signal_A.send.remote(clear=True))
results = [hA.remote() for _ in range(40)]
wait_for_condition(lambda: ray.get(signal_A.cur_num_waiters.remote()) == 40)
wait_for_condition(check_num_replicas_eq, name="A", target=2)
# ---- Deployment A: high load → targets_high["A"] = 4 ----
ray.get(signal_A.send.remote(clear=True))
assert all(r.result(timeout_s=10) for r in results)
results = [hA.remote() for _ in range(70)]
wait_for_condition(lambda: ray.get(signal_A.cur_num_waiters.remote()) == 70)
wait_for_condition(check_num_replicas_eq, name="A", target=4)
ray.get(signal_A.send.remote())
assert all(r.result(timeout_s=10) for r in results)
# ---- Deployment B: low load → targets_low["B"] = 3 ----
ray.get(signal_B.send.remote(clear=True))
results = [hB.remote() for _ in range(50)]
wait_for_condition(lambda: ray.get(signal_B.cur_num_waiters.remote()) == 50)
wait_for_condition(check_num_replicas_eq, name="B", target=3)
# ---- Deployment B: high load → targets_high["B"] = 5 ----
ray.get(signal_B.send.remote(clear=True))
assert all(r.result(timeout_s=10) for r in results)
results = [hB.remote() for _ in range(120)]
wait_for_condition(lambda: ray.get(signal_B.cur_num_waiters.remote()) == 120)
wait_for_condition(check_num_replicas_eq, name="B", target=5)
ray.get(signal_B.send.remote())
assert all(r.result(timeout_s=10) for r in results)
class ClassCallableAutoscalingPolicy:
"""Custom autoscaling policy using the class-callable pattern.
The *class itself* (not an instance) is passed to ``AutoscalingPolicy``,
and constructor arguments are supplied via ``policy_kwargs``."""
def __init__(self, signal_actor_name: str, target_when_ready: int = 3):
self._signal_actor_name = signal_actor_name
self._target_when_ready = target_when_ready
self._ready = False
self._task: asyncio.Task = None
self._started = False
# -- lazy start: schedule onto the controller's running event loop ------
def _ensure_started(self) -> None:
if self._started:
return
self._started = True
loop = asyncio.get_running_loop()
self._task = loop.create_task(self._background_work())
async def _background_work(self) -> None:
"""Simulate a long-running async IO task that eventually flips a flag.
In a real policy this could poll an external metrics service, listen
on a message queue, etc.
"""
signal = ray.get_actor(self._signal_actor_name)
while True:
try:
await signal.wait.remote()
self._ready = True
return
except Exception:
await asyncio.sleep(0.1)
# -- the policy callable ------------------------------------------------
def __call__(self, ctx: AutoscalingContext):
self._ensure_started()
if self._ready:
return self._target_when_ready, {"ready": True}
else:
return ctx.current_num_replicas, {"ready": False}
CLASS_CALLABLE_POLICY_KWARGS = {
"signal_actor_name": "class_callable_signal",
"target_when_ready": 3,
}
@pytest.mark.parametrize(
"policy",
[
{
"policy_function": "ray.serve.tests.test_autoscaling_policy.ClassCallableAutoscalingPolicy",
"policy_kwargs": CLASS_CALLABLE_POLICY_KWARGS,
},
AutoscalingPolicy(
policy_function="ray.serve.tests.test_autoscaling_policy.ClassCallableAutoscalingPolicy",
policy_kwargs=CLASS_CALLABLE_POLICY_KWARGS,
),
AutoscalingPolicy(
policy_function=ClassCallableAutoscalingPolicy,
policy_kwargs=CLASS_CALLABLE_POLICY_KWARGS,
),
],
)
def test_class_callable_autoscaling_policy(serve_instance, policy):
"""Test class-callable autoscaling policy in all three registration modes:
raw dict, AutoscalingPolicy with string import path, and AutoscalingPolicy
with direct class reference.
"""
signal = SignalActor.options(name="class_callable_signal").remote()
@serve.deployment(
autoscaling_config={
"min_replicas": 1,
"max_replicas": 5,
"downscale_delay_s": 0.5,
"upscale_delay_s": 0,
"metrics_interval_s": 0.1,
"look_back_period_s": 1,
"policy": policy,
},
graceful_shutdown_timeout_s=1,
max_ongoing_requests=1000,
)
class B:
async def __call__(self):
return "ok"
serve.run(B.bind())
wait_for_condition(
check_deployment_status, name="B", expected_status=DeploymentStatus.HEALTHY
)
# Before the signal fires the background task hasn't completed, so the
# policy should keep the replica count at the initial value (1).
wait_for_condition(check_num_replicas_eq, name="B", target=1)
print("Replicas stayed at 1 while background task is pending.")
# Fire the signal — the background task completes and flips _ready.
ray.get(signal.send.remote())
# The policy now returns target_when_ready=3, so Serve should scale up.
wait_for_condition(check_num_replicas_eq, name="B", target=3, timeout=30)
print("Scaled up to 3 replicas after background task completed.")
ray.kill(signal)
def test_warmup_no_runaway_scaling_with_control_loop(serve_instance):
"""Deploy with upscaling_factor > 1 and no traffic.
After the deployment becomes healthy the replica count must stay at
min_replicas for several seconds — the control loop must not amplify
the target while replicas are warming up.
"""
min_replicas = 2
max_replicas = 10
@serve.deployment(
autoscaling_config={
"min_replicas": min_replicas,
"max_replicas": max_replicas,
"target_ongoing_requests": 1,
"upscaling_factor": 2.0,
"metrics_interval_s": 0.1,
"look_back_period_s": 0.2,
"upscale_delay_s": 0,
"downscale_delay_s": 30,
},
)
class A:
def __call__(self):
return "ok"
serve.run(A.bind())
wait_for_condition(
check_deployment_status, name="A", expected_status=DeploymentStatus.HEALTHY
)
# Give the control loop time to run many iterations with no traffic.
for _ in range(10):
time.sleep(0.5)
num = get_num_alive_replicas("A")
assert num <= min_replicas, (
f"Expected at most {min_replicas} replicas with no traffic, "
f"but found {num}. The autoscaler may be runaway-scaling "
f"during warmup due to the upscaling_factor feedback loop bug."
)
class TestAutoscalingWithRejection:
"""Autoscaling tests with rejection under HTTP load.
Original issue: https://github.com/ray-project/ray/issues/61551
Tests that replicas scale from 1->2 under load and back to 1 after drain.
The way this test is written makes it somewhat non-deterministic and harder to interpret.
It relies on a replica rejecting a request after power-of-two routing
has already made a decision based on stale replica state.
Since that scenario depends on timing and stale state,
its not something we can reproduce deterministically.
"""
@staticmethod
async def _run_phase(session, url, stream, qps, duration_s, inflight, counters):
"""Run one load phase at the given QPS for duration_s seconds."""
interval_s = 1.0 / qps
deadline = time.monotonic() + duration_s
async def one_request():
counters["sent"] += 1
try:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=120)
) as resp:
if stream:
async for _ in resp.content.iter_chunked(1024):
pass
else:
await resp.read()
counters["ok"] += 1
except Exception:
counters["errors"] += 1
while time.monotonic() < deadline:
task = asyncio.create_task(one_request())
inflight.add(task)
task.add_done_callback(inflight.discard)
await asyncio.sleep(interval_s)
@classmethod
async def _run_load(cls, url: str, stream: bool):
"""Execute the load profile and return final counters.
Load profile (qps, duration_s): [(1.0, 6), (8.0, 12), (1.0, 10)]
"""
inflight: set = set()
counters = {"sent": 0, "ok": 0, "errors": 0}
async with aiohttp.ClientSession() as session:
for qps, duration_s in [(1.0, 6), (8.0, 12), (1.0, 10)]:
await cls._run_phase(
session, url, stream, qps, duration_s, inflight, counters
)
await asyncio.sleep(20)
if inflight:
await asyncio.gather(*list(inflight), return_exceptions=True)
return counters
@classmethod
def _send_load_in_thread(cls, url: str, stream: bool):
"""Run the load generator in a background thread."""
result = {}
error = [None]
def _run():
try:
result.update(asyncio.run(cls._run_load(url, stream)))
except Exception as e:
error[0] = e
t = threading.Thread(target=_run, daemon=True)
t.start()
return t, result, error
def _assert_scale_up_and_down(self, client, dep_id: DeploymentID, stream: bool):
"""Send load, assert 1->2 scale-up, drain, assert 2->1 scale-down."""
# 1) Send load
url = "http://localhost:8000/app"
load_thread, load_counters, load_error = self._send_load_in_thread(url, stream)
tlog("Load generation started.")
# 2) Assert replicas scale-up: 1 -> 2
wait_for_condition(
check_num_replicas_eq,
name="Backend",
target=2,
app_name="app",
timeout=60,
retry_interval_ms=1000,
)
tlog("Replicas scaled up to 2.")
# 3) Drain: wait for load to finish, assert all requests 'ok'
load_thread.join(timeout=180)
assert (
not load_thread.is_alive()
), "Load generation thread did not finish in time"
assert load_error[0] is None, f"Load generation failed: {load_error[0]}"
tlog(f"Load finished. counters={load_counters}")
assert load_counters["ok"] == load_counters["sent"], (
f"Expected all {load_counters['sent']} requests to succeed, "
f"but ok={load_counters['ok']}, errors={load_counters['errors']}"
)
tlog(f"All {load_counters['ok']} requests reported ok.")
# 4) Assert replicas scale-down: 2 -> 1
wait_for_condition(
check_num_replicas_eq,
name="Backend",
target=1,
app_name="app",
timeout=60,
)
tlog("Replicas scaled back down to 1.")
# 5) Assert total running requests reaches 0 after scale-down
wait_for_condition(
check_num_requests_eq,
client=client,
id=dep_id,
expected=0,
timeout=20,
)
tlog("Total running requests reached 0.")
def test_streaming_with_rejection(self, serve_instance):
client = serve_instance
@serve.deployment(
autoscaling_config={
"min_replicas": 1,
"max_replicas": 2,
"target_ongoing_requests": 2,
"upscale_delay_s": 2,
"downscale_delay_s": 8,
"metrics_interval_s": 1,
"look_back_period_s": 5,
},
max_ongoing_requests=4,
graceful_shutdown_timeout_s=1,
)
class Backend:
async def stream(self):
for i in range(20):
yield f"{i}\n".encode()
await asyncio.sleep(0.15)
@serve.deployment(num_replicas=4, max_ongoing_requests=1000)
class Ingress:
def __init__(self, backend: DeploymentHandle):
self._backend = backend.options(
stream=True, method_name="stream", _by_reference=False
)
async def __call__(self, request: Request):
return StreamingResponse(
self._backend.remote(), media_type="text/plain"
)
serve.run(Ingress.bind(Backend.bind()), name="app", route_prefix="/app")
wait_for_condition(
check_deployment_status,
name="Backend",
expected_status=DeploymentStatus.HEALTHY,
app_name="app",
timeout=30,
)
wait_for_condition(
check_num_replicas_eq,
name="Backend",
target=1,
app_name="app",
timeout=30,
)
tlog(
f"Deployed app with configuration: "
f"{' '.join(f'{k}={v}' for k, v in os.environ.items() if k.startswith('RAY_SERVE_'))}"
)
tlog("Streaming deployment healthy with 1 replica.")
dep_id = DeploymentID(name="Backend", app_name="app")
self._assert_scale_up_and_down(client=client, dep_id=dep_id, stream=True)
tlog("Test passed.")
def test_unary_with_rejection(self, serve_instance):
client = serve_instance
@serve.deployment(
autoscaling_config={
"min_replicas": 1,
"max_replicas": 2,
"target_ongoing_requests": 2,
"upscale_delay_s": 2,
"downscale_delay_s": 8,
"metrics_interval_s": 1,
"look_back_period_s": 5,
},
max_ongoing_requests=4,
graceful_shutdown_timeout_s=1,
)
class Backend:
async def __call__(self):
await asyncio.sleep(20 * 0.15)
return {"ok": True}
@serve.deployment(num_replicas=4, max_ongoing_requests=1000)
class Ingress:
def __init__(self, backend: DeploymentHandle):
self._backend = backend.options(_by_reference=False)
async def __call__(self, request: Request):
return await self._backend.remote()
serve.run(Ingress.bind(Backend.bind()), name="app", route_prefix="/app")
wait_for_condition(
check_deployment_status,
name="Backend",
expected_status=DeploymentStatus.HEALTHY,
app_name="app",
timeout=30,
)
wait_for_condition(
check_num_replicas_eq,
name="Backend",
target=1,
app_name="app",
timeout=30,
)
tlog(
f"Deployed app with configuration: "
f"{' '.join(f'{k}={v}' for k, v in os.environ.items() if k.startswith('RAY_SERVE_'))}"
)
tlog("Unary deployment healthy with 1 replica.")
dep_id = DeploymentID(name="Backend", app_name="app")
self._assert_scale_up_and_down(client=client, dep_id=dep_id, stream=False)
tlog("Test passed.")
if __name__ == "__main__":
import sys
import pytest
sys.exit(pytest.main(["-v", "-s", __file__] + sys.argv[1:]))