chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
from typing import Any, Dict, Optional
from ray._private.utils import split_address
from ray.dashboard.modules.dashboard_sdk import SubmissionClient
try:
import aiohttp
import requests
except ImportError:
aiohttp = None
requests = None
DEPLOY_PATH = "/api/serve/applications/"
DELETE_PATH = "/api/serve/applications/"
STATUS_PATH = "/api/serve/applications/"
class ServeSubmissionClient(SubmissionClient):
def __init__(
self,
dashboard_head_address: str,
create_cluster_if_needed=False,
cookies: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, Any]] = None,
):
if requests is None:
raise RuntimeError(
"The Serve CLI requires the ray[default] "
'installation: `pip install "ray[default]"`'
)
invalid_address_message = (
"Got an unexpected address"
f'"{dashboard_head_address}" while trying '
"to connect to the Ray dashboard. The Serve SDK/CLI requires the "
"Ray dashboard's HTTP(S) address (which should start with "
'"http://" or "https://". If this address '
"wasn't passed explicitly, it may be set in the "
"RAY_DASHBOARD_ADDRESS environment variable."
)
if "://" not in dashboard_head_address:
raise ValueError(invalid_address_message)
module_string, _ = split_address(dashboard_head_address)
# If user passes in ray://, raise error. Serve submission should
# not use a Ray client address.
if module_string not in ["http", "https"]:
raise ValueError(invalid_address_message)
super().__init__(
address=dashboard_head_address,
create_cluster_if_needed=create_cluster_if_needed,
cookies=cookies,
metadata=metadata,
headers=headers,
)
self._check_connection_and_version_with_url(
min_version="1.12",
version_error_message="Serve CLI is not supported on the Ray "
"cluster. Please ensure the cluster is "
"running Ray 1.12 or higher.",
url="/api/ray/version",
)
def get_serve_details(self) -> Dict:
response = self._do_request("GET", STATUS_PATH)
if response.status_code != 200:
self._raise_error(response)
return response.json()
def deploy_applications(self, config: Dict):
"""Deploy multiple applications."""
response = self._do_request("PUT", DEPLOY_PATH, json_data=config)
if response.status_code != 200:
self._raise_error(response)
def delete_applications(self):
response = self._do_request("DELETE", DELETE_PATH)
if response.status_code != 200:
self._raise_error(response)
@@ -0,0 +1,330 @@
import asyncio
import dataclasses
import json
import logging
from functools import wraps
from typing import Optional
import aiohttp
from aiohttp.web import Request, Response
from pydantic import ValidationError
import ray
import ray.dashboard.optional_utils as dashboard_optional_utils
from ray.dashboard.modules.version import CURRENT_VERSION, VersionResponse
from ray.dashboard.subprocesses.module import SubprocessModule
from ray.dashboard.subprocesses.routes import SubprocessRouteTable as routes
from ray.exceptions import RayTaskError
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
def validate_endpoint():
def decorator(func):
@wraps(func)
async def check(self, *args, **kwargs):
try:
from ray import serve # noqa: F401
except ImportError:
return Response(
status=501,
text=(
"Serve dependencies are not installed. Please run `pip "
'install "ray[serve]"`.'
),
)
return await func(self, *args, **kwargs)
return check
return decorator
# NOTE (shrekris-anyscale): This class uses delayed imports for all
# Ray Serve-related modules. That way, users can use the Ray dashboard agent for
# non-Serve purposes without downloading Serve dependencies.
class ServeHead(SubprocessModule):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._controller = None
self._controller_lock = asyncio.Lock()
# serve_start_async is not thread-safe call. This lock
# will make sure there is only one call that starts the serve instance.
# If the lock is already acquired by another async task, the async task
# will asynchronously wait for the lock.
self._controller_start_lock = asyncio.Lock()
# To init gcs_client in internal_kv for record_extra_usage_tag.
assert self.gcs_client is not None
assert ray.experimental.internal_kv._internal_kv_initialized()
# TODO: It's better to use `/api/version`.
# It requires a refactor of ClassMethodRouteTable to differentiate the server.
@routes.get("/api/ray/version")
async def get_version(self, req: Request) -> Response:
# NOTE(edoakes): CURRENT_VERSION should be bumped and checked on the
# client when we have backwards-incompatible changes.
resp = VersionResponse(
version=CURRENT_VERSION,
ray_version=ray.__version__,
ray_commit=ray.__commit__,
session_name=self.session_name,
)
return Response(
text=json.dumps(dataclasses.asdict(resp)),
content_type="application/json",
status=aiohttp.web.HTTPOk.status_code,
)
@routes.get("/api/serve/applications/")
@dashboard_optional_utils.init_ray_and_catch_exceptions()
@validate_endpoint()
async def get_serve_instance_details(self, req: Request) -> Response:
from ray.serve.schema import APIType, ServeInstanceDetails
api_type: Optional[APIType] = None
api_type_str = req.query.get("api_type")
if api_type_str:
api_type_lower = api_type_str.lower()
valid_values = APIType.get_valid_user_values()
if api_type_lower not in valid_values:
# Explicitly check against valid user values (excludes 'unknown')
return Response(
status=400,
text=(
f"Invalid 'api_type' value: '{api_type_str}'. "
f"Must be one of: {', '.join(valid_values)}"
),
content_type="text/plain",
)
api_type = APIType(api_type_lower)
controller = await self.get_serve_controller()
if controller is None:
# If no serve instance is running, return a dict that represents that.
details = ServeInstanceDetails.get_empty_schema_dict()
else:
try:
details = await controller.get_serve_instance_details.remote(
source=api_type
)
except ray.exceptions.RayTaskError as e:
# Task failure sometimes are due to GCS
# failure. When GCS failed, we expect a longer time
# to recover.
return Response(
status=503,
text=(
"Failed to get a response from the controller. "
f"The GCS may be down, please retry later: {e}"
),
)
return Response(
text=json.dumps(details),
content_type="application/json",
)
@routes.delete("/api/serve/applications/")
@dashboard_optional_utils.init_ray_and_catch_exceptions()
async def delete_serve_applications(self, req: Request) -> Response:
from ray import serve
if await self.get_serve_controller() is not None:
serve.shutdown()
return Response()
@routes.put("/api/serve/applications/")
@dashboard_optional_utils.init_ray_and_catch_exceptions()
@validate_endpoint()
async def put_all_applications(self, req: Request) -> Response:
from ray._common.usage.usage_lib import TagKey, record_extra_usage_tag
from ray.serve._private.api import serve_start_async
from ray.serve.schema import ServeDeploySchema
try:
config: ServeDeploySchema = ServeDeploySchema.model_validate(
await req.json()
)
except ValidationError as e:
return Response(
status=400,
text=repr(e),
)
full_http_options = config.http_options.model_dump()
grpc_options = config.grpc_options.model_dump()
async with self._controller_start_lock:
client = await serve_start_async(
http_options=full_http_options,
proxy_location=config.proxy_location,
grpc_options=grpc_options,
global_logging_config=config.logging_config,
controller_options=config.controller_options,
)
# Serve ignores HTTP options if it was already running when
# serve_start_async() is called. Therefore we validate that no
# existing HTTP options are updated and print warning in case they are
self.validate_http_options(client, full_http_options)
try:
if config.logging_config:
client.update_global_logging_config(config.logging_config)
client.deploy_apps(config)
record_extra_usage_tag(TagKey.SERVE_REST_API_VERSION, "v2")
except RayTaskError as e:
return Response(
status=400,
text=str(e),
)
else:
return Response()
def _create_json_response(self, data, status: int) -> Response:
"""Create a JSON response with the given data and status."""
return Response(
status=status,
text=json.dumps(data),
content_type="application/json",
)
@routes.post(
"/api/v1/applications/{application_name}/deployments/{deployment_name}/scale"
)
@dashboard_optional_utils.init_ray_and_catch_exceptions()
@validate_endpoint()
async def scale_deployment(self, req: Request) -> Response:
from ray.serve._private.common import DeploymentID
from ray.serve._private.exceptions import (
DeploymentIsBeingDeletedError,
ExternalScalerDisabledError,
)
from ray.serve.schema import ScaleDeploymentRequest
# Extract path parameters
application_name = req.match_info.get("application_name")
deployment_name = req.match_info.get("deployment_name")
if not application_name or not deployment_name:
return self._create_json_response(
{"error": "Missing application_name or deployment_name in path"}, 400
)
try:
request_data = await req.json()
scale_request = ScaleDeploymentRequest(**request_data)
except Exception as e:
return self._create_json_response(
{"error": f"Invalid request body: {str(e)}"}, 400
)
controller = await self.get_serve_controller()
if controller is None:
return self._create_json_response(
{"error": "Serve controller is not available"}, 503
)
try:
deployment_id = DeploymentID(
name=deployment_name, app_name=application_name
)
# Update the target number of replicas
logger.info(
f"Scaling deployment {deployment_name}, application {application_name} to {scale_request.target_num_replicas} replicas"
)
await controller.update_deployment_replicas.remote(
deployment_id, scale_request.target_num_replicas
)
return self._create_json_response(
{
"message": "Scaling request received. Deployment will get scaled asynchronously."
},
200,
)
except Exception as e:
if isinstance(e, DeploymentIsBeingDeletedError):
# From customer's viewpoint, the deployment is deleted instead of being deleted
# as they must have already executed the delete command
return self._create_json_response(
{"error": "Deployment is deleted"}, 412
)
elif isinstance(e, ExternalScalerDisabledError):
return self._create_json_response({"error": str(e.cause)}, 412)
if isinstance(e, ValueError) and "not found" in str(e):
return self._create_json_response(
{"error": "Application or Deployment not found"}, 400
)
else:
logger.error(
f"Got an Internal Server Error while scaling deployment, error: {e}"
)
return self._create_json_response(
{"error": "Internal Server Error"}, 503
)
def validate_http_options(self, client, http_options):
divergent_http_options = []
for option, new_value in http_options.items():
prev_value = getattr(client.http_config, option)
if prev_value != new_value:
divergent_http_options.append(option)
if divergent_http_options:
logger.warning(
"Serve is already running on this Ray cluster and "
"it's not possible to update its HTTP options without "
"restarting it. Following options are attempted to be "
f"updated: {divergent_http_options}."
)
async def get_serve_controller(self):
"""Gets the ServeController to the this cluster's Serve app.
return: If Serve is running on this Ray cluster, returns a client to
the Serve controller. If Serve is not running, returns None.
"""
async with self._controller_lock:
if self._controller is not None:
try:
await self._controller.check_alive.remote()
return self._controller
except ray.exceptions.RayActorError:
logger.info("Controller is dead")
self._controller = None
# Try to connect to serve even when we detect the actor is dead
# because the user might have started a new
# serve cluter.
from ray.serve._private.constants import (
SERVE_CONTROLLER_NAME,
SERVE_NAMESPACE,
)
try:
# get_actor is a sync call but it'll timeout after
# ray.dashboard.consts.GCS_RPC_TIMEOUT_SECONDS
self._controller = ray.get_actor(
SERVE_CONTROLLER_NAME, namespace=SERVE_NAMESPACE
)
except Exception as e:
logger.debug(
"There is no "
"instance running on this Ray cluster. Please "
"call `serve.start(detached=True) to start "
f"one: {e}"
)
return self._controller
@@ -0,0 +1,16 @@
# Run in a subprocess by test_get_serve_instance_details_for_imperative_apps
from ray import serve
from ray.serve.tests.test_config_files import world
def submit_imperative_apps() -> None:
serve.run(world.DagNode, name="app1", route_prefix="/apple")
print("Deployed app1")
serve.run(world.DagNode, name="app2", route_prefix="/banana")
print("Deployed app2")
if __name__ == "__main__":
submit_imperative_apps()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,371 @@
import copy
import importlib
import os
import re
import sys
from typing import Dict
import grpc
import pytest
import requests
import ray
import ray._private.ray_constants as ray_constants
from ray import serve
from ray._common.test_utils import wait_for_condition
from ray._private.test_utils import generate_system_config_map
from ray.serve.generated import serve_pb2, serve_pb2_grpc
from ray.serve.schema import HTTPOptionsSchema, ServeInstanceDetails
from ray.serve.tests.conftest import * # noqa: F401 F403
from ray.tests.conftest import * # noqa: F401 F403
# For local testing on a Macbook, set `export TEST_ON_DARWIN=1`.
TEST_ON_DARWIN = os.environ.get("TEST_ON_DARWIN", "0") == "1"
SERVE_HEAD_URL = "http://localhost:8265/api/serve/applications/"
def deploy_config_multi_app(config: Dict, url: str):
put_response = requests.put(url, json=config, timeout=30)
assert put_response.status_code == 200
print("PUT request sent successfully.")
@pytest.mark.skipif(
sys.platform == "darwin" and not TEST_ON_DARWIN, reason="Flaky on OSX."
)
def test_serve_namespace(ray_start_stop):
"""Check that the driver can interact with Serve using the Python API."""
config = {
"applications": [
{
"name": "my_app",
"import_path": "ray.serve.tests.test_config_files.world.DagNode",
}
],
}
print("Deploying config.")
deploy_config_multi_app(config, SERVE_HEAD_URL)
wait_for_condition(
lambda: requests.post("http://localhost:8000/").text == "wonderful world",
timeout=15,
)
print("Deployments are live and reachable over HTTP.\n")
my_app_status = serve.status().applications["my_app"]
assert (
len(my_app_status.deployments) == 2
and my_app_status.deployments["f"] is not None
)
print("Successfully retrieved deployment statuses with Python API.")
print("Shutting down Python API.")
@pytest.mark.parametrize(
"option,override",
[
("proxy_location", "HeadOnly"),
("http_options", {"host": "127.0.0.2"}),
("http_options", {"port": 8000}),
("http_options", {"root_path": "/serve_updated"}),
],
)
def test_put_with_http_options(ray_start_stop, option, override):
"""Submits a config with HTTP options specified.
Trying to submit a config to the serve agent with the HTTP options modified should
NOT fail:
- If Serve is NOT running, HTTP options will be honored when starting Serve
- If Serve is running, HTTP options will be ignored, and warning will be logged
urging users to restart Serve if they want their options to take effect
"""
pizza_import_path = "ray.serve.tests.test_config_files.pizza.serve_dag"
world_import_path = "ray.serve.tests.test_config_files.world.DagNode"
original_http_options_json = {
"host": "127.0.0.1",
"port": 8000,
"root_path": "/serve",
}
original_serve_config_json = {
"proxy_location": "EveryNode",
"http_options": original_http_options_json,
"applications": [
{
"name": "app1",
"route_prefix": "/app1",
"import_path": pizza_import_path,
},
{
"name": "app2",
"route_prefix": "/app2",
"import_path": world_import_path,
},
],
}
deploy_config_multi_app(original_serve_config_json, SERVE_HEAD_URL)
# Wait for deployments to be up
wait_for_condition(
lambda: requests.post("http://localhost:8000/serve/app1", json=["ADD", 2]).text
== "4 pizzas please!",
timeout=15,
)
wait_for_condition(
lambda: requests.post("http://localhost:8000/serve/app2").text
== "wonderful world",
timeout=15,
)
updated_serve_config_json = copy.deepcopy(original_serve_config_json)
updated_serve_config_json[option] = override
put_response = requests.put(
SERVE_HEAD_URL, json=updated_serve_config_json, timeout=5
)
assert put_response.status_code == 200
# Fetch Serve status and confirm that HTTP options are unchanged
get_response = requests.get(SERVE_HEAD_URL, timeout=5)
serve_details = ServeInstanceDetails.model_validate(get_response.json())
original_http_options = HTTPOptionsSchema.model_validate(original_http_options_json)
# Compare the key fields that were explicitly set
assert original_http_options.host == serve_details.http_options.host
assert original_http_options.port == serve_details.http_options.port
assert original_http_options.root_path == serve_details.http_options.root_path
# Deployments should still be up
assert (
requests.post("http://localhost:8000/serve/app1", json=["ADD", 2]).text
== "4 pizzas please!"
)
assert requests.post("http://localhost:8000/serve/app2").text == "wonderful world"
def test_put_with_grpc_options(ray_start_stop):
"""Submits a config with gRPC options specified.
Ensure gRPC options can be accepted by the api. HTTP deployment continue to
accept requests. gRPC deployment is also able to accept requests.
"""
grpc_servicer_functions = [
"ray.serve.generated.serve_pb2_grpc.add_UserDefinedServiceServicer_to_server",
]
test_files_import_path = "ray.serve.tests.test_config_files."
grpc_import_path = f"{test_files_import_path}grpc_deployment:g"
world_import_path = "ray.serve.tests.test_config_files.world.DagNode"
original_config = {
"proxy_location": "EveryNode",
"http_options": {
"host": "127.0.0.1",
"port": 8000,
"root_path": "/serve",
},
"grpc_options": {
"port": 9000,
"grpc_servicer_functions": grpc_servicer_functions,
},
"applications": [
{
"name": "app1",
"route_prefix": "/app1",
"import_path": grpc_import_path,
},
{
"name": "app2",
"route_prefix": "/app2",
"import_path": world_import_path,
},
],
}
# Ensure api can accept config with gRPC options
deploy_config_multi_app(original_config, SERVE_HEAD_URL)
# Ensure HTTP requests are still working
wait_for_condition(
lambda: requests.post("http://localhost:8000/serve/app2").text
== "wonderful world",
timeout=15,
)
# Ensure gRPC requests also work
channel = grpc.insecure_channel("localhost:9000")
stub = serve_pb2_grpc.UserDefinedServiceStub(channel)
test_in = serve_pb2.UserDefinedMessage(name="foo", num=30)
metadata = (("application", "app1"),)
response = stub.Method1(request=test_in, metadata=metadata)
assert response.greeting == "Hello foo from method1"
def test_default_dashboard_agent_listen_port():
"""
Defaults in the code and the documentation assume
the dashboard agent listens to HTTP on port 52365.
"""
assert ray_constants.DEFAULT_DASHBOARD_AGENT_LISTEN_PORT == 52365
@pytest.fixture
def short_serve_kv_timeout(monkeypatch):
monkeypatch.setenv("RAY_SERVE_KV_TIMEOUT_S", "3")
importlib.reload(ray.serve._private.constants) # to reload the constants set above
yield
@pytest.mark.skipif(
sys.platform == "darwin" and not TEST_ON_DARWIN, reason="Flaky on OSX."
)
@pytest.mark.parametrize(
"ray_start_regular_with_external_redis",
[
{
**generate_system_config_map(
gcs_rpc_server_reconnect_timeout_s=3600,
gcs_server_request_timeout_seconds=3,
),
}
],
indirect=True,
)
def test_get_applications_while_gcs_down(
short_serve_kv_timeout, ray_start_regular_with_external_redis
):
# Test serve REST API availability when the GCS is down.
serve.start(detached=True)
get_response = requests.get(SERVE_HEAD_URL, timeout=15)
assert get_response.status_code == 200
ray._private.worker._global_node.kill_gcs_server()
for _ in range(10):
assert requests.get(SERVE_HEAD_URL, timeout=30).status_code == 200
ray._private.worker._global_node.start_gcs_server()
for _ in range(10):
assert requests.get(SERVE_HEAD_URL, timeout=30).status_code == 200
serve.shutdown()
@pytest.mark.skipif(
sys.platform == "darwin" and not TEST_ON_DARWIN, reason="Flaky on OSX."
)
def test_target_capacity_field(ray_start_stop):
"""Test that the `target_capacity` field is always populated as expected."""
raw_json = requests.get(SERVE_HEAD_URL).json()
# `target_capacity` should be present in the response before deploying anything.
assert raw_json["target_capacity"] is None
config = {
"http_options": {
"host": "127.0.0.1",
"port": 8000,
},
"applications": [],
}
deploy_config_multi_app(config, SERVE_HEAD_URL)
# `target_capacity` should be present in the response even if not set.
raw_json = requests.get(SERVE_HEAD_URL).json()
assert raw_json["target_capacity"] is None
details = ServeInstanceDetails(**raw_json)
assert details.target_capacity is None
assert details.http_options.host == "127.0.0.1"
assert details.http_options.port == 8000
assert details.applications == {}
# Set `target_capacity`, ensure it is returned properly.
config["target_capacity"] = 20
deploy_config_multi_app(config, SERVE_HEAD_URL)
raw_json = requests.get(SERVE_HEAD_URL).json()
assert raw_json["target_capacity"] == 20
details = ServeInstanceDetails(**raw_json)
assert details.target_capacity == 20
assert details.http_options.host == "127.0.0.1"
assert details.http_options.port == 8000
assert details.applications == {}
# Update `target_capacity`, ensure it is returned properly.
config["target_capacity"] = 40
deploy_config_multi_app(config, SERVE_HEAD_URL)
raw_json = requests.get(SERVE_HEAD_URL).json()
assert raw_json["target_capacity"] == 40
details = ServeInstanceDetails(**raw_json)
assert details.target_capacity == 40
assert details.http_options.host == "127.0.0.1"
assert details.http_options.port == 8000
assert details.applications == {}
# Reset `target_capacity` by omitting it, ensure it is returned properly.
del config["target_capacity"]
deploy_config_multi_app(config, SERVE_HEAD_URL)
raw_json = requests.get(SERVE_HEAD_URL).json()
assert raw_json["target_capacity"] is None
details = ServeInstanceDetails(**raw_json)
assert details.target_capacity is None
assert details.http_options.host == "127.0.0.1"
assert details.http_options.port == 8000
assert details.applications == {}
# Try to set an invalid `target_capacity`, ensure a `400` status is returned.
config["target_capacity"] = 101
assert requests.put(SERVE_HEAD_URL, json=config, timeout=30).status_code == 400
def check_log_file(log_file: str, expected_regex: list):
with open(log_file, "r") as f:
s = f.read()
for regex in expected_regex:
assert re.findall(regex, s) != []
return True
def test_put_with_logging_config(ray_start_stop):
"""Test serve component logging config can be updated via REST API."""
url = "http://localhost:8265/api/serve/applications/"
import_path = "ray.serve.tests.test_config_files.logging_config_test.model"
config1 = {
"http_options": {
"host": "127.0.0.1",
"port": 8000,
},
"logging_config": {
"encoding": "JSON",
},
"applications": [
{
"name": "app",
"route_prefix": "/app",
"import_path": import_path,
},
],
}
deploy_config_multi_app(config1, url)
wait_for_condition(
lambda: requests.get("http://localhost:8000/app").status_code == 200,
timeout=15,
)
print("Deployments are live and reachable over HTTP.\n")
# Make sure deployment & controller both log in json format.
resp = requests.post("http://localhost:8000/app").json()
replica_id = resp["replica"].split("#")[-1]
expected_log_regex = [f'"replica": "{replica_id}", ']
check_log_file(resp["log_file"], expected_log_regex)
expected_log_regex = ['.*"component_name": "controller".*']
check_log_file(resp["controller_log_file"], expected_log_regex)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))