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

852 lines
31 KiB
Python

import logging
import re
import sys
import time
from copy import copy
from functools import partial
from typing import List
import httpx
import pytest
import ray
import ray.actor
from ray import serve
from ray._common.test_utils import SignalActor, wait_for_condition
from ray.serve._private.common import DeploymentID, ReplicaID
from ray.serve._private.constants import (
RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER,
RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE,
SERVE_DEFAULT_APP_NAME,
SERVE_NAMESPACE,
)
from ray.serve._private.test_utils import (
check_num_replicas_eq,
check_num_replicas_gte,
check_running,
check_target_groups_ready,
get_application_url,
)
from ray.serve.schema import (
ApplicationStatus,
ServeApplicationSchema,
ServeDeploySchema,
ServeInstanceDetails,
)
from ray.tests.conftest import call_ray_stop_only # noqa: F401
from ray.util.state import list_actors
def check_log_file(log_file: str, expected_regex: list):
with open(log_file, "r") as f:
s = f.read()
print(s)
for regex in expected_regex:
assert re.findall(regex, s) != [], f"Did not find pattern '{regex}' in {s}"
return True
def check_deployments_dead(deployment_ids: List[DeploymentID]):
prefixes = [f"{id.app_name}#{id.name}" for id in deployment_ids]
actor_names = [
actor["name"] for actor in list_actors(filters=[("state", "=", "ALIVE")])
]
return all(f"ServeReplica::{p}" not in actor_names for p in prefixes)
class TestDeploywithLoggingConfig:
def get_deploy_config(self, model_within_logging_config: bool = False):
if model_within_logging_config:
path = "ray.serve.tests.test_config_files.logging_config_test.model2"
else:
path = "ray.serve.tests.test_config_files.logging_config_test.model"
return {
"applications": [
{
"name": "app1",
"route_prefix": "/app1",
"import_path": path,
},
],
}
@pytest.mark.parametrize("encoding_type", ["TEXT", "JSON"])
def test_deploy_app_with_application_logging_config(
self, serve_instance, encoding_type: str
):
"""Deploy application with application logging config"""
client = serve_instance
config_dict = self.get_deploy_config()
config_dict["applications"][0]["logging_config"] = {
"encoding": encoding_type,
}
config = ServeDeploySchema.model_validate(config_dict)
client.deploy_apps(config)
wait_for_condition(
lambda: httpx.post("http://localhost:8000/app1").status_code == 200
)
resp = httpx.post("http://localhost:8000/app1").json()
replica_id = resp["replica"].split("#")[-1]
if encoding_type == "JSON":
expected_log_regex = [f'"replica": "{replica_id}", ']
else:
expected_log_regex = [f".*{replica_id}.*"]
check_log_file(resp["log_file"], expected_log_regex)
@pytest.mark.parametrize("encoding_type", ["TEXT", "JSON"])
def test_deploy_app_with_deployment_logging_config(
self, serve_instance, encoding_type: str
):
client = serve_instance
"""Deploy application with deployment logging config inside the yaml"""
config_dict = self.get_deploy_config()
config_dict["applications"][0]["deployments"] = [
{
"name": "Model",
"logging_config": {
"encoding": encoding_type,
},
},
]
config = ServeDeploySchema.model_validate(config_dict)
client.deploy_apps(config)
wait_for_condition(
lambda: httpx.post("http://localhost:8000/app1").status_code == 200
)
resp = httpx.post("http://localhost:8000/app1").json()
replica_id = resp["replica"].split("#")[-1]
if encoding_type == "JSON":
expected_log_regex = [f'"replica": "{replica_id}", ']
else:
expected_log_regex = [f".*{replica_id}.*"]
check_log_file(resp["log_file"], expected_log_regex)
def test_deployment_logging_config_in_code(self, serve_instance):
"""Deploy application with deployment logging config inside the code"""
client = serve_instance
config_dict = self.get_deploy_config(model_within_logging_config=True)
config = ServeDeploySchema.model_validate(config_dict)
client.deploy_apps(config)
wait_for_condition(
lambda: httpx.post("http://localhost:8000/app1").status_code == 200
)
resp = httpx.post("http://localhost:8000/app1").json()
check_log_file(resp["log_file"], [".*this_is_debug_info.*"])
def test_overwritting_logging_config(self, serve_instance):
"""Overwrite the default logging config with application logging config"""
client = serve_instance
config_dict = self.get_deploy_config()
config = ServeDeploySchema.model_validate(config_dict)
client.deploy_apps(config)
wait_for_condition(
lambda: httpx.post("http://localhost:8000/app1").status_code == 200
)
def get_replica_info_format(replica_id: ReplicaID) -> str:
app_name = replica_id.deployment_id.app_name
deployment_name = replica_id.deployment_id.name
return f"{app_name}_{deployment_name} {replica_id.unique_id}"
# By default, log level is "INFO"
r = httpx.post("http://localhost:8000/app1")
r.raise_for_status()
request_id = r.headers["X-Request-Id"]
replica_id = ReplicaID.from_full_id_str(r.json()["replica"])
# Make sure 'model_debug_level' log content does not exist.
with pytest.raises(AssertionError):
check_log_file(r.json()["log_file"], [".*this_is_debug_info.*"])
# Check the log formatting.
check_log_file(
r.json()["log_file"],
f" {get_replica_info_format(replica_id)} {request_id} ",
)
# Set log level to "DEBUG"
config_dict["applications"][0]["logging_config"] = {
"log_level": "DEBUG",
}
config = ServeDeploySchema.model_validate(config_dict)
client.deploy_apps(config)
wait_for_condition(
lambda: httpx.post("http://localhost:8000/app1").status_code == 200
and httpx.post("http://localhost:8000/app1").json()["log_level"]
== logging.DEBUG,
)
r = httpx.post("http://localhost:8000/app1")
r.raise_for_status()
request_id = r.headers["X-Request-Id"]
replica_id = ReplicaID.from_full_id_str(r.json()["replica"])
check_log_file(
r.json()["log_file"],
[
# Check for DEBUG-level log statement.
".*this_is_debug_info.*",
# Check that the log formatting has remained the same.
f" {get_replica_info_format(replica_id)} {request_id} ",
],
)
def test_not_overwritting_logging_config_in_yaml(self, serve_instance):
"""Deployment logging config in yaml should not be overwritten
by application logging config.
"""
client = serve_instance
config_dict = self.get_deploy_config()
config_dict["applications"][0]["deployments"] = [
{
"name": "Model",
"logging_config": {
"log_level": "DEBUG",
},
},
]
config_dict["applications"][0]["logging_config"] = {
"log_level": "INFO",
}
config = ServeDeploySchema.model_validate(config_dict)
client.deploy_apps(config)
wait_for_condition(
lambda: httpx.post("http://localhost:8000/app1").status_code == 200
)
resp = httpx.post("http://localhost:8000/app1").json()
check_log_file(resp["log_file"], [".*this_is_debug_info.*"])
def test_not_overwritting_logging_config_in_code(self, serve_instance):
"""Deployment logging config in code should not be overwritten
by application logging config.
"""
client = serve_instance
config_dict = self.get_deploy_config(model_within_logging_config=True)
config_dict["applications"][0]["logging_config"] = {
"log_level": "INFO",
}
config = ServeDeploySchema.model_validate(config_dict)
client.deploy_apps(config)
wait_for_condition(
lambda: httpx.post("http://localhost:8000/app1").status_code == 200
)
resp = httpx.post("http://localhost:8000/app1").json()
check_log_file(resp["log_file"], [".*this_is_debug_info.*"])
def test_logs_dir(self, serve_instance):
client = serve_instance
config_dict = self.get_deploy_config()
config_dict["applications"][0]["logging_config"] = {
"log_level": "DEBUG",
}
config = ServeDeploySchema.model_validate(config_dict)
client.deploy_apps(config)
wait_for_condition(
lambda: httpx.post("http://localhost:8000/app1").status_code == 200
)
resp = httpx.get("http://127.0.0.1:8000/app1").json()
# Construct a new path
# "/tmp/ray/session_xxx/logs/serve/new_dir"
paths = resp["log_file"].split("/")
paths[-1] = "new_dir"
new_log_dir = "/".join(paths)
config_dict["applications"][0]["logging_config"] = {
"log_level": "DEBUG",
"logs_dir": new_log_dir,
}
config = ServeDeploySchema.model_validate(config_dict)
client.deploy_apps(config)
wait_for_condition(
lambda: httpx.post("http://localhost:8000/app1").status_code == 200
and "new_dir" in httpx.get("http://127.0.0.1:8000/app1").json()["log_file"]
)
resp = httpx.get("http://127.0.0.1:8000/app1").json()
# log content should be redirected to new file
check_log_file(resp["log_file"], [".*this_is_debug_info.*"])
@pytest.mark.parametrize("enable_access_log", [True, False])
def test_access_log(self, serve_instance, enable_access_log: bool):
client = serve_instance
config_dict = self.get_deploy_config()
config_dict["applications"][0]["logging_config"] = {
"enable_access_log": enable_access_log,
}
config = ServeDeploySchema.model_validate(config_dict)
client.deploy_apps(config)
wait_for_condition(
lambda: httpx.post("http://localhost:8000/app1").status_code == 200
)
resp = httpx.get("http://127.0.0.1:8000/app1")
assert resp.status_code == 200
resp = resp.json()
if enable_access_log:
check_log_file(resp["log_file"], [".*this_is_access_log.*"])
else:
with pytest.raises(AssertionError):
check_log_file(resp["log_file"], [".*this_is_access_log.*"])
def test_deploy_with_no_applications(serve_instance):
"""Deploy an empty list of applications, serve should just be started."""
client = serve_instance
config = ServeDeploySchema.model_validate({"applications": []})
client.deploy_apps(config)
def serve_running():
ServeInstanceDetails.model_validate(
ray.get(client._controller.get_serve_instance_details.remote())
)
actors = list_actors(
filters=[
("ray_namespace", "=", SERVE_NAMESPACE),
("state", "=", "ALIVE"),
]
)
actor_names = [actor["class_name"] for actor in actors]
has_proxy = any("Proxy" in name for name in actor_names)
return "ServeController" in actor_names and has_proxy
wait_for_condition(serve_running)
def test_deployments_not_listed_in_config(serve_instance):
"""Apply a config without the app's deployments listed. The deployments should
not redeploy.
"""
client = serve_instance
config = {
"applications": [{"import_path": "ray.serve.tests.test_config_files.pid.node"}]
}
client.deploy_apps(ServeDeploySchema(**config), _blocking=True)
check_running()
pid1, _ = httpx.get("http://localhost:8000/").json()
# Redeploy the same config (with no deployments listed)
client.deploy_apps(ServeDeploySchema(**config))
wait_for_condition(check_running, timeout=15)
# It should be the same replica actor
pids = []
for _ in range(4):
pids.append(httpx.get("http://localhost:8000/").json()[0])
assert all(pid == pid1 for pid in pids)
@pytest.mark.parametrize("rebuild", [True, False])
def test_redeploy_old_config_after_failed_deployment(serve_instance, rebuild):
"""
1. Deploy application which succeeds.
2. Redeploy application with an import path that fails.
3. Redeploy the exact same config from step 1.
Verify that step 3 succeeds and the application returns to running state.
"""
client = serve_instance
app_config = {
"name": "default",
"import_path": "ray.serve.tests.test_config_files.world.DagNode",
}
client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]}))
def check_application_running():
status = serve.status().applications["default"]
assert status.status == "RUNNING"
assert httpx.post("http://localhost:8000/").text == "wonderful world"
return True
wait_for_condition(check_application_running)
# Change config so that redeploy will error
new_app_config = copy(app_config)
if rebuild:
# New import path will cause an error upon importing app
new_app_config[
"import_path"
] = "ray.serve.tests.test_config_files.import_error.app"
err_msg = "ZeroDivisionError"
else:
# Set config for a nonexistent deployment
new_app_config["deployments"] = [{"name": "nonexistent", "num_replicas": 1}]
err_msg = "Deployment 'nonexistent' does not exist."
client.deploy_apps(ServeDeploySchema(**{"applications": [new_app_config]}))
def check_deploy_failed(message):
status = serve.status().applications["default"]
assert status.status == "DEPLOY_FAILED"
assert message in status.message
return True
wait_for_condition(check_deploy_failed, message=err_msg)
# Redeploy old config
client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]}))
wait_for_condition(check_application_running)
def test_deploy_does_not_affect_dynamic_apps(serve_instance):
"""
Deploy a set of apps via the declarative API (REST API) and then a dynamic
app via the imperative API (`serve.run`).
Check that applying a new config via the declarative API does not affect
the app deployed using the imperative API.
"""
client = serve_instance
config = ServeDeploySchema(
applications=[
ServeApplicationSchema(
name="declarative-app-1",
route_prefix="/app-1",
import_path="ray.serve.tests.test_config_files.world.DagNode",
),
],
)
client.deploy_apps(config, _blocking=True)
check_running(app_name="declarative-app-1")
url = get_application_url(app_name="declarative-app-1")
assert httpx.post(url).text == "wonderful world"
# Now `serve.run` a dynamic app.
@serve.deployment
class D:
def __call__(self, *args) -> str:
return "Hello!"
serve.run(D.bind(), name="dynamic-app", route_prefix="/dynamic")
wait_for_condition(check_running, app_name="dynamic-app")
url = get_application_url(app_name="dynamic-app")
assert httpx.post(url).text == "Hello!"
# Add a new app via declarative API.
# Existing declarative app and dynamic app should not be affected.
config.applications.append(
ServeApplicationSchema(
name="declarative-app-2",
route_prefix="/app-2",
import_path="ray.serve.tests.test_config_files.world.DagNode",
),
)
client.deploy_apps(config, _blocking=True)
check_running(app_name="declarative-app-2")
url = get_application_url(app_name="declarative-app-2")
assert httpx.post(url).text == "wonderful world"
url = get_application_url(app_name="declarative-app-1")
assert httpx.post(url).text == "wonderful world"
url = get_application_url(app_name="dynamic-app")
assert httpx.post(url).text == "Hello!"
# Delete one of the apps via declarative API.
# Other declarative app and dynamic app should not be affected.
config.applications.pop(0)
client.deploy_apps(config)
wait_for_condition(check_running, app_name="declarative-app-2")
url = get_application_url(app_name="declarative-app-2")
assert httpx.post(url).text == "wonderful world"
url = get_application_url(app_name="dynamic-app")
assert httpx.post(url).text == "Hello!"
wait_for_condition(lambda: "declarative-app-1" not in serve.status().applications)
# Now overwrite the declarative app with a dynamic app with the same name.
# On subsequent declarative apply, that app should not be affected.
serve.run(D.bind(), name="declarative-app-2", route_prefix="/app-2")
wait_for_condition(check_running, app_name="declarative-app-2")
url = get_application_url(app_name="declarative-app-2")
assert httpx.post(url).text == "Hello!"
config.applications = [
ServeApplicationSchema(
name="declarative-app-1",
route_prefix="/app-1",
import_path="ray.serve.tests.test_config_files.world.DagNode",
),
]
client.deploy_apps(config, _blocking=True)
check_running(app_name="declarative-app-1")
url = get_application_url(app_name="declarative-app-1")
assert httpx.post(url).text == "wonderful world"
wait_for_condition(check_running, app_name="dynamic-app")
url = get_application_url(app_name="dynamic-app")
assert httpx.post(url).text == "Hello!"
wait_for_condition(check_running, app_name="declarative-app-2")
url = get_application_url(app_name="declarative-app-2")
assert httpx.post(url).text == "Hello!"
# Verify that the controller does not delete the dynamic apps on recovery.
ray.kill(client._controller, no_restart=False)
wait_for_condition(check_running, app_name="declarative-app-1")
# It takes some time for the target groups to be ready after controller recovery.
# So we make sure the target groups are ready before obtaining the URL.
wait_for_condition(
check_target_groups_ready, client=client, app_name="declarative-app-1"
)
url = get_application_url(app_name="declarative-app-1")
assert httpx.post(url).text == "wonderful world"
wait_for_condition(check_running, app_name="dynamic-app")
wait_for_condition(check_target_groups_ready, client=client, app_name="dynamic-app")
url = get_application_url(app_name="dynamic-app")
assert httpx.post(url).text == "Hello!"
wait_for_condition(check_running, app_name="declarative-app-2")
wait_for_condition(
check_target_groups_ready, client=client, app_name="declarative-app-2"
)
url = get_application_url(app_name="declarative-app-2")
assert httpx.post(url).text == "Hello!"
# Now overwrite the dynamic app with a declarative one and check that it gets
# deleted upon another apply that doesn't include it.
config.applications = [
ServeApplicationSchema(
name="declarative-app-2",
route_prefix="/app-2",
import_path="ray.serve.tests.test_config_files.world.DagNode",
),
]
client.deploy_apps(config, _blocking=True)
check_running(app_name="declarative-app-2")
url = get_application_url(app_name="declarative-app-2")
assert httpx.post(url).text == "wonderful world"
config.applications = []
client.deploy_apps(config)
wait_for_condition(lambda: "declarative-app-2" not in serve.status().applications)
def test_change_route_prefix(serve_instance):
# Deploy application with route prefix /old
client = serve_instance
app_config = {
"name": "default",
"route_prefix": "/old",
"import_path": "ray.serve.tests.test_config_files.pid.node",
}
client.deploy_apps(
ServeDeploySchema(**{"applications": [app_config]}), _blocking=True
)
check_running()
url = get_application_url()
pid1 = httpx.get(url).json()[0]
# Redeploy application with route prefix /new.
app_config["route_prefix"] = "/new"
client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]}))
wait_for_condition(check_running)
# Check that the old route is gone and the response from the new route
# has the same PID (replica wasn't restarted).
def check_switched():
# Old route should be gone
url = get_application_url(exclude_route_prefix=True)
resp = httpx.get(f"{url}/old")
assert "Path '/old' not found." in resp.text
# Response from new route should be same PID
url = get_application_url(exclude_route_prefix=True)
pid2 = httpx.get(f"{url}/new").json()[0]
assert pid2 == pid1
return True
wait_for_condition(check_switched)
def test_num_replicas_auto_api(serve_instance):
"""Test setting only `num_replicas="auto"`."""
client = serve_instance
config_template = {
"import_path": "ray.serve.tests.test_config_files.pid.node",
"deployments": [{"name": "f", "num_replicas": "auto"}],
}
client.deploy_apps(
ServeDeploySchema.model_validate({"applications": [config_template]})
)
wait_for_condition(check_running, timeout=15)
print("Application is RUNNING.")
check_num_replicas_eq("f", 1)
app_details = client.get_serve_details()["applications"][SERVE_DEFAULT_APP_NAME]
deployment_config = app_details["deployments"]["f"]["deployment_config"]
assert "num_replicas" not in deployment_config
assert deployment_config["max_ongoing_requests"] == 5
assert deployment_config["autoscaling_config"] == {
# Set by `num_replicas="auto"`
"target_ongoing_requests": 2.0,
"min_replicas": 1,
"max_replicas": 100,
# Untouched defaults
"look_back_period_s": 30.0,
"metrics_interval_s": 10.0,
"upscale_delay_s": 30.0,
"downscale_delay_s": 600.0,
"downscale_to_zero_delay_s": None,
"upscale_smoothing_factor": None,
"downscale_smoothing_factor": None,
"upscaling_factor": None,
"downscaling_factor": None,
"smoothing_factor": 1.0,
"initial_replicas": None,
"aggregation_function": "mean",
"policy": {
"policy_function": "ray.serve.autoscaling_policy:default_autoscaling_policy",
"policy_kwargs": {},
},
}
def test_num_replicas_auto_basic(serve_instance):
"""Test `num_replicas="auto"` and the default values are used in autoscaling."""
client = serve_instance
signal = SignalActor.options(name="signal123").remote()
config_template = {
"import_path": "ray.serve.tests.test_config_files.get_signal.app",
"deployments": [
{
"name": "A",
"num_replicas": "auto",
"autoscaling_config": {
"look_back_period_s": 2.0,
"metrics_interval_s": 1.0,
"upscale_delay_s": 1.0,
},
"graceful_shutdown_timeout_s": 1,
}
],
}
print(time.ctime(), "Deploying pid application.")
client.deploy_apps(
ServeDeploySchema.model_validate({"applications": [config_template]})
)
wait_for_condition(check_running, timeout=15)
print(time.ctime(), "Application is RUNNING.")
check_num_replicas_eq("A", 1)
app_details = client.get_serve_details()["applications"][SERVE_DEFAULT_APP_NAME]
deployment_config = app_details["deployments"]["A"]["deployment_config"]
# Set by `num_replicas="auto"`
assert "num_replicas" not in deployment_config
assert deployment_config["max_ongoing_requests"] == 5
assert deployment_config["autoscaling_config"] == {
# Set by `num_replicas="auto"`
"target_ongoing_requests": 2.0,
"min_replicas": 1,
"max_replicas": 100,
# Overrided by `autoscaling_config`
"look_back_period_s": 2.0,
"metrics_interval_s": 1.0,
"upscale_delay_s": 1.0,
# Untouched defaults
"downscale_delay_s": 600.0,
"downscale_to_zero_delay_s": None,
"upscale_smoothing_factor": None,
"downscale_smoothing_factor": None,
"upscaling_factor": None,
"downscaling_factor": None,
"smoothing_factor": 1.0,
"initial_replicas": None,
"aggregation_function": "mean",
"policy": {
"policy_function": "ray.serve.autoscaling_policy:default_autoscaling_policy",
"policy_kwargs": {},
},
}
h = serve.get_app_handle(SERVE_DEFAULT_APP_NAME)
for i in range(3):
[h.remote() for _ in range(2)]
def check_num_waiters(target: int):
assert ray.get(signal.cur_num_waiters.remote()) == target
return True
wait_for_condition(check_num_waiters, target=2 * (i + 1), timeout=30)
print(time.time(), f"Number of waiters on signal reached {2*(i+1)}.")
if (
RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE is False
and RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER is True
):
# When merging timeseries from replicas and handles with LOCF, the same request can appear in
# both because they report at different times (e.g. replica: 4 running, handle: 2 queued).
# That double-counts requests and inflates the total, biasing aggregations
# (especially mean) upward and causing over-scaling.
wait_for_condition(
check_num_replicas_gte, name="A", target=i + 1, timeout=30
)
else:
wait_for_condition(
check_num_replicas_eq, name="A", target=i + 1, timeout=30
)
print(time.time(), f"Confirmed number of replicas are at {i+1}.")
ray.get(signal.send.remote())
def test_deploy_one_app_failed(serve_instance):
"""Deploy two applications with separate runtime envs."""
client = serve_instance
world_import_path = "ray.serve.tests.test_config_files.world.DagNode"
fail_import_path = "ray.serve.tests.test_config_files.fail.node"
config_template = {
"applications": [
{
"name": "app1",
"route_prefix": "/app1",
"import_path": world_import_path,
},
{
"name": "app2",
"route_prefix": "/app2",
"import_path": fail_import_path,
},
],
}
client.deploy_apps(ServeDeploySchema(**config_template))
wait_for_condition(
lambda: httpx.post("http://localhost:8000/app1").text == "wonderful world"
)
wait_for_condition(
lambda: serve.status().applications["app1"].status == ApplicationStatus.RUNNING
and serve.status().applications["app2"].status
== ApplicationStatus.DEPLOY_FAILED
)
# Ensure the request doesn't hang and actually returns a 503 error.
# The timeout is there to prevent the test from hanging and blocking
# the test suite if it does fail.
r = httpx.post("http://localhost:8000/app2", timeout=10)
assert r.status_code == 503 and "unavailable" in r.text.lower()
def test_deploy_with_route_prefix_conflict(serve_instance):
world_import_path = "ray.serve.tests.test_config_files.world.DagNode"
pizza_import_path = "ray.serve.tests.test_config_files.pizza.serve_dag"
client = serve_instance
test_config = {
"applications": [
{
"name": "app1",
"route_prefix": "/app1",
"import_path": world_import_path,
},
{
"name": "app2",
"route_prefix": "/app2",
"import_path": pizza_import_path,
},
],
}
client.deploy_apps(ServeDeploySchema(**test_config))
wait_for_condition(
lambda: httpx.get("http://localhost:8000/app1").text == "wonderful world"
)
wait_for_condition(
lambda: httpx.post("http://localhost:8000/app2", json=["ADD", 2]).text
== "4 pizzas please!"
)
# Buffer time
time.sleep(1)
test_config["applications"][1] = {
"name": "app3",
"route_prefix": "/app2",
"import_path": world_import_path,
}
client.deploy_apps(ServeDeploySchema(**test_config))
def check():
serve_details = ServeInstanceDetails(
**ray.get(client._controller.get_serve_instance_details.remote())
)
app1_running = (
"app1" in serve_details.applications
and serve_details.applications["app1"].status == "RUNNING"
)
app3_running = (
"app3" in serve_details.applications
and serve_details.applications["app3"].status == "RUNNING"
)
app2_gone = "app2" not in serve_details.applications
return app1_running and app3_running and app2_gone
wait_for_condition(check)
# app1 and app3 should be up and running
wait_for_condition(
lambda: httpx.get("http://localhost:8000/app1").text == "wonderful world"
)
wait_for_condition(
lambda: httpx.get("http://localhost:8000/app2").text == "wonderful world"
)
def test_update_config_graceful_shutdown_timeout(serve_instance):
"""Check that replicas stay alive when graceful_shutdown_timeout_s is updated"""
client = serve_instance
config_template = {
"import_path": "ray.serve.tests.test_config_files.pid.node",
"deployments": [{"name": "f", "graceful_shutdown_timeout_s": 1000}],
}
# Deploy first time
client.deploy_apps(
ServeDeploySchema.model_validate({"applications": [config_template]})
)
wait_for_condition(check_running, timeout=15)
handle = serve.get_app_handle(SERVE_DEFAULT_APP_NAME)
# Start off with signal ready, and send query
handle.send.remote().result()
pid1 = handle.remote().result()[0]
print("PID of replica after first deployment:", pid1)
# Redeploy with shutdown timeout set to 5 seconds
config_template["deployments"][0]["graceful_shutdown_timeout_s"] = 5
client.deploy_apps(
ServeDeploySchema.model_validate({"applications": [config_template]})
)
wait_for_condition(check_running, timeout=15)
pid2 = handle.remote().result()[0]
assert pid1 == pid2
print("PID of replica after redeployment:", pid2)
# Send blocking query
handle.send.remote(clear=True)
handle.remote()
# Try to delete deployment, should be blocked until the timeout at 5 seconds
client.delete_apps([SERVE_DEFAULT_APP_NAME], blocking=False)
# Replica should be dead within 10 second timeout, which means
# graceful_shutdown_timeout_s was successfully updated lightweightly
wait_for_condition(partial(check_deployments_dead, [DeploymentID(name="f")]))
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))