Files
2026-07-13 13:17:40 +08:00

292 lines
9.0 KiB
Python

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()