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__]))