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
+383
View File
@@ -0,0 +1,383 @@
import asyncio
import logging
import multiprocessing
import os
from typing import Optional, Union
import multidict
import ray.dashboard.consts as dashboard_consts
from ray.dashboard.optional_deps import aiohttp
from ray.dashboard.subprocesses.module import (
SubprocessModule,
SubprocessModuleConfig,
run_module,
)
from ray.dashboard.subprocesses.utils import (
ResponseType,
get_http_session_to_module,
module_logging_filename,
)
"""
This file contains code run in the parent process. It can start a subprocess and send
messages to it. Requires non-minimal Ray.
"""
logger = logging.getLogger(__name__)
def filter_hop_by_hop_headers(
headers: Union[dict[str, str], multidict.CIMultiDictProxy[str]],
) -> dict[str, str]:
"""
Filter out hop-by-hop headers from the headers dict.
"""
HOP_BY_HOP_HEADERS = {
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
}
if isinstance(headers, multidict.CIMultiDictProxy):
headers = dict(headers)
filtered_headers = {
key: value
for key, value in headers.items()
if key.lower() not in HOP_BY_HOP_HEADERS
}
return filtered_headers
class SubprocessModuleHandle:
"""
A handle to a module created as a subprocess. Can send messages to the module and
receive responses. It only acts as a proxy to the aiohttp server running in the
subprocess. On destruction, the subprocess is terminated.
Lifecycle:
1. In SubprocessModuleHandle creation, the subprocess is started and runs an aiohttp
server.
2. User must call start_module() and wait_for_module_ready() first.
3. SubprocessRouteTable.bind(handle)
4. app.add_routes(routes=SubprocessRouteTable.bound_routes())
5. Run the app.
Health check (_do_periodic_health_check):
Every 1s, do a health check by _do_once_health_check. If the module is
unhealthy:
1. log the exception
2. log the last N lines of the log file
3. fail all active requests
4. restart the module
TODO(ryw): define policy for health check:
- check period (Now: 1s)
- define unhealthy. (Now: process exits. TODO: check_health() for event loop hang)
- check number of failures in a row before we deem it unhealthy (Now: N/A)
- "max number of restarts"? (Now: infinite)
"""
# Class variable. Force using spawn because Ray C bindings have static variables
# that need to be re-initialized for a new process.
mp_context = multiprocessing.get_context("spawn")
def __init__(
self,
loop: asyncio.AbstractEventLoop,
module_cls: type[SubprocessModule],
config: SubprocessModuleConfig,
):
self.loop = loop
self.module_cls = module_cls
self.config = config
# Increment this when the module is restarted.
self.incarnation = 0
# Runtime states, set by start_module() and wait_for_module_ready(),
# reset by destroy_module().
self.parent_conn = None
self.process = None
self.http_client_session: Optional[aiohttp.ClientSession] = None
self.health_check_task = None
def str_for_state(self, incarnation: int, pid: Optional[int]):
return f"SubprocessModuleHandle(module_cls={self.module_cls.__name__}, incarnation={incarnation}, pid={pid})"
def __str__(self):
return self.str_for_state(
self.incarnation, self.process.pid if self.process else None
)
def start_module(self):
"""
Start the module. Should be non-blocking.
"""
self.parent_conn, child_conn = self.mp_context.Pipe()
if not os.path.exists(self.config.socket_dir):
os.makedirs(self.config.socket_dir)
self.process = self.mp_context.Process(
target=run_module,
args=(
self.module_cls,
self.config,
self.incarnation,
child_conn,
),
daemon=True,
name=f"{self.module_cls.__name__}-{self.incarnation}",
)
self.process.start()
child_conn.close()
def wait_for_module_ready(self):
"""
Wait for the module to be ready. This is called after start_module()
and can be blocking.
"""
if self.parent_conn.poll(dashboard_consts.SUBPROCESS_MODULE_WAIT_READY_TIMEOUT):
try:
self.parent_conn.recv()
except EOFError:
raise RuntimeError(
f"Module {self.module_cls.__name__} failed to start. "
"Received EOF from pipe."
)
self.parent_conn.close()
self.parent_conn = None
else:
raise RuntimeError(
f"Module {self.module_cls.__name__} failed to start. "
f"Timeout after {dashboard_consts.SUBPROCESS_MODULE_WAIT_READY_TIMEOUT} seconds."
)
module_name = self.module_cls.__name__
self.http_client_session = get_http_session_to_module(
module_name, self.config.socket_dir, self.config.session_name
)
self.health_check_task = self.loop.create_task(self._do_periodic_health_check())
async def destroy_module(self):
"""
Destroy the module with complete resource cleanup.
This is called when the module is unhealthy or being shut down.
"""
self.incarnation += 1
# 1. Cancel health check task first to avoid race conditions
if self.health_check_task:
# NOTE: destroy_module() can be invoked from within the periodic health
# check task itself (see _do_periodic_health_check()).
# Cancelling the *current* task would raise CancelledError at the next
# await and prevent cleanup + restart from completing.
current_task = asyncio.current_task()
if current_task is None or self.health_check_task is not current_task:
self.health_check_task.cancel()
self.health_check_task = None
# 2. Close parent connection
if self.parent_conn:
self.parent_conn.close()
self.parent_conn = None
# 3. Terminate process gracefully, then forcefully if needed
if self.process:
try:
# First, try graceful termination
if self.process.is_alive():
self.process.terminate()
logger.debug(
f"Terminated process {self.process.pid}, waiting for exit..."
)
# Wait for process to exit (with timeout)
self.process.join(
timeout=dashboard_consts.SUBPROCESS_MODULE_GRACEFUL_SHUTDOWN_TIMEOUT
)
# Force kill if still alive
if self.process.is_alive():
logger.warning(
f"Process {self.process.pid} did not exit gracefully, "
"force killing..."
)
self.process.kill()
self.process.join(
timeout=dashboard_consts.SUBPROCESS_MODULE_JOIN_TIMEOUT
)
else:
# Process already dead, just wait for it
self.process.join(
timeout=dashboard_consts.SUBPROCESS_MODULE_JOIN_TIMEOUT
)
logger.debug(f"Process {self.process.pid} terminated successfully")
except Exception as e:
logger.warning(f"Error terminating process: {e}")
finally:
self.process = None
# 4. Close HTTP client session with proper cleanup
if self.http_client_session:
await self.http_client_session.close()
self.http_client_session = None
async def _health_check(self) -> aiohttp.web.Response:
"""
Do internal health check. The module should respond immediately with a 200 OK.
This can be used to measure module responsiveness in RTT, it also indicates
subprocess event loop lag.
Currently you get a 200 OK with body = b'success'. Later if we want we can add more
observability payloads.
"""
resp = await self.http_client_session.get("http://localhost/api/healthz")
return aiohttp.web.Response(
status=resp.status,
headers=filter_hop_by_hop_headers(resp.headers),
body=await resp.read(),
)
async def _do_once_health_check(self):
"""
Do a health check once. We check for:
1. if the process exits, it's considered died.
2. if the health check endpoint returns non-200, it's considered unhealthy.
"""
if self.process.exitcode is not None:
raise RuntimeError(f"Process exited with code {self.process.exitcode}")
resp = await self._health_check()
if resp.status != 200:
raise RuntimeError(f"Health check failed: status code is {resp.status}")
async def _do_periodic_health_check(self):
"""
Every 1s, do a health check. If the module is unhealthy:
1. log the exception
2. log the last N lines of the log file
3. restart the module
"""
while True:
try:
await self._do_once_health_check()
except Exception:
filename = module_logging_filename(
self.module_cls.__name__, self.config.logging_filename
)
logger.exception(
f"Module {self.module_cls.__name__} is unhealthy. Please refer to "
f"{self.config.log_dir}/{filename} for more details. Failing all "
"active requests."
)
await self.destroy_module()
self.start_module()
self.wait_for_module_ready()
return
await asyncio.sleep(1)
async def proxy_request(
self, request: aiohttp.web.Request, resp_type: ResponseType = ResponseType.HTTP
) -> aiohttp.web.StreamResponse:
"""
Sends a new request to the subprocess and returns the response.
"""
if resp_type == ResponseType.HTTP:
return await self.proxy_http(request)
if resp_type == ResponseType.STREAM:
return await self.proxy_stream(request)
if resp_type == ResponseType.WEBSOCKET:
return await self.proxy_websocket(request)
raise ValueError(f"Unknown response type: {resp_type}")
async def proxy_http(self, request: aiohttp.web.Request) -> aiohttp.web.Response:
"""
Proxy handler for non-streaming HTTP API
It forwards the method, query string, headers, and body to the backend.
"""
url = f"http://localhost{request.path_qs}"
body = await request.read()
async with self.http_client_session.request(
request.method,
url,
data=body,
headers=filter_hop_by_hop_headers(request.headers),
allow_redirects=False,
) as backend_resp:
resp_body = await backend_resp.read()
return aiohttp.web.Response(
status=backend_resp.status,
headers=filter_hop_by_hop_headers(backend_resp.headers),
body=resp_body,
)
async def proxy_stream(
self, request: aiohttp.web.Request
) -> aiohttp.web.StreamResponse:
"""
Proxy handler for streaming HTTP API.
It forwards the method, query string, and body to the backend.
"""
url = f"http://localhost{request.path_qs}"
body = await request.read()
async with self.http_client_session.request(
request.method,
url,
data=body,
headers=filter_hop_by_hop_headers(request.headers),
) as backend_resp:
proxy_resp = aiohttp.web.StreamResponse(
status=backend_resp.status,
headers=filter_hop_by_hop_headers(backend_resp.headers),
)
await proxy_resp.prepare(request)
async for chunk, _ in backend_resp.content.iter_chunks():
await proxy_resp.write(chunk)
await proxy_resp.write_eof()
return proxy_resp
async def proxy_websocket(
self, request: aiohttp.web.Request
) -> aiohttp.web.StreamResponse:
"""
Proxy handler for WebSocket API
It establishes a WebSocket connection with the client and simultaneously connects
to the backend server's WebSocket endpoint. Messages are forwarded in single
direction from the backend to the client.
If the backend responds with normal HTTP response, then try to treat it as a normal
HTTP request and calls proxy_http instead.
TODO: Support bidirectional communication if needed. We only support one direction
because it's sufficient for the current use case.
"""
url = f"http://localhost{request.path_qs}"
try:
async with self.http_client_session.ws_connect(
url, headers=filter_hop_by_hop_headers(request.headers)
) as ws_to_backend:
ws_from_client = aiohttp.web.WebSocketResponse()
await ws_from_client.prepare(request)
async for msg in ws_to_backend:
if msg.type == aiohttp.WSMsgType.TEXT:
await ws_from_client.send_str(msg.data)
elif msg.type == aiohttp.WSMsgType.BINARY:
await ws_from_client.send_bytes(msg.data)
else:
logger.error(f"Unknown msg type: {msg.type}")
await ws_from_client.close()
return ws_from_client
except aiohttp.WSServerHandshakeError as e:
logger.warning(f"WebSocket handshake error: {repr(e)}")
# Try to treat it as a normal HTTP request
return await self.proxy_http(request)
except Exception as e:
logger.error(f"WebSocket proxy error: {repr(e)}")
raise aiohttp.web.HTTPInternalServerError(reason="WebSocket proxy error")
+291
View File
@@ -0,0 +1,291 @@
import abc
import asyncio
import inspect
import logging
import multiprocessing
import multiprocessing.connection
import os
import sys
from dataclasses import dataclass
import aiohttp
import ray
from ray import ray_constants
from ray._private import logging_utils
from ray._private.gcs_utils import GcsChannel
from ray._private.ray_logging import setup_component_logger
from ray._raylet import GcsClient
from ray.dashboard.subprocesses.utils import (
get_named_pipe_path,
get_socket_path,
module_logging_filename,
)
logger = logging.getLogger(__name__)
@dataclass
class SubprocessModuleConfig:
"""
Configuration for a SubprocessModule.
Pickleable.
"""
cluster_id_hex: str
gcs_address: str
session_name: str
temp_dir: str
session_dir: str
# Logger configs. Will be set up in subprocess entrypoint `run_module`.
logging_level: str
logging_format: str
log_dir: str
# Name of the "base" log file. Its stem is appended with the Module.__name__.
# e.g. when logging_filename = "dashboard.log", and Module is JobHead,
# we will set up logger with name "dashboard_JobHead.log". This name will again be
# appended with .1 and .2 for rotation.
logging_filename: str
logging_rotate_bytes: int
logging_rotate_backup_count: int
# The directory where the socket file will be created.
socket_dir: str
class SubprocessModule(abc.ABC):
"""
A Dashboard Head Module that runs in a subprocess as a standalone aiohttp server.
"""
def __init__(
self,
config: SubprocessModuleConfig,
):
"""Initialize current module when DashboardHead loading modules.
Args:
config: The SubprocessModuleConfig instance.
"""
self._config = config
self._parent_process = multiprocessing.parent_process()
# Lazy init
self._gcs_client = None
self._aiogrpc_gcs_channel = None
self._parent_process_death_detection_task = None
self._http_session = None
async def _detect_parent_process_death(self):
"""
Detect parent process liveness. Only returns when parent process is dead.
"""
while True:
if not self._parent_process.is_alive():
logger.warning(
f"Parent process {self._parent_process.pid} died. Exiting..."
)
return
await asyncio.sleep(1)
@staticmethod
def is_minimal_module():
"""
Currently all SubprocessModule classes should be non-minimal.
We require this because SubprocessModuleHandle tracks aiohttp requests and
responses. To ease this, we can define another SubprocessModuleMinimalHandle
that doesn't track requests and responses, but still provides Queue interface
and health check.
TODO(ryw): If needed, create SubprocessModuleMinimalHandle.
"""
return False
async def run(self):
"""
Start running the module.
This method should be called first before the module starts receiving requests.
"""
app = aiohttp.web.Application(
client_max_size=ray_constants.DASHBOARD_CLIENT_MAX_SIZE,
)
routes: list[aiohttp.web.RouteDef] = [
aiohttp.web.get("/api/healthz", self._internal_module_health_check)
]
handlers = inspect.getmembers(
self,
lambda x: (
inspect.ismethod(x)
and hasattr(x, "__route_method__")
and hasattr(x, "__route_path__")
),
)
for _, handler in handlers:
routes.append(
aiohttp.web.route(
handler.__route_method__,
handler.__route_path__,
handler,
)
)
app.add_routes(routes)
runner = aiohttp.web.AppRunner(app, access_log=None)
await runner.setup()
module_name = self.__class__.__name__
if sys.platform == "win32":
named_pipe_path = get_named_pipe_path(
module_name, self._config.session_name
)
site = aiohttp.web.NamedPipeSite(runner, named_pipe_path)
logger.info(f"Started aiohttp server over {named_pipe_path}.")
else:
socket_path = get_socket_path(self._config.socket_dir, module_name)
site = aiohttp.web.UnixSite(runner, socket_path)
logger.info(f"Started aiohttp server over {socket_path}.")
await site.start()
@property
def gcs_client(self):
if self._gcs_client is None:
if not ray.experimental.internal_kv._internal_kv_initialized():
gcs_client = GcsClient(
address=self._config.gcs_address,
cluster_id=self._config.cluster_id_hex,
)
ray.experimental.internal_kv._initialize_internal_kv(gcs_client)
self._gcs_client = ray.experimental.internal_kv.internal_kv_get_gcs_client()
return self._gcs_client
@property
def aiogrpc_gcs_channel(self):
if self._aiogrpc_gcs_channel is None:
gcs_channel = GcsChannel(gcs_address=self._config.gcs_address, aio=True)
gcs_channel.connect()
self._aiogrpc_gcs_channel = gcs_channel.channel()
return self._aiogrpc_gcs_channel
@property
def session_name(self):
"""
Return the Ray session name. It's not related to the aiohttp session.
"""
return self._config.session_name
@property
def temp_dir(self):
return self._config.temp_dir
@property
def session_dir(self):
return self._config.session_dir
@property
def log_dir(self):
return self._config.log_dir
@property
def http_session(self):
if self._http_session is None:
self._http_session = aiohttp.ClientSession()
return self._http_session
@property
def gcs_address(self):
return self._config.gcs_address
async def _internal_module_health_check(self, request):
return aiohttp.web.Response(
text="success",
content_type="application/text",
)
async def run_module_inner(
cls: type[SubprocessModule],
config: SubprocessModuleConfig,
incarnation: int,
child_conn: multiprocessing.connection.Connection,
):
module_name = cls.__name__
logger.info(
f"Starting module {module_name} with incarnation {incarnation} and config {config}"
)
try:
module = cls(config)
module._parent_process_death_detection_task = asyncio.create_task(
module._detect_parent_process_death()
)
module._parent_process_death_detection_task.add_done_callback(
lambda _: sys.exit()
)
await module.run()
child_conn.send(None)
child_conn.close()
logger.info(f"Module {module_name} initialized, receiving messages...")
except Exception as e:
logger.exception(f"Error creating module {module_name}")
raise e
def run_module(
cls: type[SubprocessModule],
config: SubprocessModuleConfig,
incarnation: int,
child_conn: multiprocessing.connection.Connection,
):
"""
Entrypoint for a subprocess module.
"""
module_name = cls.__name__
current_proctitle = ray._raylet.getproctitle()
ray._raylet.setproctitle(
f"ray-dashboard-{module_name}-{incarnation} ({current_proctitle})"
)
logging_filename = module_logging_filename(module_name, config.logging_filename)
setup_component_logger(
logging_level=config.logging_level,
logging_format=config.logging_format,
log_dir=config.log_dir,
filename=logging_filename,
max_bytes=config.logging_rotate_bytes,
backup_count=config.logging_rotate_backup_count,
)
if config.logging_filename:
stdout_filename = module_logging_filename(
module_name, config.logging_filename, extension=".out"
)
stderr_filename = module_logging_filename(
module_name, config.logging_filename, extension=".err"
)
logging_utils.redirect_stdout_stderr_if_needed(
os.path.join(config.log_dir, stdout_filename),
os.path.join(config.log_dir, stderr_filename),
config.logging_rotate_bytes,
config.logging_rotate_backup_count,
)
loop = asyncio.new_event_loop()
task = loop.create_task(
run_module_inner(
cls,
config,
incarnation,
child_conn,
)
)
# TODO: do graceful shutdown.
# 1. define a stop token.
# 2. join the loop to wait for all pending tasks to finish, up until a timeout.
# 3. close the loop and exit.
def sigterm_handler(signum, frame):
logger.warning(f"Exiting with signal {signum} immediately...")
sys.exit(signum)
ray._private.utils.set_sigterm_handler(sigterm_handler)
loop.run_until_complete(task)
loop.run_forever()
@@ -0,0 +1,96 @@
import collections
import inspect
from ray.dashboard.optional_deps import aiohttp
from ray.dashboard.routes import BaseRouteTable
from ray.dashboard.subprocesses.handle import SubprocessModuleHandle
from ray.dashboard.subprocesses.utils import ResponseType
class SubprocessRouteTable(BaseRouteTable):
"""
A route table to bind http route to SubprocessModuleHandle and SubprocessModule.
This class is used in cls object: all the decorator methods are @classmethod, and
the routes are binded to the cls object.
Note we have 2 handlers:
1. the child side handler, that is `handler` that contains real logic and
is executed in the child process. It's added with __route_method__ and
__route_path__ attributes. The child process runs a standalone aiohttp
server.
2. the parent side handler, that just sends the request to the
SubprocessModuleHandle at cls._bind_map[method][path].instance.
With modifications:
- __route_method__ and __route_path__ are added to both side's handlers.
- method and path are added to self._bind_map.
Lifecycle of a request:
1. Parent receives an aiohttp request.
2. Router finds by [method][path] and calls parent_side_handler.
3. `parent_side_handler` bookkeeps the request with a Future and sends a
request to the subprocess.
4. Subprocesses receives the response and sends it back to the parent.
5. Parent responds to the aiohttp request with the response from the subprocess.
"""
_bind_map = collections.defaultdict(dict)
_routes = aiohttp.web.RouteTableDef()
@classmethod
def bind(cls, instance: "SubprocessModuleHandle"):
# __route_method__ and __route_path__ are added to SubprocessModule's methods,
# not the SubprocessModuleHandle's methods.
def predicate(o):
if inspect.isfunction(o):
return hasattr(o, "__route_method__") and hasattr(o, "__route_path__")
return False
handler_routes = inspect.getmembers(instance.module_cls, predicate)
for _, h in handler_routes:
cls._bind_map[h.__route_method__][h.__route_path__].instance = instance
@classmethod
def _register_route(
cls, method, path, resp_type: ResponseType = ResponseType.HTTP, **kwargs
):
"""
Register a route to the module and return the decorated handler.
"""
def _wrapper(handler):
if path in cls._bind_map[method]:
bind_info = cls._bind_map[method][path]
raise Exception(
f"Duplicated route path: {path}, "
f"previous one registered at "
f"{bind_info.filename}:{bind_info.lineno}"
)
bind_info = cls._BindInfo(
handler.__code__.co_filename, handler.__code__.co_firstlineno, None
)
cls._bind_map[method][path] = bind_info
async def parent_side_handler(
request: aiohttp.web.Request,
) -> aiohttp.web.Response:
bind_info = cls._bind_map[method][path]
subprocess_module_handle = bind_info.instance
return await subprocess_module_handle.proxy_request(request, resp_type)
# Used in bind().
handler.__route_method__ = method
handler.__route_path__ = path
# Used in bound_routes().
parent_side_handler.__route_method__ = method
parent_side_handler.__route_path__ = path
parent_side_handler.__name__ = handler.__name__
cls._routes.route(method, path)(parent_side_handler)
return handler
return _wrapper
@@ -0,0 +1,489 @@
import asyncio
import pathlib
import re
import shutil
import sys
import tempfile
from typing import List
import pytest
import ray._private.ray_constants as ray_constants
import ray.dashboard.consts as dashboard_consts
from ray._common.ray_constants import (
LOGGING_ROTATE_BACKUP_COUNT,
LOGGING_ROTATE_BYTES,
)
from ray._common.test_utils import async_wait_for_condition, wait_for_condition
from ray.dashboard.optional_deps import aiohttp
from ray.dashboard.subprocesses.handle import SubprocessModuleHandle
from ray.dashboard.subprocesses.module import SubprocessModule, SubprocessModuleConfig
from ray.dashboard.subprocesses.routes import SubprocessRouteTable
from ray.dashboard.subprocesses.tests.utils import TestModule, TestModule1
# This test requires non-minimal Ray.
@pytest.fixture
def default_module_config(tmp_path) -> SubprocessModuleConfig:
"""
Creates a tmpdir to hold the logs.
"""
# macOS caps AF_UNIX sun_path at 104 bytes. pytest's tmp_path on macOS is
# rooted at /private/var/folders/... which is already long enough that
# appending the socket filename exceeds the limit, so use a short directory
# under /tmp for the socket files specifically.
socket_dir = tempfile.mkdtemp(
prefix="ray_e2e_", dir="/tmp" if sys.platform != "win32" else None
)
try:
yield SubprocessModuleConfig(
cluster_id_hex="test_cluster_id",
gcs_address="",
session_name="test_session",
temp_dir=str(tmp_path),
session_dir=str(tmp_path),
logging_level=ray_constants.LOGGER_LEVEL,
logging_format=ray_constants.LOGGER_FORMAT,
log_dir=str(tmp_path),
logging_filename=dashboard_consts.DASHBOARD_LOG_FILENAME,
logging_rotate_bytes=LOGGING_ROTATE_BYTES,
logging_rotate_backup_count=LOGGING_ROTATE_BACKUP_COUNT,
socket_dir=socket_dir,
)
finally:
shutil.rmtree(socket_dir, ignore_errors=True)
class _DummyConn:
def __init__(self):
self.closed = False
def close(self):
self.closed = True
class _DummyProcess:
def __init__(self, alive: bool = True, pid: int = 12345):
self._alive = alive
self.pid = pid
self.terminate_called = False
self.kill_called = False
self.join_called = False
self.join_timeout = None
def is_alive(self) -> bool:
return self._alive
def terminate(self):
self.terminate_called = True
# Simulate graceful exit.
self._alive = False
def kill(self):
self.kill_called = True
self._alive = False
def join(self, timeout: float | None = None):
self.join_called = True
self.join_timeout = timeout
class _DummySession:
def __init__(self):
self.closed = False
async def close(self):
self.closed = True
@pytest.mark.asyncio
async def test_handle_can_health_check(default_module_config):
loop = asyncio.get_event_loop()
subprocess_handle = SubprocessModuleHandle(loop, TestModule, default_module_config)
subprocess_handle.start_module()
subprocess_handle.wait_for_module_ready()
response = await subprocess_handle._health_check()
assert response.status == 200
assert response.body == b"success"
@pytest.mark.asyncio
async def test_destroy_module_cleans_up_resources(default_module_config, monkeypatch):
"""Ensure destroy_module closes connections, terminates process, and cancels tasks."""
loop = asyncio.get_event_loop()
handle = SubprocessModuleHandle(loop, TestModule, default_module_config)
# Inject dummy resources.
parent_conn = _DummyConn()
process = _DummyProcess(alive=True)
http_session = _DummySession()
health_task = asyncio.create_task(asyncio.sleep(1000))
handle.parent_conn = parent_conn
handle.process = process
handle.http_client_session = http_session
handle.health_check_task = health_task
await handle.destroy_module()
# Parent connection is closed and cleared.
assert parent_conn.closed
assert handle.parent_conn is None
# Process was terminated gracefully and cleared.
assert process.terminate_called
assert process.join_called
assert not process.kill_called
assert handle.process is None
# HTTP client session is closed and cleared.
assert http_session.closed
assert handle.http_client_session is None
# Health check task is cancelled and cleared.
# Note: destroy_module() may call cancel() without awaiting the task, so the
# cancelled state might not be observable immediately. Wait briefly for the
# cancellation to be delivered.
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(health_task, timeout=0.2)
assert health_task.cancelled()
assert handle.health_check_task is None
async def start_http_server_app(
default_module_config, modules: List[type(SubprocessModule)]
):
loop = asyncio.get_event_loop()
handles = [
SubprocessModuleHandle(loop, module, default_module_config)
for module in modules
]
# Parallel start all modules.
for handle in handles:
handle.start_module()
# Wait for all modules to be ready.
for handle in handles:
handle.wait_for_module_ready()
SubprocessRouteTable.bind(handle)
app = aiohttp.web.Application()
app.add_routes(routes=SubprocessRouteTable.bound_routes())
async def _cleanup_app(app):
# Best-effort cleanup to avoid leaking subprocesses across the pytest session.
# If subprocesses leak, later tests (e.g. starting a Ray cluster) can fail due
# to process/FD exhaustion and manifest as unrelated startup errors.
for handle in handles:
try:
await handle.destroy_module()
except Exception:
# Don't mask original test failures.
pass
# SubprocessRouteTable is a global singleton route table. Ensure we drop strong
# references to handles so they can be GC'd.
for _, path_map in list(SubprocessRouteTable._bind_map.items()):
for _, bind_info in list(path_map.items()):
try:
bind_info.instance = None
except Exception:
pass
app.on_cleanup.append(_cleanup_app)
return app
# @pytest.mark.asyncio is not compatible with aiohttp_client.
async def test_http_server(aiohttp_client, default_module_config):
"""
Tests that the http server works. It must
1. bind a SubprocessModuleHandle
2. add_routes
3. run
"""
app = await start_http_server_app(default_module_config, [TestModule])
client = await aiohttp_client(app)
# Test HTTP
response = await client.get("/test")
assert response.status == 200
assert await response.text() == "Hello, World from GET /test, run_finished: True"
response = await client.post("/echo", data="a new dashboard")
assert response.status == 200
assert await response.text() == "Hello, World from POST /echo from a new dashboard"
response = await client.put("/error")
assert response.status == 500
assert "Internal Server Error" in await response.text()
response = await client.put("/error_403")
assert response.status == 403
assert "you shall not pass" in await response.text()
async def test_load_multiple_modules(aiohttp_client, default_module_config):
"""
Tests multiple modules can be loaded.
"""
app = await start_http_server_app(default_module_config, [TestModule, TestModule1])
client = await aiohttp_client(app)
response = await client.get("/test")
assert response.status == 200
assert await response.text() == "Hello, World from GET /test, run_finished: True"
response = await client.get("/test1")
assert response.status == 200
assert await response.text() == "Hello from TestModule1"
async def test_redirect_between_modules(aiohttp_client, default_module_config):
"""Tests that a redirect can be handled between modules."""
app = await start_http_server_app(default_module_config, [TestModule, TestModule1])
client = await aiohttp_client(app)
# Allow redirects to be handled between modules.
# NOTE: If redirects were followed at the module level,
# the test would error, since following /test in TestModule1 would
# result in a 404.
# Instead, the redirect should be handled at the subprocess proxy level,
# where the redirect request is forwarded to the correct module.
response = await client.get("/redirect_between_modules", allow_redirects=True)
assert response.status == 200
assert await response.text() == "Hello, World from GET /test, run_finished: True"
response = await client.get("/redirect_within_module", allow_redirects=True)
assert response.status == 200
assert await response.text() == "Hello from TestModule1"
async def test_cached_endpoint(aiohttp_client, default_module_config):
"""
Test whether the ray.dashboard.optional_utils.aiohttp_cache decorator works.
"""
app = await start_http_server_app(default_module_config, [TestModule])
client = await aiohttp_client(app)
response = await client.get("/not_cached")
assert response.status == 200
assert await response.text() == "Hello, World from GET /not_cached, count: 1"
# Call again, count should increase.
response = await client.get("/not_cached")
assert response.status == 200
assert await response.text() == "Hello, World from GET /not_cached, count: 2"
response = await client.get("/cached")
assert response.status == 200
assert await response.text() == "Hello, World from GET /cached, count: 1"
# Call again, count should NOT increase.
response = await client.get("/cached")
assert response.status == 200
assert await response.text() == "Hello, World from GET /cached, count: 1"
async def test_streamed_iota(aiohttp_client, default_module_config):
# TODO(ryw): also test streams that raise exceptions.
app = await start_http_server_app(default_module_config, [TestModule])
client = await aiohttp_client(app)
response = await client.post("/streamed_iota", data=b"10")
assert response.status == 200
assert await response.text() == "0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n"
async def test_streamed_error(aiohttp_client, default_module_config):
app = await start_http_server_app(default_module_config, [TestModule])
client = await aiohttp_client(app)
response = await client.post("/streamed_401", data=b"")
assert response.status == 401
assert await response.text() == "401: Unauthorized although I am not a teapot"
async def test_websocket_bytes_res(aiohttp_client, default_module_config):
app = await start_http_server_app(default_module_config, [TestModule])
client = await aiohttp_client(app)
res = []
async with client.ws_connect("/websocket_one_to_five_bytes") as ws:
async for msg in ws:
assert msg.type == aiohttp.WSMsgType.BINARY
res.append(msg.data)
assert res == [b"1\n", b"2\n", b"3\n", b"4\n", b"5\n"]
async def test_websocket_bytes_str(aiohttp_client, default_module_config):
app = await start_http_server_app(default_module_config, [TestModule])
client = await aiohttp_client(app)
res = []
async with client.ws_connect("/websocket_one_to_five_strs") as ws:
async for msg in ws:
assert msg.type == aiohttp.WSMsgType.TEXT
res.append(msg.data)
assert res == ["1\n", "2\n", "3\n", "4\n", "5\n"]
async def test_websocket_raise_http_error(aiohttp_client, default_module_config):
app = await start_http_server_app(default_module_config, [TestModule])
client = await aiohttp_client(app)
response = await client.get("/websocket_raise_http_error")
assert response.status == 400
assert await response.text() == "400: Hello this is a bad request"
async def test_websocket_raise_non_http_error(aiohttp_client, default_module_config):
app = await start_http_server_app(default_module_config, [TestModule])
client = await aiohttp_client(app)
response = await client.get("/websocket_raise_non_http_error")
assert response.status == 500
async def test_kill_self(aiohttp_client, default_module_config):
"""
If a module died, all pending requests should be failed, and the module should be
restarted. After the restart, subsequent requests should be successful.
"""
app = await start_http_server_app(default_module_config, [TestModule])
client = await aiohttp_client(app)
long_running_task = asyncio.create_task(client.post("/run_forever", data=b""))
# Wait for 1s for the long running request to start.
await asyncio.sleep(1)
response = await client.post("/kill_self", data=b"")
assert response.status == 500
assert (
await response.text()
== "500 Internal Server Error\n\nServer got itself in trouble"
)
# Long running request should get a 500.
long_running_response = await long_running_task
assert long_running_response.status == 500
assert (
await long_running_response.text()
== "500 Internal Server Error\n\nServer got itself in trouble"
)
async def verify():
response = await client.post(
"/echo", data=b"a restarted dashboard", timeout=0.5
)
assert response.status == 200
assert (
await response.text()
== "Hello, World from POST /echo from a restarted dashboard"
)
return True
await async_wait_for_condition(verify)
async def test_logging_in_module(aiohttp_client, default_module_config):
app = await start_http_server_app(default_module_config, [TestModule])
client = await aiohttp_client(app)
def read_file_content(file_path):
with file_path.open("r") as f:
return f.read()
response = await client.post(
"/logging_in_module", data=b"Not all those who wander are lost"
)
assert response.status == 200
assert await response.text() == "done!"
# Assert the log file name and read the log file
log_file_path = (
pathlib.Path(default_module_config.log_dir) / "dashboard_TestModule.log"
)
def verify():
with log_file_path.open("r") as f:
log_file_content = f.read()
# Assert on the log format and the content.
log_pattern = (
r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}\tINFO ([\w\.]+):\d+ -- (.*)"
)
matches = re.findall(log_pattern, log_file_content)
# Expected format: [(file_name, content), ...]
expected_logs = [
("utils.py", "TestModule is initing"),
("utils.py", "TestModule is done initing"),
("utils.py", "In /logging_in_module, Not all those who wander are lost."),
]
return all(
(file_name, content) in matches for (file_name, content) in expected_logs
)
wait_for_condition(verify)
# Assert that stdout is logged to "dashboard_TestModule.out"
out_log_file_path = (
pathlib.Path(default_module_config.log_dir) / "dashboard_TestModule.out"
)
wait_for_condition(
lambda: read_file_content(out_log_file_path)
== "In /logging_in_module, stdout\n"
)
# Assert that stderr is logged to "dashboard_TestModule.err"
err_log_file_path = (
pathlib.Path(default_module_config.log_dir) / "dashboard_TestModule.err"
)
wait_for_condition(
lambda: read_file_content(err_log_file_path)
== "In /logging_in_module, stderr\n"
)
async def test_logging_in_module_with_multiple_incarnations(
aiohttp_client, default_module_config
):
app = await start_http_server_app(default_module_config, [TestModule])
client = await aiohttp_client(app)
response = await client.post(
"/logging_in_module", data=b"this is from incarnation 0"
)
assert response.status == 200
assert await response.text() == "done!"
response = await client.post("/kill_self", data=b"")
assert response.status == 500
assert (
await response.text()
== "500 Internal Server Error\n\nServer got itself in trouble"
)
async def verify():
response = await client.post(
"/logging_in_module", data=b"and this is from incarnation 1"
)
assert response.status == 200
assert await response.text() == "done!"
return True
await async_wait_for_condition(verify)
log_file_path = (
pathlib.Path(default_module_config.log_dir) / "dashboard_TestModule.log"
)
with log_file_path.open("r") as f:
log_file_content = f.read()
assert "In /logging_in_module, this is from incarnation 0." in log_file_content
assert "In /logging_in_module, and this is from incarnation 1." in log_file_content
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))
@@ -0,0 +1,179 @@
import asyncio
import logging
import os
import signal
import sys
from typing import AsyncIterator
from ray.dashboard import optional_utils
from ray.dashboard.optional_deps import aiohttp
from ray.dashboard.subprocesses.module import SubprocessModule
from ray.dashboard.subprocesses.routes import SubprocessRouteTable as routes
from ray.dashboard.subprocesses.utils import ResponseType
logger = logging.getLogger(__name__)
class BaseTestModule(SubprocessModule):
@property
def gcs_client(self):
return None
@property
def aiogrpc_gcs_channel(self):
return None
class TestModule(BaseTestModule):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.run_finished = False
self.not_cached_count = 0
self.cached_count = 0
async def run(self):
await super().run()
logger.info("TestModule is initing")
self.run_finished = True
await asyncio.sleep(0.1)
logger.info("TestModule is done initing")
@routes.get("/test")
async def test(self, req: aiohttp.web.Request) -> aiohttp.web.Response:
return aiohttp.web.Response(
text="Hello, World from GET /test, run_finished: " + str(self.run_finished)
)
@routes.post("/echo")
async def echo(self, req: aiohttp.web.Request) -> aiohttp.web.Response:
# await works
await asyncio.sleep(0.1)
body = await req.text()
return aiohttp.web.Response(text="Hello, World from POST /echo from " + body)
@routes.get("/not_cached")
async def not_cached(self, req: aiohttp.web.Request) -> aiohttp.web.Response:
self.not_cached_count += 1
return aiohttp.web.Response(
text=f"Hello, World from GET /not_cached, count: {self.not_cached_count}"
)
@routes.get("/cached")
@optional_utils.aiohttp_cache
async def cached(self, req: aiohttp.web.Request) -> aiohttp.web.Response:
self.cached_count += 1
return aiohttp.web.Response(
text=f"Hello, World from GET /cached, count: {self.cached_count}"
)
@routes.put("/error")
async def make_error(self, req: aiohttp.web.Request) -> aiohttp.web.Response:
raise ValueError("This is an error")
@routes.put("/error_403")
async def make_error_403(self, req: aiohttp.web.Request) -> aiohttp.web.Response:
# For an ascii art of Gandalf the Grey, see:
# https://github.com/ray-project/ray/pull/49732#discussion_r1919292428
raise aiohttp.web.HTTPForbidden(reason="you shall not pass")
@routes.post("/streamed_iota", resp_type=ResponseType.STREAM)
async def streamed_iota(
self, req: aiohttp.web.Request
) -> aiohttp.web.StreamResponse:
"""
Streams the numbers 0 to N.
"""
request_body = await req.text()
n = int(request_body)
resp = aiohttp.web.StreamResponse()
await resp.prepare(req)
for i in range(n):
await asyncio.sleep(0.001)
await resp.write(f"{i}\n".encode())
await resp.write_eof()
return resp
@routes.post("/streamed_401", resp_type=ResponseType.STREAM)
async def streamed_401(self, req: aiohttp.web.Request) -> AsyncIterator[bytes]:
"""
Raise a 401 error instead of streaming.
"""
raise aiohttp.web.HTTPUnauthorized(
reason="Unauthorized although I am not a teapot"
)
@routes.get("/websocket_one_to_five_bytes", resp_type=ResponseType.WEBSOCKET)
async def websocket_one_to_five_bytes(
self, req: aiohttp.web.Request
) -> aiohttp.web.WebSocketResponse:
ws = aiohttp.web.WebSocketResponse()
await ws.prepare(req)
for i in range(1, 6):
await asyncio.sleep(0.001)
await ws.send_bytes(f"{i}\n".encode())
await ws.close()
return ws
@routes.get("/websocket_one_to_five_strs", resp_type=ResponseType.WEBSOCKET)
async def websocket_one_to_five_strs(
self, req: aiohttp.web.Request
) -> aiohttp.web.WebSocketResponse:
ws = aiohttp.web.WebSocketResponse()
await ws.prepare(req)
for i in range(1, 6):
await asyncio.sleep(0.001)
await ws.send_str(f"{i}\n")
await ws.close()
return ws
@routes.get("/websocket_raise_http_error", resp_type=ResponseType.WEBSOCKET)
async def websocket_raise_http_error(
self, req: aiohttp.web.Request
) -> aiohttp.web.WebSocketResponse:
raise aiohttp.web.HTTPBadRequest(reason="Hello this is a bad request")
@routes.get("/websocket_raise_non_http_error", resp_type=ResponseType.WEBSOCKET)
async def websocket_raise_non_http_error(
self, req: aiohttp.web.Request
) -> aiohttp.web.WebSocketResponse:
raise ValueError("Hello world")
@routes.post("/run_forever")
async def run_forever(self, req: aiohttp.web.Request) -> aiohttp.web.Response:
while True:
await asyncio.sleep(1)
return aiohttp.web.Response(text="done in the infinite future!")
@routes.post("/logging_in_module")
async def logging_in_module(self, req: aiohttp.web.Request) -> aiohttp.web.Response:
request_body_str = await req.text()
logger.info(f"In /logging_in_module, {request_body_str}.")
print("In /logging_in_module, stdout")
print("In /logging_in_module, stderr", file=sys.stderr)
return aiohttp.web.Response(text="done!")
@routes.post("/kill_self")
async def kill_self(self, req: aiohttp.web.Request) -> aiohttp.web.Response:
logger.error("Crashing by sending myself a sigkill")
os.kill(os.getpid(), signal.SIGKILL)
asyncio.sleep(1000)
return aiohttp.web.Response(text="done!")
class TestModule1(BaseTestModule):
@routes.get("/test1")
async def test(self, req: aiohttp.web.Request) -> aiohttp.web.Response:
return aiohttp.web.Response(text="Hello from TestModule1")
@routes.get("/redirect_between_modules")
async def redirect_between_modules(
self, req: aiohttp.web.Request
) -> aiohttp.web.Response:
# Redirect to the /test route in TestModule
raise aiohttp.web.HTTPFound(location="/test")
@routes.get("/redirect_within_module")
async def redirect_within_module(
self, req: aiohttp.web.Request
) -> aiohttp.web.Response:
raise aiohttp.web.HTTPFound(location="/test1")
@@ -0,0 +1,75 @@
import enum
import os
import sys
from typing import TypeVar
import aiohttp
from ray._private.utils import validate_socket_filepath
K = TypeVar("K")
V = TypeVar("V")
class ResponseType(enum.Enum):
HTTP = "http"
STREAM = "stream"
WEBSOCKET = "websocket"
def module_logging_filename(
module_name: str, logging_filename: str, extension: str = ""
) -> str:
"""Parse logging_filename = STEM EXTENSION, return STEM _ MODULE_NAME _ EXTENSION.
If logging_filename is empty, return empty string.
If extension is empty, use the extension from logging_filename.
Args:
module_name: Name of the subprocess module, embedded in the output stem.
logging_filename: Original log filename (e.g. ``dashboard.log``).
extension: Override extension. Defaults to the extension of
``logging_filename``.
Returns:
The new filename with the module name embedded between the stem and
extension, or an empty string when ``logging_filename`` is empty.
Example:
module_name = "TestModule"
logging_filename = "dashboard.log"
STEM = "dashboard"
EXTENSION = ".log"
return "dashboard_TestModule.log"
"""
if not logging_filename:
return ""
stem, ext = os.path.splitext(logging_filename)
if not extension:
extension = ext
return f"{stem}_{module_name}{extension}"
def get_socket_path(socket_dir: str, module_name: str) -> str:
socket_path = os.path.join(socket_dir, "dash_" + module_name)
validate_socket_filepath(socket_path)
return socket_path
def get_named_pipe_path(module_name: str, session_name: str) -> str:
return r"\\.\pipe\dash_" + module_name + "_" + session_name
def get_http_session_to_module(
module_name: str, socket_dir: str, session_name: str
) -> aiohttp.ClientSession:
"""
Get the aiohttp http client session to the subprocess module.
"""
if sys.platform == "win32":
named_pipe_path = get_named_pipe_path(module_name, session_name)
connector = aiohttp.NamedPipeConnector(named_pipe_path)
else:
socket_path = get_socket_path(socket_dir, module_name)
connector = aiohttp.UnixConnector(socket_path)
return aiohttp.ClientSession(connector=connector)