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
@@ -0,0 +1,4 @@
import ray
NIL_NODE_ID = ray.NodeID.nil().hex()
RETRY_GET_ALL_ACTOR_INFO_INTERVAL_SECONDS = 1
@@ -0,0 +1,248 @@
import copy
from typing import List, Optional
import ray.dashboard.consts as dashboard_consts
from ray._common.utils import (
get_or_create_event_loop,
)
from ray._private.utils import (
parse_pg_formatted_resources_to_original,
)
from ray.dashboard.utils import (
async_loop_forever,
compose_state_message,
)
class DataSource:
# {node id hex(str): node stats(dict of GetNodeStatsReply
# in node_manager.proto)}
node_stats = {}
# {node id hex(str): node physical stats(dict from reporter_agent.py)}
node_physical_stats = {}
# {actor id hex(str): actor table data(dict of ActorTableData
# in gcs.proto)}
actors = {}
# {node id hex(str): gcs node info(dict of GcsNodeInfo in gcs.proto)}
nodes = {}
# {node id hex(str): worker list}
node_workers = {}
# {node id hex(str): {actor id hex(str): actor table data}}
node_actors = {}
# {worker id(str): core worker stats}
core_worker_stats = {}
class DataOrganizer:
@staticmethod
@async_loop_forever(dashboard_consts.RAY_DASHBOARD_STATS_PURGING_INTERVAL)
async def purge():
# Purge data that is out of date.
# These data sources are maintained by DashboardHead,
# we do not needs to purge them:
# * agents
# * nodes
alive_nodes = {
node_id
for node_id, node_info in DataSource.nodes.items()
if node_info["state"] == "ALIVE"
}
for key in DataSource.node_stats.keys() - alive_nodes:
DataSource.node_stats.pop(key)
for key in DataSource.node_physical_stats.keys() - alive_nodes:
DataSource.node_physical_stats.pop(key)
@classmethod
@async_loop_forever(dashboard_consts.RAY_DASHBOARD_STATS_UPDATING_INTERVAL)
async def organize(cls, thread_pool_executor):
"""
Organizes data: read from (node_physical_stats, node_stats) and updates
(node_workers, node_worker_stats).
This methods is not really async, but DataSource is not thread safe so we need
to make sure it's on the main event loop thread. To avoid blocking the main
event loop, we yield after each node processed.
"""
loop = get_or_create_event_loop()
node_workers = {}
core_worker_stats = {}
# NOTE: We copy keys of the `DataSource.nodes` to make sure
# it doesn't change during the iteration (since its being updated
# from another async task)
for node_id in list(DataSource.nodes.keys()):
node_physical_stats = DataSource.node_physical_stats.get(node_id, {})
node_stats = DataSource.node_stats.get(node_id, {})
# Offloads the blocking operation to a thread pool executor. This also
# yields to the event loop.
workers = await loop.run_in_executor(
thread_pool_executor,
cls._extract_workers_for_node,
node_physical_stats,
node_stats,
)
for worker in workers:
for stats in worker.get("coreWorkerStats", []):
worker_id = stats["workerId"]
core_worker_stats[worker_id] = stats
node_workers[node_id] = workers
DataSource.node_workers = node_workers
DataSource.core_worker_stats = core_worker_stats
@classmethod
def _extract_workers_for_node(cls, node_physical_stats, node_stats):
workers = []
# Merge coreWorkerStats (node stats) to workers (node physical stats)
pid_to_worker_stats = {}
pid_to_language = {}
pid_to_job_id = {}
for core_worker_stats in node_stats.get("coreWorkersStats", []):
pid = core_worker_stats["pid"]
pid_to_worker_stats[pid] = core_worker_stats
pid_to_language[pid] = core_worker_stats["language"]
pid_to_job_id[pid] = core_worker_stats["jobId"]
for worker in node_physical_stats.get("workers", []):
worker = dict(worker)
pid = worker["pid"]
core_worker_stats = pid_to_worker_stats.get(pid)
# Empty list means core worker stats is not available.
worker["coreWorkerStats"] = [core_worker_stats] if core_worker_stats else []
worker["language"] = pid_to_language.get(
pid, dashboard_consts.DEFAULT_LANGUAGE
)
worker["jobId"] = pid_to_job_id.get(pid, dashboard_consts.DEFAULT_JOB_ID)
workers.append(worker)
return workers
@classmethod
async def get_node_info(cls, node_id, get_summary=False):
node_physical_stats = dict(DataSource.node_physical_stats.get(node_id, {}))
node_stats = dict(DataSource.node_stats.get(node_id, {}))
node = DataSource.nodes.get(node_id, {})
if get_summary:
node_physical_stats.pop("workers", None)
node_stats.pop("workersStats", None)
else:
node_stats.pop("coreWorkersStats", None)
store_stats = node_stats.get("storeStats", {})
used = int(store_stats.get("objectStoreBytesUsed", 0))
# objectStoreBytesAvail == total in the object_manager.cc definition.
total = int(store_stats.get("objectStoreBytesAvail", 0))
ray_stats = {
"object_store_used_memory": used,
"object_store_available_memory": total - used,
}
node_info = node_physical_stats
# Merge node stats to node physical stats under raylet
node_info["raylet"] = node_stats
node_info["raylet"].update(ray_stats)
# Merge GcsNodeInfo to node physical stats
node_info["raylet"].update(node)
death_info = node.get("deathInfo", {})
node_info["raylet"]["stateMessage"] = compose_state_message(
death_info.get("reason", None), death_info.get("reasonMessage", None)
)
# TODO(spencer-p): TPU process linking is currently prone to over-counting
# as it attaches all TPU workers to every chip. This will be addressed
# with a more robust mapping in a future update.
node_info["tpus"] = copy.deepcopy(node_info.get("tpus", []))
if not get_summary:
actor_table_entries = DataSource.node_actors.get(node_id, {})
# Merge actors to node physical stats
node_info["actors"] = {
actor_id: await DataOrganizer._get_actor_info(actor_table_entry)
for actor_id, actor_table_entry in actor_table_entries.items()
}
# Update workers to node physical stats
node_info["workers"] = DataSource.node_workers.get(node_id, [])
return node_info
@classmethod
async def get_all_node_summary(cls):
return [
# NOTE: We're intentionally awaiting in a loop to avoid excessive
# concurrency spinning up excessive # of tasks for large clusters
await DataOrganizer.get_node_info(node_id, get_summary=True)
for node_id in DataSource.nodes.keys()
]
@classmethod
async def get_actor_infos(cls, actor_ids: Optional[List[str]] = None):
target_actor_table_entries: dict[str, Optional[dict]]
if actor_ids is not None:
target_actor_table_entries = {
actor_id: DataSource.actors.get(actor_id) for actor_id in actor_ids
}
else:
target_actor_table_entries = DataSource.actors
return {
actor_id: await DataOrganizer._get_actor_info(actor_table_entry)
for actor_id, actor_table_entry in target_actor_table_entries.items()
}
@staticmethod
async def _get_actor_info(actor: Optional[dict]) -> Optional[dict]:
if actor is None:
return None
actor = actor.copy()
worker_id = actor["address"]["workerId"]
core_worker_stats = DataSource.core_worker_stats.get(worker_id, {})
actor.update(core_worker_stats)
# TODO(fyrestone): remove this, give a link from actor
# info to worker info in front-end.
node_id = actor["address"]["nodeId"]
pid = core_worker_stats.get("pid")
node_physical_stats = DataSource.node_physical_stats.get(node_id, {})
actor_process_stats = None
actor_process_gpu_stats = []
if pid:
for process_stats in node_physical_stats.get("workers", []):
if process_stats["pid"] == pid:
actor_process_stats = process_stats
break
for gpu_stats in node_physical_stats.get("gpus", []):
# gpu_stats.get("processesPids") can be None, an empty list or a
# list of dictionaries.
for process in gpu_stats.get("processesPids") or []:
if process["pid"] == pid:
actor_process_gpu_stats.append(gpu_stats)
break
actor["gpus"] = actor_process_gpu_stats
actor["processStats"] = actor_process_stats
actor["mem"] = node_physical_stats.get("mem", [])
required_resources = parse_pg_formatted_resources_to_original(
actor["requiredResources"]
)
actor["requiredResources"] = required_resources
# TODO(spencer-p): TPU process linking is currently prone to over-counting.
# This will be addressed with a more robust mapping in a future update.
actor["tpus"] = []
return actor
@@ -0,0 +1,17 @@
from ray._private.ray_constants import env_integer
NODE_STATS_UPDATE_INTERVAL_SECONDS = env_integer(
"NODE_STATS_UPDATE_INTERVAL_SECONDS", 15
)
RAY_DASHBOARD_HEAD_NODE_REGISTRATION_TIMEOUT = env_integer(
"RAY_DASHBOARD_HEAD_NODE_REGISTRATION_TIMEOUT", 10
)
MAX_COUNT_OF_GCS_RPC_ERROR = 10
# This is consistent with gcs_node_manager.cc
MAX_DEAD_NODES_TO_CACHE = env_integer("RAY_maximum_gcs_dead_node_cached_count", 1000)
RAY_DASHBOARD_NODE_SUBSCRIBER_POLL_SIZE = env_integer(
"RAY_DASHBOARD_NODE_SUBSCRIBER_POLL_SIZE", 200
)
RAY_DASHBOARD_AGENT_POLL_INTERVAL_S = env_integer(
"RAY_DASHBOARD_AGENT_POLL_INTERVAL_S", 1
)
@@ -0,0 +1,776 @@
import asyncio
import json
import logging
import time
from collections import defaultdict, deque
from concurrent.futures import ThreadPoolExecutor
from itertools import chain
from typing import Any, AsyncGenerator, Dict, Iterable, List, Optional, Set
import aiohttp.web
import grpc
import ray._private.utils
import ray.dashboard.optional_utils as dashboard_optional_utils
import ray.dashboard.utils as dashboard_utils
from ray._common.utils import get_or_create_event_loop
from ray._private import ray_constants
from ray._private.collections_utils import split
from ray._private.gcs_pubsub import (
GcsAioActorSubscriber,
GcsAioNodeInfoSubscriber,
GcsAioResourceUsageSubscriber,
)
from ray._private.grpc_utils import init_grpc_channel
from ray._private.ray_constants import (
DEBUG_AUTOSCALING_ERROR,
DEBUG_AUTOSCALING_STATUS,
env_integer,
)
from ray.autoscaler._private.util import (
LoadMetricsSummary,
get_per_node_breakdown_as_dict,
parse_usage,
)
from ray.core.generated import gcs_pb2, node_manager_pb2, node_manager_pb2_grpc
from ray.dashboard.consts import (
DASHBOARD_AGENT_ADDR_IP_PREFIX,
DASHBOARD_AGENT_ADDR_NODE_ID_PREFIX,
GCS_RPC_TIMEOUT_SECONDS,
)
from ray.dashboard.modules.node import actor_consts, node_consts
from ray.dashboard.modules.node.datacenter import DataOrganizer, DataSource
from ray.dashboard.modules.reporter.reporter_models import StatsPayload
from ray.dashboard.subprocesses.module import SubprocessModule
from ray.dashboard.subprocesses.routes import SubprocessRouteTable as routes
from ray.dashboard.utils import async_loop_forever
logger = logging.getLogger(__name__)
# NOTE: Executor in this head is intentionally constrained to just 1 thread by
# default to limit its concurrency, therefore reducing potential for
# GIL contention
RAY_DASHBOARD_NODE_HEAD_TPE_MAX_WORKERS = env_integer(
"RAY_DASHBOARD_NODE_HEAD_TPE_MAX_WORKERS", 1
)
MAX_DESTROYED_ACTORS_TO_CACHE = max(
0, ray._config.maximum_gcs_destroyed_actor_cached_count()
)
ACTOR_CLEANUP_FREQUENCY = 1 # seconds
ACTOR_TABLE_STATE_COLUMNS = (
"state",
"address",
"numRestarts",
"timestamp",
"pid",
"exitDetail",
"startTime",
"endTime",
"reprName",
)
def _gcs_node_info_to_dict(message: gcs_pb2.GcsNodeInfo) -> dict:
return dashboard_utils.message_to_dict(
message, {"nodeId"}, always_print_fields_with_no_presence=True
)
def _actor_table_data_to_dict(message):
orig_message = dashboard_utils.message_to_dict(
message,
{
"actorId",
"parentId",
"jobId",
"workerId",
"nodeId",
"callerId",
"taskId",
"parentTaskId",
"sourceActorId",
"placementGroupId",
},
always_print_fields_with_no_presence=True,
)
# The complete schema for actor table is here:
# src/ray/protobuf/gcs.proto
# It is super big and for dashboard, we don't need that much information.
# Only preserve the necessary ones here for memory usage.
fields = {
"actorId",
"jobId",
"pid",
"address",
"state",
"name",
"numRestarts",
"timestamp",
"className",
"startTime",
"endTime",
"reprName",
"placementGroupId",
"callSite",
"labelSelector",
"fallbackStrategy",
}
light_message = {k: v for (k, v) in orig_message.items() if k in fields}
light_message["actorClass"] = orig_message["className"]
exit_detail = "-"
if "deathCause" in orig_message:
context = orig_message["deathCause"]
if "actorDiedErrorContext" in context:
exit_detail = context["actorDiedErrorContext"]["errorMessage"] # noqa
elif "runtimeEnvFailedContext" in context:
exit_detail = context["runtimeEnvFailedContext"]["errorMessage"] # noqa
elif "actorUnschedulableContext" in context:
exit_detail = context["actorUnschedulableContext"]["errorMessage"] # noqa
elif "creationTaskFailureContext" in context:
exit_detail = context["creationTaskFailureContext"][
"formattedExceptionString"
] # noqa
light_message["exitDetail"] = exit_detail
light_message["startTime"] = int(light_message["startTime"])
light_message["endTime"] = int(light_message["endTime"])
light_message["requiredResources"] = dict(message.required_resources)
light_message["labelSelector"] = dict(message.label_selector)
return light_message
class NodeHead(SubprocessModule):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._stubs = {}
self._collect_memory_info = False
# The time where the module is started.
self._module_start_time = time.time()
# The time it takes until the head node is registered. None means
# head node hasn't been registered.
self._head_node_registration_time_s = None
# The node ID of the current head node
self._registered_head_node_id = None
# Queue of dead nodes to be removed, up to MAX_DEAD_NODES_TO_CACHE
self._dead_node_queue = deque()
self._node_executor = ThreadPoolExecutor(
max_workers=RAY_DASHBOARD_NODE_HEAD_TPE_MAX_WORKERS,
thread_name_prefix="node_head_node_executor",
)
self._gcs_actor_channel_subscriber = None
# A queue of dead actors in order of when they died
self._destroyed_actors_queue = deque()
# -- Internal state --
self._loop = get_or_create_event_loop()
# NOTE: This executor is intentionally constrained to just 1 thread to
# limit its concurrency, therefore reducing potential for GIL contention
self._actor_executor = ThreadPoolExecutor(
max_workers=1, thread_name_prefix="node_head_actor_executor"
)
self._background_tasks: Set[asyncio.Task] = set()
def get_internal_states(self):
return {
"head_node_registration_time_s": self._head_node_registration_time_s,
"registered_nodes": len(DataSource.nodes),
"module_lifetime_s": time.time() - self._module_start_time,
}
async def _subscribe_for_node_updates(self) -> AsyncGenerator[dict, None]:
"""
Yields the initial state of all nodes, then yields the updated state of nodes.
It makes GetAllNodeInfo call only once after the subscription is done, to get
the initial state of the nodes.
"""
subscriber = GcsAioNodeInfoSubscriber(address=self.gcs_address)
await subscriber.subscribe()
# Get all node info from GCS. To prevent Time-of-check to time-of-use issue [1],
# it happens after the subscription. That is, an update between
# get-all-node-info and the subscription is not missed.
# [1] https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use
node_infos, _ = await self.gcs_client.async_get_all_node_info(timeout=None)
def _convert_to_dict(messages: Iterable[gcs_pb2.GcsNodeInfo]) -> List[dict]:
return [_gcs_node_info_to_dict(m) for m in messages]
all_node_infos = await self._loop.run_in_executor(
self._node_executor,
_convert_to_dict,
node_infos.values(),
)
for node in all_node_infos:
yield node
while True:
try:
node_id_updated_info_tuples = await subscriber.poll(
batch_size=node_consts.RAY_DASHBOARD_NODE_SUBSCRIBER_POLL_SIZE
)
if node_id_updated_info_tuples:
_, updated_infos_proto = zip(*node_id_updated_info_tuples)
else:
updated_infos_proto = []
updated_infos = await self._loop.run_in_executor(
self._node_executor,
_convert_to_dict,
updated_infos_proto,
)
for node in updated_infos:
yield node
except Exception:
logger.exception("Failed handling updated nodes.")
async def _update_node(self, node: dict):
node_id = node["nodeId"]
if (
node["isHeadNode"]
and node["state"] == "ALIVE"
and self._registered_head_node_id != node_id
):
if self._registered_head_node_id is not None:
logger.warning(
"A new head node has become ALIVE. New head node ID: %s, old head node ID: %s, internal states: %s",
node_id,
self._registered_head_node_id,
self.get_internal_states(),
)
self._registered_head_node_id = node_id
self._head_node_registration_time_s = time.time() - self._module_start_time
# Put head node ID in the internal KV to be read by JobAgent.
# TODO(architkulkarni): Remove once State API exposes which
# node is the head node.
await self.gcs_client.async_internal_kv_put(
ray_constants.KV_HEAD_NODE_ID_KEY,
node_id.encode(),
overwrite=True,
namespace=ray_constants.KV_NAMESPACE_JOB,
timeout=GCS_RPC_TIMEOUT_SECONDS,
)
assert node["state"] in ["ALIVE", "DEAD"]
is_alive = node["state"] == "ALIVE"
if not is_alive:
# Remove the agent address from the internal KV.
keys = [
f"{DASHBOARD_AGENT_ADDR_NODE_ID_PREFIX}{node_id}",
f"{DASHBOARD_AGENT_ADDR_IP_PREFIX}{node['nodeManagerAddress']}",
]
tasks = [
self.gcs_client.async_internal_kv_del(
key,
del_by_prefix=False,
namespace=ray_constants.KV_NAMESPACE_DASHBOARD,
timeout=GCS_RPC_TIMEOUT_SECONDS,
)
for key in keys
]
await asyncio.gather(*tasks)
self._dead_node_queue.append(node_id)
if len(self._dead_node_queue) > node_consts.MAX_DEAD_NODES_TO_CACHE:
evicted_node_id = self._dead_node_queue.popleft()
DataSource.nodes.pop(evicted_node_id, None)
self._stubs.pop(evicted_node_id, None)
DataSource.nodes[node_id] = node
# TODO(fyrestone): Handle exceptions.
address = "{}:{}".format(
node["nodeManagerAddress"], int(node["nodeManagerPort"])
)
options = ray_constants.GLOBAL_GRPC_OPTIONS
channel = init_grpc_channel(address, options, asynchronous=True)
stub = node_manager_pb2_grpc.NodeManagerServiceStub(channel)
self._stubs[node_id] = stub
async def _update_nodes(self):
"""
Subscribe to node updates and update the internal states. If the head node is
not registered after RAY_DASHBOARD_HEAD_NODE_REGISTRATION_TIMEOUT, it logs a
warning only once.
"""
warning_shown = False
async for node in self._subscribe_for_node_updates():
await self._update_node(node)
if not self._head_node_registration_time_s:
# head node is not registered yet
if (
not warning_shown
and (time.time() - self._module_start_time)
> node_consts.RAY_DASHBOARD_HEAD_NODE_REGISTRATION_TIMEOUT
):
logger.warning(
"Head node is not registered even after "
f"{node_consts.RAY_DASHBOARD_HEAD_NODE_REGISTRATION_TIMEOUT} seconds. "
"The API server might not work correctly. Please "
"report a Github issue. Internal states :"
f"{self.get_internal_states()}"
)
warning_shown = True
async def get_nodes_logical_resources(self) -> dict:
from ray.autoscaler.v2.utils import is_autoscaler_v2
if is_autoscaler_v2():
from ray.autoscaler.v2.schema import Stats
from ray.autoscaler.v2.sdk import ClusterStatusParser
try:
# here we have a sync request
req_time = time.time()
cluster_status = await self.gcs_client.async_get_cluster_status()
reply_time = time.time()
cluster_status = ClusterStatusParser.from_get_cluster_status_reply(
cluster_status,
stats=Stats(
gcs_request_time_s=reply_time - req_time, request_ts_s=req_time
),
)
except Exception:
logger.exception("Error getting cluster status")
return {}
per_node_resources = {}
# TODO(rickyx): we should just return structure data rather than strings.
for node in chain(cluster_status.active_nodes, cluster_status.idle_nodes):
if not node.resource_usage:
continue
usage_dict = {
r.resource_name: (r.used, r.total)
for r in node.resource_usage.usage
}
per_node_resources[node.node_id] = "\n".join(
parse_usage(usage_dict, verbose=True)
)
return per_node_resources
# Legacy autoscaler status code.
(status_string, error) = await asyncio.gather(
*[
self.gcs_client.async_internal_kv_get(
key.encode(), namespace=None, timeout=GCS_RPC_TIMEOUT_SECONDS
)
for key in [
DEBUG_AUTOSCALING_STATUS,
DEBUG_AUTOSCALING_ERROR,
]
]
)
if not status_string:
return {}
status_dict = json.loads(status_string)
lm_summary_dict = status_dict.get("load_metrics_report")
if lm_summary_dict:
lm_summary = LoadMetricsSummary(**lm_summary_dict)
node_logical_resources = get_per_node_breakdown_as_dict(lm_summary)
return node_logical_resources if error is None else {}
@routes.get("/nodes")
@dashboard_optional_utils.aiohttp_cache
async def get_all_nodes(self, req) -> aiohttp.web.Response:
view = req.query.get("view")
if view == "summary":
all_node_summary_task = DataOrganizer.get_all_node_summary()
nodes_logical_resource_task = self.get_nodes_logical_resources()
all_node_summary, nodes_logical_resources = await asyncio.gather(
all_node_summary_task, nodes_logical_resource_task
)
return dashboard_optional_utils.rest_response(
status_code=dashboard_utils.HTTPStatusCode.OK,
message="Node summary fetched.",
summary=all_node_summary,
node_logical_resources=nodes_logical_resources,
)
elif view is not None and view.lower() == "hostNameList".lower():
alive_hostnames = set()
for node in DataSource.nodes.values():
if node["state"] == "ALIVE":
alive_hostnames.add(node["nodeManagerHostname"])
return dashboard_optional_utils.rest_response(
status_code=dashboard_utils.HTTPStatusCode.OK,
message="Node hostname list fetched.",
host_name_list=list(alive_hostnames),
)
else:
return dashboard_optional_utils.rest_response(
status_code=dashboard_utils.HTTPStatusCode.INTERNAL_ERROR,
message=f"Unknown view {view}",
)
@routes.get("/nodes/{node_id}")
@dashboard_optional_utils.aiohttp_cache
async def get_node(self, req) -> aiohttp.web.Response:
node_id = req.match_info.get("node_id")
node_info = await DataOrganizer.get_node_info(node_id)
return dashboard_optional_utils.rest_response(
status_code=dashboard_utils.HTTPStatusCode.OK,
message="Node details fetched.",
detail=node_info,
)
@async_loop_forever(node_consts.NODE_STATS_UPDATE_INTERVAL_SECONDS)
async def _update_node_stats(self):
timeout = max(2, node_consts.NODE_STATS_UPDATE_INTERVAL_SECONDS - 1)
# NOTE: We copy stubs to make sure
# it doesn't change during the iteration (since its being updated
# from another async task)
current_stub_node_id_tuples = list(self._stubs.items())
node_ids = []
get_node_stats_tasks = []
for _, (node_id, stub) in enumerate(current_stub_node_id_tuples):
node_info = DataSource.nodes.get(node_id)
if node_info["state"] != "ALIVE":
continue
node_ids.append(node_id)
get_node_stats_tasks.append(
stub.GetNodeStats(
node_manager_pb2.GetNodeStatsRequest(
include_memory_info=self._collect_memory_info
),
timeout=timeout,
)
)
responses = []
# NOTE: We're chunking up fetching of the stats to run in batches of no more
# than 100 nodes at a time to avoid flooding the event-loop's queue
# with potentially a large, uninterrupted sequence of tasks updating
# the node stats for very large clusters.
for get_node_stats_tasks_chunk in split(get_node_stats_tasks, 100):
current_chunk_responses = await asyncio.gather(
*get_node_stats_tasks_chunk,
return_exceptions=True,
)
responses.extend(current_chunk_responses)
# We're doing short (25ms) yield after every chunk to make sure
# - We're not overloading the event-loop with excessive # of tasks
# - Allowing 10k nodes stats fetches be sent out performed in 2.5s
await asyncio.sleep(0.025)
def postprocess(node_id_response_tuples):
"""Pure function reorganizing the data into {node_id: stats}."""
new_node_stats = {}
for node_id, response in node_id_response_tuples:
if isinstance(response, asyncio.CancelledError):
pass
elif isinstance(response, grpc.RpcError):
if response.code() == grpc.StatusCode.DEADLINE_EXCEEDED:
message = (
f"Cannot reach the node, {node_id}, after timeout "
f" {timeout}. This node may have been overloaded, "
"terminated, or the network is slow."
)
elif response.code() == grpc.StatusCode.UNAVAILABLE:
message = (
f"Cannot reach the node, {node_id}. "
"The node may have been terminated."
)
else:
message = f"Error updating node stats of {node_id}."
logger.error(message, exc_info=response)
elif isinstance(response, Exception):
logger.error(
f"Error updating node stats of {node_id}.", exc_info=response
)
else:
new_node_stats[node_id] = dashboard_utils.node_stats_to_dict(
response
)
return new_node_stats
# NOTE: Zip will silently truncate to shorter argument that potentially
# could lead to subtle hard to catch issues, hence the assertion
assert len(node_ids) == len(
responses
), f"node_ids({len(node_ids)}): {node_ids}, responses({len(responses)}): {responses}"
new_node_stats = await self._loop.run_in_executor(
self._node_executor, postprocess, zip(node_ids, responses)
)
for node_id, new_stat in new_node_stats.items():
DataSource.node_stats[node_id] = new_stat
async def _update_node_physical_stats(self):
"""
Update DataSource.node_physical_stats by subscribing to the GCS resource usage.
"""
subscriber = GcsAioResourceUsageSubscriber(address=self.gcs_address)
await subscriber.subscribe()
while True:
try:
# The key is b'RAY_REPORTER:{node id hex}',
# e.g. b'RAY_REPORTER:2b4fbd...'
key, data = await subscriber.poll()
if key is None:
continue
# NOTE: Every iteration is executed inside the thread-pool executor
# (TPE) to avoid blocking the Dashboard's event-loop
parsed_data = await self._loop.run_in_executor(
self._node_executor, _parse_node_stats, data
)
node_id = key.split(":")[-1]
DataSource.node_physical_stats[node_id] = parsed_data
except Exception:
logger.exception(
"Error receiving node physical stats from _update_node_physical_stats."
)
async def _update_actors(self):
"""
Processes actor info. First gets all actors from GCS, then subscribes to
actor updates. For each actor update, updates DataSource.node_actors and
DataSource.actors.
"""
# To prevent Time-of-check to time-of-use issue [1], the get-all-actor-info
# happens after the subscription. That is, an update between get-all-actor-info
# and the subscription is not missed.
#
# [1] https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use
gcs_addr = self.gcs_address
actor_channel_subscriber = GcsAioActorSubscriber(address=gcs_addr)
await actor_channel_subscriber.subscribe()
# Get all actor info.
while True:
try:
logger.info("Getting all actor info from GCS.")
actor_dicts = await self._get_all_actors()
# Update actors
DataSource.actors = actor_dicts
# Update node actors and job actors.
node_actors = defaultdict(dict)
for actor_id_bytes, updated_actor_table in actor_dicts.items():
node_id = updated_actor_table["address"]["nodeId"]
# Update only when node_id is not Nil.
if node_id != actor_consts.NIL_NODE_ID:
node_actors[node_id][actor_id_bytes] = updated_actor_table
# Update node's actor info
DataSource.node_actors = node_actors
logger.info("Received %d actor info from GCS.", len(actor_dicts))
# Break, once all initial actors are successfully fetched
break
except Exception as e:
logger.exception("Error Getting all actor info from GCS", exc_info=e)
await asyncio.sleep(
actor_consts.RETRY_GET_ALL_ACTOR_INFO_INTERVAL_SECONDS
)
# Pull incremental updates from the GCS channel
while True:
try:
updated_actor_table_entries = await self._poll_updated_actor_table_data(
actor_channel_subscriber
)
for (
actor_id,
updated_actor_table,
) in updated_actor_table_entries.items():
self._process_updated_actor_table(actor_id, updated_actor_table)
# TODO emit metrics
logger.debug(
f"Total events processed: {len(updated_actor_table_entries)}, "
f"queue size: {actor_channel_subscriber.queue_size}"
)
except Exception as e:
logger.exception("Error processing actor info from GCS.", exc_info=e)
async def _poll_updated_actor_table_data(
self, actor_channel_subscriber: GcsAioActorSubscriber
) -> Dict[str, Dict[str, Any]]:
# TODO make batch size configurable
batch = await actor_channel_subscriber.poll(batch_size=200)
# NOTE: We're offloading conversion to a TPE to make sure we're not
# blocking the event-loop for prolonged period of time irrespective
# of the batch size
def _convert_to_dict():
return {
actor_id_bytes.hex(): _actor_table_data_to_dict(
actor_table_data_message
)
for actor_id_bytes, actor_table_data_message in batch
if actor_id_bytes is not None
}
return await self._loop.run_in_executor(self._actor_executor, _convert_to_dict)
def _process_updated_actor_table(
self, actor_id: str, actor_table_data: Dict[str, Any]
):
"""NOTE: This method has to be executed on the event-loop, provided that it
accesses DataSource data structures (to follow its thread-safety model)"""
# If actor is not new registered but updated, we only update
# states related fields.
actor = DataSource.actors.get(actor_id)
if actor and actor_table_data["state"] != "DEPENDENCIES_UNREADY":
for k in ACTOR_TABLE_STATE_COLUMNS:
if k in actor_table_data:
actor[k] = actor_table_data[k]
actor_table_data = actor
actor_id = actor_table_data["actorId"]
node_id = actor_table_data["address"]["nodeId"]
if actor_table_data["state"] == "DEAD":
self._destroyed_actors_queue.append(actor_id)
# Update actors.
DataSource.actors[actor_id] = actor_table_data
# Update node actors (only when node_id is not Nil).
if node_id != actor_consts.NIL_NODE_ID:
node_actors = DataSource.node_actors.get(node_id, {})
node_actors[actor_id] = actor_table_data
DataSource.node_actors[node_id] = node_actors
async def _get_all_actors(self) -> Dict[str, dict]:
actors = await self.gcs_client.async_get_all_actor_info(
timeout=GCS_RPC_TIMEOUT_SECONDS
)
# NOTE: We're offloading conversion to a TPE to make sure we're not
# blocking the event-loop for prolonged period of time for large clusters
def _convert_to_dict():
return {
actor_id.hex(): _actor_table_data_to_dict(actor_table_data)
for actor_id, actor_table_data in actors.items()
}
return await self._loop.run_in_executor(self._actor_executor, _convert_to_dict)
async def _cleanup_actors(self):
while True:
try:
while len(self._destroyed_actors_queue) > MAX_DESTROYED_ACTORS_TO_CACHE:
actor_id = self._destroyed_actors_queue.popleft()
if actor_id in DataSource.actors:
actor = DataSource.actors.pop(actor_id)
node_id = actor["address"].get("nodeId")
if node_id and node_id != actor_consts.NIL_NODE_ID:
del DataSource.node_actors[node_id][actor_id]
await asyncio.sleep(ACTOR_CLEANUP_FREQUENCY)
except Exception:
logger.exception("Error cleaning up actor info from GCS.")
@routes.get("/logical/actors")
@dashboard_optional_utils.aiohttp_cache
async def get_all_actors(self, req) -> aiohttp.web.Response:
actor_ids: Optional[List[str]] = None
if "ids" in req.query:
actor_ids = req.query["ids"].split(",")
actors = await DataOrganizer.get_actor_infos(actor_ids=actor_ids)
return dashboard_optional_utils.rest_response(
status_code=dashboard_utils.HTTPStatusCode.OK,
message="All actors fetched.",
actors=actors,
# False to avoid converting Ray resource name to google style.
# It's not necessary here because the fields are already
# google formatted when protobuf was converted into dict.
convert_google_style=False,
)
@routes.get("/logical/actors/{actor_id}")
@dashboard_optional_utils.aiohttp_cache
async def get_actor(self, req) -> aiohttp.web.Response:
actor_id = req.match_info.get("actor_id")
actors = await DataOrganizer.get_actor_infos(actor_ids=[actor_id])
return dashboard_optional_utils.rest_response(
status_code=dashboard_utils.HTTPStatusCode.OK,
message="Actor details fetched.",
detail=actors[actor_id],
)
@routes.get("/test/dump")
async def dump(self, req) -> aiohttp.web.Response:
"""
Dump all data from datacenter. This is used for testing purpose only.
"""
key = req.query.get("key")
if key is None:
all_data = {
k: dict(v)
for k, v in DataSource.__dict__.items()
if not k.startswith("_")
}
return dashboard_optional_utils.rest_response(
status_code=dashboard_utils.HTTPStatusCode.OK,
message="Fetch all data from datacenter success.",
**all_data,
)
else:
data = dict(DataSource.__dict__.get(key))
return dashboard_optional_utils.rest_response(
status_code=dashboard_utils.HTTPStatusCode.OK,
message=f"Fetch {key} from datacenter success.",
**{key: data},
)
async def run(self):
await super().run()
coros = [
self._update_nodes(),
self._update_node_stats(),
self._update_node_physical_stats(),
self._update_actors(),
self._cleanup_actors(),
DataOrganizer.purge(),
DataOrganizer.organize(self._node_executor),
]
for coro in coros:
task = self._loop.create_task(coro)
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
def _parse_node_stats(node_stats_str: str) -> dict:
stats_dict = json.loads(node_stats_str)
if StatsPayload is not None:
# Validate the response by parsing the stats_dict.
StatsPayload.parse_obj(stats_dict)
return stats_dict
else:
return stats_dict
@@ -0,0 +1,378 @@
import logging
import sys
import time
import traceback
import pytest
import requests
import ray
from ray._private.test_utils import format_web_url, wait_until_server_available
from ray.dashboard.modules.node import actor_consts
from ray.dashboard.tests.conftest import * # noqa
from ray.util.placement_group import placement_group
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
logger = logging.getLogger(__name__)
def test_actors(disable_aiohttp_cache, ray_start_with_dashboard):
"""
Tests the REST API dashboard calls on:
- alive actors
- infeasible actors
- dead actors
- pg actors (with pg_id set and required_resources formatted)
"""
@ray.remote
class Foo:
def __init__(self, num):
self.num = num
def do_task(self):
return self.num
def get_node_id(self):
return ray.get_runtime_context().get_node_id()
def get_pid(self):
import os
return os.getpid()
def __repr__(self) -> str:
return "Foo1"
@ray.remote(num_cpus=0, resources={"infeasible_actor": 1})
class InfeasibleActor:
pass
pg = placement_group([{"CPU": 1}])
@ray.remote(num_cpus=1)
class PgActor:
def __init__(self):
pass
def do_task(self):
return 1
def get_placement_group_id(self):
return ray.get_runtime_context().get_placement_group_id()
foo_actors = [Foo.options(name="first").remote(4), Foo.remote(5)]
infeasible_actor = InfeasibleActor.options(name="infeasible").remote() # noqa
dead_actor = Foo.options(name="dead").remote(1)
pg_actor = PgActor.options(
name="pg",
scheduling_strategy=PlacementGroupSchedulingStrategy(
placement_group=pg,
),
).remote()
ray.kill(dead_actor)
[actor.do_task.remote() for actor in foo_actors]
pg_actor.do_task.remote()
webui_url = ray_start_with_dashboard["webui_url"]
assert wait_until_server_available(webui_url)
webui_url = format_web_url(webui_url)
job_id = ray.get_runtime_context().get_job_id()
node_id = ray.get(foo_actors[0].get_node_id.remote())
pid = ray.get(foo_actors[0].get_pid.remote())
placement_group_id = ray.get(pg_actor.get_placement_group_id.remote())
timeout_seconds = 5
start_time = time.time()
last_ex = None
while True:
time.sleep(1)
try:
resp = requests.get(f"{webui_url}/logical/actors")
resp_json = resp.json()
resp_data = resp_json["data"]
actors = resp_data["actors"]
assert len(actors) == 5
for a in actors.values():
if a["name"] == "first":
actor_response = a
assert actor_response["jobId"] == job_id
assert "Foo" in actor_response["className"]
assert "address" in actor_response
assert type(actor_response["address"]) is dict
assert actor_response["address"]["nodeId"] == node_id
assert actor_response["state"] == "ALIVE"
assert actor_response["name"] == "first"
assert actor_response["numRestarts"] == "0"
assert actor_response["pid"] == pid
assert actor_response["startTime"] > 0
assert actor_response["requiredResources"] == {}
assert actor_response["endTime"] == 0
assert actor_response["exitDetail"] == "-"
assert actor_response["reprName"] == "Foo1"
for a in actors.values():
# "exitDetail always exits from the response"
assert "exitDetail" in a
# Check the dead actor metadata.
for a in actors.values():
if a["name"] == "dead":
dead_actor_response = a
assert dead_actor_response["endTime"] > 0
assert "ray.kill" in dead_actor_response["exitDetail"]
assert dead_actor_response["state"] == "DEAD"
assert dead_actor_response["name"] == "dead"
# Check the infeasible actor metadata.
for a in actors.values():
if a["name"] == "infeasible":
infeasible_actor_response = a
# Make sure the infeasible actor's resource name is correct.
assert infeasible_actor_response["requiredResources"] == {
"infeasible_actor": 1
}
assert infeasible_actor_response["pid"] == 0
all_pids = {entry["pid"] for entry in actors.values()}
assert 0 in all_pids # The infeasible actor
assert len(all_pids) > 1
# Check the pg actor metadata.
for a in actors.values():
if a["name"] == "pg":
pg_actor_response = a
assert pg_actor_response["placementGroupId"] == placement_group_id
assert pg_actor_response["requiredResources"] == {"CPU": 1.0}
break
except Exception as ex:
last_ex = ex
finally:
if time.time() > start_time + timeout_seconds:
ex_stack = (
traceback.format_exception(
type(last_ex), last_ex, last_ex.__traceback__
)
if last_ex
else []
)
ex_stack = "".join(ex_stack)
raise Exception(f"Timed out while testing, {ex_stack}")
def test_actor_with_ids(disable_aiohttp_cache, ray_start_with_dashboard):
"""
Tests the REST API dashboard calls with actor ids in the URL
"""
@ray.remote
class Actor:
def __init__(self, num):
self.num = num
def do_task(self):
return self.num
def get_actor_id(self):
return ray.get_runtime_context().get_actor_id()
webui_url = ray_start_with_dashboard["webui_url"]
assert wait_until_server_available(webui_url)
webui_url = format_web_url(webui_url)
actors = [Actor.options(name=f"Actor{i}").remote(i) for i in range(5)]
for actor in actors:
actor.do_task.remote()
actor_ids = [ray.get(actor.get_actor_id.remote()) for actor in actors]
timeout_seconds = 5
start_time = time.time()
last_ex = None
while True:
time.sleep(1)
try:
actor_idx = 2
resp = requests.get(f"{webui_url}/logical/actors/{actor_ids[actor_idx]}")
resp_json = resp.json()
resp_data = resp_json["data"]
actor_detail = resp_data["detail"]
assert actor_detail["state"] == "ALIVE"
assert actor_detail["name"] == f"Actor{actor_idx}"
assert actor_detail["numRestarts"] == "0"
assert actor_detail["startTime"] > 0
assert actor_detail["requiredResources"] == {}
assert actor_detail["endTime"] == 0
assert actor_detail["exitDetail"] == "-"
actor_idxs = [0, 1, 4]
actor_idxs_to_id_str = ",".join([str(actor_ids[i]) for i in actor_idxs])
resp = requests.get(
f"{webui_url}/logical/actors?ids={actor_idxs_to_id_str}"
)
resp_json = resp.json()
resp_actors = resp_json["data"]["actors"]
assert len(resp_actors) == len(actor_idxs)
for actor_idx in actor_idxs:
actor_detail = resp_actors[str(actor_ids[actor_idx])]
assert actor_detail["state"] == "ALIVE"
assert actor_detail["name"] == f"Actor{actor_idx}"
assert actor_detail["numRestarts"] == "0"
assert actor_detail["startTime"] > 0
assert actor_detail["requiredResources"] == {}
assert actor_detail["endTime"] == 0
assert actor_detail["exitDetail"] == "-"
break
except Exception as ex:
last_ex = ex
finally:
if time.time() > start_time + timeout_seconds:
ex_stack = (
traceback.format_exception(
type(last_ex), last_ex, last_ex.__traceback__
)
if last_ex
else []
)
ex_stack = "".join(ex_stack)
raise Exception(f"Timed out while testing, {ex_stack}")
def test_nil_node(enable_test_module, disable_aiohttp_cache, ray_start_with_dashboard):
assert wait_until_server_available(ray_start_with_dashboard["webui_url"]) is True
webui_url = ray_start_with_dashboard["webui_url"]
assert wait_until_server_available(webui_url)
webui_url = format_web_url(webui_url)
@ray.remote(num_gpus=1)
class InfeasibleActor:
pass
infeasible_actor = InfeasibleActor.remote() # noqa
timeout_seconds = 5
start_time = time.time()
last_ex = None
while True:
time.sleep(1)
try:
resp = requests.get(f"{webui_url}/logical/actors")
resp_json = resp.json()
resp_data = resp_json["data"]
actors = resp_data["actors"]
assert len(actors) == 1
response = requests.get(webui_url + "/test/dump?key=node_actors")
response.raise_for_status()
result = response.json()
assert actor_consts.NIL_NODE_ID not in result["data"]["nodeActors"]
break
except Exception as ex:
last_ex = ex
finally:
if time.time() > start_time + timeout_seconds:
ex_stack = (
traceback.format_exception(
type(last_ex), last_ex, last_ex.__traceback__
)
if last_ex
else []
)
ex_stack = "".join(ex_stack)
raise Exception(f"Timed out while testing, {ex_stack}")
def test_actor_cleanup(
disable_aiohttp_cache, reduce_actor_cache, ray_start_with_dashboard
):
@ray.remote
class Foo:
def __init__(self, num):
self.num = num
def do_task(self):
return self.num
@ray.remote(num_gpus=1)
class InfeasibleActor:
pass
infeasible_actor = InfeasibleActor.remote() # noqa
foo_actors = [
Foo.remote(1),
Foo.remote(2),
Foo.remote(3),
Foo.remote(4),
Foo.remote(5),
Foo.remote(6),
]
results = [actor.do_task.remote() for actor in foo_actors] # noqa
webui_url = ray_start_with_dashboard["webui_url"]
assert wait_until_server_available(webui_url)
webui_url = format_web_url(webui_url)
timeout_seconds = 8
start_time = time.time()
last_ex = None
while True:
time.sleep(1)
try:
resp = requests.get(f"{webui_url}/logical/actors")
resp_json = resp.json()
resp_data = resp_json["data"]
actors = resp_data["actors"]
# Although max cache is 3, there should be 7 actors
# because they are all still alive.
assert len(actors) == 7
break
except Exception as ex:
last_ex = ex
finally:
if time.time() > start_time + timeout_seconds:
ex_stack = (
traceback.format_exception(
type(last_ex), last_ex, last_ex.__traceback__
)
if last_ex
else []
)
ex_stack = "".join(ex_stack)
raise Exception(f"Timed out while testing, {ex_stack}")
# kill
ray.kill(infeasible_actor)
[ray.kill(foo_actor) for foo_actor in foo_actors]
# Wait 5 seconds for cleanup to finish
time.sleep(5)
# Check only three remaining in cache
start_time = time.time()
while True:
time.sleep(1)
try:
resp = requests.get(f"{webui_url}/logical/actors")
resp_json = resp.json()
resp_data = resp_json["data"]
actors = resp_data["actors"]
# Max cache is 3 so only 3 actors should be left.
assert len(actors) == 3
break
except Exception as ex:
last_ex = ex
finally:
if time.time() > start_time + timeout_seconds:
ex_stack = (
traceback.format_exception(
type(last_ex), last_ex, last_ex.__traceback__
)
if last_ex
else []
)
ex_stack = "".join(ex_stack)
raise Exception(f"Timed out while testing, {ex_stack}")
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,416 @@
import logging
import os
import random
import sys
import threading
import time
import traceback
from datetime import datetime, timedelta
import pytest
import requests
import ray
from ray._common.test_utils import wait_for_condition
from ray._private.test_utils import (
format_web_url,
wait_until_server_available,
)
from ray.cluster_utils import Cluster
from ray.dashboard.consts import RAY_DASHBOARD_STATS_UPDATING_INTERVAL
from ray.dashboard.tests.conftest import * # noqa
logger = logging.getLogger(__name__)
def test_nodes_update(enable_test_module, ray_start_with_dashboard):
assert wait_until_server_available(ray_start_with_dashboard["webui_url"]) is True
webui_url = ray_start_with_dashboard["webui_url"]
webui_url = format_web_url(webui_url)
timeout_seconds = 10
start_time = time.time()
while True:
time.sleep(1)
try:
response = requests.get(webui_url + "/test/dump")
response.raise_for_status()
try:
dump_info = response.json()
except Exception as ex:
logger.info("failed response: %s", response.text)
raise ex
assert dump_info["result"] is True
dump_data = dump_info["data"]
assert len(dump_data["nodes"]) == 1
break
except (AssertionError, requests.exceptions.ConnectionError):
logger.exception("Retry")
finally:
if time.time() > start_time + timeout_seconds:
raise Exception("Timed out while testing.")
def test_node_info(disable_aiohttp_cache, ray_start_with_dashboard):
@ray.remote
class Actor:
def getpid(self):
print(f"actor pid={os.getpid()}")
return os.getpid()
actors = [Actor.remote(), Actor.remote()]
actor_pids = [actor.getpid.remote() for actor in actors]
actor_pids = set(ray.get(actor_pids))
assert wait_until_server_available(ray_start_with_dashboard["webui_url"]) is True
webui_url = ray_start_with_dashboard["webui_url"]
webui_url = format_web_url(webui_url)
node_id = ray_start_with_dashboard["node_id"]
# NOTE: Leaving sum buffer time for data to get refreshed
timeout_seconds = RAY_DASHBOARD_STATS_UPDATING_INTERVAL * 1.5
start_time = time.time()
last_ex = None
while True:
time.sleep(1)
try:
response = requests.get(webui_url + "/nodes?view=hostnamelist")
response.raise_for_status()
hostname_list = response.json()
assert hostname_list["result"] is True, hostname_list["msg"]
hostname_list = hostname_list["data"]["hostNameList"]
assert len(hostname_list) == 1
hostname = hostname_list[0]
response = requests.get(webui_url + f"/nodes/{node_id}")
response.raise_for_status()
detail = response.json()
assert detail["result"] is True, detail["msg"]
detail = detail["data"]["detail"]
assert detail["hostname"] == hostname
assert detail["raylet"]["state"] == "ALIVE"
assert detail["raylet"]["isHeadNode"] is True
assert "raylet" in detail["cmdline"][0]
assert len(detail["workers"]) >= 2
assert len(detail["actors"]) == 2, detail["actors"]
actor_worker_pids = set()
for worker in detail["workers"]:
if "ray::Actor" in worker["cmdline"][0]:
actor_worker_pids.add(worker["pid"])
assert actor_worker_pids == actor_pids
response = requests.get(webui_url + "/nodes?view=summary")
response.raise_for_status()
summary = response.json()
assert summary["result"] is True, summary["msg"]
assert len(summary["data"]["summary"]) == 1
summary = summary["data"]["summary"][0]
assert summary["hostname"] == hostname
assert summary["raylet"]["state"] == "ALIVE"
assert "raylet" in summary["cmdline"][0]
assert "workers" not in summary
assert "actors" not in summary
assert "objectStoreAvailableMemory" in summary["raylet"]
assert "objectStoreUsedMemory" in summary["raylet"]
break
except Exception as ex:
last_ex = ex
finally:
if time.time() > start_time + timeout_seconds:
ex_stack = (
traceback.format_exception(
type(last_ex), last_ex, last_ex.__traceback__
)
if last_ex
else []
)
ex_stack = "".join(ex_stack)
raise Exception(f"Timed out while testing, {ex_stack}")
@pytest.mark.parametrize(
"ray_start_cluster_head_with_env_vars",
[
{
"include_dashboard": True,
"env_vars": {
"RAY_maximum_gcs_dead_node_cached_count": "1",
},
"_system_config": {
"health_check_initial_delay_ms": 0,
"health_check_timeout_ms": 100,
"health_check_failure_threshold": 3,
"health_check_period_ms": 100,
},
}
],
indirect=True,
)
def test_dead_node_cache_contains_latest_dead_node_if_cache_overflows(
enable_test_module, disable_aiohttp_cache, ray_start_cluster_head_with_env_vars
):
cluster: Cluster = ray_start_cluster_head_with_env_vars
assert wait_until_server_available(
cluster.webui_url
), "Failed to connect to the Dashboard Server"
webui_url = format_web_url(cluster.webui_url)
def _compare_dead_node_set(expected_alive_nodes, expected_dead_nodes):
try:
response = requests.get(webui_url + "/nodes?view=summary")
response.raise_for_status()
summary = response.json()
assert summary["result"] is True, summary["msg"]
summary = summary["data"]["summary"]
dead_nodes = set()
alive_nodes = set()
for node_info in summary:
node_id = node_info["raylet"]["nodeId"]
response = requests.get(webui_url + f"/nodes/{node_id}")
response.raise_for_status()
if node_info["raylet"]["state"] == "DEAD":
dead_nodes.add(node_id)
if node_info["raylet"]["state"] == "ALIVE":
alive_nodes.add(node_id)
assert alive_nodes == expected_alive_nodes
assert dead_nodes == expected_dead_nodes
return True
except Exception as ex:
logger.info(ex)
return False
node_1 = cluster.add_node()
head_node_id = ray.get_runtime_context().get_node_id()
node_1_id = node_1.node_id
curr_alive_nodes = {head_node_id, node_1_id}
curr_dead_nodes = set()
wait_for_condition(
_compare_dead_node_set,
10,
expected_alive_nodes=curr_alive_nodes,
expected_dead_nodes=curr_dead_nodes,
)
node_2 = cluster.add_node()
node_2_id = node_2.node_id
curr_alive_nodes.add(node_2_id)
wait_for_condition(
_compare_dead_node_set,
10,
expected_alive_nodes=curr_alive_nodes,
expected_dead_nodes=curr_dead_nodes,
)
cluster.remove_node(node_1, allow_graceful=False)
curr_alive_nodes.remove(node_1_id)
curr_dead_nodes.add(node_1_id)
wait_for_condition(
_compare_dead_node_set,
10,
expected_alive_nodes=curr_alive_nodes,
expected_dead_nodes=curr_dead_nodes,
)
cluster.remove_node(node_2, allow_graceful=False)
curr_alive_nodes.remove(node_2_id)
curr_dead_nodes.remove(node_1_id)
curr_dead_nodes.add(node_2_id)
wait_for_condition(
_compare_dead_node_set,
10,
expected_alive_nodes=curr_alive_nodes,
expected_dead_nodes=curr_dead_nodes,
)
@pytest.mark.parametrize(
"ray_start_cluster_head",
[
{
"include_dashboard": True,
"_system_config": {
"health_check_initial_delay_ms": 0,
"health_check_timeout_ms": 100,
"health_check_failure_threshold": 3,
"health_check_period_ms": 100,
},
}
],
indirect=True,
)
def test_multi_nodes_info(
enable_test_module, disable_aiohttp_cache, ray_start_cluster_head
):
cluster: Cluster = ray_start_cluster_head
assert wait_until_server_available(cluster.webui_url) is True
webui_url = cluster.webui_url
webui_url = format_web_url(webui_url)
cluster.add_node()
cluster.add_node()
dead_node = cluster.add_node()
cluster.remove_node(dead_node, allow_graceful=False)
def _check_nodes():
try:
response = requests.get(webui_url + "/nodes?view=summary")
response.raise_for_status()
summary = response.json()
assert summary["result"] is True, summary["msg"]
summary = summary["data"]["summary"]
assert len(summary) == 4
for node_info in summary:
node_id = node_info["raylet"]["nodeId"]
response = requests.get(webui_url + f"/nodes/{node_id}")
response.raise_for_status()
detail = response.json()
assert detail["result"] is True, detail["msg"]
detail = detail["data"]["detail"]
if node_id != dead_node.node_id:
assert detail["raylet"]["state"] == "ALIVE"
else:
assert detail["raylet"]["state"] == "DEAD"
assert detail["raylet"].get("objectStoreAvailableMemory", 0) == 0
return True
except Exception as ex:
logger.info(ex)
return False
wait_for_condition(_check_nodes, timeout=15)
@pytest.mark.parametrize(
"ray_start_cluster_head", [{"include_dashboard": True}], indirect=True
)
def test_multi_node_churn(
enable_test_module, disable_aiohttp_cache, ray_start_cluster_head
):
cluster: Cluster = ray_start_cluster_head
assert wait_until_server_available(cluster.webui_url) is True
webui_url = format_web_url(cluster.webui_url)
success = True
def verify():
nonlocal success
while True:
try:
resp = requests.get(webui_url)
resp.raise_for_status()
resp = requests.get(webui_url + "/nodes?view=summary")
resp.raise_for_status()
summary = resp.json()
assert summary["result"] is True, summary["msg"]
assert summary["data"]["summary"]
time.sleep(1)
except Exception:
success = False
break
t = threading.Thread(target=verify, daemon=True)
t.start()
t_st = datetime.now()
duration = timedelta(seconds=60)
worker_nodes = []
while datetime.now() < t_st + duration:
time.sleep(5)
if len(worker_nodes) < 2:
worker_nodes.append(cluster.add_node())
continue
should_add_node = random.randint(0, 1)
if should_add_node:
worker_nodes.append(cluster.add_node())
else:
node_index = random.randrange(0, len(worker_nodes))
node_to_remove = worker_nodes.pop(node_index)
cluster.remove_node(node_to_remove)
assert success
@pytest.mark.skipif(
sys.platform == "win32", reason="setproctitle does not change psutil.cmdline"
)
def test_node_physical_stats(enable_test_module, shutdown_only):
"""
Tests NodeHead._update_node_physical_stats.
"""
addresses = ray.init(include_dashboard=True, num_cpus=6)
@ray.remote(num_cpus=1)
class Actor:
def getpid(self):
return os.getpid()
actors = [Actor.remote() for _ in range(6)]
actor_pids = ray.get([actor.getpid.remote() for actor in actors])
actor_pids = set(actor_pids)
webui_url = addresses["webui_url"]
assert wait_until_server_available(webui_url) is True
webui_url = format_web_url(webui_url)
def _check_workers():
try:
resp = requests.get(webui_url + "/test/dump?key=node_physical_stats")
resp.raise_for_status()
result = resp.json()
assert result["result"] is True
node_physical_stats = result["data"]["nodePhysicalStats"]
assert len(node_physical_stats) == 1
current_stats = node_physical_stats[addresses["node_id"]]
# Check Actor workers
current_actor_pids = set()
for worker in current_stats["workers"]:
if "ray::Actor" in worker["cmdline"][0]:
current_actor_pids.add(worker["pid"])
assert current_actor_pids == actor_pids
# Check raylet cmdline
assert "raylet" in current_stats["cmdline"][0]
return True
except Exception as ex:
logger.info(ex)
return False
wait_for_condition(_check_workers, timeout=10)
def test_worker_pids_reported(enable_test_module, ray_start_with_dashboard):
assert wait_until_server_available(ray_start_with_dashboard["webui_url"]) is True
webui_url = ray_start_with_dashboard["webui_url"]
webui_url = format_web_url(webui_url)
node_id = ray_start_with_dashboard["node_id"]
@ray.remote(runtime_env={"uv": {"packages": ["requests==2.32.5"]}})
class UvActor:
def get_pid(self):
return os.getpid()
uv_actor = UvActor.remote()
uv_actor_pid = ray.get(uv_actor.get_pid.remote())
driver_pid = os.getpid()
def _check_worker_pids():
try:
response = requests.get(webui_url + f"/nodes/{node_id}")
response.raise_for_status()
dump_info = response.json()
assert dump_info["result"] is True
detail = dump_info["data"]["detail"]
pids = [worker["pid"] for worker in detail["workers"]]
assert len(pids) >= 2 # might include idle worker
assert uv_actor_pid in pids
assert driver_pid in pids
return True
except Exception as ex:
logger.info(ex)
return False
wait_for_condition(_check_worker_pids, timeout=20)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))