Skip to content

Commit 2ca3d01

Browse files
axreldableYoussefEssDS
authored andcommitted
[serve] Fix bug with 'proxy_location' set for 'serve run' CLI command + discrepancy fix in Python API 'serve.start' function (ray-project#57622)
<!-- Thank you for your contribution! Please review https://github.com/ray-project/ray/blob/master/CONTRIBUTING.rst before opening a pull request. --> <!-- Please add a reviewer to the assignee section when you create a PR. If you don't have the access to it, we will shortly find a reviewer and assign them to your PR. --> ## Why are these changes needed? 1. Fix bug with 'proxy_location' set for 'serve run' CLI command `serve run` CLI command ignores `proxy_location` from config and uses default value `EveryNode`. Steps to reproduce: - have a script: ```python # hello_world.py from ray.serve import deployment @deployment async def hello_world(): return "Hello, world!" hello_world_app = hello_world.bind() ``` Execute: ``` ray stop ray start --head serve build -o config.yaml hello_world:hello_world_app ``` - change `proxy_location` in the `config.yaml`: EveryNode -> Disabled ``` serve run config.yaml curl -s -X GET "http://localhost:8265/api/serve/applications/" | jq -r '.proxy_location' ``` Output: ``` Before change: EveryNode - but Disabled expected After change: Disabled ``` 2. Fix discrepancy for 'proxy_location' in the Python API 'start' method `serve.start` function in Python API sets different `http_options.location` depending on if `http_options` is provided. Steps to reproduce: - have a script: ```python # discrepancy.py import time from ray import serve from ray.serve.context import _get_global_client if __name__ == '__main__': serve.start() client = _get_global_client() print(f"Empty http_options: `{client.http_config.location}`") serve.shutdown() time.sleep(5) serve.start(http_options={"host": "0.0.0.0"}) client = _get_global_client() print(f"Non empty http_options: `{client.http_config.location}`") ``` Execute: ``` ray stop ray start --head python -m discrepancy ``` Output: ``` Before change: Empty http_options: `EveryNode` Non empty http_options: `HeadOnly` After change: Empty http_options: `EveryNode` Non empty http_options: `EveryNode` ``` ------------------------------------------------------------- It changes current behavior in the following ways: 1. `serve run` CLI command respects `proxy_location` parameter from config instead of using the hardcoded `EveryNode`. 2. `serve.start` function in Python API stops using the default `HeadOnly` in case of empty `proxy_location` and provided `http_options` dictionary without `location` specified. <!-- Please give a short summary of the change and the problem this solves. --> ## Related issue number <!-- For example: "Closes ray-project#1234" --> Aims to simplify changes in the PR: ray-project#56507 ## Checks - [x] I've signed off every commit(by using the -s flag, i.e., `git commit -s`) in this PR. - [x] I've run pre-commit jobs to lint the changes in this PR. ([pre-commit setup](https://docs.ray.io/en/latest/ray-contribute/getting-involved.html#lint-and-formatting)) - [x] I've included any doc changes needed for https://docs.ray.io/en/master/. - [ ] I've added any new APIs to the API Reference. For example, if I added a method in Tune, I've added it in `doc/source/tune/api/` under the corresponding `.rst` file. - [x] I've made sure the tests are passing. Note that there might be a few flaky tests, see the recent failures at https://flakey-tests.ray.io/ - Testing Strategy - [x] Unit tests - [ ] Release tests - [ ] This PR is not tested :( --------- Signed-off-by: axreldable <aleksei.starikov.ax@gmail.com>
1 parent e29eae2 commit 2ca3d01

File tree

7 files changed

+224
-19
lines changed

7 files changed

+224
-19
lines changed

python/ray/serve/_private/config.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,14 @@
2828
MAX_REPLICAS_PER_NODE_MAX_VALUE,
2929
)
3030
from ray.serve._private.utils import DEFAULT, DeploymentOptionUpdateType
31-
from ray.serve.config import AggregationFunction, AutoscalingConfig, RequestRouterConfig
31+
from ray.serve.config import (
32+
AggregationFunction,
33+
AutoscalingConfig,
34+
DeploymentMode,
35+
HTTPOptions,
36+
ProxyLocation,
37+
RequestRouterConfig,
38+
)
3239
from ray.serve.generated.serve_pb2 import (
3340
AutoscalingConfig as AutoscalingConfigProto,
3441
DeploymentConfig as DeploymentConfigProto,
@@ -799,3 +806,55 @@ def to_proto(self):
799806

800807
def to_proto_bytes(self):
801808
return self.to_proto().SerializeToString()
809+
810+
811+
def prepare_imperative_http_options(
812+
proxy_location: Union[None, str, ProxyLocation],
813+
http_options: Union[None, dict, HTTPOptions],
814+
) -> HTTPOptions:
815+
"""Prepare `HTTPOptions` with a resolved `location` based on `proxy_location` and `http_options`.
816+
817+
Precedence:
818+
- If `proxy_location` is provided, it overrides any `location` in `http_options`.
819+
- Else if `http_options` specifies a `location` explicitly (HTTPOptions(...) or dict with 'location'), keep it.
820+
- Else (no `proxy_location` and no explicit `location`) set `location` to `DeploymentMode.EveryNode`.
821+
A bare `HTTPOptions()` counts as an explicit default (`HeadOnly`).
822+
823+
Args:
824+
proxy_location: Optional ProxyLocation (or its string representation).
825+
http_options: Optional HTTPOptions instance or dict. If None, a new HTTPOptions() is created.
826+
827+
Returns:
828+
HTTPOptions: New instance with resolved location.
829+
830+
Note:
831+
1. Default ProxyLocation (when unspecified) resolves to DeploymentMode.EveryNode.
832+
2. Default HTTPOptions() location is DeploymentMode.HeadOnly.
833+
3. `HTTPOptions` is used in `imperative` mode (Python API) cluster set-up.
834+
`Declarative` mode (CLI / REST) uses `HTTPOptionsSchema`.
835+
836+
Raises:
837+
ValueError: If http_options is not None, dict, or HTTPOptions.
838+
"""
839+
if http_options is None:
840+
location_set_explicitly = False
841+
http_options = HTTPOptions()
842+
elif isinstance(http_options, dict):
843+
location_set_explicitly = "location" in http_options
844+
http_options = HTTPOptions(**http_options)
845+
elif isinstance(http_options, HTTPOptions):
846+
# empty `HTTPOptions()` is considered as user specified the default location value `HeadOnly` explicitly
847+
location_set_explicitly = True
848+
http_options = HTTPOptions(**http_options.dict(exclude_unset=True))
849+
else:
850+
raise ValueError(
851+
f"Unexpected type for http_options: `{type(http_options).__name__}`"
852+
)
853+
854+
if proxy_location is None:
855+
if not location_set_explicitly:
856+
http_options.location = DeploymentMode.EveryNode
857+
else:
858+
http_options.location = ProxyLocation._to_deployment_mode(proxy_location)
859+
860+
return http_options

python/ray/serve/api.py

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
DeploymentConfig,
1717
ReplicaConfig,
1818
handle_num_replicas_auto,
19+
prepare_imperative_http_options,
1920
)
2021
from ray.serve._private.constants import (
2122
RAY_SERVE_FORCE_LOCAL_TESTING_MODE,
@@ -39,7 +40,6 @@
3940
)
4041
from ray.serve.config import (
4142
AutoscalingConfig,
42-
DeploymentMode,
4343
HTTPOptions,
4444
ProxyLocation,
4545
RequestRouterConfig,
@@ -96,20 +96,7 @@ class See `gRPCOptions` for supported options.
9696
logging_config: logging config options for the serve component (
9797
controller & proxy).
9898
"""
99-
if proxy_location is None:
100-
if http_options is None:
101-
http_options = HTTPOptions(location=DeploymentMode.EveryNode)
102-
else:
103-
if http_options is None:
104-
http_options = HTTPOptions()
105-
elif isinstance(http_options, dict):
106-
http_options = HTTPOptions(**http_options)
107-
108-
if isinstance(proxy_location, str):
109-
proxy_location = ProxyLocation(proxy_location)
110-
111-
http_options.location = ProxyLocation._to_deployment_mode(proxy_location)
112-
99+
http_options = prepare_imperative_http_options(proxy_location, http_options)
113100
_private_api.serve_start(
114101
http_options=http_options,
115102
grpc_options=grpc_options,

python/ray/serve/scripts.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@
2727
SERVE_DEFAULT_APP_NAME,
2828
SERVE_NAMESPACE,
2929
)
30-
from ray.serve.config import DeploymentMode, ProxyLocation, gRPCOptions
30+
from ray.serve.config import (
31+
DeploymentMode,
32+
ProxyLocation,
33+
gRPCOptions,
34+
)
3135
from ray.serve.deployment import Application, deployment_to_schema
3236
from ray.serve.schema import (
3337
LoggingConfig,
@@ -533,6 +537,9 @@ def run(
533537
grpc_options = gRPCOptions()
534538
# Merge http_options and grpc_options with the ones on ServeDeploySchema.
535539
if is_config and isinstance(config, ServeDeploySchema):
540+
http_options["location"] = ProxyLocation._to_deployment_mode(
541+
config.proxy_location
542+
).value
536543
config_http_options = config.http_options.dict()
537544
http_options = {**config_http_options, **http_options}
538545
grpc_options = gRPCOptions(**config.grpc_options.dict())

python/ray/serve/tests/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
check_ray_stopped,
2222
start_telemetry_app,
2323
)
24-
from ray.serve.config import HTTPOptions, gRPCOptions
24+
from ray.serve.config import HTTPOptions, ProxyLocation, gRPCOptions
2525
from ray.serve.context import _get_global_client
2626
from ray.tests.conftest import ( # noqa
2727
external_redis,
@@ -148,6 +148,7 @@ def _shared_serve_instance():
148148
_system_config={"metrics_report_interval_ms": 1000, "task_retry_delay_ms": 50},
149149
)
150150
serve.start(
151+
proxy_location=ProxyLocation.HeadOnly,
151152
http_options={"host": "0.0.0.0"},
152153
grpc_options={
153154
"port": 9000,

python/ray/serve/tests/test_cli_3.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import subprocess
55
import sys
66
import time
7+
from typing import Union
78

89
import httpx
910
import pytest
@@ -138,6 +139,60 @@ def build_echo_app_typed(args: TypedArgs):
138139

139140

140141
class TestRun:
142+
@pytest.mark.skipif(
143+
sys.platform == "win32", reason="File path incorrect on Windows."
144+
)
145+
@pytest.mark.parametrize(
146+
"proxy_location,expected",
147+
[
148+
(
149+
None,
150+
"EveryNode",
151+
), # default ProxyLocation `EveryNode` is used as http_options.location is not specified
152+
("EveryNode", "EveryNode"),
153+
("HeadOnly", "HeadOnly"),
154+
("Disabled", "Disabled"),
155+
],
156+
)
157+
def test_proxy_location(self, ray_start_stop, tmp_path, proxy_location, expected):
158+
# when the `serve run` cli command is executed
159+
# without serve already running (for the first time)
160+
# `proxy_location` should be set from the config file if specified
161+
def is_proxy_location_correct(expected_proxy_location: str) -> bool:
162+
try:
163+
response = httpx.get(
164+
"http://localhost:8265/api/serve/applications/"
165+
).text
166+
response_json = json.loads(response)
167+
print("response_json")
168+
print(response_json)
169+
return response_json["proxy_location"] == expected_proxy_location
170+
except httpx.HTTPError:
171+
return False
172+
173+
def arithmetic_config(with_proxy_location: Union[str, None]) -> str:
174+
config_file_name = os.path.join(
175+
os.path.dirname(__file__), "test_config_files", "arithmetic.yaml"
176+
)
177+
with open(config_file_name, "r") as config_file:
178+
arithmetic_config_dict = yaml.safe_load(config_file)
179+
180+
config_path = tmp_path / "config.yaml"
181+
if with_proxy_location:
182+
arithmetic_config_dict["proxy_location"] = with_proxy_location
183+
with open(config_path, "w") as f:
184+
yaml.dump(arithmetic_config_dict, f)
185+
return str(config_path)
186+
187+
config_path = arithmetic_config(with_proxy_location=proxy_location)
188+
p = subprocess.Popen(["serve", "run", config_path])
189+
wait_for_condition(
190+
lambda: is_proxy_location_correct(expected_proxy_location=expected),
191+
timeout=10,
192+
)
193+
p.send_signal(signal.SIGINT)
194+
p.wait()
195+
141196
@pytest.mark.parametrize("number_of_kill_signals", (1, 2))
142197
@pytest.mark.skipif(
143198
sys.platform == "win32", reason="File path incorrect on Windows."

python/ray/serve/tests/test_standalone.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,32 @@ def test_build_app_task_uses_zero_cpus(ray_shutdown):
876876
"http_options": None,
877877
"expected": HTTPOptions(location=DeploymentMode.EveryNode),
878878
},
879+
{
880+
"proxy_location": None,
881+
"http_options": {"test": "test"}, # location is not specified
882+
"expected": HTTPOptions(
883+
location=DeploymentMode.EveryNode
884+
), # using default proxy_location (to align with the case when `http_options` are None)
885+
},
886+
{
887+
"proxy_location": None,
888+
"http_options": {
889+
"location": "NoServer"
890+
}, # `location` is specified, but `proxy_location` is not
891+
"expected": HTTPOptions(
892+
location=DeploymentMode.NoServer
893+
), # using `location` value
894+
},
895+
{
896+
"proxy_location": None,
897+
"http_options": HTTPOptions(location=None),
898+
"expected": HTTPOptions(location=DeploymentMode.NoServer),
899+
},
900+
{
901+
"proxy_location": None,
902+
"http_options": HTTPOptions(),
903+
"expected": HTTPOptions(location=DeploymentMode.HeadOnly),
904+
}, # using default location from HTTPOptions
879905
{
880906
"proxy_location": None,
881907
"http_options": HTTPOptions(location="NoServer"),

python/ray/serve/tests/unit/test_config.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66
from ray import cloudpickle, serve
77
from ray._common.pydantic_compat import ValidationError
88
from ray._common.utils import import_attr
9-
from ray.serve._private.config import DeploymentConfig, ReplicaConfig, _proto_to_dict
9+
from ray.serve._private.config import (
10+
DeploymentConfig,
11+
ReplicaConfig,
12+
_proto_to_dict,
13+
prepare_imperative_http_options,
14+
)
1015
from ray.serve._private.constants import (
1116
DEFAULT_AUTOSCALING_POLICY_NAME,
1217
DEFAULT_GRPC_PORT,
@@ -655,6 +660,71 @@ def test_http_options():
655660
assert HTTPOptions(location=DeploymentMode.EveryNode).location == "EveryNode"
656661

657662

663+
def test_prepare_imperative_http_options():
664+
assert prepare_imperative_http_options(
665+
proxy_location=None,
666+
http_options=None,
667+
) == HTTPOptions(location=DeploymentMode.EveryNode)
668+
669+
assert prepare_imperative_http_options(
670+
proxy_location=None,
671+
http_options={},
672+
) == HTTPOptions(location=DeploymentMode.EveryNode)
673+
674+
assert prepare_imperative_http_options(
675+
proxy_location=None,
676+
http_options=HTTPOptions(**{}),
677+
) == HTTPOptions(
678+
location=DeploymentMode.HeadOnly
679+
) # in this case we can't know whether location was provided or not
680+
681+
assert prepare_imperative_http_options(
682+
proxy_location=None,
683+
http_options=HTTPOptions(),
684+
) == HTTPOptions(location=DeploymentMode.HeadOnly)
685+
686+
assert prepare_imperative_http_options(
687+
proxy_location=None,
688+
http_options={"test": "test"},
689+
) == HTTPOptions(location=DeploymentMode.EveryNode)
690+
691+
assert prepare_imperative_http_options(
692+
proxy_location=None,
693+
http_options={"host": "0.0.0.0"},
694+
) == HTTPOptions(location=DeploymentMode.EveryNode, host="0.0.0.0")
695+
696+
assert prepare_imperative_http_options(
697+
proxy_location=None,
698+
http_options={"location": "NoServer"},
699+
) == HTTPOptions(location=DeploymentMode.NoServer)
700+
701+
assert prepare_imperative_http_options(
702+
proxy_location=ProxyLocation.Disabled,
703+
http_options=None,
704+
) == HTTPOptions(location=DeploymentMode.NoServer)
705+
706+
assert prepare_imperative_http_options(
707+
proxy_location=ProxyLocation.HeadOnly,
708+
http_options={"host": "0.0.0.0"},
709+
) == HTTPOptions(location=DeploymentMode.HeadOnly, host="0.0.0.0")
710+
711+
assert prepare_imperative_http_options(
712+
proxy_location=ProxyLocation.HeadOnly,
713+
http_options={"location": "NoServer"},
714+
) == HTTPOptions(location=DeploymentMode.HeadOnly)
715+
716+
with pytest.raises(ValueError, match="not a valid ProxyLocation"):
717+
prepare_imperative_http_options(proxy_location="wrong", http_options=None)
718+
719+
with pytest.raises(ValueError, match="not a valid enumeration"):
720+
prepare_imperative_http_options(
721+
proxy_location=None, http_options={"location": "123"}
722+
)
723+
724+
with pytest.raises(ValueError, match="Unexpected type"):
725+
prepare_imperative_http_options(proxy_location=None, http_options="wrong")
726+
727+
658728
def test_with_proto():
659729
# Test roundtrip
660730
config = DeploymentConfig(num_replicas=100, max_ongoing_requests=16)

0 commit comments

Comments
 (0)