chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import ray
|
||||
import ray.dashboard.utils as dashboard_utils
|
||||
from ray._private import ray_constants
|
||||
from ray._private.telemetry.open_telemetry_metric_recorder import (
|
||||
OpenTelemetryMetricRecorder,
|
||||
)
|
||||
from ray.core.generated import (
|
||||
events_event_aggregator_service_pb2,
|
||||
events_event_aggregator_service_pb2_grpc,
|
||||
)
|
||||
from ray.dashboard.modules.aggregator.constants import AGGREGATOR_AGENT_METRIC_PREFIX
|
||||
from ray.dashboard.modules.aggregator.multi_consumer_event_buffer import (
|
||||
MultiConsumerEventBuffer,
|
||||
)
|
||||
from ray.dashboard.modules.aggregator.publisher.async_publisher_client import (
|
||||
AsyncGCSTaskEventsPublisherClient,
|
||||
AsyncHttpPublisherClient,
|
||||
)
|
||||
from ray.dashboard.modules.aggregator.publisher.ray_event_publisher import (
|
||||
NoopPublisher,
|
||||
RayEventPublisher,
|
||||
)
|
||||
from ray.dashboard.modules.aggregator.task_events_metadata_buffer import (
|
||||
TaskEventsMetadataBuffer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Max number of threads for the thread pool executor handling CPU intensive tasks
|
||||
THREAD_POOL_EXECUTOR_MAX_WORKERS = ray_constants.env_integer(
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_THREAD_POOL_EXECUTOR_MAX_WORKERS", 1
|
||||
)
|
||||
# Interval to check the main thread liveness
|
||||
CHECK_MAIN_THREAD_LIVENESS_INTERVAL_SECONDS = ray_constants.env_float(
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_CHECK_MAIN_THREAD_LIVENESS_INTERVAL_SECONDS", 0.1
|
||||
)
|
||||
# Maximum size of the event buffer in the aggregator agent
|
||||
# The default value was 1,000,000 but was reduced to 100,000 now to avoid being OOM Killed.
|
||||
# We observed that the previous 1,000,000 could take up to 20 GB of memory.
|
||||
# TODO (rueian): Find a better way for the event buffer to store events while avoiding being OOM Killed. For example:
|
||||
# 1. Store bytes instead of python objects and count the size in bytes.
|
||||
# 2. Compress the bytes before storing them in the buffer? (This will increase the CPU usage)
|
||||
# 3. Don't be fixed at 10,0000 but adjust the buffer size based on the available memory on startup.
|
||||
MAX_EVENT_BUFFER_SIZE = ray_constants.env_integer(
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_MAX_EVENT_BUFFER_SIZE", 100000
|
||||
)
|
||||
# Maximum number of events to send in a single batch to the destination
|
||||
MAX_EVENT_SEND_BATCH_SIZE = ray_constants.env_integer(
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_MAX_EVENT_SEND_BATCH_SIZE", 1000
|
||||
)
|
||||
# Address of the external service to send events with format of "http://<ip>:<port>"
|
||||
EVENTS_EXPORT_ADDR = os.environ.get(
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_EVENTS_EXPORT_ADDR", ""
|
||||
)
|
||||
# flag to enable publishing events to the external HTTP service
|
||||
PUBLISH_EVENTS_TO_EXTERNAL_HTTP_SERVICE = ray_constants.env_bool(
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_PUBLISH_EVENTS_TO_EXTERNAL_HTTP_SERVICE", True
|
||||
)
|
||||
# flag to enable publishing events to GCS
|
||||
PUBLISH_EVENTS_TO_GCS = ray_constants.env_bool(
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_PUBLISH_EVENTS_TO_GCS", False
|
||||
)
|
||||
# flag to control whether preserve the proto field name when converting the events to
|
||||
# JSON. If True, the proto field name will be preserved. If False, the proto field name
|
||||
# will be converted to camel case.
|
||||
PRESERVE_PROTO_FIELD_NAME = ray_constants.env_bool(
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_PRESERVE_PROTO_FIELD_NAME", False
|
||||
)
|
||||
|
||||
|
||||
class AggregatorAgent(
|
||||
dashboard_utils.DashboardAgentModule,
|
||||
events_event_aggregator_service_pb2_grpc.EventAggregatorServiceServicer,
|
||||
):
|
||||
"""
|
||||
AggregatorAgent is a dashboard agent module that collects events sent with
|
||||
gRPC from other components, buffers them, and periodically sends them to GCS and
|
||||
an external service with HTTP POST requests for further processing or storage
|
||||
"""
|
||||
|
||||
def __init__(self, dashboard_agent) -> None:
|
||||
super().__init__(dashboard_agent)
|
||||
self._ip = dashboard_agent.ip
|
||||
self._pid = os.getpid()
|
||||
|
||||
# common prometheus labels for aggregator-owned metrics
|
||||
self._common_tags = {
|
||||
"ip": self._ip,
|
||||
"pid": str(self._pid),
|
||||
"Version": ray.__version__,
|
||||
"Component": "aggregator_agent",
|
||||
"SessionName": self.session_name,
|
||||
}
|
||||
|
||||
self._event_buffer = MultiConsumerEventBuffer(
|
||||
max_size=MAX_EVENT_BUFFER_SIZE,
|
||||
max_batch_size=MAX_EVENT_SEND_BATCH_SIZE,
|
||||
common_metric_tags=self._common_tags,
|
||||
)
|
||||
self._executor = ThreadPoolExecutor(
|
||||
max_workers=THREAD_POOL_EXECUTOR_MAX_WORKERS,
|
||||
thread_name_prefix="aggregator_agent_executor",
|
||||
)
|
||||
|
||||
# Task metadata buffer accumulates dropped task attempts for GCS publishing
|
||||
self._task_metadata_buffer = TaskEventsMetadataBuffer(
|
||||
common_metric_tags=self._common_tags
|
||||
)
|
||||
|
||||
self._events_export_addr = (
|
||||
dashboard_agent.events_export_addr or EVENTS_EXPORT_ADDR
|
||||
)
|
||||
|
||||
self._event_processing_enabled = False
|
||||
if PUBLISH_EVENTS_TO_EXTERNAL_HTTP_SERVICE and self._events_export_addr:
|
||||
logger.info(
|
||||
f"Publishing events to external HTTP service is enabled. events_export_addr: {self._events_export_addr}"
|
||||
)
|
||||
self._event_processing_enabled = True
|
||||
self._http_endpoint_publisher = RayEventPublisher(
|
||||
name="http_service",
|
||||
publish_client=AsyncHttpPublisherClient(
|
||||
endpoint=self._events_export_addr,
|
||||
executor=self._executor,
|
||||
preserve_proto_field_name=PRESERVE_PROTO_FIELD_NAME,
|
||||
),
|
||||
event_buffer=self._event_buffer,
|
||||
common_metric_tags=self._common_tags,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Event HTTP target is not enabled or publishing events to external HTTP service is disabled. Skipping sending events to external HTTP service. events_export_addr: {self._events_export_addr}"
|
||||
)
|
||||
self._http_endpoint_publisher = NoopPublisher()
|
||||
|
||||
if PUBLISH_EVENTS_TO_GCS:
|
||||
logger.info("Publishing events to GCS is enabled")
|
||||
self._event_processing_enabled = True
|
||||
self._gcs_publisher = RayEventPublisher(
|
||||
name="ray_gcs",
|
||||
publish_client=AsyncGCSTaskEventsPublisherClient(
|
||||
gcs_client=self._dashboard_agent.gcs_client,
|
||||
executor=self._executor,
|
||||
),
|
||||
event_buffer=self._event_buffer,
|
||||
common_metric_tags=self._common_tags,
|
||||
task_metadata_buffer=self._task_metadata_buffer,
|
||||
)
|
||||
else:
|
||||
logger.info("Publishing events to GCS is disabled")
|
||||
self._gcs_publisher = NoopPublisher()
|
||||
|
||||
# Metrics
|
||||
self._open_telemetry_metric_recorder = OpenTelemetryMetricRecorder()
|
||||
|
||||
# Register counter metrics
|
||||
self._events_received_metric_name = (
|
||||
f"{AGGREGATOR_AGENT_METRIC_PREFIX}_events_received_total"
|
||||
)
|
||||
self._open_telemetry_metric_recorder.register_counter_metric(
|
||||
self._events_received_metric_name,
|
||||
"Total number of events received via AddEvents gRPC.",
|
||||
)
|
||||
|
||||
self._events_failed_to_add_metric_name = (
|
||||
f"{AGGREGATOR_AGENT_METRIC_PREFIX}_events_buffer_add_failures_total"
|
||||
)
|
||||
self._open_telemetry_metric_recorder.register_counter_metric(
|
||||
self._events_failed_to_add_metric_name,
|
||||
"Total number of events that failed to be added to the event buffer.",
|
||||
)
|
||||
|
||||
async def AddEvents(self, request, context) -> None:
|
||||
"""
|
||||
gRPC handler for adding events to the event aggregator. Receives events from the
|
||||
request and adds them to the event buffer.
|
||||
"""
|
||||
if not self._event_processing_enabled:
|
||||
return events_event_aggregator_service_pb2.AddEventsReply()
|
||||
|
||||
received_count = len(request.events_data.events)
|
||||
failed_count = 0
|
||||
events_data = request.events_data
|
||||
|
||||
if PUBLISH_EVENTS_TO_GCS:
|
||||
self._task_metadata_buffer.merge(events_data.task_events_metadata)
|
||||
|
||||
for event in events_data.events:
|
||||
try:
|
||||
await self._event_buffer.add_event(event)
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logger.error(
|
||||
f"Failed to add event with id={event.event_id.decode()} to buffer. "
|
||||
"Error: %s",
|
||||
e,
|
||||
)
|
||||
|
||||
if received_count > 0:
|
||||
self._open_telemetry_metric_recorder.set_metric_value(
|
||||
self._events_received_metric_name, self._common_tags, received_count
|
||||
)
|
||||
if failed_count > 0:
|
||||
self._open_telemetry_metric_recorder.set_metric_value(
|
||||
self._events_failed_to_add_metric_name, self._common_tags, failed_count
|
||||
)
|
||||
|
||||
return events_event_aggregator_service_pb2.AddEventsReply()
|
||||
|
||||
async def run(self, server) -> None:
|
||||
if server:
|
||||
events_event_aggregator_service_pb2_grpc.add_EventAggregatorServiceServicer_to_server(
|
||||
self, server
|
||||
)
|
||||
try:
|
||||
await asyncio.gather(
|
||||
self._http_endpoint_publisher.run_forever(),
|
||||
self._gcs_publisher.run_forever(),
|
||||
)
|
||||
finally:
|
||||
self._executor.shutdown()
|
||||
|
||||
@staticmethod
|
||||
def is_minimal_module() -> bool:
|
||||
return False
|
||||
@@ -0,0 +1,2 @@
|
||||
AGGREGATOR_AGENT_METRIC_PREFIX = "aggregator_agent"
|
||||
CONSUMER_TAG_KEY = "consumer"
|
||||
@@ -0,0 +1,194 @@
|
||||
import asyncio
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from ray._private.telemetry.open_telemetry_metric_recorder import (
|
||||
OpenTelemetryMetricRecorder,
|
||||
)
|
||||
from ray.core.generated import (
|
||||
events_base_event_pb2,
|
||||
)
|
||||
from ray.core.generated.events_base_event_pb2 import RayEvent
|
||||
from ray.dashboard.modules.aggregator.constants import (
|
||||
AGGREGATOR_AGENT_METRIC_PREFIX,
|
||||
CONSUMER_TAG_KEY,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ConsumerState:
|
||||
# Index of the next event to be consumed by this consumer
|
||||
cursor_index: int
|
||||
|
||||
|
||||
class MultiConsumerEventBuffer:
|
||||
"""A buffer which allows adding one event at a time and consuming events in batches.
|
||||
Supports multiple consumers, each with their own cursor index. Tracks the number of events evicted for each consumer.
|
||||
|
||||
Buffer is not thread-safe but is asyncio-friendly. All operations must be called from within the same event loop.
|
||||
|
||||
Arguments:
|
||||
max_size: Maximum number of events to store in the buffer.
|
||||
max_batch_size: Maximum number of events to return in a batch when calling wait_for_batch.
|
||||
common_metric_tags: Tags to add to all metrics.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_size: int,
|
||||
max_batch_size: int,
|
||||
common_metric_tags: Optional[Dict[str, str]] = None,
|
||||
):
|
||||
self._buffer = deque(maxlen=max_size)
|
||||
self._max_size = max_size
|
||||
self._lock = asyncio.Lock()
|
||||
self._has_new_events_to_consume = asyncio.Condition(self._lock)
|
||||
self._consumers: Dict[str, _ConsumerState] = {}
|
||||
|
||||
self._max_batch_size = max_batch_size
|
||||
|
||||
self._common_metrics_tags = common_metric_tags or {}
|
||||
self._metric_recorder = OpenTelemetryMetricRecorder()
|
||||
self.evicted_events_metric_name = (
|
||||
f"{AGGREGATOR_AGENT_METRIC_PREFIX}_queue_dropped_events"
|
||||
)
|
||||
self._metric_recorder.register_counter_metric(
|
||||
self.evicted_events_metric_name,
|
||||
"Total number of events dropped because the publish/buffer queue was full.",
|
||||
)
|
||||
|
||||
async def add_event(self, event: events_base_event_pb2.RayEvent) -> None:
|
||||
"""Add an event to the buffer.
|
||||
|
||||
If the buffer is full, the oldest event is dropped.
|
||||
"""
|
||||
async with self._lock:
|
||||
dropped_event = None
|
||||
if len(self._buffer) >= self._max_size:
|
||||
dropped_event = self._buffer.popleft()
|
||||
self._buffer.append(event)
|
||||
|
||||
if dropped_event is not None:
|
||||
for consumer_name, consumer_state in self._consumers.items():
|
||||
# Update consumer cursor index and evicted events metric if an event was dropped
|
||||
if consumer_state.cursor_index == 0:
|
||||
# The dropped event was the next event this consumer would have consumed, publish eviction metric
|
||||
self._metric_recorder.set_metric_value(
|
||||
self.evicted_events_metric_name,
|
||||
{
|
||||
**self._common_metrics_tags,
|
||||
CONSUMER_TAG_KEY: consumer_name,
|
||||
"event_type": RayEvent.EventType.Name(
|
||||
dropped_event.event_type
|
||||
),
|
||||
},
|
||||
1,
|
||||
)
|
||||
else:
|
||||
# The dropped event was already consumed by the consumer, so we need to adjust the cursor
|
||||
consumer_state.cursor_index -= 1
|
||||
|
||||
# Signal the consumers that there are new events to consume
|
||||
self._has_new_events_to_consume.notify_all()
|
||||
|
||||
def _evict_old_events(self) -> None:
|
||||
"""Clean the buffer by removing events from the buffer who have index lower than
|
||||
all the cursor indexes of all consumers and updating the cursor index of all
|
||||
consumers.
|
||||
"""
|
||||
if not self._consumers:
|
||||
return
|
||||
|
||||
min_cursor_index = min(
|
||||
consumer_state.cursor_index for consumer_state in self._consumers.values()
|
||||
)
|
||||
for _ in range(min_cursor_index):
|
||||
self._buffer.popleft()
|
||||
|
||||
# update the cursor index of all consumers
|
||||
for consumer_state in self._consumers.values():
|
||||
consumer_state.cursor_index -= min_cursor_index
|
||||
|
||||
async def wait_for_batch(
|
||||
self, consumer_name: str, timeout_seconds: float = 1.0
|
||||
) -> List[events_base_event_pb2.RayEvent]:
|
||||
"""Wait for batch respecting self.max_batch_size and timeout_seconds.
|
||||
|
||||
Returns a batch of up to self.max_batch_size items. Waits for up to
|
||||
timeout_seconds after receiving the first event that will be in
|
||||
the next batch. After the timeout, returns as many items as are ready.
|
||||
|
||||
Always returns a batch with at least one item - will block
|
||||
indefinitely until an item comes in.
|
||||
|
||||
Arguments:
|
||||
consumer_name: name of the consumer consuming the batch
|
||||
timeout_seconds: maximum time to wait for a batch
|
||||
|
||||
Returns:
|
||||
A list of up to max_batch_size events ready for consumption.
|
||||
The list always contains at least one event.
|
||||
"""
|
||||
max_batch = self._max_batch_size
|
||||
batch = []
|
||||
async with self._has_new_events_to_consume:
|
||||
consumer_state = self._consumers.get(consumer_name)
|
||||
if consumer_state is None:
|
||||
raise KeyError(f"unknown consumer '{consumer_name}'")
|
||||
|
||||
# Phase 1: read the first event, wait indefinitely until there is at least one event to consume
|
||||
while consumer_state.cursor_index >= len(self._buffer):
|
||||
await self._has_new_events_to_consume.wait()
|
||||
|
||||
# Add the first event to the batch
|
||||
event = self._buffer[consumer_state.cursor_index]
|
||||
consumer_state.cursor_index += 1
|
||||
batch.append(event)
|
||||
|
||||
# Phase 2: add items to the batch up to timeout or until full
|
||||
deadline = time.monotonic() + max(0.0, float(timeout_seconds))
|
||||
while len(batch) < max_batch:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
# Drain whatever is available
|
||||
while len(batch) < max_batch and consumer_state.cursor_index < len(
|
||||
self._buffer
|
||||
):
|
||||
batch.append(self._buffer[consumer_state.cursor_index])
|
||||
consumer_state.cursor_index += 1
|
||||
|
||||
if len(batch) >= max_batch:
|
||||
break
|
||||
|
||||
# There is still room in the batch, but no new events to consume; wait until notified or timeout
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._has_new_events_to_consume.wait(), remaining
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# Timeout, return the current batch
|
||||
break
|
||||
|
||||
self._evict_old_events()
|
||||
return batch
|
||||
|
||||
async def register_consumer(self, consumer_name: str) -> None:
|
||||
"""Register a new consumer with a name.
|
||||
|
||||
Arguments:
|
||||
consumer_name: A unique name for the consumer.
|
||||
|
||||
"""
|
||||
async with self._lock:
|
||||
if self._consumers.get(consumer_name) is not None:
|
||||
raise ValueError(f"consumer '{consumer_name}' already registered")
|
||||
|
||||
self._consumers[consumer_name] = _ConsumerState(cursor_index=0)
|
||||
|
||||
async def size(self) -> int:
|
||||
"""Get total number of events in the buffer. Does not take consumer cursors into account."""
|
||||
return len(self._buffer)
|
||||
@@ -0,0 +1,291 @@
|
||||
import json
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
import ray.dashboard.utils as dashboard_utils
|
||||
from ray._common.utils import get_or_create_event_loop
|
||||
from ray._private.protobuf_compat import message_to_json
|
||||
from ray._raylet import GcsClient
|
||||
from ray.core.generated import (
|
||||
events_base_event_pb2,
|
||||
events_event_aggregator_service_pb2,
|
||||
)
|
||||
from ray.dashboard.modules.aggregator.publisher.configs import (
|
||||
GCS_EXPOSABLE_EVENT_TYPES,
|
||||
HTTP_EXPOSABLE_EVENT_TYPES,
|
||||
PUBLISHER_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PublishStats:
|
||||
"""Data class that represents stats of publishing a batch of events."""
|
||||
|
||||
# Whether the publish was successful
|
||||
is_publish_successful: bool
|
||||
# Number of events published
|
||||
num_events_published: int
|
||||
# Number of events filtered out
|
||||
num_events_filtered_out: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class PublishBatch:
|
||||
"""Data class that represents a batch of events to publish."""
|
||||
|
||||
# The list of events to publish
|
||||
events: list[events_base_event_pb2.RayEvent]
|
||||
# dropped task events metadata
|
||||
task_events_metadata: Optional[
|
||||
events_event_aggregator_service_pb2.TaskEventsMetadata
|
||||
] = None
|
||||
|
||||
|
||||
class PublisherClientInterface(ABC):
|
||||
"""Abstract interface for publishing Ray event batches to external destinations.
|
||||
|
||||
Implementations should handle the actual publishing logic, filtering,
|
||||
and format conversion appropriate for their specific destination type.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._exposable_event_types_list: List[str] = []
|
||||
self._allow_all_event_types: bool = False
|
||||
|
||||
def count_num_events_in_batch(self, batch: PublishBatch) -> int:
|
||||
"""Count the number of events in a given PublishBatch."""
|
||||
return len(batch.events)
|
||||
|
||||
def _can_expose_event(self, event) -> bool:
|
||||
"""
|
||||
Check if an event should be allowed to be published.
|
||||
"""
|
||||
if self._allow_all_event_types:
|
||||
return True
|
||||
if not self._exposable_event_types_list:
|
||||
return False
|
||||
|
||||
event_type_name = events_base_event_pb2.RayEvent.EventType.Name(
|
||||
event.event_type
|
||||
)
|
||||
return event_type_name in self._exposable_event_types_list
|
||||
|
||||
@abstractmethod
|
||||
async def publish(self, batch: PublishBatch) -> PublishStats:
|
||||
"""Publish a batch of events to the destination."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
"""Clean up any resources used by this client. Should be called when the publisherClient is no longer required"""
|
||||
pass
|
||||
|
||||
|
||||
class AsyncHttpPublisherClient(PublisherClientInterface):
|
||||
"""Client for publishing ray event batches to an external HTTP service."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str,
|
||||
executor: ThreadPoolExecutor,
|
||||
timeout: float = PUBLISHER_TIMEOUT_SECONDS,
|
||||
preserve_proto_field_name: bool = False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._endpoint = endpoint
|
||||
self._executor = executor
|
||||
self._timeout = aiohttp.ClientTimeout(total=timeout)
|
||||
self._session = None
|
||||
self._preserve_proto_field_name = preserve_proto_field_name
|
||||
|
||||
if HTTP_EXPOSABLE_EVENT_TYPES.strip().upper() == "ALL":
|
||||
self._allow_all_event_types = True
|
||||
self._exposable_event_types_list = []
|
||||
else:
|
||||
self._exposable_event_types_list = [
|
||||
event_type.strip()
|
||||
for event_type in HTTP_EXPOSABLE_EVENT_TYPES.split(",")
|
||||
if event_type.strip()
|
||||
]
|
||||
|
||||
async def publish(self, batch: PublishBatch) -> PublishStats:
|
||||
events_batch: list[events_base_event_pb2.RayEvent] = batch.events
|
||||
if not events_batch:
|
||||
# Nothing to publish -> success but nothing published
|
||||
return PublishStats(
|
||||
is_publish_successful=True,
|
||||
num_events_published=0,
|
||||
num_events_filtered_out=0,
|
||||
)
|
||||
filtered = [e for e in events_batch if self._can_expose_event(e)]
|
||||
num_filtered_out = len(events_batch) - len(filtered)
|
||||
if not filtered:
|
||||
# All filtered out -> success but nothing published
|
||||
return PublishStats(
|
||||
is_publish_successful=True,
|
||||
num_events_published=0,
|
||||
num_events_filtered_out=num_filtered_out,
|
||||
)
|
||||
|
||||
# Convert protobuf objects to python dictionaries for HTTP POST. Run in executor to avoid blocking the event loop.
|
||||
filtered_json = await get_or_create_event_loop().run_in_executor(
|
||||
self._executor,
|
||||
lambda: [
|
||||
json.loads(
|
||||
message_to_json(
|
||||
e,
|
||||
always_print_fields_with_no_presence=True,
|
||||
preserving_proto_field_name=self._preserve_proto_field_name,
|
||||
)
|
||||
)
|
||||
for e in filtered
|
||||
],
|
||||
)
|
||||
|
||||
try:
|
||||
# Create session on first use (lazy initialization)
|
||||
if not self._session:
|
||||
self._session = aiohttp.ClientSession(timeout=self._timeout)
|
||||
|
||||
return await self._send_http_request(filtered_json, num_filtered_out)
|
||||
except Exception as e:
|
||||
logger.error("Failed to send events to external service. Error: %r", e)
|
||||
return PublishStats(
|
||||
is_publish_successful=False,
|
||||
num_events_published=0,
|
||||
num_events_filtered_out=0,
|
||||
)
|
||||
|
||||
async def _send_http_request(self, json_data, num_filtered_out) -> PublishStats:
|
||||
async with self._session.post(
|
||||
self._endpoint,
|
||||
json=json_data,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
return PublishStats(
|
||||
is_publish_successful=True,
|
||||
num_events_published=len(json_data),
|
||||
num_events_filtered_out=num_filtered_out,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Closes the http session if one was created. Should be called when the publisherClient is no longer required"""
|
||||
if self._session:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
def set_session(self, session) -> None:
|
||||
"""Inject an HTTP client session.
|
||||
|
||||
If a session is set explicitly, it will be used and managed by close().
|
||||
"""
|
||||
self._session = session
|
||||
|
||||
|
||||
class AsyncGCSTaskEventsPublisherClient(PublisherClientInterface):
|
||||
"""Client for publishing ray event batches to GCS."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gcs_client: GcsClient,
|
||||
executor: ThreadPoolExecutor,
|
||||
timeout_s: float = PUBLISHER_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._gcs_client = gcs_client
|
||||
self._executor = executor
|
||||
self._timeout_s = timeout_s
|
||||
|
||||
self._exposable_event_types_list = GCS_EXPOSABLE_EVENT_TYPES
|
||||
|
||||
async def publish(
|
||||
self,
|
||||
batch: PublishBatch,
|
||||
) -> PublishStats:
|
||||
events = batch.events
|
||||
task_events_metadata = batch.task_events_metadata
|
||||
has_dropped_task_attempts = (
|
||||
task_events_metadata and task_events_metadata.dropped_task_attempts
|
||||
)
|
||||
if not events and not has_dropped_task_attempts:
|
||||
# Nothing to publish -> success but nothing published
|
||||
return PublishStats(
|
||||
is_publish_successful=True,
|
||||
num_events_published=0,
|
||||
num_events_filtered_out=0,
|
||||
)
|
||||
|
||||
# Filter events based on exposable event types
|
||||
filtered_events = [e for e in events if self._can_expose_event(e)]
|
||||
num_filtered_out = len(events) - len(filtered_events)
|
||||
|
||||
if not filtered_events and not has_dropped_task_attempts:
|
||||
# all events filtered out and no task events metadata -> success but nothing published
|
||||
return PublishStats(
|
||||
is_publish_successful=True,
|
||||
num_events_published=0,
|
||||
num_events_filtered_out=num_filtered_out,
|
||||
)
|
||||
|
||||
try:
|
||||
events_data = self._create_ray_events_data(
|
||||
filtered_events, task_events_metadata
|
||||
)
|
||||
request = events_event_aggregator_service_pb2.AddEventsRequest(
|
||||
events_data=events_data
|
||||
)
|
||||
serialized_request = await get_or_create_event_loop().run_in_executor(
|
||||
self._executor,
|
||||
lambda: request.SerializeToString(),
|
||||
)
|
||||
status_code = await self._gcs_client.async_add_events(
|
||||
serialized_request, self._timeout_s, self._executor
|
||||
)
|
||||
|
||||
if status_code != dashboard_utils.HTTPStatusCode.OK:
|
||||
logger.error(f"GCS AddEvents failed: {status_code}")
|
||||
return PublishStats(
|
||||
is_publish_successful=False,
|
||||
num_events_published=0,
|
||||
num_events_filtered_out=0,
|
||||
)
|
||||
return PublishStats(
|
||||
is_publish_successful=True,
|
||||
num_events_published=len(filtered_events),
|
||||
num_events_filtered_out=num_filtered_out,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send events to GCS: {e}")
|
||||
return PublishStats(
|
||||
is_publish_successful=False,
|
||||
num_events_published=0,
|
||||
num_events_filtered_out=0,
|
||||
)
|
||||
|
||||
def _create_ray_events_data(
|
||||
self,
|
||||
event_batch: List[events_base_event_pb2.RayEvent],
|
||||
task_events_metadata: Optional[
|
||||
events_event_aggregator_service_pb2.TaskEventsMetadata
|
||||
] = None,
|
||||
) -> events_event_aggregator_service_pb2.RayEventsData:
|
||||
"""
|
||||
Helper method to create RayEventsData from event batch and metadata.
|
||||
"""
|
||||
events_data = events_event_aggregator_service_pb2.RayEventsData()
|
||||
events_data.events.extend(event_batch)
|
||||
|
||||
if task_events_metadata:
|
||||
events_data.task_events_metadata.CopyFrom(task_events_metadata)
|
||||
|
||||
return events_data
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
@@ -0,0 +1,54 @@
|
||||
# Environment variables for the aggregator agent publisher component.
|
||||
import os
|
||||
|
||||
from ray._private import ray_constants
|
||||
|
||||
env_var_prefix = "RAY_DASHBOARD_AGGREGATOR_AGENT_PUBLISHER"
|
||||
# Timeout for the publisher to publish events to the destination
|
||||
PUBLISHER_TIMEOUT_SECONDS = ray_constants.env_integer(
|
||||
f"{env_var_prefix}_TIMEOUT_SECONDS", 3
|
||||
)
|
||||
# Maximum number of retries for publishing events to the destination, if less than 0, will retry indefinitely
|
||||
PUBLISHER_MAX_RETRIES = ray_constants.env_integer(f"{env_var_prefix}_MAX_RETRIES", -1)
|
||||
# Initial backoff time for publishing events to the destination
|
||||
PUBLISHER_INITIAL_BACKOFF_SECONDS = ray_constants.env_float(
|
||||
f"{env_var_prefix}_INITIAL_BACKOFF_SECONDS", 0.01
|
||||
)
|
||||
# Maximum backoff time for publishing events to the destination
|
||||
PUBLISHER_MAX_BACKOFF_SECONDS = ray_constants.env_float(
|
||||
f"{env_var_prefix}_MAX_BACKOFF_SECONDS", 5.0
|
||||
)
|
||||
# Jitter ratio for publishing events to the destination
|
||||
PUBLISHER_JITTER_RATIO = ray_constants.env_float(f"{env_var_prefix}_JITTER_RATIO", 0.1)
|
||||
# Maximum sleep time between sending batches of events to the destination, should be greater than 0.0 to avoid busy looping
|
||||
PUBLISHER_MAX_BUFFER_SEND_INTERVAL_SECONDS = ray_constants.env_float(
|
||||
f"{env_var_prefix}_MAX_BUFFER_SEND_INTERVAL_SECONDS", 0.1
|
||||
)
|
||||
|
||||
# HTTP Publisher specific configurations
|
||||
# Comma-separated list of event types that are allowed to be exposed to external HTTP services
|
||||
# Valid values: TASK_DEFINITION_EVENT, TASK_LIFECYCLE_EVENT, ACTOR_TASK_DEFINITION_EVENT, etc.
|
||||
# Set to "ALL" to allow all event types.
|
||||
# The list of all supported event types can be found in src/ray/protobuf/public/events_base_event.proto (EventType enum)
|
||||
# By default TASK_PROFILE_EVENT is not exposed to external services
|
||||
DEFAULT_HTTP_EXPOSABLE_EVENT_TYPES = (
|
||||
"TASK_DEFINITION_EVENT,TASK_LIFECYCLE_EVENT,ACTOR_TASK_DEFINITION_EVENT,"
|
||||
"DRIVER_JOB_DEFINITION_EVENT,DRIVER_JOB_LIFECYCLE_EVENT,"
|
||||
"ACTOR_DEFINITION_EVENT,ACTOR_LIFECYCLE_EVENT,"
|
||||
"NODE_DEFINITION_EVENT,NODE_LIFECYCLE_EVENT,"
|
||||
"PLATFORM_EVENT,"
|
||||
)
|
||||
HTTP_EXPOSABLE_EVENT_TYPES = os.environ.get(
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_EXPOSABLE_EVENT_TYPES",
|
||||
DEFAULT_HTTP_EXPOSABLE_EVENT_TYPES,
|
||||
)
|
||||
|
||||
# GCS Publisher specific configurations
|
||||
# List of event types that are allowed to be exposed to GCS, not overridden by environment variable
|
||||
# as GCS only supports Task event types
|
||||
GCS_EXPOSABLE_EVENT_TYPES = [
|
||||
"TASK_DEFINITION_EVENT",
|
||||
"TASK_LIFECYCLE_EVENT",
|
||||
"TASK_PROFILE_EVENT",
|
||||
"ACTOR_TASK_DEFINITION_EVENT",
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
from ray._private.telemetry.open_telemetry_metric_recorder import (
|
||||
OpenTelemetryMetricRecorder,
|
||||
)
|
||||
from ray.dashboard.modules.aggregator.constants import (
|
||||
AGGREGATOR_AGENT_METRIC_PREFIX,
|
||||
)
|
||||
|
||||
# OpenTelemetry metrics setup (registered once at import time)
|
||||
metric_recorder = OpenTelemetryMetricRecorder()
|
||||
|
||||
# Counter metrics
|
||||
published_counter_name = f"{AGGREGATOR_AGENT_METRIC_PREFIX}_published_events"
|
||||
metric_recorder.register_counter_metric(
|
||||
published_counter_name,
|
||||
"Total number of events successfully published to the destination.",
|
||||
)
|
||||
|
||||
filtered_counter_name = f"{AGGREGATOR_AGENT_METRIC_PREFIX}_filtered_events"
|
||||
metric_recorder.register_counter_metric(
|
||||
filtered_counter_name,
|
||||
"Total number of events filtered out before publishing to the destination.",
|
||||
)
|
||||
|
||||
failed_counter_name = f"{AGGREGATOR_AGENT_METRIC_PREFIX}_publish_failures"
|
||||
metric_recorder.register_counter_metric(
|
||||
failed_counter_name,
|
||||
"Total number of events that failed to publish after retries.",
|
||||
)
|
||||
|
||||
# Histogram metric
|
||||
publish_latency_hist_name = f"{AGGREGATOR_AGENT_METRIC_PREFIX}_publish_latency_seconds"
|
||||
metric_recorder.register_histogram_metric(
|
||||
publish_latency_hist_name,
|
||||
"Duration of publish calls in seconds.",
|
||||
[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5],
|
||||
)
|
||||
|
||||
# Gauge metrics
|
||||
consecutive_failures_gauge_name = (
|
||||
f"{AGGREGATOR_AGENT_METRIC_PREFIX}_consecutive_failures_since_last_success"
|
||||
)
|
||||
metric_recorder.register_gauge_metric(
|
||||
consecutive_failures_gauge_name,
|
||||
"Number of consecutive failed publish attempts since the last success.",
|
||||
)
|
||||
|
||||
time_since_last_success_gauge_name = (
|
||||
f"{AGGREGATOR_AGENT_METRIC_PREFIX}_time_since_last_success_seconds"
|
||||
)
|
||||
metric_recorder.register_gauge_metric(
|
||||
time_since_last_success_gauge_name,
|
||||
"Seconds since the last successful publish to the destination.",
|
||||
)
|
||||
@@ -0,0 +1,289 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Optional
|
||||
|
||||
from ray.dashboard.modules.aggregator.constants import (
|
||||
CONSUMER_TAG_KEY,
|
||||
)
|
||||
from ray.dashboard.modules.aggregator.multi_consumer_event_buffer import (
|
||||
MultiConsumerEventBuffer,
|
||||
)
|
||||
from ray.dashboard.modules.aggregator.publisher.async_publisher_client import (
|
||||
PublishBatch,
|
||||
PublisherClientInterface,
|
||||
)
|
||||
from ray.dashboard.modules.aggregator.publisher.configs import (
|
||||
PUBLISHER_INITIAL_BACKOFF_SECONDS,
|
||||
PUBLISHER_JITTER_RATIO,
|
||||
PUBLISHER_MAX_BACKOFF_SECONDS,
|
||||
PUBLISHER_MAX_BUFFER_SEND_INTERVAL_SECONDS,
|
||||
PUBLISHER_MAX_RETRIES,
|
||||
)
|
||||
from ray.dashboard.modules.aggregator.publisher.metrics import (
|
||||
consecutive_failures_gauge_name,
|
||||
failed_counter_name,
|
||||
filtered_counter_name,
|
||||
metric_recorder,
|
||||
publish_latency_hist_name,
|
||||
published_counter_name,
|
||||
time_since_last_success_gauge_name,
|
||||
)
|
||||
from ray.dashboard.modules.aggregator.task_events_metadata_buffer import (
|
||||
TaskEventsMetadataBuffer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RayEventPublisherInterface(ABC):
|
||||
"""Abstract interface for publishing Ray event batches to external destinations."""
|
||||
|
||||
@abstractmethod
|
||||
async def run_forever(self) -> None:
|
||||
"""Run the publisher forever until cancellation or process death."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def wait_until_running(self, timeout: Optional[float] = None) -> bool:
|
||||
"""Wait until the publisher has started."""
|
||||
pass
|
||||
|
||||
|
||||
class RayEventPublisher(RayEventPublisherInterface):
|
||||
"""RayEvents publisher that publishes batches of events to a destination by running a worker loop.
|
||||
|
||||
The worker loop continuously pulls batches from the event buffer and publishes them to the destination.
|
||||
"""
|
||||
|
||||
# Cap the exponent to avoid computing unnecessarily large intermediate
|
||||
_MAX_BACKOFF_EXPONENT = 30
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
publish_client: PublisherClientInterface,
|
||||
event_buffer: MultiConsumerEventBuffer,
|
||||
common_metric_tags: Optional[Dict[str, str]] = None,
|
||||
task_metadata_buffer: Optional[TaskEventsMetadataBuffer] = None,
|
||||
max_retries: int = PUBLISHER_MAX_RETRIES,
|
||||
initial_backoff: float = PUBLISHER_INITIAL_BACKOFF_SECONDS,
|
||||
max_backoff: float = PUBLISHER_MAX_BACKOFF_SECONDS,
|
||||
jitter_ratio: float = PUBLISHER_JITTER_RATIO,
|
||||
) -> None:
|
||||
"""Initialize a RayEventsPublisher.
|
||||
|
||||
Args:
|
||||
name: Name identifier for this publisher instance
|
||||
publish_client: Client for publishing events to the destination
|
||||
event_buffer: Buffer for reading batches of events
|
||||
common_metric_tags: Common labels for all prometheus metrics
|
||||
task_metadata_buffer: Buffer for reading a batch of droppedtask metadata
|
||||
max_retries: Maximum number of retries for failed publishes
|
||||
initial_backoff: Initial backoff time between retries in seconds
|
||||
max_backoff: Maximum backoff time between retries in seconds
|
||||
jitter_ratio: Random jitter ratio to add to backoff times
|
||||
"""
|
||||
self._name = name
|
||||
self._common_metric_tags = dict(common_metric_tags or {})
|
||||
self._common_metric_tags[CONSUMER_TAG_KEY] = name
|
||||
self._max_retries = int(max_retries)
|
||||
self._initial_backoff = float(initial_backoff)
|
||||
self._max_backoff = float(max_backoff)
|
||||
self._jitter_ratio = float(jitter_ratio)
|
||||
self._publish_client = publish_client
|
||||
self._event_buffer = event_buffer
|
||||
self._task_metadata_buffer = task_metadata_buffer
|
||||
|
||||
# Event set once the publisher has registered as a consumer and is ready to publish events
|
||||
self._started_event: asyncio.Event = asyncio.Event()
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
"""Run the publisher forever until cancellation or process death.
|
||||
|
||||
Registers as a consumer, starts the worker loop, and handles cleanup on cancellation.
|
||||
"""
|
||||
await self._event_buffer.register_consumer(self._name)
|
||||
|
||||
# Signal that the publisher is ready to publish events
|
||||
self._started_event.set()
|
||||
|
||||
try:
|
||||
logger.info(f"Starting publisher {self._name}")
|
||||
while True:
|
||||
events_batch = await self._event_buffer.wait_for_batch(
|
||||
self._name,
|
||||
PUBLISHER_MAX_BUFFER_SEND_INTERVAL_SECONDS,
|
||||
)
|
||||
publish_batch = PublishBatch(events=events_batch)
|
||||
|
||||
if self._task_metadata_buffer is not None:
|
||||
task_metadata_batch = self._task_metadata_buffer.get()
|
||||
publish_batch.task_events_metadata = task_metadata_batch
|
||||
|
||||
await self._async_publish_with_retries(publish_batch)
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"Publisher {self._name} cancelled, shutting down gracefully")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Publisher {self._name} encountered error: {e}")
|
||||
raise
|
||||
finally:
|
||||
self._started_event.clear()
|
||||
await self._publish_client.close()
|
||||
|
||||
async def wait_until_running(self, timeout: Optional[float] = None) -> bool:
|
||||
"""Wait until the publisher has started.
|
||||
|
||||
Args:
|
||||
timeout: Maximum time to wait in seconds. If None, waits indefinitely.
|
||||
|
||||
Returns:
|
||||
True if the publisher started before the timeout, False otherwise.
|
||||
If timeout is None, waits indefinitely.
|
||||
"""
|
||||
if timeout is None:
|
||||
await self._started_event.wait()
|
||||
return True
|
||||
try:
|
||||
await asyncio.wait_for(self._started_event.wait(), timeout)
|
||||
return True
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
|
||||
async def _async_publish_with_retries(self, batch) -> None:
|
||||
"""Attempts to publish a batch with retries.
|
||||
|
||||
Will retry failed publishes up to max_retries times with increasing delays.
|
||||
"""
|
||||
num_events_in_batch = self._publish_client.count_num_events_in_batch(batch)
|
||||
failed_attempts_since_last_success = 0
|
||||
while True:
|
||||
start = asyncio.get_running_loop().time()
|
||||
result = await self._publish_client.publish(batch)
|
||||
duration = asyncio.get_running_loop().time() - start
|
||||
|
||||
if result.is_publish_successful:
|
||||
await self._record_success(
|
||||
num_published=int(result.num_events_published),
|
||||
num_filtered=int(result.num_events_filtered_out),
|
||||
duration=float(duration),
|
||||
)
|
||||
failed_attempts_since_last_success = 0
|
||||
return
|
||||
|
||||
# Failed attempt
|
||||
# case 1: if max retries are exhausted mark as failed and break out, retry indefinitely if max_retries is less than 0
|
||||
if (
|
||||
self._max_retries >= 0
|
||||
and failed_attempts_since_last_success >= self._max_retries
|
||||
):
|
||||
await self._record_final_failure(
|
||||
num_failed_events=int(num_events_in_batch),
|
||||
duration=float(duration),
|
||||
)
|
||||
return
|
||||
|
||||
# case 2: max retries not exhausted, increment failed attempts counter and add latency to failure list, retry publishing batch with backoff
|
||||
failed_attempts_since_last_success += 1
|
||||
await self._record_retry_failure(
|
||||
duration=float(duration),
|
||||
failed_attempts=int(failed_attempts_since_last_success),
|
||||
)
|
||||
|
||||
await self._async_sleep_with_backoff(failed_attempts_since_last_success)
|
||||
|
||||
async def _async_sleep_with_backoff(self, attempt: int) -> None:
|
||||
"""Sleep with exponential backoff and optional jitter.
|
||||
|
||||
Args:
|
||||
attempt: The current attempt number (0-based)
|
||||
"""
|
||||
capped_attempt = min(attempt, self._MAX_BACKOFF_EXPONENT)
|
||||
delay = min(
|
||||
self._max_backoff,
|
||||
self._initial_backoff * (2**capped_attempt),
|
||||
)
|
||||
if self._jitter_ratio > 0:
|
||||
jitter = delay * self._jitter_ratio
|
||||
delay = max(0.0, random.uniform(delay - jitter, delay + jitter))
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
async def _record_success(
|
||||
self, num_published: int, num_filtered: int, duration: float
|
||||
) -> None:
|
||||
"""Update in-memory stats and Prometheus metrics for a successful publish."""
|
||||
if num_published > 0:
|
||||
metric_recorder.set_metric_value(
|
||||
published_counter_name,
|
||||
self._common_metric_tags,
|
||||
int(num_published),
|
||||
)
|
||||
if num_filtered > 0:
|
||||
metric_recorder.set_metric_value(
|
||||
filtered_counter_name, self._common_metric_tags, int(num_filtered)
|
||||
)
|
||||
metric_recorder.set_metric_value(
|
||||
consecutive_failures_gauge_name, self._common_metric_tags, 0
|
||||
)
|
||||
metric_recorder.set_metric_value(
|
||||
time_since_last_success_gauge_name, self._common_metric_tags, 0
|
||||
)
|
||||
metric_recorder.set_metric_value(
|
||||
publish_latency_hist_name,
|
||||
{**self._common_metric_tags, "Outcome": "success"},
|
||||
float(duration),
|
||||
)
|
||||
|
||||
async def _record_retry_failure(
|
||||
self, duration: float, failed_attempts: int
|
||||
) -> None:
|
||||
"""Update Prometheus metrics for a retryable failure attempt."""
|
||||
metric_recorder.set_metric_value(
|
||||
consecutive_failures_gauge_name,
|
||||
self._common_metric_tags,
|
||||
int(failed_attempts),
|
||||
)
|
||||
metric_recorder.set_metric_value(
|
||||
publish_latency_hist_name,
|
||||
{**self._common_metric_tags, "Outcome": "failure"},
|
||||
float(duration),
|
||||
)
|
||||
|
||||
async def _record_final_failure(
|
||||
self, num_failed_events: int, duration: float
|
||||
) -> None:
|
||||
"""Update in-memory stats and Prometheus metrics for a final (non-retryable) failure."""
|
||||
if num_failed_events > 0:
|
||||
metric_recorder.set_metric_value(
|
||||
failed_counter_name,
|
||||
self._common_metric_tags,
|
||||
int(num_failed_events),
|
||||
)
|
||||
metric_recorder.set_metric_value(
|
||||
consecutive_failures_gauge_name, self._common_metric_tags, 0
|
||||
)
|
||||
metric_recorder.set_metric_value(
|
||||
publish_latency_hist_name,
|
||||
{**self._common_metric_tags, "Outcome": "failure"},
|
||||
float(duration),
|
||||
)
|
||||
|
||||
|
||||
class NoopPublisher(RayEventPublisherInterface):
|
||||
"""A no-op publisher that adheres to the minimal interface used by AggregatorAgent.
|
||||
|
||||
Used when a destination is disabled. It runs forever but does nothing.
|
||||
"""
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
"""Run forever doing nothing until cancellation."""
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError:
|
||||
logger.info("NoopPublisher cancelled")
|
||||
raise
|
||||
|
||||
async def wait_until_running(self, timeout: Optional[float] = None) -> bool:
|
||||
return True
|
||||
@@ -0,0 +1,85 @@
|
||||
from collections import deque
|
||||
from typing import Dict, Optional
|
||||
|
||||
from ray._private.telemetry.open_telemetry_metric_recorder import (
|
||||
OpenTelemetryMetricRecorder,
|
||||
)
|
||||
from ray.core.generated import events_event_aggregator_service_pb2
|
||||
from ray.dashboard.modules.aggregator.constants import AGGREGATOR_AGENT_METRIC_PREFIX
|
||||
|
||||
|
||||
class TaskEventsMetadataBuffer:
|
||||
"""Buffer for accumulating task event metadata and batching it into a bounded queue.
|
||||
|
||||
This buffer is used to construct TaskEventsMetadata protobuf messages (defined in events_event_aggregator_service.proto).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_buffer_size: int = 1000,
|
||||
max_dropped_attempts_per_metadata_entry: int = 100,
|
||||
common_metric_tags: Optional[Dict[str, str]] = None,
|
||||
):
|
||||
self._buffer_maxlen = max(
|
||||
max_buffer_size - 1, 1
|
||||
) # -1 to account for the current batch
|
||||
self._buffer = deque(maxlen=self._buffer_maxlen)
|
||||
self._current_metadata_batch = (
|
||||
events_event_aggregator_service_pb2.TaskEventsMetadata()
|
||||
)
|
||||
self._max_dropped_attempts = max_dropped_attempts_per_metadata_entry
|
||||
|
||||
self._common_metric_tags = common_metric_tags or {}
|
||||
self._metric_recorder = OpenTelemetryMetricRecorder()
|
||||
self._dropped_metadata_count_metric_name = f"{AGGREGATOR_AGENT_METRIC_PREFIX}_task_metadata_buffer_dropped_attempts_total"
|
||||
self._metric_recorder.register_counter_metric(
|
||||
self._dropped_metadata_count_metric_name,
|
||||
"Total number of dropped task attempt metadata entries which were dropped due to buffer being full",
|
||||
)
|
||||
|
||||
def merge(
|
||||
self,
|
||||
new_metadata: Optional[events_event_aggregator_service_pb2.TaskEventsMetadata],
|
||||
) -> None:
|
||||
"""Merge new task event metadata into the current entry, enqueuing when limits are reached."""
|
||||
if new_metadata is None:
|
||||
return
|
||||
|
||||
for new_attempt in new_metadata.dropped_task_attempts:
|
||||
if (
|
||||
len(self._current_metadata_batch.dropped_task_attempts)
|
||||
>= self._max_dropped_attempts
|
||||
):
|
||||
# Add current metadata to buffer, if buffer is full, drop the oldest entry
|
||||
if len(self._buffer) >= self._buffer_maxlen:
|
||||
# Record the number of dropped attempts
|
||||
oldest_entry = self._buffer.popleft()
|
||||
self._metric_recorder.set_metric_value(
|
||||
self._dropped_metadata_count_metric_name,
|
||||
self._common_metric_tags,
|
||||
len(oldest_entry.dropped_task_attempts),
|
||||
)
|
||||
|
||||
# Enqueue current metadata batch and start a new batch
|
||||
metadata_copy = events_event_aggregator_service_pb2.TaskEventsMetadata()
|
||||
metadata_copy.CopyFrom(self._current_metadata_batch)
|
||||
self._buffer.append(metadata_copy)
|
||||
self._current_metadata_batch.Clear()
|
||||
|
||||
# Now add the new attempt
|
||||
new_entry = self._current_metadata_batch.dropped_task_attempts.add()
|
||||
new_entry.CopyFrom(new_attempt)
|
||||
|
||||
def get(self) -> events_event_aggregator_service_pb2.TaskEventsMetadata:
|
||||
"""Return the next buffered metadata entry or a snapshot of the current one and reset state."""
|
||||
if len(self._buffer) == 0:
|
||||
# create a copy of the current metadata and return it
|
||||
current_metadata = events_event_aggregator_service_pb2.TaskEventsMetadata()
|
||||
current_metadata.CopyFrom(self._current_metadata_batch)
|
||||
|
||||
# Reset the current metadata and start merging afresh
|
||||
self._current_metadata_batch.Clear()
|
||||
|
||||
return current_metadata
|
||||
|
||||
return self._buffer.popleft()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,270 @@
|
||||
import asyncio
|
||||
import random
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from google.protobuf.timestamp_pb2 import Timestamp
|
||||
|
||||
from ray.core.generated.events_base_event_pb2 import RayEvent
|
||||
from ray.dashboard.modules.aggregator.multi_consumer_event_buffer import (
|
||||
MultiConsumerEventBuffer,
|
||||
)
|
||||
|
||||
|
||||
def _create_test_event(
|
||||
event_id: bytes = b"test",
|
||||
event_type_enum=RayEvent.EventType.TASK_DEFINITION_EVENT,
|
||||
message: str = "test message",
|
||||
):
|
||||
"""Helper function to create a test RayEvent."""
|
||||
event = RayEvent()
|
||||
event.event_id = event_id
|
||||
event.source_type = RayEvent.SourceType.CORE_WORKER
|
||||
event.event_type = event_type_enum
|
||||
event.severity = RayEvent.Severity.INFO
|
||||
event.message = message
|
||||
event.session_name = "test_session"
|
||||
|
||||
# Set timestamp
|
||||
timestamp = Timestamp()
|
||||
timestamp.GetCurrentTime()
|
||||
event.timestamp.CopyFrom(timestamp)
|
||||
|
||||
return event
|
||||
|
||||
|
||||
class TestMultiConsumerEventBuffer:
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_and_consume_event_basic(self):
|
||||
"""Test basic event addition."""
|
||||
buffer = MultiConsumerEventBuffer(max_size=10, max_batch_size=5)
|
||||
consumer_name = "test_consumer"
|
||||
await buffer.register_consumer(consumer_name)
|
||||
assert await buffer.size() == 0
|
||||
|
||||
event = _create_test_event(b"event1")
|
||||
await buffer.add_event(event)
|
||||
|
||||
assert await buffer.size() == 1
|
||||
|
||||
batch = await buffer.wait_for_batch(consumer_name, timeout_seconds=0)
|
||||
assert len(batch) == 1
|
||||
assert batch[0] == event
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_event_buffer_overflow(self):
|
||||
"""Test buffer overflow behavior and eviction logic."""
|
||||
buffer = MultiConsumerEventBuffer(max_size=3, max_batch_size=2)
|
||||
consumer_name = "test_consumer"
|
||||
await buffer.register_consumer(consumer_name)
|
||||
|
||||
# Add events to fill buffer
|
||||
events = []
|
||||
event_types = [
|
||||
RayEvent.EventType.TASK_DEFINITION_EVENT,
|
||||
RayEvent.EventType.TASK_LIFECYCLE_EVENT,
|
||||
RayEvent.EventType.ACTOR_TASK_DEFINITION_EVENT,
|
||||
]
|
||||
for i in range(3):
|
||||
event = _create_test_event(f"event{i}".encode(), event_types[i])
|
||||
events.append(event)
|
||||
await buffer.add_event(event)
|
||||
|
||||
assert await buffer.size() == 3
|
||||
|
||||
# Add one more event to trigger eviction
|
||||
overflow_event = _create_test_event(
|
||||
b"overflow", RayEvent.EventType.TASK_PROFILE_EVENT
|
||||
)
|
||||
await buffer.add_event(overflow_event)
|
||||
|
||||
assert await buffer.size() == 3 # Still max size
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_batch_multiple_events(self):
|
||||
"""Test waiting for batch when multiple events are immediately available and when when not all events are available."""
|
||||
buffer = MultiConsumerEventBuffer(max_size=10, max_batch_size=3)
|
||||
consumer_name = "test_consumer"
|
||||
await buffer.register_consumer(consumer_name)
|
||||
|
||||
# Add multiple events
|
||||
events = []
|
||||
for i in range(5):
|
||||
event = _create_test_event(f"event{i}".encode())
|
||||
events.append(event)
|
||||
await buffer.add_event(event)
|
||||
|
||||
# Should get max_batch_size events immediately
|
||||
batch = await buffer.wait_for_batch(consumer_name, timeout_seconds=0.1)
|
||||
assert len(batch) == 3 # max_batch_size
|
||||
assert batch == events[:3]
|
||||
# should now get the leftover events (< max_batch_size)
|
||||
batch = await buffer.wait_for_batch(consumer_name, timeout_seconds=0.1)
|
||||
assert len(batch) == 2
|
||||
assert batch == events[3:]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_batch_unknown_consumer(self):
|
||||
"""Test error handling for unknown consumer."""
|
||||
buffer = MultiConsumerEventBuffer(max_size=10, max_batch_size=5)
|
||||
|
||||
with pytest.raises(KeyError, match="unknown consumer"):
|
||||
await buffer.wait_for_batch("nonexistent_consumer", timeout_seconds=0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_consumer_duplicate(self):
|
||||
"""Test error handling for duplicate consumer registration."""
|
||||
buffer = MultiConsumerEventBuffer(max_size=10, max_batch_size=5)
|
||||
consumer_name = "test_consumer"
|
||||
await buffer.register_consumer(consumer_name)
|
||||
with pytest.raises(
|
||||
ValueError, match="consumer 'test_consumer' already registered"
|
||||
):
|
||||
await buffer.register_consumer(consumer_name)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_consumers_independent_cursors(self):
|
||||
"""Test that multiple consumers have independent cursors."""
|
||||
buffer = MultiConsumerEventBuffer(max_size=10, max_batch_size=2)
|
||||
consumer_name_1 = "test_consumer_1"
|
||||
consumer_name_2 = "test_consumer_2"
|
||||
await buffer.register_consumer(consumer_name_1)
|
||||
await buffer.register_consumer(consumer_name_2)
|
||||
|
||||
# Add events
|
||||
events = []
|
||||
for i in range(10):
|
||||
event = _create_test_event(f"event{i}".encode())
|
||||
events.append(event)
|
||||
await buffer.add_event(event)
|
||||
|
||||
# Consumer 1 reads first batch
|
||||
batch1 = await buffer.wait_for_batch(consumer_name_1, timeout_seconds=0.1)
|
||||
assert batch1 == events[:2]
|
||||
|
||||
# Consumer 2 reads from beginning
|
||||
batch2 = await buffer.wait_for_batch(consumer_name_2, timeout_seconds=0.1)
|
||||
assert batch2 == events[:2]
|
||||
|
||||
# consumer 1 reads another batch
|
||||
batch3 = await buffer.wait_for_batch(consumer_name_1, timeout_seconds=0.1)
|
||||
assert batch3 == events[2:4]
|
||||
|
||||
# more events are added leading to events not consumed by consumer 2 getting evicted
|
||||
# 4 events get evicted, consumer 1 has processed all 4 evicted events previously
|
||||
# but consumer 2 has only processed 2 out of the 4 evicted events
|
||||
for i in range(4):
|
||||
event = _create_test_event(f"event{i + 10}".encode())
|
||||
events.append(event)
|
||||
await buffer.add_event(event)
|
||||
|
||||
# Just ensure buffer remains at max size
|
||||
assert await buffer.size() == 10
|
||||
|
||||
# consumer 1 will read the next 2 events, not affected by the evictions
|
||||
# consumer 1's cursor is adjusted internally to account for the evicted events
|
||||
batch4 = await buffer.wait_for_batch(consumer_name_1, timeout_seconds=0.1)
|
||||
assert batch4 == events[4:6]
|
||||
|
||||
# consumer 2 will read 2 events, skipping the evicted events
|
||||
batch5 = await buffer.wait_for_batch(consumer_name_2, timeout_seconds=0.1)
|
||||
assert batch5 == events[4:6] # events[2:4] are lost
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_batch_blocks_until_event_available(self):
|
||||
"""Test that wait_for_batch blocks until at least one event is available."""
|
||||
buffer = MultiConsumerEventBuffer(max_size=10, max_batch_size=5)
|
||||
consumer_name = "test_consumer"
|
||||
await buffer.register_consumer(consumer_name)
|
||||
|
||||
# Start waiting for batch (should block)
|
||||
async def wait_for_batch():
|
||||
return await buffer.wait_for_batch(consumer_name, timeout_seconds=2.0)
|
||||
|
||||
wait_task = asyncio.create_task(wait_for_batch())
|
||||
|
||||
# Wait a bit to ensure the task is waiting
|
||||
await asyncio.sleep(4.0)
|
||||
assert not wait_task.done()
|
||||
|
||||
# Add an event
|
||||
event = _create_test_event(b"event1")
|
||||
await buffer.add_event(event)
|
||||
|
||||
# Now the task should complete
|
||||
batch = await wait_task
|
||||
assert len(batch) == 1
|
||||
assert batch[0] == event
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_producer_consumer_random_sleeps_with_overall_timeout(
|
||||
self,
|
||||
):
|
||||
"""Producer with random sleeps and consumer reading until all events are received.
|
||||
|
||||
Uses an overall asyncio timeout to ensure the test fails if it hangs
|
||||
before consuming all events.
|
||||
"""
|
||||
total_events = 40
|
||||
max_batch_size = 2
|
||||
buffer = MultiConsumerEventBuffer(max_size=100, max_batch_size=max_batch_size)
|
||||
consumer_name = "test_consumer"
|
||||
await buffer.register_consumer(consumer_name)
|
||||
|
||||
produced_events = []
|
||||
consumed_events = []
|
||||
|
||||
random.seed(0)
|
||||
|
||||
async def producer():
|
||||
for i in range(total_events):
|
||||
event = _create_test_event(f"e{i}".encode())
|
||||
produced_events.append(event)
|
||||
await buffer.add_event(event)
|
||||
await asyncio.sleep(random.uniform(0.0, 0.02))
|
||||
|
||||
async def consumer():
|
||||
while len(consumed_events) < total_events:
|
||||
batch = await buffer.wait_for_batch(consumer_name, timeout_seconds=0.1)
|
||||
consumed_events.extend(batch)
|
||||
|
||||
# The test should fail if this times out before all events are consumed
|
||||
await asyncio.wait_for(asyncio.gather(producer(), consumer()), timeout=5.0)
|
||||
|
||||
assert len(consumed_events) == total_events
|
||||
assert consumed_events == produced_events
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_are_evicted_once_consumed_by_all_consumers(self):
|
||||
"""Test events are evicted from the buffer once they are consumed by all consumers"""
|
||||
buffer = MultiConsumerEventBuffer(max_size=10, max_batch_size=2)
|
||||
consumer_name_1 = "test_consumer_1"
|
||||
consumer_name_2 = "test_consumer_2"
|
||||
await buffer.register_consumer(consumer_name_1)
|
||||
await buffer.register_consumer(consumer_name_2)
|
||||
|
||||
# Add events
|
||||
events = []
|
||||
for i in range(10):
|
||||
event = _create_test_event(f"event{i}".encode())
|
||||
events.append(event)
|
||||
await buffer.add_event(event)
|
||||
|
||||
assert await buffer.size() == 10
|
||||
# Consumer 1 reads first batch
|
||||
batch1 = await buffer.wait_for_batch(consumer_name_1, timeout_seconds=0.1)
|
||||
assert batch1 == events[:2]
|
||||
|
||||
# buffer size does not change as consumer 2 is yet to consume these events
|
||||
assert await buffer.size() == 10
|
||||
|
||||
# Consumer 2 reads from beginning
|
||||
batch2 = await buffer.wait_for_batch(consumer_name_2, timeout_seconds=0.1)
|
||||
assert batch2 == events[:2]
|
||||
|
||||
# size reduces by 2 as both consumers have consumed 2 events
|
||||
assert await buffer.size() == 8
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,125 @@
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray._private.test_utils import (
|
||||
wait_for_condition,
|
||||
wait_for_dashboard_agent_available,
|
||||
)
|
||||
from ray.dashboard.tests.conftest import * # noqa
|
||||
|
||||
_ACTOR_EVENT_PORT = 12346
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def httpserver_listen_address():
|
||||
return ("127.0.0.1", _ACTOR_EVENT_PORT)
|
||||
|
||||
|
||||
def test_ray_actor_events(ray_start_cluster, httpserver):
|
||||
cluster = ray_start_cluster
|
||||
cluster.add_node(
|
||||
env_vars={
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_EVENTS_EXPORT_ADDR": f"http://127.0.0.1:{_ACTOR_EVENT_PORT}",
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_EXPOSABLE_EVENT_TYPES": "ACTOR_DEFINITION_EVENT,ACTOR_LIFECYCLE_EVENT",
|
||||
},
|
||||
_system_config={
|
||||
"enable_ray_event": True,
|
||||
},
|
||||
)
|
||||
cluster.wait_for_nodes()
|
||||
head_node_id = cluster.head_node.node_id
|
||||
all_nodes_ids = [node.node_id for node in cluster.list_all_nodes()]
|
||||
|
||||
class A:
|
||||
def ping(self):
|
||||
return "pong"
|
||||
|
||||
ray.init(address=cluster.address)
|
||||
wait_for_dashboard_agent_available(cluster)
|
||||
|
||||
# Create an actor to trigger definition + lifecycle events
|
||||
a = ray.remote(A).options(name="actor-test").remote()
|
||||
ray.get(a.ping.remote())
|
||||
|
||||
# Check that an actor definition and a lifecycle event are published.
|
||||
httpserver.expect_request("/", method="POST").respond_with_data("", status=200)
|
||||
wait_for_condition(lambda: len(httpserver.log) >= 1)
|
||||
req, _ = httpserver.log[0]
|
||||
req_json = json.loads(req.data)
|
||||
# We expect batched events containing definition then lifecycle
|
||||
assert len(req_json) >= 2
|
||||
# Verify event types and IDs exist
|
||||
assert (
|
||||
base64.b64decode(req_json[0]["actorDefinitionEvent"]["actorId"]).hex()
|
||||
== a._actor_id.hex()
|
||||
)
|
||||
assert base64.b64decode(req_json[0]["nodeId"]).hex() == head_node_id
|
||||
# Verify ActorId and state for ActorLifecycleEvents
|
||||
has_alive_state = False
|
||||
for actorLifeCycleEvent in req_json[1:]:
|
||||
assert base64.b64decode(actorLifeCycleEvent["nodeId"]).hex() == head_node_id
|
||||
assert (
|
||||
base64.b64decode(
|
||||
actorLifeCycleEvent["actorLifecycleEvent"]["actorId"]
|
||||
).hex()
|
||||
== a._actor_id.hex()
|
||||
)
|
||||
for stateTransition in actorLifeCycleEvent["actorLifecycleEvent"][
|
||||
"stateTransitions"
|
||||
]:
|
||||
assert stateTransition["state"] in [
|
||||
"DEPENDENCIES_UNREADY",
|
||||
"PENDING_CREATION",
|
||||
"ALIVE",
|
||||
"RESTARTING",
|
||||
"DEAD",
|
||||
]
|
||||
if stateTransition["state"] == "ALIVE":
|
||||
has_alive_state = True
|
||||
assert (
|
||||
base64.b64decode(stateTransition["nodeId"]).hex() in all_nodes_ids
|
||||
)
|
||||
assert base64.b64decode(stateTransition["workerId"]).hex() != ""
|
||||
assert has_alive_state
|
||||
|
||||
# Kill the actor and verify we get a DEAD state with death cause
|
||||
ray.kill(a)
|
||||
|
||||
# Wait for the death event to be published
|
||||
httpserver.expect_request("/", method="POST").respond_with_data("", status=200)
|
||||
wait_for_condition(lambda: len(httpserver.log) >= 2)
|
||||
|
||||
has_dead_state = False
|
||||
for death_req, _ in httpserver.log:
|
||||
death_req_json = json.loads(death_req.data)
|
||||
|
||||
for actorLifeCycleEvent in death_req_json:
|
||||
if "actorLifecycleEvent" in actorLifeCycleEvent:
|
||||
assert (
|
||||
base64.b64decode(
|
||||
actorLifeCycleEvent["actorLifecycleEvent"]["actorId"]
|
||||
).hex()
|
||||
== a._actor_id.hex()
|
||||
)
|
||||
|
||||
for stateTransition in actorLifeCycleEvent["actorLifecycleEvent"][
|
||||
"stateTransitions"
|
||||
]:
|
||||
if stateTransition["state"] == "DEAD":
|
||||
has_dead_state = True
|
||||
assert (
|
||||
stateTransition["deathCause"]["actorDiedErrorContext"][
|
||||
"reason"
|
||||
]
|
||||
== "RAY_KILL"
|
||||
)
|
||||
|
||||
assert has_dead_state
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,164 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from google.protobuf.timestamp_pb2 import Timestamp
|
||||
|
||||
from ray._common.test_utils import async_wait_for_condition
|
||||
from ray.core.generated import events_base_event_pb2
|
||||
from ray.dashboard.modules.aggregator.multi_consumer_event_buffer import (
|
||||
MultiConsumerEventBuffer,
|
||||
)
|
||||
from ray.dashboard.modules.aggregator.publisher.async_publisher_client import (
|
||||
PublisherClientInterface,
|
||||
PublishStats,
|
||||
)
|
||||
from ray.dashboard.modules.aggregator.publisher.ray_event_publisher import (
|
||||
NoopPublisher,
|
||||
RayEventPublisher,
|
||||
)
|
||||
|
||||
|
||||
class MockPublisherClient(PublisherClientInterface):
|
||||
"""Test implementation of PublisherClientInterface."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
batch_size: int = 1,
|
||||
side_effect=lambda batch: PublishStats(True, 1, 0),
|
||||
):
|
||||
self.batch_size = batch_size
|
||||
self.publish_calls = []
|
||||
self._side_effect = side_effect
|
||||
|
||||
async def publish(self, batch) -> PublishStats:
|
||||
self.publish_calls.append(batch)
|
||||
return self._side_effect(batch)
|
||||
|
||||
def count_num_events_in_batch(self, batch) -> int:
|
||||
return self.batch_size
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_kwargs():
|
||||
"""Common kwargs for publisher initialization."""
|
||||
return {
|
||||
"name": "test",
|
||||
"max_retries": 2,
|
||||
"initial_backoff": 0,
|
||||
"max_backoff": 0,
|
||||
"jitter_ratio": 0,
|
||||
"enable_publisher_stats": True,
|
||||
}
|
||||
|
||||
|
||||
class TestRayEventPublisher:
|
||||
"""Test the main RayEventsPublisher functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_with_retries_failure_then_success(self, base_kwargs):
|
||||
"""Test publish that fails then succeeds."""
|
||||
call_count = {"count": 0}
|
||||
|
||||
# fail the first publish call but succeed on retry
|
||||
def side_effect(batch):
|
||||
call_count["count"] += 1
|
||||
if call_count["count"] == 1:
|
||||
return PublishStats(False, 0, 0)
|
||||
return PublishStats(True, 1, 0)
|
||||
|
||||
client = MockPublisherClient(side_effect=side_effect)
|
||||
event_buffer = MultiConsumerEventBuffer(max_size=10, max_batch_size=10)
|
||||
publisher = RayEventPublisher(
|
||||
name=base_kwargs["name"] + str(uuid.uuid4()),
|
||||
publish_client=client,
|
||||
event_buffer=event_buffer,
|
||||
max_retries=base_kwargs["max_retries"],
|
||||
initial_backoff=base_kwargs["initial_backoff"],
|
||||
max_backoff=base_kwargs["max_backoff"],
|
||||
jitter_ratio=base_kwargs["jitter_ratio"],
|
||||
)
|
||||
|
||||
task = asyncio.create_task(publisher.run_forever())
|
||||
try:
|
||||
# ensure consumer is registered
|
||||
assert await publisher.wait_until_running(2.0)
|
||||
# Enqueue one event into buffer
|
||||
e = events_base_event_pb2.RayEvent(
|
||||
event_id=b"1",
|
||||
source_type=events_base_event_pb2.RayEvent.SourceType.CORE_WORKER,
|
||||
event_type=events_base_event_pb2.RayEvent.EventType.TASK_DEFINITION_EVENT,
|
||||
timestamp=Timestamp(seconds=123, nanos=0),
|
||||
severity=events_base_event_pb2.RayEvent.Severity.INFO,
|
||||
message="hello",
|
||||
)
|
||||
await event_buffer.add_event(e)
|
||||
|
||||
# wait for two publish attempts (failure then success)
|
||||
await async_wait_for_condition(lambda: len(client.publish_calls) == 2)
|
||||
finally:
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_with_retries_max_retries_exceeded(self, base_kwargs):
|
||||
"""Test publish that fails all retries and records failed events."""
|
||||
client = MockPublisherClient(
|
||||
side_effect=lambda batch: PublishStats(False, 0, 0)
|
||||
)
|
||||
event_buffer = MultiConsumerEventBuffer(max_size=10, max_batch_size=10)
|
||||
publisher = RayEventPublisher(
|
||||
name=base_kwargs["name"] + str(uuid.uuid4()),
|
||||
publish_client=client,
|
||||
event_buffer=event_buffer,
|
||||
max_retries=2, # override to finite retries
|
||||
initial_backoff=0,
|
||||
max_backoff=0,
|
||||
jitter_ratio=0,
|
||||
)
|
||||
|
||||
task = asyncio.create_task(publisher.run_forever())
|
||||
try:
|
||||
# ensure consumer is registered
|
||||
assert await publisher.wait_until_running(2.0)
|
||||
e = events_base_event_pb2.RayEvent(
|
||||
event_id=b"1",
|
||||
source_type=events_base_event_pb2.RayEvent.SourceType.CORE_WORKER,
|
||||
event_type=events_base_event_pb2.RayEvent.EventType.TASK_DEFINITION_EVENT,
|
||||
timestamp=Timestamp(seconds=123, nanos=0),
|
||||
severity=events_base_event_pb2.RayEvent.Severity.INFO,
|
||||
message="hello",
|
||||
)
|
||||
await event_buffer.add_event(e)
|
||||
|
||||
# wait for publish attempts (initial + 2 retries)
|
||||
await async_wait_for_condition(lambda: len(client.publish_calls) == 3)
|
||||
assert len(client.publish_calls) == 3
|
||||
finally:
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
class TestNoopPublisher:
|
||||
"""Test no-op publisher implementation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_methods_noop(self):
|
||||
"""Test that run_forever can be cancelled and metrics return expected values."""
|
||||
publisher = NoopPublisher()
|
||||
|
||||
# Start and cancel run_forever
|
||||
task = asyncio.create_task(publisher.run_forever())
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,58 @@
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray._private.test_utils import (
|
||||
wait_for_condition,
|
||||
wait_for_dashboard_agent_available,
|
||||
)
|
||||
from ray.dashboard.tests.conftest import * # noqa
|
||||
|
||||
_RAY_EVENT_PORT = 12345
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def httpserver_listen_address():
|
||||
return ("127.0.0.1", _RAY_EVENT_PORT)
|
||||
|
||||
|
||||
def test_ray_job_events(ray_start_cluster, httpserver):
|
||||
cluster = ray_start_cluster
|
||||
cluster.add_node(
|
||||
env_vars={
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_EVENTS_EXPORT_ADDR": f"http://127.0.0.1:{_RAY_EVENT_PORT}",
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_EXPOSABLE_EVENT_TYPES": "DRIVER_JOB_DEFINITION_EVENT,DRIVER_JOB_LIFECYCLE_EVENT",
|
||||
},
|
||||
_system_config={
|
||||
"enable_ray_event": True,
|
||||
},
|
||||
)
|
||||
cluster.wait_for_nodes()
|
||||
ray.init(address=cluster.address)
|
||||
wait_for_dashboard_agent_available(cluster)
|
||||
|
||||
# Submit a ray job
|
||||
@ray.remote
|
||||
def f():
|
||||
return 1
|
||||
|
||||
ray.get(f.remote())
|
||||
|
||||
# Check that a driver job event with the correct job id is published.
|
||||
httpserver.expect_request("/", method="POST").respond_with_data("", status=200)
|
||||
wait_for_condition(lambda: len(httpserver.log) >= 1)
|
||||
req, _ = httpserver.log[0]
|
||||
req_json = json.loads(req.data)
|
||||
head_node_id = cluster.head_node.node_id
|
||||
assert base64.b64decode(req_json[0]["nodeId"]).hex() == head_node_id
|
||||
assert (
|
||||
base64.b64decode(req_json[0]["driverJobDefinitionEvent"]["jobId"]).hex()
|
||||
== ray.get_runtime_context().get_job_id()
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,75 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray._private.test_utils import (
|
||||
wait_for_condition,
|
||||
wait_for_dashboard_agent_available,
|
||||
)
|
||||
from ray.dashboard.tests.conftest import * # noqa
|
||||
|
||||
_RAY_EVENT_PORT = 12345
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def httpserver_listen_address():
|
||||
return ("127.0.0.1", _RAY_EVENT_PORT)
|
||||
|
||||
|
||||
def test_ray_node_events(ray_start_cluster, httpserver):
|
||||
cluster = ray_start_cluster
|
||||
cluster.add_node(
|
||||
node_name="test-head-node",
|
||||
env_vars={
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_EVENTS_EXPORT_ADDR": f"http://127.0.0.1:{_RAY_EVENT_PORT}",
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_EXPOSABLE_EVENT_TYPES": "NODE_DEFINITION_EVENT,NODE_LIFECYCLE_EVENT",
|
||||
},
|
||||
_system_config={
|
||||
"enable_ray_event": True,
|
||||
},
|
||||
)
|
||||
cluster.wait_for_nodes()
|
||||
head_node_id = cluster.head_node.node_id
|
||||
ray.init(address=cluster.address)
|
||||
wait_for_dashboard_agent_available(cluster)
|
||||
|
||||
# Check that a node definition and a node lifecycle event are published.
|
||||
httpserver.expect_request("/", method="POST").respond_with_data("", status=200)
|
||||
wait_for_condition(lambda: len(httpserver.log) >= 1)
|
||||
req, _ = httpserver.log[0]
|
||||
req_json = json.loads(req.data)
|
||||
assert len(req_json) == 2
|
||||
assert base64.b64decode(req_json[0]["nodeId"]).hex() == head_node_id
|
||||
assert (
|
||||
base64.b64decode(req_json[0]["nodeDefinitionEvent"]["nodeId"]).hex()
|
||||
== cluster.head_node.node_id
|
||||
)
|
||||
|
||||
node_def_event = req_json[0]["nodeDefinitionEvent"]
|
||||
assert node_def_event["hostname"] == socket.gethostname()
|
||||
assert node_def_event["nodeName"] == "test-head-node"
|
||||
# instanceId and instanceTypeName are set via env vars by cloud providers.
|
||||
# In local/CI environments these are typically empty.
|
||||
assert node_def_event["instanceId"] == os.environ.get("RAY_CLOUD_INSTANCE_ID", "")
|
||||
assert node_def_event["instanceTypeName"] == os.environ.get(
|
||||
"RAY_CLOUD_INSTANCE_TYPE_NAME", ""
|
||||
)
|
||||
assert base64.b64decode(req_json[1]["nodeId"]).hex() == head_node_id
|
||||
assert (
|
||||
base64.b64decode(req_json[1]["nodeLifecycleEvent"]["nodeId"]).hex()
|
||||
== cluster.head_node.node_id
|
||||
)
|
||||
assert req_json[1]["nodeLifecycleEvent"]["stateTransitions"][0]["state"] == "ALIVE"
|
||||
assert (
|
||||
req_json[1]["nodeLifecycleEvent"]["stateTransitions"][0]["aliveSubState"]
|
||||
== "UNSPECIFIED"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,117 @@
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray._private.test_utils import (
|
||||
wait_for_condition,
|
||||
wait_for_dashboard_agent_available,
|
||||
)
|
||||
from ray.dashboard.tests.conftest import * # noqa
|
||||
|
||||
_PLATFORM_EVENT_PORT = 12348
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def httpserver_listen_address():
|
||||
return ("127.0.0.1", _PLATFORM_EVENT_PORT)
|
||||
|
||||
|
||||
def test_ray_platform_events(ray_start_cluster, httpserver):
|
||||
cluster = ray_start_cluster
|
||||
cluster.add_node(
|
||||
env_vars={
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_EVENTS_EXPORT_ADDR": f"http://127.0.0.1:{_PLATFORM_EVENT_PORT}",
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_EXPOSABLE_EVENT_TYPES": "PLATFORM_EVENT",
|
||||
"RAY_ENABLE_PYTHON_RAY_EVENT_TYPES": "PLATFORM_EVENT",
|
||||
},
|
||||
_system_config={
|
||||
"enable_ray_event": True,
|
||||
},
|
||||
)
|
||||
cluster.wait_for_nodes()
|
||||
head_node_id = cluster.head_node.node_id
|
||||
|
||||
ray.init(address=cluster.address)
|
||||
wait_for_dashboard_agent_available(cluster)
|
||||
|
||||
# Define a task that explicitly initializes and emits a platform event via EventRecorder
|
||||
@ray.remote
|
||||
def emit_test_platform_event(aggregator_port, node_ip, node_id):
|
||||
from ray._common.observability.platform_events import PlatformEventBuilder
|
||||
from ray._raylet import EventRecorder
|
||||
from ray.core.generated.events_base_event_pb2 import RayEvent
|
||||
from ray.core.generated.platform_event_pb2 import Source
|
||||
|
||||
EventRecorder.initialize(
|
||||
aggregator_port=aggregator_port,
|
||||
node_ip=node_ip,
|
||||
node_id_hex=node_id,
|
||||
max_buffer_size=1000,
|
||||
metric_source="platform_events",
|
||||
)
|
||||
|
||||
builder = PlatformEventBuilder(
|
||||
event_uid="uid-test-platform-e2e",
|
||||
platform=Source.Platform.KUBERNETES,
|
||||
object_kind="Pod",
|
||||
object_name="test-pod-name",
|
||||
reason="OOMKilled",
|
||||
message="Container exited with code 137",
|
||||
severity=RayEvent.Severity.WARNING,
|
||||
component="kubelet",
|
||||
)
|
||||
cython_event = builder.build(
|
||||
event_id=b"uid-test-platform-e2e",
|
||||
timestamp_ns=int(time.time() * 1e9),
|
||||
)
|
||||
EventRecorder.emit(cython_event)
|
||||
EventRecorder.shutdown()
|
||||
return True
|
||||
|
||||
# Expect the POST request on the HTTP server
|
||||
httpserver.expect_request("/", method="POST").respond_with_data("", status=200)
|
||||
|
||||
# Fetch the aggregator agent's address from GCS
|
||||
from ray._private.test_utils import GcsClient, get_dashboard_agent_address
|
||||
|
||||
gcs_client = GcsClient(address=cluster.address)
|
||||
agent_address = get_dashboard_agent_address(gcs_client, head_node_id)
|
||||
ip, port_str = agent_address.split(":")
|
||||
aggregator_port = int(port_str)
|
||||
|
||||
# Execute the remote task to emit the event on the node
|
||||
ray.get(emit_test_platform_event.remote(aggregator_port, ip, head_node_id))
|
||||
|
||||
# Wait for the HTTP log collector to receive the batched payload
|
||||
wait_for_condition(lambda: len(httpserver.log) >= 1, timeout=20)
|
||||
|
||||
# Validate the captured POST payload
|
||||
req, _ = httpserver.log[0]
|
||||
req_json = json.loads(req.data)
|
||||
|
||||
assert len(req_json) >= 1
|
||||
platform_event_entry = None
|
||||
for entry in req_json:
|
||||
if "platformEvent" in entry:
|
||||
platform_event_entry = entry
|
||||
break
|
||||
|
||||
assert platform_event_entry is not None
|
||||
assert platform_event_entry["eventType"] == "PLATFORM_EVENT"
|
||||
assert base64.b64decode(platform_event_entry["nodeId"]).hex() == head_node_id
|
||||
|
||||
pe_data = platform_event_entry["platformEvent"]
|
||||
assert pe_data["objectKind"] == "Pod"
|
||||
assert pe_data["objectName"] == "test-pod-name"
|
||||
assert pe_data["reason"] == "OOMKilled"
|
||||
assert pe_data["message"] == "Container exited with code 137"
|
||||
assert pe_data["source"]["platform"] == "KUBERNETES"
|
||||
assert pe_data["source"]["component"] == "kubelet"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,115 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from ray.core.generated.events_event_aggregator_service_pb2 import TaskEventsMetadata
|
||||
from ray.dashboard.modules.aggregator.task_events_metadata_buffer import (
|
||||
TaskEventsMetadataBuffer,
|
||||
)
|
||||
|
||||
|
||||
def _create_test_metadata(dropped_task_ids: list = None, attempt_number=1):
|
||||
"""Helper function to create test metadata"""
|
||||
metadata = TaskEventsMetadata()
|
||||
if dropped_task_ids:
|
||||
for task_id in dropped_task_ids:
|
||||
attempt = metadata.dropped_task_attempts.add()
|
||||
attempt.task_id = task_id.encode()
|
||||
attempt.attempt_number = attempt_number
|
||||
return metadata
|
||||
|
||||
|
||||
def _result_to_attempts_list(result):
|
||||
"""Normalize return value from buffer.get() to a python list of attempts."""
|
||||
if hasattr(result, "dropped_task_attempts"):
|
||||
attempts = result.dropped_task_attempts
|
||||
else:
|
||||
attempts = result
|
||||
return list(attempts)
|
||||
|
||||
|
||||
def _drain_all_attempts(buffer: TaskEventsMetadataBuffer):
|
||||
"""Drain the buffer completely via public API and return list of bytes task_ids.
|
||||
|
||||
Continues calling get() until it returns an empty set of attempts.
|
||||
"""
|
||||
collected_ids = []
|
||||
num_metadata_entries = 0
|
||||
while True:
|
||||
result = buffer.get()
|
||||
attempts = _result_to_attempts_list(result)
|
||||
if len(attempts) == 0:
|
||||
break
|
||||
|
||||
num_metadata_entries += 1
|
||||
collected_ids.extend([a.task_id for a in attempts])
|
||||
return collected_ids, num_metadata_entries
|
||||
|
||||
|
||||
class TestTaskMetadataBuffer:
|
||||
"""tests for TaskMetadataBuffer class"""
|
||||
|
||||
def test_merge_and_get(self):
|
||||
"""Test merging multiple metadata objects and verify task attempts are combined."""
|
||||
buffer = TaskEventsMetadataBuffer(
|
||||
max_buffer_size=100, max_dropped_attempts_per_metadata_entry=10
|
||||
)
|
||||
|
||||
# Create two separate metadata objects with different task IDs
|
||||
metadata1 = _create_test_metadata(["task_1", "task_2"])
|
||||
metadata2 = _create_test_metadata(["task_3", "task_4"])
|
||||
|
||||
# Merge both metadata objects
|
||||
buffer.merge(metadata1)
|
||||
buffer.merge(metadata2)
|
||||
|
||||
# Get the merged results
|
||||
result = buffer.get()
|
||||
attempts = _result_to_attempts_list(result)
|
||||
|
||||
# Verify we have all 4 task attempts
|
||||
assert len(attempts) == 4
|
||||
|
||||
# Verify all expected task IDs are present
|
||||
task_ids = [attempt.task_id for attempt in attempts]
|
||||
assert sorted(task_ids) == [b"task_1", b"task_2", b"task_3", b"task_4"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"max_attempts_per_metadata_entry,num_tasks,max_buffer_size,expected_drop_attempts,expected_num_metadata_entries",
|
||||
[
|
||||
# No overflow, two metadata entries should be created
|
||||
(2, 3, 100, 0, 2),
|
||||
# No overflow, three metadata entries should be created
|
||||
(5, 15, 100, 0, 3),
|
||||
# Overflow scenario: buffer too small, ensure drop count is tracked.
|
||||
(1, 4, 2, 2, 2),
|
||||
],
|
||||
)
|
||||
def test_buffer_merge_and_overflow(
|
||||
self,
|
||||
max_attempts_per_metadata_entry,
|
||||
num_tasks,
|
||||
max_buffer_size,
|
||||
expected_drop_attempts,
|
||||
expected_num_metadata_entries,
|
||||
):
|
||||
buffer = TaskEventsMetadataBuffer(
|
||||
max_buffer_size=max_buffer_size,
|
||||
max_dropped_attempts_per_metadata_entry=max_attempts_per_metadata_entry,
|
||||
)
|
||||
|
||||
for i in range(num_tasks):
|
||||
test_metadata = _create_test_metadata([f"task_{i}"])
|
||||
buffer.merge(test_metadata)
|
||||
|
||||
# Drain everything and verify number of attempts in buffer is as expected
|
||||
drained_ids, num_metadata_entries = _drain_all_attempts(buffer)
|
||||
assert len(drained_ids) == num_tasks - expected_drop_attempts
|
||||
assert num_metadata_entries == expected_num_metadata_entries
|
||||
|
||||
# Buffer should now be empty
|
||||
assert len(_result_to_attempts_list(buffer.get())) == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,477 @@
|
||||
import dataclasses
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import packaging.version
|
||||
import yaml
|
||||
|
||||
import ray
|
||||
from ray._private.authentication.http_token_authentication import (
|
||||
format_authentication_http_error,
|
||||
get_auth_headers_if_auth_enabled,
|
||||
)
|
||||
from ray._private.runtime_env.packaging import (
|
||||
create_package,
|
||||
get_uri_for_directory,
|
||||
get_uri_for_package,
|
||||
)
|
||||
from ray._private.runtime_env.py_modules import upload_py_modules_if_needed
|
||||
from ray._private.runtime_env.working_dir import upload_working_dir_if_needed
|
||||
from ray._private.utils import split_address
|
||||
from ray.autoscaler._private.cli_logger import cli_logger
|
||||
from ray.dashboard.modules.job.common import uri_to_http_components
|
||||
from ray.exceptions import AuthenticationError
|
||||
from ray.util.annotations import DeveloperAPI, PublicAPI
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
requests = None
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# By default, connect to local cluster.
|
||||
DEFAULT_DASHBOARD_ADDRESS = "http://localhost:8265"
|
||||
|
||||
|
||||
def parse_runtime_env_args(
|
||||
runtime_env: Optional[str] = None,
|
||||
runtime_env_json: Optional[str] = None,
|
||||
working_dir: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Generates a runtime_env dictionary using `runtime_env`, `runtime_env_json`,
|
||||
and `working_dir` CLI options. Only one of `runtime_env` or
|
||||
`runtime_env_json` may be defined. `working_dir` overwrites the
|
||||
`working_dir` from any other option.
|
||||
"""
|
||||
|
||||
final_runtime_env = {}
|
||||
if runtime_env is not None:
|
||||
if runtime_env_json is not None:
|
||||
raise ValueError(
|
||||
"Only one of --runtime_env and --runtime-env-json can be provided."
|
||||
)
|
||||
with open(runtime_env, "r") as f:
|
||||
final_runtime_env = yaml.safe_load(f)
|
||||
|
||||
elif runtime_env_json is not None:
|
||||
final_runtime_env = json.loads(runtime_env_json)
|
||||
|
||||
if working_dir is not None:
|
||||
if "working_dir" in final_runtime_env:
|
||||
cli_logger.warning(
|
||||
"Overriding runtime_env working_dir with --working-dir option"
|
||||
)
|
||||
|
||||
final_runtime_env["working_dir"] = working_dir
|
||||
|
||||
return final_runtime_env
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ClusterInfo:
|
||||
address: str
|
||||
cookies: Optional[Dict[str, Any]] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
headers: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
# TODO (shrekris-anyscale): renaming breaks compatibility, do NOT rename
|
||||
def get_job_submission_client_cluster_info(
|
||||
address: str,
|
||||
# For backwards compatibility
|
||||
*,
|
||||
# only used in importlib case in parse_cluster_info, but needed
|
||||
# in function signature.
|
||||
create_cluster_if_needed: Optional[bool] = False,
|
||||
cookies: Optional[Dict[str, Any]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, Any]] = None,
|
||||
_use_tls: Optional[bool] = False,
|
||||
**kwargs,
|
||||
) -> ClusterInfo:
|
||||
"""Get address, cookies, and metadata used for SubmissionClient.
|
||||
|
||||
If no port is specified in `address`, the Ray dashboard default will be
|
||||
inserted.
|
||||
|
||||
Args:
|
||||
address: Address without the module prefix that is passed
|
||||
to SubmissionClient.
|
||||
create_cluster_if_needed: Indicates whether the cluster
|
||||
of the address returned needs to be running. Ray doesn't
|
||||
start a cluster before interacting with jobs, but other
|
||||
implementations may do so.
|
||||
cookies: Optional cookies forwarded to ``SubmissionClient``.
|
||||
metadata: Optional metadata forwarded to ``SubmissionClient``.
|
||||
headers: Optional HTTP headers forwarded to ``SubmissionClient``.
|
||||
_use_tls: When True, use ``https`` instead of ``http`` for the
|
||||
constructed address.
|
||||
**kwargs: Reserved for forward-compatibility with other client
|
||||
implementations; unused here.
|
||||
|
||||
Returns:
|
||||
ClusterInfo object consisting of address, cookies, and metadata
|
||||
for SubmissionClient to use.
|
||||
"""
|
||||
|
||||
scheme = "https" if _use_tls else "http"
|
||||
return ClusterInfo(
|
||||
address=f"{scheme}://{address}",
|
||||
cookies=cookies,
|
||||
metadata=metadata,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def parse_cluster_info(
|
||||
address: Optional[str] = None,
|
||||
create_cluster_if_needed: bool = False,
|
||||
cookies: Optional[Dict[str, Any]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
) -> ClusterInfo:
|
||||
"""Create a cluster if needed and return its address, cookies, and metadata."""
|
||||
if address is None:
|
||||
if (
|
||||
ray.is_initialized()
|
||||
and ray._private.worker.global_worker.node.address_info["webui_url"]
|
||||
is not None
|
||||
):
|
||||
address = (
|
||||
"http://"
|
||||
f"{ray._private.worker.global_worker.node.address_info['webui_url']}"
|
||||
)
|
||||
logger.info(
|
||||
f"No address provided but Ray is running; using address {address}."
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"No address provided, defaulting to {DEFAULT_DASHBOARD_ADDRESS}."
|
||||
)
|
||||
address = DEFAULT_DASHBOARD_ADDRESS
|
||||
|
||||
if address == "auto":
|
||||
raise ValueError("Internal error: unexpected address 'auto'.")
|
||||
|
||||
if "://" not in address:
|
||||
# Default to HTTP.
|
||||
logger.info(
|
||||
"No scheme (e.g. 'http://') or module string (e.g. 'ray://') "
|
||||
f"provided in address {address}, defaulting to HTTP."
|
||||
)
|
||||
address = f"http://{address}"
|
||||
|
||||
module_string, inner_address = split_address(address)
|
||||
|
||||
if module_string == "ray":
|
||||
raise ValueError(f"Internal error: unexpected Ray Client address {address}.")
|
||||
# If user passes http(s)://, go through normal parsing.
|
||||
if module_string in {"http", "https"}:
|
||||
return get_job_submission_client_cluster_info(
|
||||
inner_address,
|
||||
create_cluster_if_needed=create_cluster_if_needed,
|
||||
cookies=cookies,
|
||||
metadata=metadata,
|
||||
headers=headers,
|
||||
_use_tls=(module_string == "https"),
|
||||
**kwargs,
|
||||
)
|
||||
# Try to dynamically import the function to get cluster info.
|
||||
else:
|
||||
try:
|
||||
module = importlib.import_module(module_string)
|
||||
except Exception:
|
||||
raise RuntimeError(
|
||||
f"Module: {module_string} does not exist.\n"
|
||||
f"This module was parsed from address: {address}"
|
||||
) from None
|
||||
assert "get_job_submission_client_cluster_info" in dir(module), (
|
||||
f"Module: {module_string} does "
|
||||
"not have `get_job_submission_client_cluster_info`.\n"
|
||||
f"This module was parsed from address: {address}"
|
||||
)
|
||||
|
||||
return module.get_job_submission_client_cluster_info(
|
||||
inner_address,
|
||||
create_cluster_if_needed=create_cluster_if_needed,
|
||||
cookies=cookies,
|
||||
metadata=metadata,
|
||||
headers=headers,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class SubmissionClient:
|
||||
def __init__(
|
||||
self,
|
||||
address: Optional[str] = None,
|
||||
create_cluster_if_needed: bool = False,
|
||||
cookies: Optional[Dict[str, Any]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, Any]] = None,
|
||||
verify: Optional[Union[str, bool]] = True,
|
||||
**kwargs,
|
||||
):
|
||||
# Remove any trailing slashes
|
||||
if address is not None and address.endswith("/"):
|
||||
address = address.rstrip("/")
|
||||
logger.debug(
|
||||
"The submission address cannot contain trailing slashes. Removing "
|
||||
f'them from the requested submission address of "{address}".'
|
||||
)
|
||||
|
||||
cluster_info = parse_cluster_info(
|
||||
address, create_cluster_if_needed, cookies, metadata, headers, **kwargs
|
||||
)
|
||||
self._address = cluster_info.address
|
||||
self._cookies = cluster_info.cookies
|
||||
self._default_metadata = cluster_info.metadata or {}
|
||||
# Headers used for all requests sent to job server, optional and only
|
||||
# needed for cases like authentication to remote cluster.
|
||||
self._headers = cluster_info.headers or {}
|
||||
self._headers.update(**get_auth_headers_if_auth_enabled(self._headers))
|
||||
|
||||
# Set SSL verify parameter for the requests library and create an ssl_context
|
||||
# object when needed for the aiohttp library.
|
||||
self._verify = verify
|
||||
if isinstance(self._verify, str):
|
||||
if os.path.isdir(self._verify):
|
||||
cafile, capath = None, self._verify
|
||||
elif os.path.isfile(self._verify):
|
||||
cafile, capath = self._verify, None
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
f"Path to CA certificates: '{self._verify}', does not exist."
|
||||
)
|
||||
self._ssl_context = ssl.create_default_context(cafile=cafile, capath=capath)
|
||||
else:
|
||||
if self._verify is False:
|
||||
self._ssl_context = False
|
||||
else:
|
||||
self._ssl_context = None
|
||||
|
||||
self._server_ray_version: Optional[str] = None
|
||||
|
||||
def _check_connection_and_version(
|
||||
self, min_version: str = "1.9", version_error_message: str = None
|
||||
):
|
||||
self._check_connection_and_version_with_url(min_version, version_error_message)
|
||||
|
||||
def _check_connection_and_version_with_url(
|
||||
self,
|
||||
min_version: str = "1.9",
|
||||
version_error_message: str = None,
|
||||
url: str = "/api/version",
|
||||
):
|
||||
if version_error_message is None:
|
||||
version_error_message = (
|
||||
f"Please ensure the cluster is running Ray {min_version} or higher."
|
||||
)
|
||||
|
||||
try:
|
||||
r = self._do_request("GET", url)
|
||||
if r.status_code == 404:
|
||||
raise RuntimeError(
|
||||
"Version check returned 404. " + version_error_message
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
running_ray_version = r.json()["ray_version"]
|
||||
self._server_ray_version = running_ray_version
|
||||
if packaging.version.parse(running_ray_version) < packaging.version.parse(
|
||||
min_version
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Ray version {running_ray_version} is running on the cluster. "
|
||||
+ version_error_message
|
||||
)
|
||||
except requests.exceptions.ConnectionError:
|
||||
raise ConnectionError(
|
||||
f"Failed to connect to Ray at address: {self._address}."
|
||||
)
|
||||
|
||||
def _raise_error(self, r: "requests.Response"):
|
||||
raise RuntimeError(
|
||||
f"Request failed with status code {r.status_code}: {r.text}."
|
||||
)
|
||||
|
||||
def _do_request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
*,
|
||||
data: Optional[bytes] = None,
|
||||
json_data: Optional[dict] = None,
|
||||
**kwargs,
|
||||
) -> "requests.Response":
|
||||
"""Perform the actual HTTP request with authentication error handling.
|
||||
|
||||
Keyword arguments other than "cookies", "headers" are forwarded to the
|
||||
`requests.request()`.
|
||||
"""
|
||||
url = self._address + endpoint
|
||||
logger.debug(f"Sending request to {url} with json data: {json_data or {}}.")
|
||||
|
||||
response = requests.request(
|
||||
method,
|
||||
url,
|
||||
cookies=self._cookies,
|
||||
data=data,
|
||||
json=json_data,
|
||||
headers=self._headers,
|
||||
verify=self._verify,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Check for authentication errors and provide helpful messages
|
||||
formatted_error = format_authentication_http_error(
|
||||
response.status_code, response.text
|
||||
)
|
||||
if formatted_error:
|
||||
raise AuthenticationError(formatted_error)
|
||||
|
||||
return response
|
||||
|
||||
def _package_exists(
|
||||
self,
|
||||
package_uri: str,
|
||||
) -> bool:
|
||||
protocol, package_name = uri_to_http_components(package_uri)
|
||||
r = self._do_request("GET", f"/api/packages/{protocol}/{package_name}")
|
||||
|
||||
if r.status_code == 200:
|
||||
logger.debug(f"Package {package_uri} already exists.")
|
||||
return True
|
||||
elif r.status_code == 404:
|
||||
logger.debug(f"Package {package_uri} does not exist.")
|
||||
return False
|
||||
else:
|
||||
self._raise_error(r)
|
||||
|
||||
def _upload_package(
|
||||
self,
|
||||
package_uri: str,
|
||||
package_path: str,
|
||||
include_gitignore: bool,
|
||||
include_parent_dir: Optional[bool] = False,
|
||||
excludes: Optional[List[str]] = None,
|
||||
is_file: bool = False,
|
||||
) -> bool:
|
||||
logger.info(f"Uploading package {package_uri}.")
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
protocol, package_name = uri_to_http_components(package_uri)
|
||||
if is_file:
|
||||
package_file = Path(package_path)
|
||||
else:
|
||||
package_file = Path(tmp_dir) / package_name
|
||||
create_package(
|
||||
package_path,
|
||||
package_file,
|
||||
include_gitignore=include_gitignore,
|
||||
include_parent_dir=include_parent_dir,
|
||||
excludes=excludes,
|
||||
)
|
||||
try:
|
||||
r = self._do_request(
|
||||
"PUT",
|
||||
f"/api/packages/{protocol}/{package_name}",
|
||||
data=package_file.read_bytes(),
|
||||
)
|
||||
if r.status_code != 200:
|
||||
self._raise_error(r)
|
||||
finally:
|
||||
# If the package is a user's existing file, don't delete it.
|
||||
if not is_file:
|
||||
package_file.unlink()
|
||||
|
||||
def _upload_package_if_needed(
|
||||
self,
|
||||
package_path: str,
|
||||
include_gitignore: bool,
|
||||
include_parent_dir: bool = False,
|
||||
excludes: Optional[List[str]] = None,
|
||||
is_file: bool = False,
|
||||
) -> str:
|
||||
if is_file:
|
||||
package_uri = get_uri_for_package(Path(package_path))
|
||||
else:
|
||||
package_uri = get_uri_for_directory(
|
||||
package_path, include_gitignore, excludes=excludes
|
||||
)
|
||||
|
||||
if not self._package_exists(package_uri):
|
||||
self._upload_package(
|
||||
package_uri,
|
||||
package_path,
|
||||
include_gitignore=include_gitignore,
|
||||
include_parent_dir=include_parent_dir,
|
||||
excludes=excludes,
|
||||
is_file=is_file,
|
||||
)
|
||||
else:
|
||||
logger.info(f"Package {package_uri} already exists, skipping upload.")
|
||||
|
||||
return package_uri
|
||||
|
||||
def _upload_working_dir_if_needed(self, runtime_env: Dict[str, Any]):
|
||||
from ray._private.ray_constants import RAY_RUNTIME_ENV_IGNORE_GITIGNORE
|
||||
|
||||
# Determine whether to respect .gitignore files based on environment variable
|
||||
# Default is True (respect .gitignore). Set to False if env var is "1".
|
||||
include_gitignore = os.environ.get(RAY_RUNTIME_ENV_IGNORE_GITIGNORE, "0") != "1"
|
||||
|
||||
def _upload_fn(working_dir, excludes, is_file=False):
|
||||
self._upload_package_if_needed(
|
||||
working_dir,
|
||||
include_gitignore=include_gitignore,
|
||||
include_parent_dir=False,
|
||||
excludes=excludes,
|
||||
is_file=is_file,
|
||||
)
|
||||
|
||||
upload_working_dir_if_needed(
|
||||
runtime_env, include_gitignore=include_gitignore, upload_fn=_upload_fn
|
||||
)
|
||||
|
||||
def _upload_py_modules_if_needed(self, runtime_env: Dict[str, Any]):
|
||||
from ray._private.ray_constants import RAY_RUNTIME_ENV_IGNORE_GITIGNORE
|
||||
|
||||
# Determine whether to respect .gitignore files based on environment variable
|
||||
# Default is True (respect .gitignore). Set to False if env var is "1".
|
||||
include_gitignore = os.environ.get(RAY_RUNTIME_ENV_IGNORE_GITIGNORE, "0") != "1"
|
||||
|
||||
def _upload_fn(module_path, excludes, is_file=False):
|
||||
self._upload_package_if_needed(
|
||||
module_path,
|
||||
include_gitignore=include_gitignore,
|
||||
include_parent_dir=True,
|
||||
excludes=excludes,
|
||||
is_file=is_file,
|
||||
)
|
||||
|
||||
upload_py_modules_if_needed(
|
||||
runtime_env, include_gitignore=include_gitignore, upload_fn=_upload_fn
|
||||
)
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
def get_version(self) -> str:
|
||||
r = self._do_request("GET", "/api/version")
|
||||
if r.status_code == 200:
|
||||
return r.json().get("version")
|
||||
else:
|
||||
self._raise_error(r)
|
||||
|
||||
@DeveloperAPI
|
||||
def get_address(self) -> str:
|
||||
return self._address
|
||||
@@ -0,0 +1,161 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from enum import Enum
|
||||
from urllib.parse import quote
|
||||
|
||||
import aiohttp
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
import ray.dashboard.optional_utils as optional_utils
|
||||
from ray.dashboard.modules.metrics.metrics_head import (
|
||||
DEFAULT_PROMETHEUS_HEADERS,
|
||||
DEFAULT_PROMETHEUS_HOST,
|
||||
PROMETHEUS_HEADERS_ENV_VAR,
|
||||
PROMETHEUS_HOST_ENV_VAR,
|
||||
PrometheusQueryError,
|
||||
parse_prom_headers,
|
||||
)
|
||||
from ray.dashboard.subprocesses.module import SubprocessModule
|
||||
from ray.dashboard.subprocesses.routes import SubprocessRouteTable as routes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
# Window and sampling rate used for certain Prometheus queries.
|
||||
# Datapoints up until `MAX_TIME_WINDOW` ago are queried at `SAMPLE_RATE` intervals.
|
||||
MAX_TIME_WINDOW = "1h"
|
||||
SAMPLE_RATE = "1s"
|
||||
|
||||
|
||||
class PrometheusQuery(Enum):
|
||||
"""Enum to store types of Prometheus queries for a given metric and grouping."""
|
||||
|
||||
VALUE = ("value", "sum({}{{SessionName='{}'}}) by ({})")
|
||||
MAX = (
|
||||
"max",
|
||||
"max_over_time(sum({}{{SessionName='{}'}}) by ({})["
|
||||
+ f"{MAX_TIME_WINDOW}:{SAMPLE_RATE}])",
|
||||
)
|
||||
|
||||
|
||||
DATASET_METRICS = {
|
||||
"ray_data_output_rows": (PrometheusQuery.MAX,),
|
||||
"ray_data_spilled_bytes": (PrometheusQuery.MAX,),
|
||||
"ray_data_current_bytes": (PrometheusQuery.VALUE, PrometheusQuery.MAX),
|
||||
"ray_data_cpu_usage_cores": (PrometheusQuery.VALUE, PrometheusQuery.MAX),
|
||||
"ray_data_gpu_usage_cores": (PrometheusQuery.VALUE, PrometheusQuery.MAX),
|
||||
}
|
||||
|
||||
|
||||
class DataHead(SubprocessModule):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.prometheus_host = os.environ.get(
|
||||
PROMETHEUS_HOST_ENV_VAR, DEFAULT_PROMETHEUS_HOST
|
||||
)
|
||||
self.prometheus_headers = parse_prom_headers(
|
||||
os.environ.get(
|
||||
PROMETHEUS_HEADERS_ENV_VAR,
|
||||
DEFAULT_PROMETHEUS_HEADERS,
|
||||
)
|
||||
)
|
||||
|
||||
@routes.get("/api/data/datasets/{job_id}")
|
||||
@optional_utils.init_ray_and_catch_exceptions()
|
||||
async def get_datasets(self, req: Request) -> Response:
|
||||
job_id = req.match_info["job_id"]
|
||||
|
||||
try:
|
||||
from ray.data._internal.stats import get_or_create_stats_actor
|
||||
|
||||
_stats_actor = get_or_create_stats_actor()
|
||||
datasets = await _stats_actor.get_datasets.remote(job_id)
|
||||
# Initializes dataset metric values
|
||||
for dataset in datasets:
|
||||
for metric, queries in DATASET_METRICS.items():
|
||||
datasets[dataset][metric] = {query.value[0]: 0 for query in queries}
|
||||
for operator in datasets[dataset]["operators"]:
|
||||
datasets[dataset]["operators"][operator][metric] = {
|
||||
query.value[0]: 0 for query in queries
|
||||
}
|
||||
# Query dataset metric values from prometheus
|
||||
try:
|
||||
# TODO (Zandew): store results of completed datasets in stats actor.
|
||||
for metric, queries in DATASET_METRICS.items():
|
||||
for query in queries:
|
||||
query_name, prom_query = query.value
|
||||
# Dataset level
|
||||
dataset_result = await self._query_prometheus(
|
||||
prom_query.format(metric, self.session_name, "dataset")
|
||||
)
|
||||
for res in dataset_result["data"]["result"]:
|
||||
dataset, value = res["metric"]["dataset"], res["value"][1]
|
||||
if dataset in datasets:
|
||||
datasets[dataset][metric][query_name] = value
|
||||
|
||||
# Operator level
|
||||
operator_result = await self._query_prometheus(
|
||||
prom_query.format(
|
||||
metric, self.session_name, "dataset, operator"
|
||||
)
|
||||
)
|
||||
for res in operator_result["data"]["result"]:
|
||||
dataset, operator, value = (
|
||||
res["metric"]["dataset"],
|
||||
res["metric"]["operator"],
|
||||
res["value"][1],
|
||||
)
|
||||
# Check if dataset/operator is in current _StatsActor scope.
|
||||
# Prometheus server may contain metrics from previous
|
||||
# cluster if not reset.
|
||||
if (
|
||||
dataset in datasets
|
||||
and operator in datasets[dataset]["operators"]
|
||||
):
|
||||
datasets[dataset]["operators"][operator][metric][
|
||||
query_name
|
||||
] = value
|
||||
except aiohttp.client_exceptions.ClientConnectorError:
|
||||
# Prometheus server may not be running,
|
||||
# leave these values blank and return other data
|
||||
logging.exception(
|
||||
"Exception occurred while querying Prometheus. "
|
||||
"The Prometheus server may not be running."
|
||||
)
|
||||
# Flatten response
|
||||
for dataset in datasets:
|
||||
datasets[dataset]["operators"] = list(
|
||||
map(
|
||||
lambda item: {"operator": item[0], **item[1]},
|
||||
datasets[dataset]["operators"].items(),
|
||||
)
|
||||
)
|
||||
datasets = list(
|
||||
map(lambda item: {"dataset": item[0], **item[1]}, datasets.items())
|
||||
)
|
||||
# Sort by descending start time
|
||||
datasets = sorted(datasets, key=lambda x: x["start_time"], reverse=True)
|
||||
return Response(
|
||||
text=json.dumps({"datasets": datasets}),
|
||||
content_type="application/json",
|
||||
)
|
||||
except Exception as e:
|
||||
logging.exception("Exception occurred while getting datasets.")
|
||||
return Response(
|
||||
status=503,
|
||||
text=str(e),
|
||||
)
|
||||
|
||||
async def _query_prometheus(self, query):
|
||||
async with self.http_session.get(
|
||||
f"{self.prometheus_host}/api/v1/query?query={quote(query)}",
|
||||
headers=self.prometheus_headers,
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
prom_data = await resp.json()
|
||||
return prom_data
|
||||
|
||||
message = await resp.text()
|
||||
raise PrometheusQueryError(resp.status, message)
|
||||
@@ -0,0 +1,141 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
import ray
|
||||
from ray.job_submission import JobSubmissionClient
|
||||
from ray.tests.conftest import * # noqa
|
||||
|
||||
# For local testing on a Macbook, set `export TEST_ON_DARWIN=1`.
|
||||
TEST_ON_DARWIN = os.environ.get("TEST_ON_DARWIN", "0") == "1"
|
||||
|
||||
DATA_HEAD_URLS = {"GET": "http://localhost:8265/api/data/datasets/{job_id}"}
|
||||
|
||||
DATA_SCHEMA = [
|
||||
"state",
|
||||
"progress",
|
||||
"total",
|
||||
"total_rows",
|
||||
"ray_data_output_rows",
|
||||
"ray_data_spilled_bytes",
|
||||
"ray_data_current_bytes",
|
||||
"ray_data_cpu_usage_cores",
|
||||
"ray_data_gpu_usage_cores",
|
||||
]
|
||||
|
||||
RESPONSE_SCHEMA = [
|
||||
"dataset",
|
||||
"job_id",
|
||||
"start_time",
|
||||
"end_time",
|
||||
"operators",
|
||||
] + DATA_SCHEMA
|
||||
|
||||
OPERATOR_SCHEMA = [
|
||||
"name",
|
||||
"operator",
|
||||
"queued_blocks",
|
||||
] + DATA_SCHEMA
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "darwin" and not TEST_ON_DARWIN, reason="Flaky on OSX."
|
||||
)
|
||||
def test_unique_operator_id(ray_start_regular_shared):
|
||||
# This regression test addresses a bug caused by using a non-unique operator ID
|
||||
# format. Specifically, the third operator's name is limit11 with the ID limit112,
|
||||
# while the thirteenth operator's name is limit1 with the same ID limit112, leading
|
||||
# to a collision.
|
||||
ds = ray.data.range(100, override_num_blocks=20).limit(11) # 3 operators
|
||||
for i in range(11): # 11 more operators
|
||||
ds = ds.limit(1)
|
||||
ds._set_name("unique_operator_id_test")
|
||||
ds.materialize()
|
||||
|
||||
client = JobSubmissionClient()
|
||||
jobs = client.list_jobs()
|
||||
assert len(jobs) == 1, jobs
|
||||
job_id = jobs[0].job_id
|
||||
|
||||
data = requests.get(DATA_HEAD_URLS["GET"].format(job_id=job_id)).json()
|
||||
datasets = [
|
||||
dataset
|
||||
for dataset in data["datasets"]
|
||||
if dataset["dataset"].startswith("unique_operator_id_test")
|
||||
]
|
||||
assert len(datasets) == 1
|
||||
dataset = datasets[0]
|
||||
|
||||
operators = dataset["operators"]
|
||||
assert len(operators) == 3 # Should be 3 because of limiter operator fusion.
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "darwin" and not TEST_ON_DARWIN, reason="Flaky on OSX."
|
||||
)
|
||||
def test_get_datasets(ray_start_regular_shared):
|
||||
ds = ray.data.range(100, override_num_blocks=20).map_batches(lambda x: x)
|
||||
ds.set_name("data_head_test")
|
||||
ds.materialize()
|
||||
|
||||
client = JobSubmissionClient()
|
||||
jobs = client.list_jobs()
|
||||
assert len(jobs) == 1, jobs
|
||||
job_id = jobs[0].job_id
|
||||
|
||||
data = requests.get(DATA_HEAD_URLS["GET"].format(job_id=job_id)).json()
|
||||
datasets = [
|
||||
dataset
|
||||
for dataset in data["datasets"]
|
||||
if dataset["dataset"].startswith("data_head_test")
|
||||
]
|
||||
|
||||
assert len(datasets) == 1
|
||||
assert sorted(datasets[0].keys()) == sorted(RESPONSE_SCHEMA)
|
||||
|
||||
dataset = datasets[0]
|
||||
assert dataset["dataset"].startswith("data_head_test")
|
||||
assert dataset["job_id"] == job_id
|
||||
assert dataset["state"] == "FINISHED"
|
||||
assert dataset["end_time"] is not None
|
||||
|
||||
operators = dataset["operators"]
|
||||
assert len(operators) == 2
|
||||
op0 = operators[0]
|
||||
op1 = operators[1]
|
||||
assert sorted(op0.keys()) == sorted(OPERATOR_SCHEMA)
|
||||
assert sorted(op1.keys()) == sorted(OPERATOR_SCHEMA)
|
||||
assert {
|
||||
"operator": "Input_0",
|
||||
"name": "Input",
|
||||
"state": "FINISHED",
|
||||
"progress": 20,
|
||||
"total": 20,
|
||||
}.items() <= op0.items()
|
||||
assert {
|
||||
"operator": "ReadRange->MapBatches(<lambda>)_1",
|
||||
"name": "ReadRange->MapBatches(<lambda>)",
|
||||
"state": "FINISHED",
|
||||
"progress": 20,
|
||||
"total": 20,
|
||||
}.items() <= op1.items()
|
||||
|
||||
ds._set_name("another_data_head_test")
|
||||
ds.map_batches(lambda x: x).materialize()
|
||||
data = requests.get(DATA_HEAD_URLS["GET"].format(job_id=job_id)).json()
|
||||
|
||||
dataset = [
|
||||
dataset
|
||||
for dataset in data["datasets"]
|
||||
if dataset["dataset"].startswith("another_data_head_test")
|
||||
][0]
|
||||
assert dataset["dataset"].startswith("another_data_head_test")
|
||||
assert dataset["job_id"] == job_id
|
||||
assert dataset["state"] == "FINISHED"
|
||||
assert dataset["end_time"] is not None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-vv", __file__]))
|
||||
@@ -0,0 +1,138 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Union
|
||||
|
||||
import ray._private.ray_constants as ray_constants
|
||||
import ray.dashboard.consts as dashboard_consts
|
||||
import ray.dashboard.utils as dashboard_utils
|
||||
from ray._private.authentication.http_token_authentication import (
|
||||
get_auth_headers_if_auth_enabled,
|
||||
)
|
||||
from ray.dashboard.modules.event import event_consts
|
||||
from ray.dashboard.modules.event.event_utils import monitor_events
|
||||
from ray.dashboard.utils import async_loop_forever, create_task
|
||||
|
||||
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_EVENT_AGENT_TPE_MAX_WORKERS = ray_constants.env_integer(
|
||||
"RAY_DASHBOARD_EVENT_AGENT_TPE_MAX_WORKERS", 1
|
||||
)
|
||||
|
||||
|
||||
class EventAgent(dashboard_utils.DashboardAgentModule):
|
||||
def __init__(self, dashboard_agent):
|
||||
super().__init__(dashboard_agent)
|
||||
self._event_dir = os.path.join(self._dashboard_agent.log_dir, "events")
|
||||
os.makedirs(self._event_dir, exist_ok=True)
|
||||
self._monitor: Union[asyncio.Task, None] = None
|
||||
# Lazy initialized on first use. Once initialized, it will not be
|
||||
# changed.
|
||||
self._dashboard_http_address = None
|
||||
self._cached_events = asyncio.Queue(event_consts.EVENT_AGENT_CACHE_SIZE)
|
||||
self._gcs_client = dashboard_agent.gcs_client
|
||||
# Total number of event created from this agent.
|
||||
self.total_event_reported = 0
|
||||
# Total number of event report request sent.
|
||||
self.total_request_sent = 0
|
||||
self.module_started = time.monotonic()
|
||||
|
||||
self._executor = ThreadPoolExecutor(
|
||||
max_workers=RAY_DASHBOARD_EVENT_AGENT_TPE_MAX_WORKERS,
|
||||
thread_name_prefix="event_agent_executor",
|
||||
)
|
||||
|
||||
logger.info("Event agent cache buffer size: %s", self._cached_events.maxsize)
|
||||
|
||||
async def _get_dashboard_http_address(self):
|
||||
"""
|
||||
Lazily get the dashboard http address from InternalKV. If it's not set, sleep
|
||||
and retry forever.
|
||||
"""
|
||||
while True:
|
||||
if self._dashboard_http_address:
|
||||
return self._dashboard_http_address
|
||||
try:
|
||||
dashboard_http_address = await self._gcs_client.async_internal_kv_get(
|
||||
ray_constants.DASHBOARD_ADDRESS.encode(),
|
||||
namespace=ray_constants.KV_NAMESPACE_DASHBOARD,
|
||||
timeout=dashboard_consts.GCS_RPC_TIMEOUT_SECONDS,
|
||||
)
|
||||
if not dashboard_http_address:
|
||||
raise ValueError("Dashboard http address not found in InternalKV.")
|
||||
address = dashboard_http_address.decode()
|
||||
if not address.startswith(("http://", "https://")):
|
||||
address = f"http://{address}"
|
||||
self._dashboard_http_address = address
|
||||
return self._dashboard_http_address
|
||||
except Exception:
|
||||
logger.exception("Get dashboard http address failed.")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
@async_loop_forever(event_consts.EVENT_AGENT_REPORT_INTERVAL_SECONDS)
|
||||
async def report_events(self):
|
||||
"""Report events from cached events queue. Reconnect to dashboard if
|
||||
report failed. Log error after retry EVENT_AGENT_RETRY_TIMES.
|
||||
|
||||
This method will never returns.
|
||||
"""
|
||||
dashboard_http_address = await self._get_dashboard_http_address()
|
||||
data = await self._cached_events.get()
|
||||
self.total_event_reported += len(data)
|
||||
last_exception = None
|
||||
for _ in range(event_consts.EVENT_AGENT_RETRY_TIMES):
|
||||
try:
|
||||
logger.debug("Report %s events.", len(data))
|
||||
async with self._dashboard_agent.http_session.post(
|
||||
f"{dashboard_http_address}/report_events",
|
||||
json=data,
|
||||
headers=get_auth_headers_if_auth_enabled({}),
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
self.total_request_sent += 1
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Report event failed, retrying... {e}")
|
||||
last_exception = e
|
||||
else:
|
||||
data_str = str(data)
|
||||
limit = event_consts.LOG_ERROR_EVENT_STRING_LENGTH_LIMIT
|
||||
logger.error(
|
||||
"Report event failed: %s",
|
||||
data_str[:limit] + (data_str[limit:] and "..."),
|
||||
exc_info=last_exception,
|
||||
)
|
||||
|
||||
async def get_internal_states(self):
|
||||
if self.total_event_reported <= 0 or self.total_request_sent <= 0:
|
||||
return
|
||||
|
||||
elapsed = time.monotonic() - self.module_started
|
||||
return {
|
||||
"total_events_reported": self.total_event_reported,
|
||||
"Total_report_request": self.total_request_sent,
|
||||
"queue_size": self._cached_events.qsize(),
|
||||
"total_uptime": elapsed,
|
||||
}
|
||||
|
||||
async def run(self, server):
|
||||
# Start monitor task.
|
||||
self._monitor = monitor_events(
|
||||
self._event_dir,
|
||||
lambda data: create_task(self._cached_events.put(data)),
|
||||
self._executor,
|
||||
)
|
||||
|
||||
await asyncio.gather(
|
||||
self.report_events(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_minimal_module():
|
||||
return False
|
||||
@@ -0,0 +1,20 @@
|
||||
from ray._private.ray_constants import env_float, env_integer
|
||||
from ray.core.generated import event_pb2
|
||||
|
||||
LOG_ERROR_EVENT_STRING_LENGTH_LIMIT = 1000
|
||||
# Monitor events
|
||||
SCAN_EVENT_DIR_INTERVAL_SECONDS = env_integer("SCAN_EVENT_DIR_INTERVAL_SECONDS", 2)
|
||||
SCAN_EVENT_START_OFFSET_SECONDS = -30 * 60
|
||||
CONCURRENT_READ_LIMIT = 50
|
||||
EVENT_READ_LINE_COUNT_LIMIT = 200
|
||||
EVENT_READ_LINE_LENGTH_LIMIT = env_integer(
|
||||
"EVENT_READ_LINE_LENGTH_LIMIT", 2 * 1024 * 1024
|
||||
) # 2MB
|
||||
# Report events
|
||||
EVENT_AGENT_REPORT_INTERVAL_SECONDS = env_float(
|
||||
"EVENT_AGENT_REPORT_INTERVAL_SECONDS", 0.1
|
||||
)
|
||||
EVENT_AGENT_RETRY_TIMES = 10
|
||||
EVENT_AGENT_CACHE_SIZE = 10240
|
||||
# Event sources
|
||||
EVENT_SOURCE_ALL = event_pb2.Event.SourceType.keys()
|
||||
@@ -0,0 +1,237 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections import OrderedDict, defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from itertools import islice
|
||||
from typing import Dict, List, Union
|
||||
|
||||
import aiohttp.web
|
||||
|
||||
import ray
|
||||
import ray.dashboard.optional_utils as dashboard_optional_utils
|
||||
import ray.dashboard.utils as dashboard_utils
|
||||
from ray._common.usage.usage_lib import TagKey, record_extra_usage_tag
|
||||
from ray._common.utils import get_or_create_event_loop
|
||||
from ray._private.ray_constants import env_integer
|
||||
from ray.dashboard.consts import (
|
||||
RAY_STATE_SERVER_MAX_HTTP_REQUEST,
|
||||
RAY_STATE_SERVER_MAX_HTTP_REQUEST_ALLOWED,
|
||||
RAY_STATE_SERVER_MAX_HTTP_REQUEST_ENV_NAME,
|
||||
)
|
||||
from ray.dashboard.modules.event.event_utils import monitor_events, parse_event_strings
|
||||
from ray.dashboard.state_api_utils import do_filter, handle_list_api
|
||||
from ray.dashboard.subprocesses.module import SubprocessModule
|
||||
from ray.dashboard.subprocesses.routes import SubprocessRouteTable as routes
|
||||
from ray.util.state.common import ClusterEventState, ListApiOptions, ListApiResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
JobEvents = OrderedDict
|
||||
dashboard_utils._json_compatible_types.add(JobEvents)
|
||||
|
||||
MAX_EVENTS_TO_CACHE = int(os.environ.get("RAY_DASHBOARD_MAX_EVENTS_TO_CACHE", 10000))
|
||||
|
||||
# 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_EVENT_HEAD_TPE_MAX_WORKERS = env_integer(
|
||||
"RAY_DASHBOARD_EVENT_HEAD_TPE_MAX_WORKERS", 1
|
||||
)
|
||||
|
||||
|
||||
async def _list_cluster_events_impl(
|
||||
*,
|
||||
all_events: Dict[str, JobEvents],
|
||||
executor: ThreadPoolExecutor,
|
||||
option: ListApiOptions,
|
||||
) -> ListApiResponse:
|
||||
"""List all cluster events from the cluster. Made a free function to allow unit tests.
|
||||
|
||||
Args:
|
||||
all_events: Mapping of ``job_id`` to per-job event dictionaries.
|
||||
executor: Executor used to run the (CPU-bound) transform off the event loop.
|
||||
option: Query options (filters, limit, detail flag).
|
||||
|
||||
Returns:
|
||||
A list of cluster events in the cluster.
|
||||
The schema of returned "dict" is equivalent to the
|
||||
`ClusterEventState` protobuf message.
|
||||
"""
|
||||
|
||||
def transform(all_events) -> ListApiResponse:
|
||||
result = []
|
||||
for _, events in all_events.items():
|
||||
for _, event in events.items():
|
||||
event["time"] = str(datetime.fromtimestamp(int(event["timestamp"])))
|
||||
result.append(event)
|
||||
|
||||
num_after_truncation = len(result)
|
||||
result.sort(key=lambda entry: entry["timestamp"])
|
||||
total = len(result)
|
||||
result = do_filter(result, option.filters, ClusterEventState, option.detail)
|
||||
num_filtered = len(result)
|
||||
# Sort to make the output deterministic.
|
||||
result = list(islice(result, option.limit))
|
||||
return ListApiResponse(
|
||||
result=result,
|
||||
total=total,
|
||||
num_after_truncation=num_after_truncation,
|
||||
num_filtered=num_filtered,
|
||||
)
|
||||
|
||||
return await get_or_create_event_loop().run_in_executor(
|
||||
executor, transform, all_events
|
||||
)
|
||||
|
||||
|
||||
class EventHead(
|
||||
SubprocessModule,
|
||||
dashboard_utils.RateLimitedModule,
|
||||
):
|
||||
def __init__(self, *args, **kwargs):
|
||||
SubprocessModule.__init__(self, *args, **kwargs)
|
||||
dashboard_utils.RateLimitedModule.__init__(
|
||||
self,
|
||||
min(
|
||||
RAY_STATE_SERVER_MAX_HTTP_REQUEST,
|
||||
RAY_STATE_SERVER_MAX_HTTP_REQUEST_ALLOWED,
|
||||
),
|
||||
)
|
||||
self._event_dir = os.path.join(self.log_dir, "events")
|
||||
os.makedirs(self._event_dir, exist_ok=True)
|
||||
self._monitor: Union[asyncio.Task, None] = None
|
||||
self.total_report_events_count = 0
|
||||
self.total_events_received = 0
|
||||
self.module_started = time.monotonic()
|
||||
# {job_id hex(str): {event_id (str): event (dict)}}
|
||||
self.events: Dict[str, JobEvents] = defaultdict(JobEvents)
|
||||
|
||||
self._executor = ThreadPoolExecutor(
|
||||
max_workers=RAY_DASHBOARD_EVENT_HEAD_TPE_MAX_WORKERS,
|
||||
thread_name_prefix="event_head_executor",
|
||||
)
|
||||
|
||||
# To init gcs_client in internal_kv for record_extra_usage_tag.
|
||||
assert self.gcs_client is not None
|
||||
assert ray.experimental.internal_kv._internal_kv_initialized()
|
||||
|
||||
async def limit_handler_(self):
|
||||
return dashboard_optional_utils.rest_response(
|
||||
status_code=dashboard_utils.HTTPStatusCode.INTERNAL_ERROR,
|
||||
error_message=(
|
||||
"Max number of in-progress requests="
|
||||
f"{self.max_num_call_} reached. "
|
||||
"To set a higher limit, set environment variable: "
|
||||
f"export {RAY_STATE_SERVER_MAX_HTTP_REQUEST_ENV_NAME}='xxx'. "
|
||||
f"Max allowed = {RAY_STATE_SERVER_MAX_HTTP_REQUEST_ALLOWED}"
|
||||
),
|
||||
result=None,
|
||||
)
|
||||
|
||||
def _update_events(self, event_list):
|
||||
# {job_id: {event_id: event}}
|
||||
all_job_events = defaultdict(JobEvents)
|
||||
for event in event_list:
|
||||
event_id = event["event_id"]
|
||||
custom_fields = event.get("custom_fields")
|
||||
system_event = False
|
||||
if custom_fields:
|
||||
job_id = custom_fields.get("job_id", "global") or "global"
|
||||
else:
|
||||
job_id = "global"
|
||||
if system_event is False:
|
||||
all_job_events[job_id][event_id] = event
|
||||
|
||||
for job_id, new_job_events in all_job_events.items():
|
||||
job_events = self.events[job_id]
|
||||
job_events.update(new_job_events)
|
||||
|
||||
# Limit the # of events cached if it exceeds the threshold.
|
||||
if len(job_events) > MAX_EVENTS_TO_CACHE * 1.1:
|
||||
while len(job_events) > MAX_EVENTS_TO_CACHE:
|
||||
job_events.popitem(last=False)
|
||||
|
||||
@routes.post("/report_events")
|
||||
async def report_events(self, request):
|
||||
"""
|
||||
Report events to the dashboard.
|
||||
The request body is a JSON array of event strings in type string.
|
||||
Response should contain {"success": true}.
|
||||
"""
|
||||
try:
|
||||
request_body: List[str] = await request.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse request body: {request=}, {e=}")
|
||||
raise aiohttp.web.HTTPBadRequest()
|
||||
if not isinstance(request_body, list):
|
||||
logger.warning(f"Request body is not a list, {request_body=}")
|
||||
raise aiohttp.web.HTTPBadRequest()
|
||||
events = parse_event_strings(request_body)
|
||||
logger.debug("Received %d events", len(events))
|
||||
self._update_events(events)
|
||||
self.total_report_events_count += 1
|
||||
self.total_events_received += len(events)
|
||||
return dashboard_optional_utils.rest_response(
|
||||
success=True,
|
||||
message="",
|
||||
status_code=dashboard_utils.HTTPStatusCode.OK,
|
||||
)
|
||||
|
||||
async def _periodic_state_print(self):
|
||||
if self.total_events_received <= 0 or self.total_report_events_count <= 0:
|
||||
return
|
||||
|
||||
elapsed = time.monotonic() - self.module_started
|
||||
return {
|
||||
"total_events_received": self.total_events_received,
|
||||
"Total_requests_received": self.total_report_events_count,
|
||||
"total_uptime": elapsed,
|
||||
}
|
||||
|
||||
@routes.get("/events")
|
||||
@dashboard_optional_utils.aiohttp_cache
|
||||
async def get_event(self, req) -> aiohttp.web.Response:
|
||||
job_id = req.query.get("job_id")
|
||||
if job_id is None:
|
||||
all_events = {
|
||||
job_id: list(job_events.values())
|
||||
for job_id, job_events in self.events.items()
|
||||
}
|
||||
return dashboard_optional_utils.rest_response(
|
||||
status_code=dashboard_utils.HTTPStatusCode.OK,
|
||||
message="All events fetched.",
|
||||
events=all_events,
|
||||
)
|
||||
|
||||
job_events = self.events[job_id]
|
||||
return dashboard_optional_utils.rest_response(
|
||||
status_code=dashboard_utils.HTTPStatusCode.OK,
|
||||
message="Job events fetched.",
|
||||
job_id=job_id,
|
||||
events=list(job_events.values()),
|
||||
)
|
||||
|
||||
@routes.get("/api/v0/cluster_events")
|
||||
@dashboard_utils.RateLimitedModule.enforce_max_concurrent_calls
|
||||
async def list_cluster_events(
|
||||
self, req: aiohttp.web.Request
|
||||
) -> aiohttp.web.Response:
|
||||
record_extra_usage_tag(TagKey.CORE_STATE_API_LIST_CLUSTER_EVENTS, "1")
|
||||
|
||||
async def list_api_fn(option: ListApiOptions):
|
||||
return await _list_cluster_events_impl(
|
||||
all_events=self.events, executor=self._executor, option=option
|
||||
)
|
||||
|
||||
return await handle_list_api(list_api_fn, req)
|
||||
|
||||
async def run(self):
|
||||
await super().run()
|
||||
self._monitor = monitor_events(
|
||||
self._event_dir,
|
||||
lambda data: self._update_events(parse_event_strings(data)),
|
||||
self._executor,
|
||||
)
|
||||
@@ -0,0 +1,210 @@
|
||||
import asyncio
|
||||
import collections
|
||||
import fnmatch
|
||||
import itertools
|
||||
import json
|
||||
import logging.handlers
|
||||
import mmap
|
||||
import os
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from ray._common.utils import get_or_create_event_loop, run_background_task
|
||||
from ray.dashboard.modules.event import event_consts
|
||||
from ray.dashboard.utils import async_loop_forever
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_source_files(event_dir, source_types=None, event_file_filter=None):
|
||||
event_log_names = os.listdir(event_dir)
|
||||
source_files = {}
|
||||
all_source_types = set(event_consts.EVENT_SOURCE_ALL)
|
||||
for source_type in source_types or event_consts.EVENT_SOURCE_ALL:
|
||||
assert source_type in all_source_types, f"Invalid source type: {source_type}"
|
||||
files = []
|
||||
for n in event_log_names:
|
||||
if fnmatch.fnmatch(n, f"*{source_type}*.log"):
|
||||
f = os.path.join(event_dir, n)
|
||||
if event_file_filter is not None and not event_file_filter(f):
|
||||
continue
|
||||
files.append(f)
|
||||
if files:
|
||||
source_files[source_type] = files
|
||||
return source_files
|
||||
|
||||
|
||||
def _restore_newline(event_dict):
|
||||
try:
|
||||
event_dict["message"] = (
|
||||
event_dict["message"].replace("\\n", "\n").replace("\\r", "\n")
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Restore newline for event failed: %s", event_dict)
|
||||
return event_dict
|
||||
|
||||
|
||||
def _parse_line(event_str):
|
||||
return _restore_newline(json.loads(event_str))
|
||||
|
||||
|
||||
def parse_event_strings(event_string_list):
|
||||
events = []
|
||||
for data in event_string_list:
|
||||
if not data:
|
||||
continue
|
||||
try:
|
||||
event = _parse_line(data)
|
||||
events.append(event)
|
||||
except Exception:
|
||||
logger.exception("Parse event line failed: %s", repr(data))
|
||||
return events
|
||||
|
||||
|
||||
ReadFileResult = collections.namedtuple(
|
||||
"ReadFileResult", ["fid", "size", "mtime", "position", "lines"]
|
||||
)
|
||||
|
||||
|
||||
def _read_file(
|
||||
file, pos, n_lines=event_consts.EVENT_READ_LINE_COUNT_LIMIT, closefd=True
|
||||
):
|
||||
with open(file, "rb", closefd=closefd) as f:
|
||||
# The ino may be 0 on Windows.
|
||||
stat = os.stat(f.fileno())
|
||||
fid = stat.st_ino or file
|
||||
lines = []
|
||||
with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm:
|
||||
start = pos
|
||||
for _ in range(n_lines):
|
||||
sep = mm.find(b"\n", start)
|
||||
if sep == -1:
|
||||
break
|
||||
if sep - start <= event_consts.EVENT_READ_LINE_LENGTH_LIMIT:
|
||||
lines.append(mm[start:sep].decode("utf-8"))
|
||||
else:
|
||||
truncated_size = min(100, event_consts.EVENT_READ_LINE_LENGTH_LIMIT)
|
||||
logger.warning(
|
||||
"Ignored long string: %s...(%s chars)",
|
||||
mm[start : start + truncated_size].decode("utf-8"),
|
||||
sep - start,
|
||||
)
|
||||
start = sep + 1
|
||||
return ReadFileResult(fid, stat.st_size, stat.st_mtime, start, lines)
|
||||
|
||||
|
||||
def monitor_events(
|
||||
event_dir: str,
|
||||
callback: Callable[[List[str]], None],
|
||||
monitor_thread_pool_executor: ThreadPoolExecutor,
|
||||
scan_interval_seconds: float = event_consts.SCAN_EVENT_DIR_INTERVAL_SECONDS,
|
||||
start_mtime: float = time.time() + event_consts.SCAN_EVENT_START_OFFSET_SECONDS,
|
||||
monitor_files: Optional[Dict[int, tuple]] = None,
|
||||
source_types: Optional[List[str]] = None,
|
||||
) -> asyncio.Task:
|
||||
"""Monitor events in directory. New events will be read and passed to the
|
||||
callback.
|
||||
|
||||
Args:
|
||||
event_dir: The event log directory.
|
||||
callback: A callback that accepts a list of event strings.
|
||||
monitor_thread_pool_executor: A thread pool exector to monitor/update
|
||||
events. None means it will use the default execturo which uses
|
||||
num_cpus of the machine * 5 threads (before python 3.8) or
|
||||
min(32, num_cpus + 5) (from Python 3.8).
|
||||
scan_interval_seconds: An interval seconds between two scans.
|
||||
start_mtime: Only the event log files whose last modification
|
||||
time is greater than start_mtime are monitored.
|
||||
monitor_files: The map from event log file id to MonitorFile object.
|
||||
Monitor all files start from the beginning if the value is None.
|
||||
source_types: A list of source type name from
|
||||
event_pb2.Event.SourceType.keys(). Monitor all source types if the
|
||||
value is None.
|
||||
|
||||
Returns:
|
||||
The background ``asyncio.Task`` driving the periodic directory scan.
|
||||
"""
|
||||
loop = get_or_create_event_loop()
|
||||
if monitor_files is None:
|
||||
monitor_files = {}
|
||||
|
||||
logger.info(
|
||||
"Monitor events logs modified after %s on %s, the source types are %s.",
|
||||
start_mtime,
|
||||
event_dir,
|
||||
"all" if source_types is None else source_types,
|
||||
)
|
||||
|
||||
MonitorFile = collections.namedtuple("MonitorFile", ["size", "mtime", "position"])
|
||||
|
||||
def _source_file_filter(source_file):
|
||||
stat = os.stat(source_file)
|
||||
return stat.st_mtime > start_mtime
|
||||
|
||||
def _read_monitor_file(file, pos):
|
||||
assert isinstance(
|
||||
file, str
|
||||
), f"File should be a str, but a {type(file)}({file}) found"
|
||||
fd = os.open(file, os.O_RDONLY)
|
||||
try:
|
||||
stat = os.stat(fd)
|
||||
# Check the file size to avoid raising the exception
|
||||
# ValueError: cannot mmap an empty file
|
||||
if stat.st_size <= 0:
|
||||
return []
|
||||
fid = stat.st_ino or file
|
||||
monitor_file = monitor_files.get(fid)
|
||||
if monitor_file:
|
||||
if (
|
||||
monitor_file.position == monitor_file.size
|
||||
and monitor_file.size == stat.st_size
|
||||
and monitor_file.mtime == stat.st_mtime
|
||||
):
|
||||
logger.debug(
|
||||
"Skip reading the file because there is no change: %s", file
|
||||
)
|
||||
return []
|
||||
position = monitor_file.position
|
||||
else:
|
||||
logger.info("Found new event log file: %s", file)
|
||||
position = pos
|
||||
# Close the fd in finally.
|
||||
r = _read_file(fd, position, closefd=False)
|
||||
# It should be fine to update the dict in executor thread.
|
||||
monitor_files[r.fid] = MonitorFile(r.size, r.mtime, r.position)
|
||||
loop.call_soon_threadsafe(callback, r.lines)
|
||||
except Exception as e:
|
||||
raise Exception(f"Read event file failed: {file}") from e
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
@async_loop_forever(scan_interval_seconds, cancellable=True)
|
||||
async def _scan_event_log_files():
|
||||
# Scan event files.
|
||||
source_files = await loop.run_in_executor(
|
||||
monitor_thread_pool_executor,
|
||||
_get_source_files,
|
||||
event_dir,
|
||||
source_types,
|
||||
_source_file_filter,
|
||||
)
|
||||
|
||||
# Limit concurrent read to avoid fd exhaustion.
|
||||
semaphore = asyncio.Semaphore(event_consts.CONCURRENT_READ_LIMIT)
|
||||
|
||||
async def _concurrent_coro(filename):
|
||||
async with semaphore:
|
||||
return await loop.run_in_executor(
|
||||
monitor_thread_pool_executor, _read_monitor_file, filename, 0
|
||||
)
|
||||
|
||||
# Read files.
|
||||
await asyncio.gather(
|
||||
*[
|
||||
_concurrent_coro(filename)
|
||||
for filename in list(itertools.chain(*source_files.values()))
|
||||
]
|
||||
)
|
||||
|
||||
return run_background_task(_scan_event_log_files())
|
||||
@@ -0,0 +1,780 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from pprint import pprint
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
import ray
|
||||
from ray._common.test_utils import wait_for_condition
|
||||
from ray._common.utils import binary_to_hex
|
||||
from ray._private.event.event_logger import (
|
||||
EventLoggerAdapter,
|
||||
filter_event_by_level,
|
||||
get_event_id,
|
||||
get_event_logger,
|
||||
)
|
||||
from ray._private.event.export_event_logger import (
|
||||
EventLogType,
|
||||
ExportEventLoggerAdapter,
|
||||
get_export_event_logger,
|
||||
)
|
||||
from ray._private.protobuf_compat import message_to_dict
|
||||
from ray._private.state_api_test_utils import create_api_options, verify_schema
|
||||
from ray._private.test_utils import (
|
||||
format_web_url,
|
||||
wait_until_server_available,
|
||||
)
|
||||
from ray.cluster_utils import AutoscalingCluster
|
||||
from ray.core.generated import (
|
||||
event_pb2,
|
||||
export_submission_job_event_pb2,
|
||||
)
|
||||
from ray.dashboard.modules.event import event_consts
|
||||
from ray.dashboard.modules.event.event_head import _list_cluster_events_impl
|
||||
from ray.dashboard.modules.event.event_utils import monitor_events
|
||||
from ray.dashboard.tests.conftest import * # noqa
|
||||
from ray.job_submission import JobSubmissionClient
|
||||
from ray.util.state import list_cluster_events
|
||||
from ray.util.state.common import ClusterEventState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_event(msg="empty message", job_id=None, source_type=None):
|
||||
return {
|
||||
"event_id": binary_to_hex(np.random.bytes(18)),
|
||||
"source_type": (
|
||||
random.choice(event_pb2.Event.SourceType.keys())
|
||||
if source_type is None
|
||||
else source_type
|
||||
),
|
||||
"host_name": "po-dev.inc.alipay.net",
|
||||
"pid": random.randint(1, 65536),
|
||||
"label": "",
|
||||
"message": msg,
|
||||
"timestamp": time.time(),
|
||||
"severity": "INFO",
|
||||
"custom_fields": {
|
||||
"job_id": (
|
||||
ray.JobID.from_int(random.randint(1, 100)).hex()
|
||||
if job_id is None
|
||||
else job_id
|
||||
),
|
||||
"node_id": "",
|
||||
"task_id": "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _test_logger(name, log_file, max_bytes, backup_count):
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_file, maxBytes=max_bytes, backupCount=backup_count
|
||||
)
|
||||
formatter = logging.Formatter("%(message)s")
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
logger = logging.getLogger(name)
|
||||
logger.propagate = False
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.addHandler(handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def test_python_global_event_logger(tmp_path):
|
||||
logger = get_event_logger(event_pb2.Event.SourceType.GCS, str(tmp_path))
|
||||
logger.set_global_context({"test_meta": "1"})
|
||||
logger.info("message", a="a", b="b")
|
||||
logger.error("message", a="a", b="b")
|
||||
logger.warning("message", a="a", b="b")
|
||||
logger.fatal("message", a="a", b="b")
|
||||
event_dir = tmp_path / "events"
|
||||
assert event_dir.exists()
|
||||
event_file = event_dir / "event_GCS.log"
|
||||
assert event_file.exists()
|
||||
|
||||
line_severities = ["INFO", "ERROR", "WARNING", "FATAL"]
|
||||
|
||||
with event_file.open() as f:
|
||||
for line, severity in zip(f.readlines(), line_severities):
|
||||
data = json.loads(line)
|
||||
assert data["severity"] == severity
|
||||
assert data["label"] == ""
|
||||
assert "timestamp" in data
|
||||
assert len(data["event_id"]) == 36
|
||||
assert data["message"] == "message"
|
||||
assert data["source_type"] == "GCS"
|
||||
assert data["source_hostname"] == socket.gethostname()
|
||||
assert data["source_pid"] == os.getpid()
|
||||
assert data["custom_fields"]["a"] == "a"
|
||||
assert data["custom_fields"]["b"] == "b"
|
||||
|
||||
|
||||
def test_event_basic(disable_aiohttp_cache, ray_start_with_dashboard):
|
||||
assert wait_until_server_available(ray_start_with_dashboard["webui_url"])
|
||||
webui_url = format_web_url(ray_start_with_dashboard["webui_url"])
|
||||
session_dir = ray_start_with_dashboard["session_dir"]
|
||||
event_dir = os.path.join(session_dir, "logs", "events")
|
||||
job_id = ray.JobID.from_int(100).hex()
|
||||
|
||||
source_type_gcs = event_pb2.Event.SourceType.Name(event_pb2.Event.GCS)
|
||||
source_type_raylet = event_pb2.Event.SourceType.Name(event_pb2.Event.RAYLET)
|
||||
test_count = 20
|
||||
|
||||
for source_type in [source_type_gcs, source_type_raylet]:
|
||||
test_log_file = os.path.join(event_dir, f"event_{source_type}.log")
|
||||
test_logger = _test_logger(
|
||||
__name__ + str(random.random()),
|
||||
test_log_file,
|
||||
max_bytes=2000,
|
||||
backup_count=0,
|
||||
)
|
||||
for i in range(test_count):
|
||||
sample_event = _get_event(str(i), job_id=job_id, source_type=source_type)
|
||||
test_logger.info("%s", json.dumps(sample_event))
|
||||
|
||||
def _check_events():
|
||||
try:
|
||||
resp = requests.get(f"{webui_url}/events")
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
all_events = result["data"]["events"]
|
||||
job_events = all_events[job_id]
|
||||
assert len(job_events) >= test_count * 2
|
||||
source_messages = {}
|
||||
for e in job_events:
|
||||
source_type = e["sourceType"]
|
||||
message = e["message"]
|
||||
source_messages.setdefault(source_type, set()).add(message)
|
||||
assert len(source_messages[source_type_gcs]) >= test_count
|
||||
assert len(source_messages[source_type_raylet]) >= test_count
|
||||
data = {str(i) for i in range(test_count)}
|
||||
assert data & source_messages[source_type_gcs] == data
|
||||
assert data & source_messages[source_type_raylet] == data
|
||||
return True
|
||||
except Exception as ex:
|
||||
logger.exception(ex)
|
||||
return False
|
||||
|
||||
wait_for_condition(_check_events, timeout=15)
|
||||
|
||||
|
||||
def test_event_message_limit(
|
||||
small_event_line_limit, disable_aiohttp_cache, ray_start_with_dashboard
|
||||
):
|
||||
event_read_line_length_limit = small_event_line_limit
|
||||
assert wait_until_server_available(ray_start_with_dashboard["webui_url"])
|
||||
webui_url = format_web_url(ray_start_with_dashboard["webui_url"])
|
||||
session_dir = ray_start_with_dashboard["session_dir"]
|
||||
event_dir = os.path.join(session_dir, "logs", "events")
|
||||
job_id = ray.JobID.from_int(100).hex()
|
||||
events = []
|
||||
# Sample event equals with limit.
|
||||
sample_event = _get_event("", job_id=job_id)
|
||||
message_len = event_read_line_length_limit - len(json.dumps(sample_event))
|
||||
for i in range(10):
|
||||
sample_event = copy.deepcopy(sample_event)
|
||||
sample_event["event_id"] = binary_to_hex(np.random.bytes(18))
|
||||
sample_event["message"] = str(i) * message_len
|
||||
assert len(json.dumps(sample_event)) == event_read_line_length_limit
|
||||
events.append(sample_event)
|
||||
# Sample event longer than limit.
|
||||
sample_event = copy.deepcopy(sample_event)
|
||||
sample_event["event_id"] = binary_to_hex(np.random.bytes(18))
|
||||
sample_event["message"] = "2" * (message_len + 1)
|
||||
assert len(json.dumps(sample_event)) > event_read_line_length_limit
|
||||
events.append(sample_event)
|
||||
|
||||
for i in range(event_consts.EVENT_READ_LINE_COUNT_LIMIT):
|
||||
events.append(_get_event(str(i), job_id=job_id))
|
||||
|
||||
with open(os.path.join(event_dir, "tmp.log"), "w") as f:
|
||||
f.writelines([(json.dumps(e) + "\n") for e in events])
|
||||
|
||||
try:
|
||||
os.remove(os.path.join(event_dir, "event_GCS.log"))
|
||||
except Exception:
|
||||
pass
|
||||
os.rename(
|
||||
os.path.join(event_dir, "tmp.log"), os.path.join(event_dir, "event_GCS.log")
|
||||
)
|
||||
|
||||
def _check_events():
|
||||
try:
|
||||
resp = requests.get(f"{webui_url}/events")
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
all_events = result["data"]["events"]
|
||||
assert (
|
||||
len(all_events[job_id]) >= event_consts.EVENT_READ_LINE_COUNT_LIMIT + 10
|
||||
)
|
||||
messages = [e["message"] for e in all_events[job_id]]
|
||||
for i in range(10):
|
||||
assert str(i) * message_len in messages
|
||||
assert "2" * (message_len + 1) not in messages
|
||||
assert str(event_consts.EVENT_READ_LINE_COUNT_LIMIT - 1) in messages
|
||||
return True
|
||||
except Exception as ex:
|
||||
logger.exception(ex)
|
||||
return False
|
||||
|
||||
wait_for_condition(_check_events, timeout=15)
|
||||
|
||||
|
||||
def test_report_events(ray_start_with_dashboard):
|
||||
assert wait_until_server_available(ray_start_with_dashboard["webui_url"])
|
||||
webui_url = format_web_url(ray_start_with_dashboard["webui_url"])
|
||||
url = f"{webui_url}/report_events"
|
||||
|
||||
resp = requests.post(url)
|
||||
assert resp.status_code == 400
|
||||
resp = requests.post(url, json={"Hello": "World"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
job_id = ray.JobID.from_int(100).hex()
|
||||
sample_event = _get_event("Hello", job_id=job_id)
|
||||
resp = requests.post(url, json=[json.dumps(sample_event)])
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = requests.get(f"{webui_url}/events")
|
||||
assert resp.status_code == 200
|
||||
result = resp.json()
|
||||
all_events = result["data"]["events"]
|
||||
assert len(all_events) == 1
|
||||
assert job_id in all_events
|
||||
assert len(all_events[job_id]) == 1
|
||||
assert all_events[job_id][0]["message"] == "Hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_monitor_events():
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
common = event_pb2.Event.SourceType.Name(event_pb2.Event.COMMON)
|
||||
common_log = os.path.join(temp_dir, f"event_{common}.log")
|
||||
test_logger = _test_logger(
|
||||
__name__ + str(random.random()), common_log, max_bytes=10, backup_count=0
|
||||
)
|
||||
test_events1 = []
|
||||
monitor_task = monitor_events(
|
||||
temp_dir, lambda x: test_events1.extend(x), None, scan_interval_seconds=0.01
|
||||
)
|
||||
assert not monitor_task.done()
|
||||
count = 10
|
||||
|
||||
async def _writer(*args, read_events, spin=True):
|
||||
for x in range(*args):
|
||||
test_logger.info("%s", x)
|
||||
if spin:
|
||||
while str(x) not in read_events:
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
async def _check_events(expect_events, read_events, timeout=10):
|
||||
start_time = time.time()
|
||||
while True:
|
||||
sorted_events = sorted(int(i) for i in read_events)
|
||||
sorted_events = [str(i) for i in sorted_events]
|
||||
if time.time() - start_time > timeout:
|
||||
raise TimeoutError(
|
||||
f"Timeout, read events: {sorted_events}, "
|
||||
f"expect events: {expect_events}"
|
||||
)
|
||||
if len(sorted_events) == len(expect_events):
|
||||
if sorted_events == expect_events:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await asyncio.gather(
|
||||
_writer(count, read_events=test_events1),
|
||||
_check_events([str(i) for i in range(count)], read_events=test_events1),
|
||||
)
|
||||
|
||||
monitor_task.cancel()
|
||||
test_events2 = []
|
||||
monitor_task = monitor_events(
|
||||
temp_dir, lambda x: test_events2.extend(x), None, scan_interval_seconds=0.1
|
||||
)
|
||||
|
||||
await _check_events([str(i) for i in range(count)], read_events=test_events2)
|
||||
|
||||
await _writer(count, count * 2, read_events=test_events2)
|
||||
await _check_events(
|
||||
[str(i) for i in range(count * 2)], read_events=test_events2
|
||||
)
|
||||
|
||||
log_file_count = len(os.listdir(temp_dir))
|
||||
|
||||
test_logger = _test_logger(
|
||||
__name__ + str(random.random()), common_log, max_bytes=1000, backup_count=0
|
||||
)
|
||||
assert len(os.listdir(temp_dir)) == log_file_count
|
||||
|
||||
await _writer(count * 2, count * 3, spin=False, read_events=test_events2)
|
||||
await _check_events(
|
||||
[str(i) for i in range(count * 3)], read_events=test_events2
|
||||
)
|
||||
await _writer(count * 3, count * 4, spin=False, read_events=test_events2)
|
||||
await _check_events(
|
||||
[str(i) for i in range(count * 4)], read_events=test_events2
|
||||
)
|
||||
|
||||
# Test cancel monitor task.
|
||||
monitor_task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await monitor_task
|
||||
assert monitor_task.done()
|
||||
|
||||
assert len(os.listdir(temp_dir)) == 1, "There should just be 1 event log"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("autoscaler_v2", [False, True], ids=["v1", "v2"])
|
||||
def test_autoscaler_cluster_events(autoscaler_v2, shutdown_only):
|
||||
cluster = AutoscalingCluster(
|
||||
head_resources={"CPU": 2},
|
||||
worker_node_types={
|
||||
"cpu_node": {
|
||||
"resources": {
|
||||
"CPU": 4,
|
||||
},
|
||||
"node_config": {},
|
||||
"min_workers": 0,
|
||||
"max_workers": 1,
|
||||
},
|
||||
"gpu_node": {
|
||||
"resources": {
|
||||
"CPU": 2,
|
||||
"GPU": 1,
|
||||
},
|
||||
"node_config": {},
|
||||
"min_workers": 0,
|
||||
"max_workers": 1,
|
||||
},
|
||||
},
|
||||
autoscaler_v2=autoscaler_v2,
|
||||
idle_timeout_minutes=1,
|
||||
)
|
||||
|
||||
try:
|
||||
cluster.start()
|
||||
ray.init("auto")
|
||||
|
||||
# Triggers the addition of a GPU node.
|
||||
@ray.remote(num_gpus=1)
|
||||
def f():
|
||||
print("gpu ok")
|
||||
|
||||
# Triggers the addition of a CPU node.
|
||||
@ray.remote(num_cpus=3)
|
||||
def g():
|
||||
print("cpu ok")
|
||||
|
||||
wait_for_condition(lambda: ray.cluster_resources()["CPU"] == 2)
|
||||
ray.get(f.remote())
|
||||
wait_for_condition(lambda: ray.cluster_resources()["CPU"] == 4)
|
||||
wait_for_condition(lambda: ray.cluster_resources()["GPU"] == 1)
|
||||
ray.get(g.remote())
|
||||
wait_for_condition(lambda: ray.cluster_resources()["CPU"] == 8)
|
||||
wait_for_condition(lambda: ray.cluster_resources()["GPU"] == 1)
|
||||
|
||||
# Trigger an infeasible task
|
||||
g.options(num_cpus=0, num_gpus=5).remote()
|
||||
|
||||
def verify():
|
||||
cluster_events = list_cluster_events()
|
||||
print(cluster_events)
|
||||
messages = {(e["message"], e["source_type"]) for e in cluster_events}
|
||||
if not autoscaler_v2:
|
||||
# With head node resources, we don't actually resized. So this event is
|
||||
# not really accurate.
|
||||
assert ("Resized to 2 CPUs.", "AUTOSCALER") in messages, cluster_events
|
||||
assert (
|
||||
"Adding 1 node(s) of type gpu_node.",
|
||||
"AUTOSCALER",
|
||||
) in messages, cluster_events
|
||||
assert (
|
||||
"Resized to 4 CPUs, 1 GPUs.",
|
||||
"AUTOSCALER",
|
||||
) in messages, cluster_events
|
||||
assert (
|
||||
"Adding 1 node(s) of type cpu_node.",
|
||||
"AUTOSCALER",
|
||||
) in messages, cluster_events
|
||||
assert (
|
||||
"Resized to 8 CPUs, 1 GPUs.",
|
||||
"AUTOSCALER",
|
||||
) in messages, cluster_events
|
||||
assert "No available node types can fulfill resource request" in "".join(
|
||||
[t[0] for t in messages]
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
wait_for_condition(verify, timeout=30)
|
||||
pprint(list_cluster_events())
|
||||
finally:
|
||||
ray.shutdown()
|
||||
cluster.shutdown()
|
||||
|
||||
|
||||
def test_filter_event_by_level(monkeypatch):
|
||||
def gen_event(level: str):
|
||||
return event_pb2.Event(
|
||||
source_type=event_pb2.Event.AUTOSCALER,
|
||||
severity=event_pb2.Event.Severity.Value(level),
|
||||
message=level,
|
||||
)
|
||||
|
||||
trace = gen_event("TRACE")
|
||||
debug = gen_event("DEBUG")
|
||||
info = gen_event("INFO")
|
||||
warning = gen_event("WARNING")
|
||||
error = gen_event("ERROR")
|
||||
fatal = gen_event("FATAL")
|
||||
|
||||
def assert_events_filtered(events, expected, filter_level):
|
||||
filtered = [e for e in events if filter_event_by_level(e, filter_level)]
|
||||
print(filtered)
|
||||
assert len(filtered) == len(expected)
|
||||
assert {e.message for e in filtered} == {e.message for e in expected}
|
||||
|
||||
events = [trace, debug, info, warning, error, fatal]
|
||||
assert_events_filtered(events, [], "TRACE")
|
||||
assert_events_filtered(events, [trace], "DEBUG")
|
||||
assert_events_filtered(events, [trace, debug], "INFO")
|
||||
assert_events_filtered(events, [trace, debug, info], "WARNING")
|
||||
assert_events_filtered(events, [trace, debug, info, warning], "ERROR")
|
||||
assert_events_filtered(events, [trace, debug, info, warning, error], "FATAL")
|
||||
|
||||
|
||||
def test_jobs_cluster_events(shutdown_only):
|
||||
ray.init()
|
||||
address = ray._private.worker._global_node.webui_url
|
||||
address = format_web_url(address)
|
||||
client = JobSubmissionClient(address)
|
||||
submission_id = client.submit_job(entrypoint="ls")
|
||||
|
||||
def verify():
|
||||
events = list_cluster_events()
|
||||
assert len(list_cluster_events()) == 2
|
||||
start_event = events[0]
|
||||
completed_event = events[1]
|
||||
|
||||
assert start_event["source_type"] == "JOBS"
|
||||
assert f"Started a ray job {submission_id}" in start_event["message"]
|
||||
assert start_event["severity"] == "INFO"
|
||||
assert completed_event["source_type"] == "JOBS"
|
||||
assert (
|
||||
f"Completed a ray job {submission_id} with a status SUCCEEDED."
|
||||
== completed_event["message"]
|
||||
)
|
||||
assert completed_event["severity"] == "INFO"
|
||||
return True
|
||||
|
||||
print("Test successful job run.")
|
||||
wait_for_condition(verify)
|
||||
pprint(list_cluster_events())
|
||||
|
||||
# Test the failure case. In this part, job fails because the runtime env
|
||||
# creation fails.
|
||||
submission_id = client.submit_job(
|
||||
entrypoint="ls",
|
||||
runtime_env={"pip": ["nonexistent_dep"]},
|
||||
)
|
||||
|
||||
def verify():
|
||||
events = list_cluster_events(detail=True)
|
||||
failed_events = []
|
||||
|
||||
for e in events:
|
||||
if (
|
||||
"submission_id" in e["custom_fields"]
|
||||
and e["custom_fields"]["submission_id"] == submission_id
|
||||
):
|
||||
failed_events.append(e)
|
||||
|
||||
assert len(failed_events) == 2
|
||||
failed_start = failed_events[0]
|
||||
failed_completed = failed_events[1]
|
||||
|
||||
assert failed_start["source_type"] == "JOBS"
|
||||
assert f"Started a ray job {submission_id}" in failed_start["message"]
|
||||
assert failed_completed["source_type"] == "JOBS"
|
||||
assert failed_completed["severity"] == "ERROR"
|
||||
assert (
|
||||
f"Completed a ray job {submission_id} with a status FAILED."
|
||||
in failed_completed["message"]
|
||||
)
|
||||
|
||||
# Make sure the error message is included.
|
||||
assert "ERROR: No matching distribution found" in failed_completed["message"]
|
||||
return True
|
||||
|
||||
print("Test failed (runtime_env failure) job run.")
|
||||
wait_for_condition(verify, timeout=30)
|
||||
pprint(list_cluster_events())
|
||||
|
||||
|
||||
def test_core_events(shutdown_only):
|
||||
# Test events recorded from core RAY_EVENT APIs.
|
||||
ray.init()
|
||||
|
||||
@ray.remote
|
||||
class Actor:
|
||||
def getpid(self):
|
||||
return os.getpid()
|
||||
|
||||
a = Actor.remote()
|
||||
pid = ray.get(a.getpid.remote())
|
||||
os.kill(pid, 9)
|
||||
s = time.time()
|
||||
|
||||
def verify():
|
||||
events = list_cluster_events(filters=[("source_type", "=", "RAYLET")])
|
||||
print(events)
|
||||
assert len(list_cluster_events()) == 1
|
||||
event = events[0]
|
||||
assert event["severity"] == "ERROR"
|
||||
datetime_str = event["time"]
|
||||
datetime_obj = datetime.strptime(datetime_str, "%Y-%m-%d %H:%M:%S")
|
||||
timestamp = time.mktime(datetime_obj.timetuple())
|
||||
|
||||
# Make sure timestamp is not incorrect. Add sufficient buffer (60 seconds)
|
||||
assert abs(timestamp - s) < 60
|
||||
assert (
|
||||
"A worker died or was killed while executing "
|
||||
"a task by an unexpected system error" in event["message"]
|
||||
)
|
||||
return True
|
||||
|
||||
wait_for_condition(verify)
|
||||
pprint(list_cluster_events())
|
||||
|
||||
|
||||
def test_cluster_events_retention(monkeypatch, shutdown_only):
|
||||
with monkeypatch.context() as m:
|
||||
# defer for 5s for the second node.
|
||||
# This will help the API not return until the node is killed.
|
||||
m.setenv("RAY_DASHBOARD_MAX_EVENTS_TO_CACHE", "10")
|
||||
ray.init()
|
||||
address = ray._private.worker._global_node.webui_url
|
||||
address = format_web_url(address)
|
||||
client = JobSubmissionClient(address)
|
||||
|
||||
submission_ids = []
|
||||
for _ in range(12):
|
||||
submission_ids.append(client.submit_job(entrypoint="ls"))
|
||||
print(submission_ids)
|
||||
|
||||
def verify():
|
||||
events = list_cluster_events()
|
||||
assert len(list_cluster_events()) == 10
|
||||
|
||||
messages = [event["message"] for event in events]
|
||||
|
||||
# Make sure the first two has been GC'ed.
|
||||
for m in messages:
|
||||
assert submission_ids[0] not in m
|
||||
assert submission_ids[1] not in m
|
||||
return True
|
||||
|
||||
wait_for_condition(verify)
|
||||
pprint(list_cluster_events())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_cluster_events_impl():
|
||||
executor = ThreadPoolExecutor(
|
||||
max_workers=1,
|
||||
thread_name_prefix="event_head_executor",
|
||||
)
|
||||
|
||||
event_id_1 = get_event_id()
|
||||
event_id_2 = get_event_id()
|
||||
events = {
|
||||
"job_1": {
|
||||
event_id_1: {
|
||||
"timestamp": 10,
|
||||
"severity": "DEBUG",
|
||||
"message": "a",
|
||||
"event_id": event_id_1,
|
||||
"source_type": "GCS",
|
||||
},
|
||||
event_id_2: {
|
||||
"timestamp": 10,
|
||||
"severity": "INFO",
|
||||
"message": "b",
|
||||
"event_id": event_id_2,
|
||||
"source_type": "GCS",
|
||||
},
|
||||
}
|
||||
}
|
||||
result = await _list_cluster_events_impl(
|
||||
all_events=events, executor=executor, option=create_api_options()
|
||||
)
|
||||
data = result.result
|
||||
data = data[0]
|
||||
verify_schema(ClusterEventState, data)
|
||||
assert result.total == 2
|
||||
|
||||
"""
|
||||
Test detail
|
||||
"""
|
||||
# TODO(sang)
|
||||
|
||||
"""
|
||||
Test limit
|
||||
"""
|
||||
assert len(result.result) == 2
|
||||
result = await _list_cluster_events_impl(
|
||||
all_events=events, executor=executor, option=create_api_options(limit=1)
|
||||
)
|
||||
data = result.result
|
||||
assert len(data) == 1
|
||||
assert result.total == 2
|
||||
|
||||
"""
|
||||
Test filters
|
||||
"""
|
||||
# If the column is not supported for filtering, it should raise an exception.
|
||||
with pytest.raises(ValueError):
|
||||
result = await _list_cluster_events_impl(
|
||||
all_events=events,
|
||||
executor=executor,
|
||||
option=create_api_options(filters=[("time", "=", "20")]),
|
||||
)
|
||||
result = await _list_cluster_events_impl(
|
||||
all_events=events,
|
||||
executor=executor,
|
||||
option=create_api_options(filters=[("severity", "=", "INFO")]),
|
||||
)
|
||||
assert len(result.result) == 1
|
||||
|
||||
|
||||
def test_export_event_logger(tmp_path):
|
||||
"""
|
||||
Unit test a mock export event of type ExportSubmissionJobEventData
|
||||
is correctly written to file. This doesn't events are correctly generated.
|
||||
"""
|
||||
logger = get_export_event_logger(EventLogType.SUBMISSION_JOB, str(tmp_path))
|
||||
ExportSubmissionJobEventData = (
|
||||
export_submission_job_event_pb2.ExportSubmissionJobEventData
|
||||
)
|
||||
event_data = ExportSubmissionJobEventData(
|
||||
submission_job_id="submission_job_id0",
|
||||
status=ExportSubmissionJobEventData.JobStatus.RUNNING,
|
||||
entrypoint="ls",
|
||||
metadata={},
|
||||
)
|
||||
logger.send_event(event_data)
|
||||
|
||||
event_dir = tmp_path / "export_events"
|
||||
assert event_dir.exists()
|
||||
event_file = event_dir / "event_EXPORT_SUBMISSION_JOB.log"
|
||||
assert event_file.exists()
|
||||
|
||||
with event_file.open() as f:
|
||||
lines = f.readlines()
|
||||
assert len(lines) == 1
|
||||
|
||||
line = lines[0]
|
||||
data = json.loads(line)
|
||||
assert data["source_type"] == "EXPORT_SUBMISSION_JOB"
|
||||
assert data["event_data"] == message_to_dict(
|
||||
event_data,
|
||||
always_print_fields_with_no_presence=True,
|
||||
preserving_proto_field_name=True,
|
||||
use_integers_for_enums=False,
|
||||
)
|
||||
|
||||
|
||||
def test_event_logger_flushes_all_handlers():
|
||||
mock_logger = MagicMock()
|
||||
handlers = [MagicMock() for _ in range(3)]
|
||||
mock_logger.handlers = handlers
|
||||
|
||||
adapter = EventLoggerAdapter(event_pb2.Event.GCS, mock_logger)
|
||||
adapter.info("message")
|
||||
|
||||
for handler in handlers:
|
||||
handler.flush.assert_called_once()
|
||||
|
||||
|
||||
def test_event_logger_allows_empty_handlers():
|
||||
mock_logger = MagicMock()
|
||||
mock_logger.handlers = []
|
||||
|
||||
adapter = EventLoggerAdapter(event_pb2.Event.GCS, mock_logger)
|
||||
adapter.info("message")
|
||||
|
||||
|
||||
def test_export_event_logger_flushes_all_handlers():
|
||||
mock_logger = MagicMock()
|
||||
handlers = [MagicMock() for _ in range(3)]
|
||||
mock_logger.handlers = handlers
|
||||
|
||||
adapter = ExportEventLoggerAdapter(EventLogType.SUBMISSION_JOB, mock_logger)
|
||||
event_data = export_submission_job_event_pb2.ExportSubmissionJobEventData(
|
||||
submission_job_id="submission_job_id0",
|
||||
status=(
|
||||
export_submission_job_event_pb2.ExportSubmissionJobEventData.JobStatus.RUNNING
|
||||
),
|
||||
entrypoint="ls",
|
||||
metadata={},
|
||||
)
|
||||
adapter.send_event(event_data)
|
||||
|
||||
for handler in handlers:
|
||||
handler.flush.assert_called_once()
|
||||
|
||||
|
||||
def test_export_event_logger_allows_empty_handlers():
|
||||
mock_logger = MagicMock()
|
||||
mock_logger.handlers = []
|
||||
|
||||
adapter = ExportEventLoggerAdapter(EventLogType.SUBMISSION_JOB, mock_logger)
|
||||
event_data = export_submission_job_event_pb2.ExportSubmissionJobEventData(
|
||||
submission_job_id="submission_job_id0",
|
||||
status=(
|
||||
export_submission_job_event_pb2.ExportSubmissionJobEventData.JobStatus.RUNNING
|
||||
),
|
||||
entrypoint="ls",
|
||||
metadata={},
|
||||
)
|
||||
adapter.send_event(event_data)
|
||||
|
||||
|
||||
def test_export_event_logger_continues_flushing_after_handler_error():
|
||||
mock_logger = MagicMock()
|
||||
handler1 = MagicMock()
|
||||
handler1.flush.side_effect = RuntimeError("flush failed")
|
||||
handler2 = MagicMock()
|
||||
mock_logger.handlers = [handler1, handler2]
|
||||
|
||||
adapter = ExportEventLoggerAdapter(EventLogType.SUBMISSION_JOB, mock_logger)
|
||||
event_data = export_submission_job_event_pb2.ExportSubmissionJobEventData(
|
||||
submission_job_id="submission_job_id0",
|
||||
status=(
|
||||
export_submission_job_event_pb2.ExportSubmissionJobEventData.JobStatus.RUNNING
|
||||
),
|
||||
entrypoint="ls",
|
||||
metadata={},
|
||||
)
|
||||
adapter.send_event(event_data)
|
||||
|
||||
handler2.flush.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,66 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray._common.test_utils import wait_for_condition
|
||||
from ray._private.test_utils import wait_until_server_available
|
||||
from ray.dashboard.tests.conftest import * # noqa
|
||||
|
||||
os.environ["RAY_enable_export_api_write"] = "1"
|
||||
os.environ["RAY_enable_core_worker_ray_event_to_aggregator"] = "0"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_labels(disable_aiohttp_cache, ray_start_with_dashboard):
|
||||
"""
|
||||
Test task events are correctly generated and written to file
|
||||
"""
|
||||
assert wait_until_server_available(ray_start_with_dashboard["webui_url"])
|
||||
export_event_path = os.path.join(
|
||||
ray_start_with_dashboard["session_dir"], "logs", "export_events"
|
||||
)
|
||||
|
||||
# A simple task to trigger the export event
|
||||
@ray.remote
|
||||
def hi_w00t_task():
|
||||
return 1
|
||||
|
||||
ray.get(hi_w00t_task.options(_labels={"hi": "w00t"}).remote())
|
||||
|
||||
def _verify():
|
||||
# Verify export events are written
|
||||
events = []
|
||||
for filename in os.listdir(export_event_path):
|
||||
if not filename.startswith("event_EXPORT_TASK"):
|
||||
continue
|
||||
with open(f"{export_event_path}/{filename}", "r") as f:
|
||||
for line in f.readlines():
|
||||
events.append(json.loads(line))
|
||||
|
||||
hi_w00t_event = next(
|
||||
(
|
||||
event
|
||||
for event in events
|
||||
if event["source_type"] == "EXPORT_TASK"
|
||||
and event["event_data"].get("task_info", {}).get("func_or_class_name")
|
||||
== "hi_w00t_task"
|
||||
),
|
||||
None,
|
||||
)
|
||||
return (
|
||||
hi_w00t_event is not None
|
||||
and hi_w00t_event["event_data"]
|
||||
.get("task_info", {})
|
||||
.get("labels", {})
|
||||
.get("hi")
|
||||
== "w00t"
|
||||
)
|
||||
|
||||
wait_for_condition(_verify, timeout=30)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,197 @@
|
||||
# isort: skip_file
|
||||
# ruff: noqa: E402
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
# RAY_enable_export_api_write_config env var must be set before importing
|
||||
# `ray` so the correct value is set for RAY_ENABLE_EXPORT_API_WRITE_CONFIG
|
||||
# even outside a Ray driver.
|
||||
os.environ["RAY_enable_export_api_write_config"] = "EXPORT_SUBMISSION_JOB"
|
||||
|
||||
import ray
|
||||
from ray._common.test_utils import async_wait_for_condition
|
||||
from ray.dashboard.modules.job.job_manager import JobManager
|
||||
from ray.job_submission import JobStatus
|
||||
from ray.tests.conftest import call_ray_start # noqa: F401
|
||||
|
||||
|
||||
async def check_job_succeeded(job_manager, job_id):
|
||||
data = await job_manager.get_job_info(job_id)
|
||||
status = data.status
|
||||
if status == JobStatus.FAILED:
|
||||
raise RuntimeError(f"Job failed! {data.message}")
|
||||
assert status in {JobStatus.PENDING, JobStatus.RUNNING, JobStatus.SUCCEEDED}
|
||||
if status == JobStatus.SUCCEEDED:
|
||||
assert data.driver_exit_code == 0
|
||||
else:
|
||||
assert data.driver_exit_code is None
|
||||
return status == JobStatus.SUCCEEDED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"call_ray_start",
|
||||
[
|
||||
{
|
||||
"env": {
|
||||
"RAY_enable_export_api_write_config": "EXPORT_SUBMISSION_JOB,EXPORT_TASK",
|
||||
},
|
||||
"cmd": "ray start --head",
|
||||
}
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_check_export_api_enabled(call_ray_start, tmp_path): # noqa: F811
|
||||
"""
|
||||
Test check_export_api_enabled is True for EXPORT_SUBMISSION_JOB and EXPORT_TASK but
|
||||
not for EXPORT_ACTOR because of the value of RAY_enable_export_api_write_config.
|
||||
"""
|
||||
|
||||
@ray.remote
|
||||
def test_check_export_api_enabled_remote():
|
||||
from ray._private.event.export_event_logger import check_export_api_enabled
|
||||
from ray.core.generated.export_event_pb2 import ExportEvent
|
||||
|
||||
success = True
|
||||
success = success and check_export_api_enabled(
|
||||
ExportEvent.SourceType.EXPORT_SUBMISSION_JOB
|
||||
)
|
||||
success = success and check_export_api_enabled(
|
||||
ExportEvent.SourceType.EXPORT_TASK
|
||||
)
|
||||
success = success and (
|
||||
not check_export_api_enabled(ExportEvent.SourceType.EXPORT_ACTOR)
|
||||
)
|
||||
return success
|
||||
|
||||
assert ray.get(test_check_export_api_enabled_remote.remote())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"call_ray_start",
|
||||
[
|
||||
{
|
||||
"env": {
|
||||
"RAY_enable_export_api_write": "true",
|
||||
},
|
||||
"cmd": "ray start --head",
|
||||
}
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_check_export_api_enabled_global(call_ray_start, tmp_path): # noqa: F811
|
||||
"""
|
||||
Test check_export_api_enabled always returns True because RAY_enable_export_api_write
|
||||
is set to True.
|
||||
"""
|
||||
|
||||
@ray.remote
|
||||
def test_check_export_api_enabled_remote():
|
||||
from ray._private.event.export_event_logger import check_export_api_enabled
|
||||
from ray.core.generated.export_event_pb2 import ExportEvent
|
||||
|
||||
success = True
|
||||
success = success and check_export_api_enabled(
|
||||
ExportEvent.SourceType.EXPORT_SUBMISSION_JOB
|
||||
)
|
||||
success = success and check_export_api_enabled(
|
||||
ExportEvent.SourceType.EXPORT_ACTOR
|
||||
)
|
||||
return success
|
||||
|
||||
assert ray.get(test_check_export_api_enabled_remote.remote())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"call_ray_start",
|
||||
[
|
||||
{
|
||||
"env": {
|
||||
"RAY_enable_export_api_write_config": "invalid source type",
|
||||
},
|
||||
"cmd": "ray start --head",
|
||||
}
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_check_export_api_empty_config(call_ray_start, tmp_path): # noqa: F811
|
||||
"""
|
||||
Test check_export_api_enabled is False for all sources because
|
||||
RAY_enable_export_api_write_config is not a vaild source type.
|
||||
"""
|
||||
|
||||
@ray.remote
|
||||
def test_check_export_api_enabled_remote():
|
||||
from ray._private.event.export_event_logger import check_export_api_enabled
|
||||
from ray.core.generated.export_event_pb2 import ExportEvent
|
||||
|
||||
success = True
|
||||
success = success and not (
|
||||
check_export_api_enabled(ExportEvent.SourceType.EXPORT_SUBMISSION_JOB)
|
||||
)
|
||||
success = success and (
|
||||
not check_export_api_enabled(ExportEvent.SourceType.EXPORT_ACTOR)
|
||||
)
|
||||
return success
|
||||
|
||||
assert ray.get(test_check_export_api_enabled_remote.remote())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"call_ray_start",
|
||||
[
|
||||
{
|
||||
"env": {
|
||||
"RAY_enable_export_api_write_config": "EXPORT_SUBMISSION_JOB",
|
||||
},
|
||||
"cmd": "ray start --head",
|
||||
}
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_submission_job_export_events(call_ray_start, tmp_path): # noqa: F811
|
||||
"""
|
||||
Test submission job events are correctly generated and written to file
|
||||
as the job goes through various state changes in its lifecycle.
|
||||
"""
|
||||
|
||||
ray.init(address=call_ray_start)
|
||||
gcs_client = ray._private.worker.global_worker.gcs_client
|
||||
job_manager = JobManager(gcs_client, tmp_path)
|
||||
|
||||
# Submit a job.
|
||||
submission_id = await job_manager.submit_job(
|
||||
entrypoint="ls",
|
||||
)
|
||||
|
||||
# Wait for the job to be finished.
|
||||
await async_wait_for_condition(
|
||||
check_job_succeeded, job_manager=job_manager, job_id=submission_id
|
||||
)
|
||||
|
||||
# Verify export events are written
|
||||
event_dir = f"{tmp_path}/export_events"
|
||||
assert os.path.isdir(event_dir)
|
||||
event_file = f"{event_dir}/event_EXPORT_SUBMISSION_JOB.log"
|
||||
assert os.path.isfile(event_file)
|
||||
|
||||
with open(event_file, "r") as f:
|
||||
lines = f.readlines()
|
||||
assert len(lines) == 3
|
||||
expected_status_values = ["PENDING", "RUNNING", "SUCCEEDED"]
|
||||
|
||||
for line, expected_status in zip(lines, expected_status_values):
|
||||
data = json.loads(line)
|
||||
assert data["source_type"] == "EXPORT_SUBMISSION_JOB"
|
||||
assert data["event_data"]["submission_job_id"] == submission_id
|
||||
assert data["event_data"]["status"] == expected_status
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,600 @@
|
||||
import json
|
||||
import os
|
||||
import pprint
|
||||
import shlex
|
||||
import sys
|
||||
import time
|
||||
from subprocess import list2cmdline
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
|
||||
import click
|
||||
|
||||
import ray._private.ray_constants as ray_constants
|
||||
from ray._common.utils import (
|
||||
get_or_create_event_loop,
|
||||
load_class,
|
||||
)
|
||||
from ray._private.utils import (
|
||||
parse_metadata_json,
|
||||
parse_resources_json,
|
||||
)
|
||||
from ray.autoscaler._private.cli_logger import add_click_logging_options, cf, cli_logger
|
||||
from ray.dashboard.modules.dashboard_sdk import parse_runtime_env_args
|
||||
from ray.dashboard.modules.job.cli_utils import add_common_job_options
|
||||
from ray.dashboard.modules.job.utils import redact_url_password
|
||||
from ray.job_submission import JobStatus, JobSubmissionClient
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
|
||||
def _get_sdk_client(
|
||||
address: Optional[str],
|
||||
create_cluster_if_needed: bool = False,
|
||||
headers: Optional[str] = None,
|
||||
verify: Union[bool, str] = True,
|
||||
) -> JobSubmissionClient:
|
||||
client = JobSubmissionClient(
|
||||
address,
|
||||
create_cluster_if_needed,
|
||||
headers=_handle_headers(headers),
|
||||
verify=verify,
|
||||
)
|
||||
client_address = client.get_address()
|
||||
cli_logger.labeled_value(
|
||||
"Job submission server address", redact_url_password(client_address)
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
def _handle_headers(headers: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
if headers is None and "RAY_JOB_HEADERS" in os.environ:
|
||||
headers = os.environ["RAY_JOB_HEADERS"]
|
||||
if headers is not None:
|
||||
try:
|
||||
return json.loads(headers)
|
||||
except Exception as exc:
|
||||
raise ValueError(
|
||||
"""Failed to parse headers into JSON.
|
||||
Expected format: {{"KEY": "VALUE"}}, got {}, {}""".format(
|
||||
headers, exc
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _log_big_success_msg(success_msg):
|
||||
cli_logger.newline()
|
||||
cli_logger.success("-" * len(success_msg))
|
||||
cli_logger.success(success_msg)
|
||||
cli_logger.success("-" * len(success_msg))
|
||||
cli_logger.newline()
|
||||
|
||||
|
||||
def _log_big_error_msg(success_msg):
|
||||
cli_logger.newline()
|
||||
cli_logger.error("-" * len(success_msg))
|
||||
cli_logger.error(success_msg)
|
||||
cli_logger.error("-" * len(success_msg))
|
||||
cli_logger.newline()
|
||||
|
||||
|
||||
def _log_job_status(client: JobSubmissionClient, job_id: str) -> JobStatus:
|
||||
info = client.get_job_info(job_id)
|
||||
if info.status == JobStatus.SUCCEEDED:
|
||||
_log_big_success_msg(f"Job '{job_id}' succeeded")
|
||||
elif info.status == JobStatus.STOPPED:
|
||||
cli_logger.warning(f"Job '{job_id}' was stopped")
|
||||
elif info.status == JobStatus.FAILED:
|
||||
_log_big_error_msg(f"Job '{job_id}' failed")
|
||||
if info.message is not None:
|
||||
cli_logger.print(f"Status message: {info.message}", no_format=True)
|
||||
else:
|
||||
# Catch-all.
|
||||
cli_logger.print(f"Status for job '{job_id}': {info.status}")
|
||||
if info.message is not None:
|
||||
cli_logger.print(f"Status message: {info.message}", no_format=True)
|
||||
return info.status
|
||||
|
||||
|
||||
async def _tail_logs(client: JobSubmissionClient, job_id: str) -> JobStatus:
|
||||
async for lines in client.tail_job_logs(job_id):
|
||||
print(lines, end="")
|
||||
|
||||
return _log_job_status(client, job_id)
|
||||
|
||||
|
||||
@click.group("job")
|
||||
def job_cli_group():
|
||||
"""Submit, stop, delete, or list Ray jobs."""
|
||||
pass
|
||||
|
||||
|
||||
@job_cli_group.command()
|
||||
@click.option(
|
||||
"--address",
|
||||
type=str,
|
||||
default=None,
|
||||
required=False,
|
||||
help=(
|
||||
"Address of the Ray cluster to connect to. Can also be specified "
|
||||
"using the RAY_API_SERVER_ADDRESS environment variable (falls back to RAY_ADDRESS)."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--job-id",
|
||||
type=str,
|
||||
default=None,
|
||||
required=False,
|
||||
help=("DEPRECATED: Use `--submission-id` instead."),
|
||||
)
|
||||
@click.option(
|
||||
"--submission-id",
|
||||
type=str,
|
||||
default=None,
|
||||
required=False,
|
||||
help=(
|
||||
"Submission ID to specify for the job. If not provided, one will be generated."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--runtime-env",
|
||||
type=str,
|
||||
default=None,
|
||||
required=False,
|
||||
help="Path to a local YAML file containing a runtime_env definition.",
|
||||
)
|
||||
@click.option(
|
||||
"--runtime-env-json",
|
||||
type=str,
|
||||
default=None,
|
||||
required=False,
|
||||
help="JSON-serialized runtime_env dictionary.",
|
||||
)
|
||||
@click.option(
|
||||
"--working-dir",
|
||||
type=str,
|
||||
default=None,
|
||||
required=False,
|
||||
help=(
|
||||
"Directory containing files that your job will run in. Can be a "
|
||||
"local directory or a remote URI to a .zip file (S3, GS, HTTP). "
|
||||
"If specified, this overrides the option in `--runtime-env`."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--metadata-json",
|
||||
type=str,
|
||||
default=None,
|
||||
required=False,
|
||||
help="JSON-serialized dictionary of metadata to attach to the job.",
|
||||
)
|
||||
@click.option(
|
||||
"--entrypoint-num-cpus",
|
||||
required=False,
|
||||
type=float,
|
||||
help="the quantity of CPU cores to reserve for the entrypoint command, "
|
||||
"separately from any tasks or actors that are launched by it",
|
||||
)
|
||||
@click.option(
|
||||
"--entrypoint-num-gpus",
|
||||
required=False,
|
||||
type=float,
|
||||
help="the quantity of GPUs to reserve for the entrypoint command, "
|
||||
"separately from any tasks or actors that are launched by it",
|
||||
)
|
||||
@click.option(
|
||||
"--entrypoint-memory",
|
||||
required=False,
|
||||
type=int,
|
||||
help="the amount of memory to reserve "
|
||||
"for the entrypoint command, separately from any tasks or actors that are "
|
||||
"launched by it",
|
||||
)
|
||||
@click.option(
|
||||
"--entrypoint-resources",
|
||||
required=False,
|
||||
type=str,
|
||||
help="a JSON-serialized dictionary mapping resource name to resource quantity "
|
||||
"describing resources to reserve for the entrypoint command, "
|
||||
"separately from any tasks or actors that are launched by it",
|
||||
)
|
||||
@click.option(
|
||||
"--entrypoint-label-selector",
|
||||
required=False,
|
||||
type=str,
|
||||
help="a JSON-serialized dictionary mapping label keys to selector strings "
|
||||
"describing placement constraints for the entrypoint command",
|
||||
)
|
||||
@click.option(
|
||||
"--no-wait",
|
||||
is_flag=True,
|
||||
type=bool,
|
||||
default=False,
|
||||
help="If set, will not stream logs and wait for the job to exit.",
|
||||
)
|
||||
@add_common_job_options
|
||||
@add_click_logging_options
|
||||
@click.argument("entrypoint", nargs=-1, required=True, type=click.UNPROCESSED)
|
||||
@PublicAPI
|
||||
def submit(
|
||||
address: Optional[str],
|
||||
job_id: Optional[str],
|
||||
submission_id: Optional[str],
|
||||
runtime_env: Optional[str],
|
||||
runtime_env_json: Optional[str],
|
||||
metadata_json: Optional[str],
|
||||
working_dir: Optional[str],
|
||||
entrypoint: Tuple[str],
|
||||
entrypoint_num_cpus: Optional[Union[int, float]],
|
||||
entrypoint_num_gpus: Optional[Union[int, float]],
|
||||
entrypoint_memory: Optional[int],
|
||||
entrypoint_resources: Optional[str],
|
||||
entrypoint_label_selector: Optional[str],
|
||||
no_wait: bool,
|
||||
verify: Union[bool, str],
|
||||
headers: Optional[str],
|
||||
):
|
||||
"""Submits a job to be run on the cluster.
|
||||
|
||||
By default (if --no-wait is not set), streams logs to stdout until the job finishes.
|
||||
If the job succeeded, exits with 0. If it failed, exits with 1.
|
||||
|
||||
Example:
|
||||
`ray job submit -- python my_script.py --arg=val`
|
||||
|
||||
Args:
|
||||
address: Job submission server address.
|
||||
job_id: DEPRECATED. Use submission_id instead.
|
||||
submission_id: Submission ID for the job.
|
||||
runtime_env: Path to a runtime_env YAML file.
|
||||
runtime_env_json: JSON-serialized runtime_env dictionary.
|
||||
metadata_json: JSON-serialized metadata dictionary.
|
||||
working_dir: Working directory for the job.
|
||||
entrypoint: Entrypoint command.
|
||||
entrypoint_num_cpus: CPU cores to reserve.
|
||||
entrypoint_num_gpus: GPUs to reserve.
|
||||
entrypoint_memory: Memory to reserve.
|
||||
entrypoint_resources: JSON-serialized custom resources dict.
|
||||
entrypoint_label_selector: JSON-serialized label selector dict.
|
||||
no_wait: Do not wait for job completion.
|
||||
verify: TLS verification flag or path.
|
||||
headers: JSON-serialized headers.
|
||||
"""
|
||||
if job_id:
|
||||
cli_logger.warning(
|
||||
"--job-id option is deprecated. Please use --submission-id instead."
|
||||
)
|
||||
if entrypoint_resources is not None:
|
||||
entrypoint_resources = parse_resources_json(
|
||||
entrypoint_resources, cli_logger, cf, command_arg="entrypoint-resources"
|
||||
)
|
||||
if entrypoint_label_selector is not None:
|
||||
entrypoint_label_selector = parse_resources_json(
|
||||
entrypoint_label_selector,
|
||||
cli_logger,
|
||||
cf,
|
||||
command_arg="entrypoint-label-selector",
|
||||
)
|
||||
if metadata_json is not None:
|
||||
metadata_json = parse_metadata_json(
|
||||
metadata_json, cli_logger, cf, command_arg="metadata-json"
|
||||
)
|
||||
|
||||
submission_id = submission_id or job_id
|
||||
|
||||
if ray_constants.RAY_JOB_SUBMIT_HOOK in os.environ:
|
||||
# Submit all args as **kwargs per the JOB_SUBMIT_HOOK contract.
|
||||
load_class(os.environ[ray_constants.RAY_JOB_SUBMIT_HOOK])(
|
||||
address=address,
|
||||
job_id=submission_id,
|
||||
submission_id=submission_id,
|
||||
runtime_env=runtime_env,
|
||||
runtime_env_json=runtime_env_json,
|
||||
metadata_json=metadata_json,
|
||||
working_dir=working_dir,
|
||||
entrypoint=entrypoint,
|
||||
entrypoint_num_cpus=entrypoint_num_cpus,
|
||||
entrypoint_num_gpus=entrypoint_num_gpus,
|
||||
entrypoint_memory=entrypoint_memory,
|
||||
entrypoint_resources=entrypoint_resources,
|
||||
entrypoint_label_selector=entrypoint_label_selector,
|
||||
no_wait=no_wait,
|
||||
)
|
||||
|
||||
client = _get_sdk_client(
|
||||
address, create_cluster_if_needed=True, headers=headers, verify=verify
|
||||
)
|
||||
|
||||
final_runtime_env = parse_runtime_env_args(
|
||||
runtime_env=runtime_env,
|
||||
runtime_env_json=runtime_env_json,
|
||||
working_dir=working_dir,
|
||||
)
|
||||
job_id = client.submit_job(
|
||||
entrypoint=(
|
||||
list2cmdline(entrypoint)
|
||||
if sys.platform == "win32"
|
||||
else shlex.join(entrypoint)
|
||||
),
|
||||
submission_id=submission_id,
|
||||
runtime_env=final_runtime_env,
|
||||
metadata=metadata_json,
|
||||
entrypoint_num_cpus=entrypoint_num_cpus,
|
||||
entrypoint_num_gpus=entrypoint_num_gpus,
|
||||
entrypoint_memory=entrypoint_memory,
|
||||
entrypoint_resources=entrypoint_resources,
|
||||
entrypoint_label_selector=entrypoint_label_selector,
|
||||
)
|
||||
|
||||
_log_big_success_msg(f"Job '{job_id}' submitted successfully")
|
||||
|
||||
with cli_logger.group("Next steps"):
|
||||
cli_logger.print("Query the logs of the job:")
|
||||
with cli_logger.indented():
|
||||
cli_logger.print(cf.bold(f"ray job logs {job_id}"))
|
||||
|
||||
cli_logger.print("Query the status of the job:")
|
||||
with cli_logger.indented():
|
||||
cli_logger.print(cf.bold(f"ray job status {job_id}"))
|
||||
|
||||
cli_logger.print("Request the job to be stopped:")
|
||||
with cli_logger.indented():
|
||||
cli_logger.print(cf.bold(f"ray job stop {job_id}"))
|
||||
|
||||
cli_logger.newline()
|
||||
# Flush stdout to ensure the Ray job ID is output immediately
|
||||
# for the kubectl plugin, ref PR #52780, Issue kuberay/#3508.
|
||||
cli_logger.flush()
|
||||
sdk_version = client.get_version()
|
||||
# sdk version 0 does not have log streaming
|
||||
if not no_wait:
|
||||
if int(sdk_version) > 0:
|
||||
cli_logger.print(
|
||||
"Tailing logs until the job exits (disable with --no-wait):"
|
||||
)
|
||||
job_status = get_or_create_event_loop().run_until_complete(
|
||||
_tail_logs(client, job_id)
|
||||
)
|
||||
if job_status == JobStatus.FAILED:
|
||||
sys.exit(1)
|
||||
else:
|
||||
cli_logger.warning(
|
||||
"Tailing logs is not enabled for job sdk client version "
|
||||
f"{sdk_version}. Please upgrade Ray to the latest version "
|
||||
"for this feature."
|
||||
)
|
||||
|
||||
|
||||
@job_cli_group.command()
|
||||
@click.option(
|
||||
"--address",
|
||||
type=str,
|
||||
default=None,
|
||||
required=False,
|
||||
help=(
|
||||
"Address of the Ray cluster to connect to. Can also be specified "
|
||||
"using the RAY_API_SERVER_ADDRESS environment variable (falls back to RAY_ADDRESS)."
|
||||
),
|
||||
)
|
||||
@click.argument("job-id", type=str)
|
||||
@add_common_job_options
|
||||
@add_click_logging_options
|
||||
@PublicAPI(stability="stable")
|
||||
def status(
|
||||
address: Optional[str],
|
||||
job_id: str,
|
||||
headers: Optional[str],
|
||||
verify: Union[bool, str],
|
||||
):
|
||||
"""Queries for the current status of a job.
|
||||
|
||||
Example:
|
||||
`ray job status <my_job_id>`
|
||||
|
||||
Args:
|
||||
address: Address of the Ray cluster to connect to.
|
||||
job_id: The submission ID of the job to query.
|
||||
headers: JSON string of headers to attach to requests.
|
||||
verify: Path to a CA bundle, or boolean toggling TLS verification.
|
||||
"""
|
||||
client = _get_sdk_client(address, headers=headers, verify=verify)
|
||||
_log_job_status(client, job_id)
|
||||
|
||||
|
||||
@job_cli_group.command()
|
||||
@click.option(
|
||||
"--address",
|
||||
type=str,
|
||||
default=None,
|
||||
required=False,
|
||||
help=(
|
||||
"Address of the Ray cluster to connect to. Can also be specified "
|
||||
"using the RAY_API_SERVER_ADDRESS environment variable (falls back to RAY_ADDRESS)."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--no-wait",
|
||||
is_flag=True,
|
||||
type=bool,
|
||||
default=False,
|
||||
help="If set, will not wait for the job to exit.",
|
||||
)
|
||||
@click.argument("job-id", type=str)
|
||||
@add_common_job_options
|
||||
@add_click_logging_options
|
||||
@PublicAPI(stability="stable")
|
||||
def stop(
|
||||
address: Optional[str],
|
||||
no_wait: bool,
|
||||
job_id: str,
|
||||
headers: Optional[str],
|
||||
verify: Union[bool, str],
|
||||
):
|
||||
"""Attempts to stop a job.
|
||||
|
||||
Example:
|
||||
`ray job stop <my_job_id>`
|
||||
|
||||
Args:
|
||||
address: Address of the Ray cluster to connect to.
|
||||
no_wait: If True, return immediately instead of waiting for the job to reach a terminal state.
|
||||
job_id: The submission ID of the job to stop.
|
||||
headers: JSON string of headers to attach to requests.
|
||||
verify: Path to a CA bundle, or boolean toggling TLS verification.
|
||||
|
||||
Returns:
|
||||
None. The function returns early when ``no_wait`` is True; otherwise it
|
||||
polls until the job reaches a terminal state.
|
||||
"""
|
||||
client = _get_sdk_client(address, headers=headers, verify=verify)
|
||||
cli_logger.print(f"Attempting to stop job '{job_id}'")
|
||||
client.stop_job(job_id)
|
||||
|
||||
if no_wait:
|
||||
return
|
||||
else:
|
||||
cli_logger.print(
|
||||
f"Waiting for job '{job_id}' to exit (disable with --no-wait):"
|
||||
)
|
||||
|
||||
while True:
|
||||
status = client.get_job_status(job_id)
|
||||
if status in {JobStatus.STOPPED, JobStatus.SUCCEEDED, JobStatus.FAILED}:
|
||||
_log_job_status(client, job_id)
|
||||
break
|
||||
else:
|
||||
cli_logger.print(f"Job has not exited yet. Status: {status}")
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
@job_cli_group.command()
|
||||
@click.option(
|
||||
"--address",
|
||||
type=str,
|
||||
default=None,
|
||||
required=False,
|
||||
help=(
|
||||
"Address of the Ray cluster to connect to. Can also be specified "
|
||||
"using the RAY_API_SERVER_ADDRESS environment variable (falls back to RAY_ADDRESS)."
|
||||
),
|
||||
)
|
||||
@click.argument("job-id", type=str)
|
||||
@add_common_job_options
|
||||
@add_click_logging_options
|
||||
@PublicAPI(stability="stable")
|
||||
def delete(
|
||||
address: Optional[str],
|
||||
job_id: str,
|
||||
headers: Optional[str],
|
||||
verify: Union[bool, str],
|
||||
):
|
||||
"""Deletes a stopped job and its associated data from memory.
|
||||
|
||||
Only supported for jobs that are already in a terminal state.
|
||||
Fails with exit code 1 if the job is not already stopped.
|
||||
Does not delete job logs from disk.
|
||||
Submitting a job with the same submission ID as a previously
|
||||
deleted job is not supported and may lead to unexpected behavior.
|
||||
|
||||
Example:
|
||||
ray job delete <my_job_id>
|
||||
|
||||
Args:
|
||||
address: Address of the Ray cluster to connect to.
|
||||
job_id: The submission ID of the job to delete.
|
||||
headers: JSON string of headers to attach to requests.
|
||||
verify: Path to a CA bundle, or boolean toggling TLS verification.
|
||||
"""
|
||||
client = _get_sdk_client(address, headers=headers, verify=verify)
|
||||
client.delete_job(job_id)
|
||||
cli_logger.print(f"Job '{job_id}' deleted successfully")
|
||||
|
||||
|
||||
@job_cli_group.command()
|
||||
@click.option(
|
||||
"--address",
|
||||
type=str,
|
||||
default=None,
|
||||
required=False,
|
||||
help=(
|
||||
"Address of the Ray cluster to connect to. Can also be specified "
|
||||
"using the RAY_API_SERVER_ADDRESS environment variable (falls back to RAY_ADDRESS)."
|
||||
),
|
||||
)
|
||||
@click.argument("job-id", type=str)
|
||||
@click.option(
|
||||
"-f",
|
||||
"--follow",
|
||||
is_flag=True,
|
||||
type=bool,
|
||||
default=False,
|
||||
help="If set, follow the logs (like `tail -f`).",
|
||||
)
|
||||
@add_common_job_options
|
||||
@add_click_logging_options
|
||||
@PublicAPI(stability="stable")
|
||||
def logs(
|
||||
address: Optional[str],
|
||||
job_id: str,
|
||||
follow: bool,
|
||||
headers: Optional[str],
|
||||
verify: Union[bool, str],
|
||||
):
|
||||
"""Gets the logs of a job.
|
||||
|
||||
Example:
|
||||
`ray job logs <my_job_id>`
|
||||
|
||||
Args:
|
||||
address: Address of the Ray cluster to connect to.
|
||||
job_id: The submission ID of the job whose logs to fetch.
|
||||
follow: If True, stream the logs (``tail -f`` style) instead of printing them once.
|
||||
headers: JSON string of headers to attach to requests.
|
||||
verify: Path to a CA bundle, or boolean toggling TLS verification.
|
||||
"""
|
||||
client = _get_sdk_client(address, headers=headers, verify=verify)
|
||||
sdk_version = client.get_version()
|
||||
# sdk version 0 did not have log streaming
|
||||
if follow:
|
||||
if int(sdk_version) > 0:
|
||||
get_or_create_event_loop().run_until_complete(_tail_logs(client, job_id))
|
||||
else:
|
||||
cli_logger.warning(
|
||||
"Tailing logs is not enabled for the Jobs SDK client version "
|
||||
f"{sdk_version}. Please upgrade Ray to latest version "
|
||||
"for this feature."
|
||||
)
|
||||
else:
|
||||
# Set no_format to True because the logs may have unescaped "{" and "}"
|
||||
# and the CLILogger calls str.format().
|
||||
cli_logger.print(client.get_job_logs(job_id), end="", no_format=True)
|
||||
|
||||
|
||||
@job_cli_group.command()
|
||||
@click.option(
|
||||
"--address",
|
||||
type=str,
|
||||
default=None,
|
||||
required=False,
|
||||
help=(
|
||||
"Address of the Ray cluster to connect to. Can also be specified "
|
||||
"using the RAY_API_SERVER_ADDRESS environment variable (falls back to RAY_ADDRESS)."
|
||||
),
|
||||
)
|
||||
@add_common_job_options
|
||||
@add_click_logging_options
|
||||
@PublicAPI(stability="stable")
|
||||
def list(address: Optional[str], headers: Optional[str], verify: Union[bool, str]):
|
||||
"""Lists all running jobs and their information.
|
||||
|
||||
Example:
|
||||
`ray job list`
|
||||
|
||||
Args:
|
||||
address: Address of the Ray cluster to connect to.
|
||||
headers: JSON string of headers to attach to requests.
|
||||
verify: Path to a CA bundle, or boolean toggling TLS verification.
|
||||
"""
|
||||
client = _get_sdk_client(address, headers=headers, verify=verify)
|
||||
# Set no_format to True because the logs may have unescaped "{" and "}"
|
||||
# and the CLILogger calls str.format().
|
||||
cli_logger.print(pprint.pformat(client.list_jobs()), no_format=True)
|
||||
@@ -0,0 +1,56 @@
|
||||
import functools
|
||||
from typing import Union
|
||||
|
||||
import click
|
||||
|
||||
|
||||
def bool_cast(string: str) -> Union[bool, str]:
|
||||
"""Cast a string to a boolean if possible, otherwise return the string."""
|
||||
if string.lower() == "true" or string == "1":
|
||||
return True
|
||||
elif string.lower() == "false" or string == "0":
|
||||
return False
|
||||
else:
|
||||
return string
|
||||
|
||||
|
||||
class BoolOrStringParam(click.ParamType):
|
||||
"""A click parameter that can be either a boolean or a string."""
|
||||
|
||||
name = "BOOL | TEXT"
|
||||
|
||||
def convert(self, value, param, ctx):
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
else:
|
||||
return bool_cast(value)
|
||||
|
||||
|
||||
def add_common_job_options(func):
|
||||
"""Decorator for adding CLI flags shared by all `ray job` commands."""
|
||||
|
||||
@click.option(
|
||||
"--verify",
|
||||
default=True,
|
||||
show_default=True,
|
||||
type=BoolOrStringParam(),
|
||||
help=(
|
||||
"Boolean indication to verify the server's TLS certificate or a path to"
|
||||
" a file or directory of trusted certificates."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--headers",
|
||||
required=False,
|
||||
type=str,
|
||||
default=None,
|
||||
help=(
|
||||
"Used to pass headers through http/s to the Ray Cluster."
|
||||
'please follow JSON formatting formatting {"key": "value"}'
|
||||
),
|
||||
)
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
@@ -0,0 +1,599 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
|
||||
from ray._private import ray_constants
|
||||
from ray._private.event.export_event_logger import (
|
||||
EventLogType,
|
||||
check_export_api_enabled,
|
||||
get_export_event_logger,
|
||||
)
|
||||
from ray._private.runtime_env.packaging import parse_uri
|
||||
from ray._raylet import RAY_INTERNAL_NAMESPACE_PREFIX, GcsClient
|
||||
from ray.core.generated.export_event_pb2 import ExportEvent
|
||||
from ray.core.generated.export_submission_job_event_pb2 import (
|
||||
ExportSubmissionJobEventData,
|
||||
)
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
# NOTE(edoakes): these constants should be considered a public API because
|
||||
# they're exposed in the snapshot API.
|
||||
JOB_ID_METADATA_KEY = "job_submission_id"
|
||||
JOB_NAME_METADATA_KEY = "job_name"
|
||||
JOB_ACTOR_NAME_TEMPLATE = f"{RAY_INTERNAL_NAMESPACE_PREFIX}job_actor_" + "{job_id}"
|
||||
# In order to get information about SupervisorActors launched by different jobs,
|
||||
# they must be set to the same namespace.
|
||||
SUPERVISOR_ACTOR_RAY_NAMESPACE = "SUPERVISOR_ACTOR_RAY_NAMESPACE"
|
||||
JOB_LOGS_PATH_TEMPLATE = "job-driver-{submission_id}.log"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@PublicAPI(stability="stable")
|
||||
class JobStatus(str, Enum):
|
||||
"""An enumeration for describing the status of a job."""
|
||||
|
||||
#: The job has not started yet, likely waiting for the runtime_env to be set up.
|
||||
PENDING = "PENDING"
|
||||
#: The job is currently running.
|
||||
RUNNING = "RUNNING"
|
||||
#: The job was intentionally stopped by the user.
|
||||
STOPPED = "STOPPED"
|
||||
#: The job finished successfully.
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
#: The job failed.
|
||||
FAILED = "FAILED"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.value}"
|
||||
|
||||
def is_terminal(self) -> bool:
|
||||
"""Return whether or not this status is terminal.
|
||||
|
||||
A terminal status is one that cannot transition to any other status.
|
||||
The terminal statuses are "STOPPED", "SUCCEEDED", and "FAILED".
|
||||
|
||||
Returns:
|
||||
True if this status is terminal, otherwise False.
|
||||
"""
|
||||
return self.value in {"STOPPED", "SUCCEEDED", "FAILED"}
|
||||
|
||||
|
||||
@PublicAPI(stability="stable")
|
||||
class JobErrorType(str, Enum):
|
||||
"""An enumeration for describing the error type of a job."""
|
||||
|
||||
# Runtime environment failed to be set up
|
||||
RUNTIME_ENV_SETUP_FAILURE = "RUNTIME_ENV_SETUP_FAILURE"
|
||||
# Job supervisor actor launched, but job failed to start within timeout
|
||||
JOB_SUPERVISOR_ACTOR_START_TIMEOUT = "JOB_SUPERVISOR_ACTOR_START_TIMEOUT"
|
||||
# Job supervisor actor failed to start
|
||||
JOB_SUPERVISOR_ACTOR_START_FAILURE = "JOB_SUPERVISOR_ACTOR_START_FAILURE"
|
||||
# Job supervisor actor failed to be scheduled
|
||||
JOB_SUPERVISOR_ACTOR_UNSCHEDULABLE = "JOB_SUPERVISOR_ACTOR_UNSCHEDULABLE"
|
||||
# Job supervisor actor failed for unknown exception
|
||||
JOB_SUPERVISOR_ACTOR_UNKNOWN_FAILURE = "JOB_SUPERVISOR_ACTOR_UNKNOWN_FAILURE"
|
||||
# Job supervisor actor died
|
||||
JOB_SUPERVISOR_ACTOR_DIED = "JOB_SUPERVISOR_ACTOR_DIED"
|
||||
# Job driver script failed to start due to exception
|
||||
JOB_ENTRYPOINT_COMMAND_START_ERROR = "JOB_ENTRYPOINT_COMMAND_START_ERROR"
|
||||
# Job driver script failed due to non-zero exit code
|
||||
JOB_ENTRYPOINT_COMMAND_ERROR = "JOB_ENTRYPOINT_COMMAND_ERROR"
|
||||
|
||||
|
||||
# TODO(aguo): Convert to pydantic model
|
||||
@PublicAPI(stability="stable")
|
||||
@dataclass
|
||||
class JobInfo:
|
||||
"""A class for recording information associated with a job and its execution.
|
||||
|
||||
Please keep this in sync with the JobsAPIInfo proto in src/ray/protobuf/gcs.proto.
|
||||
"""
|
||||
|
||||
#: The status of the job.
|
||||
status: JobStatus
|
||||
#: The entrypoint command for this job.
|
||||
entrypoint: str
|
||||
#: A message describing the status in more detail.
|
||||
message: Optional[str] = None
|
||||
#: Internal error, user script error
|
||||
error_type: Optional[JobErrorType] = None
|
||||
#: The time when the job was started. A Unix timestamp in ms.
|
||||
start_time: Optional[int] = None
|
||||
#: The time when the job moved into a terminal state. A Unix timestamp in ms.
|
||||
end_time: Optional[int] = None
|
||||
#: Arbitrary user-provided metadata for the job.
|
||||
metadata: Optional[Dict[str, str]] = None
|
||||
#: The runtime environment for the job.
|
||||
runtime_env: Optional[Dict[str, Any]] = None
|
||||
#: The quantity of CPU cores to reserve for the entrypoint command.
|
||||
entrypoint_num_cpus: Optional[Union[int, float]] = None
|
||||
#: The number of GPUs to reserve for the entrypoint command.
|
||||
entrypoint_num_gpus: Optional[Union[int, float]] = None
|
||||
#: The amount of memory for workers requesting memory for the entrypoint command.
|
||||
entrypoint_memory: Optional[int] = None
|
||||
#: The quantity of various custom resources to reserve for the entrypoint command.
|
||||
entrypoint_resources: Optional[Dict[str, float]] = None
|
||||
#: Driver agent http address
|
||||
driver_agent_http_address: Optional[str] = None
|
||||
#: The node id that driver running on. It will be None only when the job status
|
||||
# is PENDING, and this field will not be deleted or modified even if the driver dies
|
||||
driver_node_id: Optional[str] = None
|
||||
#: The driver process exit code after the driver executed. Return None if driver
|
||||
#: doesn't finish executing
|
||||
driver_exit_code: Optional[int] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if isinstance(self.status, str):
|
||||
self.status = JobStatus(self.status)
|
||||
if self.message is None:
|
||||
if self.status == JobStatus.PENDING:
|
||||
self.message = "Job has not started yet."
|
||||
if any(
|
||||
[
|
||||
self.entrypoint_num_cpus is not None
|
||||
and self.entrypoint_num_cpus > 0,
|
||||
self.entrypoint_num_gpus is not None
|
||||
and self.entrypoint_num_gpus > 0,
|
||||
self.entrypoint_memory is not None
|
||||
and self.entrypoint_memory > 0,
|
||||
self.entrypoint_resources not in [None, {}],
|
||||
]
|
||||
):
|
||||
self.message += (
|
||||
" It may be waiting for resources "
|
||||
"(CPUs, GPUs, memory, custom resources) to become available."
|
||||
)
|
||||
if self.runtime_env not in [None, {}]:
|
||||
self.message += (
|
||||
" It may be waiting for the runtime environment to be set up."
|
||||
)
|
||||
elif self.status == JobStatus.RUNNING:
|
||||
self.message = "Job is currently running."
|
||||
elif self.status == JobStatus.STOPPED:
|
||||
self.message = "Job was intentionally stopped."
|
||||
elif self.status == JobStatus.SUCCEEDED:
|
||||
self.message = "Job finished successfully."
|
||||
elif self.status == JobStatus.FAILED:
|
||||
self.message = "Job failed."
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
"""Convert this object to a JSON-serializable dictionary.
|
||||
|
||||
Note that the runtime_env field is converted to a JSON-serialized string
|
||||
and the field is renamed to runtime_env_json.
|
||||
|
||||
Returns:
|
||||
A JSON-serializable dictionary representing the JobInfo object.
|
||||
"""
|
||||
|
||||
json_dict = asdict(self)
|
||||
|
||||
# Convert enum values to strings.
|
||||
json_dict["status"] = str(json_dict["status"])
|
||||
json_dict["error_type"] = (
|
||||
json_dict["error_type"].value if json_dict.get("error_type") else None
|
||||
)
|
||||
|
||||
# Convert runtime_env to a JSON-serialized string.
|
||||
if "runtime_env" in json_dict:
|
||||
if json_dict["runtime_env"] is not None:
|
||||
json_dict["runtime_env_json"] = json.dumps(json_dict["runtime_env"])
|
||||
del json_dict["runtime_env"]
|
||||
|
||||
# Assert that the dictionary is JSON-serializable.
|
||||
json.dumps(json_dict)
|
||||
|
||||
return json_dict
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_dict: Dict[str, Any]) -> None:
|
||||
"""Initialize this object from a JSON dictionary.
|
||||
|
||||
Note that the runtime_env_json field is converted to a dictionary and
|
||||
the field is renamed to runtime_env.
|
||||
|
||||
Args:
|
||||
json_dict: A JSON dictionary to use to initialize the JobInfo object.
|
||||
"""
|
||||
# Convert enum values to enum objects.
|
||||
json_dict["status"] = JobStatus(json_dict["status"])
|
||||
json_dict["error_type"] = (
|
||||
JobErrorType(json_dict["error_type"])
|
||||
if json_dict.get("error_type")
|
||||
else None
|
||||
)
|
||||
|
||||
# Convert runtime_env from a JSON-serialized string to a dictionary.
|
||||
if "runtime_env_json" in json_dict:
|
||||
if json_dict["runtime_env_json"] is not None:
|
||||
json_dict["runtime_env"] = json.loads(json_dict["runtime_env_json"])
|
||||
del json_dict["runtime_env_json"]
|
||||
|
||||
return cls(**json_dict)
|
||||
|
||||
|
||||
class JobInfoStorageClient:
|
||||
"""
|
||||
Interface to put and get job data from the Internal KV store.
|
||||
"""
|
||||
|
||||
# Please keep this format in sync with JobDataKey()
|
||||
# in src/ray/gcs/gcs_server/gcs_job_manager.h.
|
||||
JOB_DATA_KEY_PREFIX = f"{RAY_INTERNAL_NAMESPACE_PREFIX}job_info_"
|
||||
JOB_DATA_KEY = f"{JOB_DATA_KEY_PREFIX}{{job_id}}"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gcs_client: GcsClient,
|
||||
export_event_log_dir_root: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the JobInfoStorageClient which manages data in the internal KV store.
|
||||
Export Submission Job events are written when the KV store is updated if
|
||||
the feature flag is on and a export_event_log_dir_root is passed.
|
||||
export_event_log_dir_root doesn't need to be passed if the caller
|
||||
is not modifying data in the KV store.
|
||||
"""
|
||||
self._gcs_client = gcs_client
|
||||
self._export_submission_job_event_logger: logging.Logger = None
|
||||
try:
|
||||
if (
|
||||
check_export_api_enabled(ExportEvent.SourceType.EXPORT_SUBMISSION_JOB)
|
||||
and export_event_log_dir_root is not None
|
||||
):
|
||||
self._export_submission_job_event_logger = get_export_event_logger(
|
||||
EventLogType.SUBMISSION_JOB,
|
||||
export_event_log_dir_root,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Unable to initialize export event logger so no export "
|
||||
"events will be written."
|
||||
)
|
||||
|
||||
async def put_info(
|
||||
self,
|
||||
job_id: str,
|
||||
job_info: JobInfo,
|
||||
overwrite: bool = True,
|
||||
timeout: Optional[int] = 30,
|
||||
) -> bool:
|
||||
"""Put job info to the internal kv store.
|
||||
|
||||
Args:
|
||||
job_id: The job id.
|
||||
job_info: The job info.
|
||||
overwrite: Whether to overwrite the existing job info.
|
||||
timeout: The timeout in seconds for the GCS operation.
|
||||
|
||||
Returns:
|
||||
True if a new key is added.
|
||||
"""
|
||||
added_num = await self._gcs_client.async_internal_kv_put(
|
||||
self.JOB_DATA_KEY.format(job_id=job_id).encode(),
|
||||
json.dumps(job_info.to_json()).encode(),
|
||||
overwrite,
|
||||
namespace=ray_constants.KV_NAMESPACE_JOB,
|
||||
timeout=timeout,
|
||||
)
|
||||
if added_num == 1 or overwrite:
|
||||
# Write export event if data was updated in the KV store
|
||||
try:
|
||||
self._write_submission_job_export_event(job_id, job_info)
|
||||
except Exception:
|
||||
logger.exception("Error while writing job submission export event.")
|
||||
return added_num == 1
|
||||
|
||||
def _write_submission_job_export_event(
|
||||
self, job_id: str, job_info: JobInfo
|
||||
) -> None:
|
||||
"""
|
||||
Write Submission Job export event if _export_submission_job_event_logger
|
||||
exists. The logger will exist if the export API feature flag is enabled
|
||||
and a log directory was passed to JobInfoStorageClient.
|
||||
"""
|
||||
if not self._export_submission_job_event_logger:
|
||||
return
|
||||
|
||||
status_value_descriptor = (
|
||||
ExportSubmissionJobEventData.JobStatus.DESCRIPTOR.values_by_name.get(
|
||||
job_info.status.name
|
||||
)
|
||||
)
|
||||
if status_value_descriptor is None:
|
||||
logger.error(
|
||||
f"{job_info.status.name} is not a valid "
|
||||
"ExportSubmissionJobEventData.JobStatus enum value. This event "
|
||||
"will not be written."
|
||||
)
|
||||
return
|
||||
job_status = status_value_descriptor.number
|
||||
submission_event_data = ExportSubmissionJobEventData(
|
||||
submission_job_id=job_id,
|
||||
status=job_status,
|
||||
entrypoint=job_info.entrypoint,
|
||||
message=job_info.message,
|
||||
metadata=job_info.metadata,
|
||||
error_type=job_info.error_type,
|
||||
start_time=job_info.start_time,
|
||||
end_time=job_info.end_time,
|
||||
runtime_env_json=json.dumps(job_info.runtime_env),
|
||||
driver_agent_http_address=job_info.driver_agent_http_address,
|
||||
driver_node_id=job_info.driver_node_id,
|
||||
driver_exit_code=job_info.driver_exit_code,
|
||||
)
|
||||
self._export_submission_job_event_logger.send_event(submission_event_data)
|
||||
|
||||
async def get_info(self, job_id: str, timeout: int = 30) -> Optional[JobInfo]:
|
||||
serialized_info = await self._gcs_client.async_internal_kv_get(
|
||||
self.JOB_DATA_KEY.format(job_id=job_id).encode(),
|
||||
namespace=ray_constants.KV_NAMESPACE_JOB,
|
||||
timeout=timeout,
|
||||
)
|
||||
if serialized_info is None:
|
||||
return None
|
||||
else:
|
||||
return JobInfo.from_json(json.loads(serialized_info))
|
||||
|
||||
async def delete_info(self, job_id: str, timeout: int = 30):
|
||||
await self._gcs_client.async_internal_kv_del(
|
||||
self.JOB_DATA_KEY.format(job_id=job_id).encode(),
|
||||
False,
|
||||
namespace=ray_constants.KV_NAMESPACE_JOB,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def put_status(
|
||||
self,
|
||||
job_id: str,
|
||||
status: JobStatus,
|
||||
message: Optional[str] = None,
|
||||
driver_exit_code: Optional[int] = None,
|
||||
error_type: Optional[JobErrorType] = None,
|
||||
jobinfo_replace_kwargs: Optional[Dict[str, Any]] = None,
|
||||
timeout: Optional[int] = 30,
|
||||
):
|
||||
"""Puts or updates job status. Sets end_time if status is terminal."""
|
||||
|
||||
old_info = await self.get_info(job_id, timeout=timeout)
|
||||
|
||||
if jobinfo_replace_kwargs is None:
|
||||
jobinfo_replace_kwargs = dict()
|
||||
jobinfo_replace_kwargs.update(
|
||||
status=status,
|
||||
message=message,
|
||||
driver_exit_code=driver_exit_code,
|
||||
error_type=error_type,
|
||||
)
|
||||
if old_info is not None:
|
||||
if status != old_info.status and old_info.status.is_terminal():
|
||||
raise RuntimeError(
|
||||
f"Attempted to change job status from a terminal state: "
|
||||
f"{old_info.status} -> {status}"
|
||||
)
|
||||
new_info = replace(old_info, **jobinfo_replace_kwargs)
|
||||
else:
|
||||
new_info = JobInfo(
|
||||
entrypoint="Entrypoint not found.", **jobinfo_replace_kwargs
|
||||
)
|
||||
|
||||
if status.is_terminal():
|
||||
new_info.end_time = int(time.time() * 1000)
|
||||
|
||||
await self.put_info(job_id, new_info, timeout=timeout)
|
||||
|
||||
async def get_status(self, job_id: str, timeout: int = 30) -> Optional[JobStatus]:
|
||||
job_info = await self.get_info(job_id, timeout)
|
||||
if job_info is None:
|
||||
return None
|
||||
else:
|
||||
return job_info.status
|
||||
|
||||
async def get_all_jobs(self, timeout: int = 30) -> Dict[str, JobInfo]:
|
||||
raw_job_ids_with_prefixes = await self._gcs_client.async_internal_kv_keys(
|
||||
self.JOB_DATA_KEY_PREFIX.encode(),
|
||||
namespace=ray_constants.KV_NAMESPACE_JOB,
|
||||
timeout=timeout,
|
||||
)
|
||||
job_ids_with_prefixes = [
|
||||
job_id.decode() for job_id in raw_job_ids_with_prefixes
|
||||
]
|
||||
job_ids = []
|
||||
for job_id_with_prefix in job_ids_with_prefixes:
|
||||
assert job_id_with_prefix.startswith(
|
||||
self.JOB_DATA_KEY_PREFIX
|
||||
), "Unexpected format for internal_kv key for Job submission"
|
||||
job_ids.append(job_id_with_prefix[len(self.JOB_DATA_KEY_PREFIX) :])
|
||||
|
||||
async def get_job_info(job_id: str):
|
||||
job_info = await self.get_info(job_id, timeout)
|
||||
return job_id, job_info
|
||||
|
||||
results = await asyncio.gather(*[get_job_info(job_id) for job_id in job_ids])
|
||||
return {
|
||||
job_id: job_info for job_id, job_info in results if job_info is not None
|
||||
}
|
||||
|
||||
|
||||
def uri_to_http_components(package_uri: str) -> Tuple[str, str]:
|
||||
suffix = Path(package_uri).suffix
|
||||
if suffix not in {".zip", ".whl"}:
|
||||
raise ValueError(f"package_uri ({package_uri}) does not end in .zip or .whl")
|
||||
# We need to strip the <protocol>:// prefix to make it possible to pass
|
||||
# the package_uri over HTTP.
|
||||
protocol, package_name = parse_uri(package_uri)
|
||||
return protocol.value, package_name
|
||||
|
||||
|
||||
def http_uri_components_to_uri(protocol: str, package_name: str) -> str:
|
||||
return f"{protocol}://{package_name}"
|
||||
|
||||
|
||||
def validate_request_type(json_data: Dict[str, Any], request_type: dataclass) -> Any:
|
||||
return request_type(**json_data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobSubmitRequest:
|
||||
# Command to start execution, ex: "python script.py"
|
||||
entrypoint: str
|
||||
# Optional submission_id to specify for the job. If the submission_id
|
||||
# is not specified, one will be generated. If a job with the same
|
||||
# submission_id already exists, it will be rejected.
|
||||
submission_id: Optional[str] = None
|
||||
# DEPRECATED. Use submission_id instead
|
||||
job_id: Optional[str] = None
|
||||
# Dict to setup execution environment.
|
||||
runtime_env: Optional[Dict[str, Any]] = None
|
||||
# Metadata to pass in to the JobConfig.
|
||||
metadata: Optional[Dict[str, str]] = None
|
||||
# The quantity of CPU cores to reserve for the execution
|
||||
# of the entrypoint command, separately from any Ray tasks or actors
|
||||
# that are created by it.
|
||||
entrypoint_num_cpus: Optional[Union[int, float]] = None
|
||||
# The quantity of GPUs to reserve for the execution
|
||||
# of the entrypoint command, separately from any Ray tasks or actors
|
||||
# that are created by it.
|
||||
entrypoint_num_gpus: Optional[Union[int, float]] = None
|
||||
# The amount of total available memory for workers requesting memory
|
||||
# for the execution of the entrypoint command, separately from any Ray
|
||||
# tasks or actors that are created by it.
|
||||
entrypoint_memory: Optional[int] = None
|
||||
# The quantity of various custom resources
|
||||
# to reserve for the entrypoint command, separately from any Ray tasks
|
||||
# or actors that are created by it.
|
||||
entrypoint_resources: Optional[Dict[str, float]] = None
|
||||
# Label selector for the entrypoint command.
|
||||
entrypoint_label_selector: Optional[Dict[str, str]] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if not isinstance(self.entrypoint, str):
|
||||
raise TypeError(f"entrypoint must be a string, got {type(self.entrypoint)}")
|
||||
|
||||
if self.submission_id is not None and not isinstance(self.submission_id, str):
|
||||
raise TypeError(
|
||||
"submission_id must be a string if provided, "
|
||||
f"got {type(self.submission_id)}"
|
||||
)
|
||||
|
||||
if self.job_id is not None and not isinstance(self.job_id, str):
|
||||
raise TypeError(
|
||||
"job_id must be a string if provided, " f"got {type(self.job_id)}"
|
||||
)
|
||||
|
||||
if self.runtime_env is not None:
|
||||
if not isinstance(self.runtime_env, dict):
|
||||
raise TypeError(
|
||||
f"runtime_env must be a dict, got {type(self.runtime_env)}"
|
||||
)
|
||||
else:
|
||||
for k in self.runtime_env.keys():
|
||||
if not isinstance(k, str):
|
||||
raise TypeError(
|
||||
f"runtime_env keys must be strings, got {type(k)}"
|
||||
)
|
||||
|
||||
if self.metadata is not None:
|
||||
if not isinstance(self.metadata, dict):
|
||||
raise TypeError(f"metadata must be a dict, got {type(self.metadata)}")
|
||||
else:
|
||||
for k in self.metadata.keys():
|
||||
if not isinstance(k, str):
|
||||
raise TypeError(f"metadata keys must be strings, got {type(k)}")
|
||||
for v in self.metadata.values():
|
||||
if not isinstance(v, str):
|
||||
raise TypeError(
|
||||
f"metadata values must be strings, got {type(v)}"
|
||||
)
|
||||
|
||||
if self.entrypoint_num_cpus is not None and not isinstance(
|
||||
self.entrypoint_num_cpus, (int, float)
|
||||
):
|
||||
raise TypeError(
|
||||
"entrypoint_num_cpus must be a number, "
|
||||
f"got {type(self.entrypoint_num_cpus)}"
|
||||
)
|
||||
|
||||
if self.entrypoint_num_gpus is not None and not isinstance(
|
||||
self.entrypoint_num_gpus, (int, float)
|
||||
):
|
||||
raise TypeError(
|
||||
"entrypoint_num_gpus must be a number, "
|
||||
f"got {type(self.entrypoint_num_gpus)}"
|
||||
)
|
||||
|
||||
if self.entrypoint_memory is not None and not isinstance(
|
||||
self.entrypoint_memory, int
|
||||
):
|
||||
raise TypeError(
|
||||
"entrypoint_memory must be an integer, "
|
||||
f"got {type(self.entrypoint_memory)}"
|
||||
)
|
||||
|
||||
if self.entrypoint_resources is not None:
|
||||
if not isinstance(self.entrypoint_resources, dict):
|
||||
raise TypeError(
|
||||
"entrypoint_resources must be a dict, "
|
||||
f"got {type(self.entrypoint_resources)}"
|
||||
)
|
||||
else:
|
||||
for k in self.entrypoint_resources.keys():
|
||||
if not isinstance(k, str):
|
||||
raise TypeError(
|
||||
"entrypoint_resources keys must be strings, "
|
||||
f"got {type(k)}"
|
||||
)
|
||||
for v in self.entrypoint_resources.values():
|
||||
if not isinstance(v, (int, float)):
|
||||
raise TypeError(
|
||||
"entrypoint_resources values must be numbers, "
|
||||
f"got {type(v)}"
|
||||
)
|
||||
|
||||
if self.entrypoint_label_selector is not None:
|
||||
if not isinstance(self.entrypoint_label_selector, dict):
|
||||
raise TypeError(
|
||||
"entrypoint_label_selector must be a dict, "
|
||||
f"got {type(self.entrypoint_label_selector)}"
|
||||
)
|
||||
else:
|
||||
for k, v in self.entrypoint_label_selector.items():
|
||||
if not isinstance(k, str):
|
||||
raise TypeError(
|
||||
"entrypoint_label_selector keys must be strings, "
|
||||
f"got {type(k)}"
|
||||
)
|
||||
if not isinstance(v, str):
|
||||
raise TypeError(
|
||||
"entrypoint_label_selector values must be strings, "
|
||||
f"got {type(v)}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobSubmitResponse:
|
||||
# DEPRECATED: Use submission_id instead.
|
||||
job_id: str
|
||||
submission_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobStopResponse:
|
||||
stopped: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobDeleteResponse:
|
||||
deleted: bool
|
||||
|
||||
|
||||
# TODO(jiaodong): Support log streaming #19415
|
||||
@dataclass
|
||||
class JobLogsResponse:
|
||||
logs: str
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "http://github.com/ray-project/ray/dashboard/modules/job/component_activities_schema.json",
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"[0-9a-f]*": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"is_active": {
|
||||
"type": "string",
|
||||
"enum": ["ACTIVE", "INACTIVE", "ERROR"]
|
||||
},
|
||||
"reason": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"timestamp": {
|
||||
"type": ["number"]
|
||||
},
|
||||
"last_activity_at": {
|
||||
"type": ["number", "null"]
|
||||
}
|
||||
},
|
||||
"required": ["is_active"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
import aiohttp
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
import ray
|
||||
import ray.dashboard.optional_utils as optional_utils
|
||||
import ray.dashboard.utils as dashboard_utils
|
||||
from ray.dashboard.modules.job.common import (
|
||||
JobDeleteResponse,
|
||||
JobLogsResponse,
|
||||
JobStopResponse,
|
||||
JobSubmitRequest,
|
||||
JobSubmitResponse,
|
||||
)
|
||||
from ray.dashboard.modules.job.job_manager import JobManager
|
||||
from ray.dashboard.modules.job.pydantic_models import JobType
|
||||
from ray.dashboard.modules.job.utils import find_job_by_ids, parse_and_validate_request
|
||||
|
||||
routes = optional_utils.DashboardAgentRouteTable
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JobAgent(dashboard_utils.DashboardAgentModule):
|
||||
def __init__(self, dashboard_agent):
|
||||
super().__init__(dashboard_agent)
|
||||
self._job_manager = None
|
||||
|
||||
@routes.post("/api/job_agent/jobs/")
|
||||
@optional_utils.init_ray_and_catch_exceptions()
|
||||
async def submit_job(self, req: Request) -> Response:
|
||||
result = await parse_and_validate_request(req, JobSubmitRequest)
|
||||
# Request parsing failed, returned with Response object.
|
||||
if isinstance(result, Response):
|
||||
return result
|
||||
else:
|
||||
submit_request = result
|
||||
|
||||
request_submission_id = submit_request.submission_id or submit_request.job_id
|
||||
try:
|
||||
ray._common.usage.usage_lib.record_library_usage("job_submission")
|
||||
submission_id = await self.get_job_manager().submit_job(
|
||||
entrypoint=submit_request.entrypoint,
|
||||
submission_id=request_submission_id,
|
||||
runtime_env=submit_request.runtime_env,
|
||||
metadata=submit_request.metadata,
|
||||
entrypoint_num_cpus=submit_request.entrypoint_num_cpus,
|
||||
entrypoint_num_gpus=submit_request.entrypoint_num_gpus,
|
||||
entrypoint_memory=submit_request.entrypoint_memory,
|
||||
entrypoint_resources=submit_request.entrypoint_resources,
|
||||
entrypoint_label_selector=submit_request.entrypoint_label_selector,
|
||||
)
|
||||
|
||||
resp = JobSubmitResponse(job_id=submission_id, submission_id=submission_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
text=traceback.format_exc(),
|
||||
status=aiohttp.web.HTTPBadRequest.status_code,
|
||||
)
|
||||
except Exception:
|
||||
return Response(
|
||||
text=traceback.format_exc(),
|
||||
status=aiohttp.web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
return Response(
|
||||
text=json.dumps(dataclasses.asdict(resp)),
|
||||
content_type="application/json",
|
||||
status=aiohttp.web.HTTPOk.status_code,
|
||||
)
|
||||
|
||||
@routes.post("/api/job_agent/jobs/{job_or_submission_id}/stop")
|
||||
@optional_utils.init_ray_and_catch_exceptions()
|
||||
async def stop_job(self, req: Request) -> Response:
|
||||
job_or_submission_id = req.match_info["job_or_submission_id"]
|
||||
job = await find_job_by_ids(
|
||||
self._dashboard_agent.gcs_client,
|
||||
self.get_job_manager().job_info_client(),
|
||||
job_or_submission_id,
|
||||
)
|
||||
if not job:
|
||||
return Response(
|
||||
text=f"Job {job_or_submission_id} does not exist",
|
||||
status=aiohttp.web.HTTPNotFound.status_code,
|
||||
)
|
||||
if job.type is not JobType.SUBMISSION:
|
||||
return Response(
|
||||
text="Can only stop submission type jobs",
|
||||
status=aiohttp.web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
stopped = self.get_job_manager().stop_job(job.submission_id)
|
||||
resp = JobStopResponse(stopped=stopped)
|
||||
except Exception:
|
||||
return Response(
|
||||
text=traceback.format_exc(),
|
||||
status=aiohttp.web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
return Response(
|
||||
text=json.dumps(dataclasses.asdict(resp)), content_type="application/json"
|
||||
)
|
||||
|
||||
@routes.delete("/api/job_agent/jobs/{job_or_submission_id}")
|
||||
@optional_utils.init_ray_and_catch_exceptions()
|
||||
async def delete_job(self, req: Request) -> Response:
|
||||
job_or_submission_id = req.match_info["job_or_submission_id"]
|
||||
job = await find_job_by_ids(
|
||||
self._dashboard_agent.gcs_client,
|
||||
self.get_job_manager().job_info_client(),
|
||||
job_or_submission_id,
|
||||
)
|
||||
if not job:
|
||||
return Response(
|
||||
text=f"Job {job_or_submission_id} does not exist",
|
||||
status=aiohttp.web.HTTPNotFound.status_code,
|
||||
)
|
||||
if job.type is not JobType.SUBMISSION:
|
||||
return Response(
|
||||
text="Can only delete submission type jobs",
|
||||
status=aiohttp.web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
deleted = await self.get_job_manager().delete_job(job.submission_id)
|
||||
resp = JobDeleteResponse(deleted=deleted)
|
||||
except Exception:
|
||||
return Response(
|
||||
text=traceback.format_exc(),
|
||||
status=aiohttp.web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
return Response(
|
||||
text=json.dumps(dataclasses.asdict(resp)), content_type="application/json"
|
||||
)
|
||||
|
||||
@routes.get("/api/job_agent/jobs/{job_or_submission_id}/logs")
|
||||
@optional_utils.init_ray_and_catch_exceptions()
|
||||
async def get_job_logs(self, req: Request) -> Response:
|
||||
job_or_submission_id = req.match_info["job_or_submission_id"]
|
||||
job = await find_job_by_ids(
|
||||
self._dashboard_agent.gcs_client,
|
||||
self.get_job_manager().job_info_client(),
|
||||
job_or_submission_id,
|
||||
)
|
||||
if not job:
|
||||
return Response(
|
||||
text=f"Job {job_or_submission_id} does not exist",
|
||||
status=aiohttp.web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
if job.type is not JobType.SUBMISSION:
|
||||
return Response(
|
||||
text="Can only get logs of submission type jobs",
|
||||
status=aiohttp.web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
resp = JobLogsResponse(
|
||||
logs=self.get_job_manager().get_job_logs(job.submission_id)
|
||||
)
|
||||
return Response(
|
||||
text=json.dumps(dataclasses.asdict(resp)), content_type="application/json"
|
||||
)
|
||||
|
||||
@routes.get("/api/job_agent/jobs/{job_or_submission_id}/logs/tail")
|
||||
@optional_utils.init_ray_and_catch_exceptions()
|
||||
async def tail_job_logs(self, req: Request) -> Response:
|
||||
job_or_submission_id = req.match_info["job_or_submission_id"]
|
||||
job = await find_job_by_ids(
|
||||
self._dashboard_agent.gcs_client,
|
||||
self.get_job_manager().job_info_client(),
|
||||
job_or_submission_id,
|
||||
)
|
||||
if not job:
|
||||
return Response(
|
||||
text=f"Job {job_or_submission_id} does not exist",
|
||||
status=aiohttp.web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
if job.type is not JobType.SUBMISSION:
|
||||
return Response(
|
||||
text="Can only get logs of submission type jobs",
|
||||
status=aiohttp.web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
ws = aiohttp.web.WebSocketResponse()
|
||||
await ws.prepare(req)
|
||||
|
||||
async for lines in self._job_manager.tail_job_logs(job.submission_id):
|
||||
await ws.send_str(lines)
|
||||
|
||||
return ws
|
||||
|
||||
def get_job_manager(self):
|
||||
if not self._job_manager:
|
||||
self._job_manager = JobManager(
|
||||
self._dashboard_agent.gcs_client, self._dashboard_agent.log_dir
|
||||
)
|
||||
return self._job_manager
|
||||
|
||||
async def run(self, server):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def is_minimal_module():
|
||||
return False
|
||||
@@ -0,0 +1,774 @@
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import enum
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from typing import AsyncIterator, Dict, Optional, Tuple
|
||||
|
||||
import aiohttp.web
|
||||
from aiohttp.client import ClientResponse
|
||||
from aiohttp.web import Request, Response, StreamResponse
|
||||
|
||||
import ray
|
||||
from ray import NodeID
|
||||
from ray._common.network_utils import build_address
|
||||
from ray._common.pydantic_compat import BaseModel, Extra, Field, validator
|
||||
from ray._common.utils import get_or_create_event_loop, load_class
|
||||
from ray._private.authentication.http_token_authentication import (
|
||||
get_auth_headers_if_auth_enabled,
|
||||
)
|
||||
from ray._private.ray_constants import KV_NAMESPACE_DASHBOARD
|
||||
from ray._private.runtime_env.packaging import (
|
||||
package_exists,
|
||||
pin_runtime_env_uri,
|
||||
upload_package_to_gcs,
|
||||
)
|
||||
from ray.dashboard.consts import (
|
||||
DASHBOARD_AGENT_ADDR_NODE_ID_PREFIX,
|
||||
GCS_RPC_TIMEOUT_SECONDS,
|
||||
RAY_CLUSTER_ACTIVITY_HOOK,
|
||||
TRY_TO_GET_AGENT_INFO_INTERVAL_SECONDS,
|
||||
WAIT_AVAILABLE_AGENT_TIMEOUT,
|
||||
)
|
||||
from ray.dashboard.modules.job.common import (
|
||||
JobDeleteResponse,
|
||||
JobInfoStorageClient,
|
||||
JobLogsResponse,
|
||||
JobStopResponse,
|
||||
JobSubmitRequest,
|
||||
JobSubmitResponse,
|
||||
http_uri_components_to_uri,
|
||||
)
|
||||
from ray.dashboard.modules.job.pydantic_models import JobDetails, JobType
|
||||
from ray.dashboard.modules.job.utils import (
|
||||
find_job_by_ids,
|
||||
get_driver_jobs,
|
||||
parse_and_validate_request,
|
||||
)
|
||||
from ray.dashboard.modules.version import CURRENT_VERSION, VersionResponse
|
||||
from ray.dashboard.subprocesses.module import SubprocessModule
|
||||
from ray.dashboard.subprocesses.routes import SubprocessRouteTable as routes
|
||||
from ray.dashboard.subprocesses.utils import ResponseType
|
||||
from ray.dashboard.utils import get_head_node_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
class RayActivityStatus(str, enum.Enum):
|
||||
ACTIVE = "ACTIVE"
|
||||
INACTIVE = "INACTIVE"
|
||||
ERROR = "ERROR"
|
||||
|
||||
|
||||
class RayActivityResponse(BaseModel, extra=Extra.allow):
|
||||
"""
|
||||
Pydantic model used to inform if a particular Ray component can be considered
|
||||
active, and metadata about observation.
|
||||
"""
|
||||
|
||||
is_active: RayActivityStatus = Field(
|
||||
...,
|
||||
description=(
|
||||
"Whether the corresponding Ray component is considered active or inactive, "
|
||||
"or if there was an error while collecting this observation."
|
||||
),
|
||||
)
|
||||
reason: Optional[str] = Field(
|
||||
None, description="Reason if Ray component is considered active or errored."
|
||||
)
|
||||
timestamp: float = Field(
|
||||
...,
|
||||
description=(
|
||||
"Timestamp of when this observation about the Ray component was made. "
|
||||
"This is in the format of seconds since unix epoch."
|
||||
),
|
||||
)
|
||||
last_activity_at: Optional[float] = Field(
|
||||
None,
|
||||
description=(
|
||||
"Timestamp when last actvity of this Ray component finished in format of "
|
||||
"seconds since unix epoch. This field does not need to be populated "
|
||||
"for Ray components where it is not meaningful."
|
||||
),
|
||||
)
|
||||
|
||||
@validator("reason", always=True)
|
||||
def reason_required(cls, v, values, **kwargs):
|
||||
if "is_active" in values and values["is_active"] != RayActivityStatus.INACTIVE:
|
||||
if v is None:
|
||||
raise ValueError(
|
||||
'Reason is required if is_active is "active" or "error"'
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class JobAgentSubmissionClient:
|
||||
"""A local client for submitting and interacting with jobs on a specific node
|
||||
in the remote cluster.
|
||||
Submits requests over HTTP to the job agent on the specific node using the REST API.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dashboard_agent_address: str,
|
||||
):
|
||||
self._agent_address = dashboard_agent_address
|
||||
self._session = aiohttp.ClientSession()
|
||||
|
||||
def _get_headers(self):
|
||||
"""Get auth headers if token authentication is enabled."""
|
||||
return get_auth_headers_if_auth_enabled({})
|
||||
|
||||
async def _raise_error(self, resp: ClientResponse):
|
||||
status = resp.status
|
||||
error_text = await resp.text()
|
||||
raise RuntimeError(f"Request failed with status code {status}: {error_text}.")
|
||||
|
||||
async def submit_job_internal(self, req: JobSubmitRequest) -> JobSubmitResponse:
|
||||
logger.debug(f"Submitting job with submission_id={req.submission_id}.")
|
||||
|
||||
async with self._session.post(
|
||||
f"{self._agent_address}/api/job_agent/jobs/",
|
||||
json=dataclasses.asdict(req),
|
||||
headers=self._get_headers(),
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
result_json = await resp.json()
|
||||
return JobSubmitResponse(**result_json)
|
||||
else:
|
||||
await self._raise_error(resp)
|
||||
|
||||
async def stop_job_internal(self, job_id: str) -> JobStopResponse:
|
||||
logger.debug(f"Stopping job with job_id={job_id}.")
|
||||
|
||||
async with self._session.post(
|
||||
f"{self._agent_address}/api/job_agent/jobs/{job_id}/stop",
|
||||
headers=self._get_headers(),
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
result_json = await resp.json()
|
||||
return JobStopResponse(**result_json)
|
||||
else:
|
||||
await self._raise_error(resp)
|
||||
|
||||
async def delete_job_internal(self, job_id: str) -> JobDeleteResponse:
|
||||
logger.debug(f"Deleting job with job_id={job_id}.")
|
||||
|
||||
async with self._session.delete(
|
||||
f"{self._agent_address}/api/job_agent/jobs/{job_id}",
|
||||
headers=self._get_headers(),
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
result_json = await resp.json()
|
||||
return JobDeleteResponse(**result_json)
|
||||
else:
|
||||
await self._raise_error(resp)
|
||||
|
||||
async def get_job_logs_internal(self, job_id: str) -> JobLogsResponse:
|
||||
async with self._session.get(
|
||||
f"{self._agent_address}/api/job_agent/jobs/{job_id}/logs",
|
||||
headers=self._get_headers(),
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
result_json = await resp.json()
|
||||
return JobLogsResponse(**result_json)
|
||||
else:
|
||||
await self._raise_error(resp)
|
||||
|
||||
async def tail_job_logs(self, job_id: str) -> AsyncIterator[str]:
|
||||
"""Get an iterator that follows the logs of a job."""
|
||||
ws = await self._session.ws_connect(
|
||||
f"{self._agent_address}/api/job_agent/jobs/{job_id}/logs/tail",
|
||||
headers=self._get_headers(),
|
||||
)
|
||||
|
||||
while True:
|
||||
msg = await ws.receive()
|
||||
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
yield msg.data
|
||||
elif msg.type == aiohttp.WSMsgType.CLOSED:
|
||||
logger.info(
|
||||
f"WebSocket to job agent closed for job {job_id} "
|
||||
f"with close code {ws.close_code}"
|
||||
)
|
||||
break
|
||||
elif msg.type == aiohttp.WSMsgType.ERROR:
|
||||
logger.warning(
|
||||
f"WebSocket to job agent received an error message "
|
||||
f"while tailing logs for job {job_id}: {ws.exception()!r}. "
|
||||
)
|
||||
pass
|
||||
|
||||
async def close(self, ignore_error=True):
|
||||
try:
|
||||
await self._session.close()
|
||||
except Exception:
|
||||
if not ignore_error:
|
||||
raise
|
||||
|
||||
|
||||
class JobHead(SubprocessModule):
|
||||
"""Runs on the head node of a Ray cluster and handles Ray Jobs APIs.
|
||||
|
||||
NOTE(architkulkarni): Please keep this class in sync with the OpenAPI spec at
|
||||
`doc/source/cluster/running-applications/job-submission/openapi.yml`.
|
||||
We currently do not automatically check that the OpenAPI
|
||||
spec is in sync with the implementation. If any changes are made to the
|
||||
paths in the @route decorators or in the Responses returned by the
|
||||
methods (or any nested fields in the Responses), you will need to find the
|
||||
corresponding field of the OpenAPI yaml file and update it manually. Also,
|
||||
bump the version number in the yaml file and in this class's `get_version`.
|
||||
"""
|
||||
|
||||
# Time that we sleep while tailing logs while waiting for
|
||||
# the supervisor actor to start. We don't know which node
|
||||
# to read the logs from until then.
|
||||
WAIT_FOR_SUPERVISOR_ACTOR_INTERVAL_S = 1
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._job_info_client = None
|
||||
|
||||
# To make sure that the internal KV is initialized by getting the lazy property
|
||||
assert self.gcs_client is not None
|
||||
assert ray.experimental.internal_kv._internal_kv_initialized()
|
||||
|
||||
# It contains all `JobAgentSubmissionClient` that
|
||||
# `JobHead` has ever used, and will not be deleted
|
||||
# from it unless `JobAgentSubmissionClient` is no
|
||||
# longer available (the corresponding agent process is dead)
|
||||
# {node_id: JobAgentSubmissionClient}
|
||||
self._agents: Dict[NodeID, JobAgentSubmissionClient] = dict()
|
||||
|
||||
async def get_target_agent(
|
||||
self, timeout_s: float = WAIT_AVAILABLE_AGENT_TIMEOUT
|
||||
) -> JobAgentSubmissionClient:
|
||||
"""
|
||||
Get a `JobAgentSubmissionClient`, which is a client for interacting with jobs
|
||||
via an agent process.
|
||||
|
||||
Args:
|
||||
timeout_s: The timeout for the operation.
|
||||
|
||||
Returns:
|
||||
A `JobAgentSubmissionClient` for interacting with jobs via an agent process.
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the operation times out.
|
||||
"""
|
||||
return await self._get_head_node_agent(timeout_s)
|
||||
|
||||
async def _get_head_node_agent_once(self) -> JobAgentSubmissionClient:
|
||||
head_node_id_hex = await get_head_node_id(self.gcs_client)
|
||||
|
||||
if not head_node_id_hex:
|
||||
raise Exception("Head node id has not yet been persisted in GCS")
|
||||
|
||||
head_node_id = NodeID.from_hex(head_node_id_hex)
|
||||
|
||||
if head_node_id not in self._agents:
|
||||
ip, http_port, _ = await self._fetch_agent_info(head_node_id)
|
||||
agent_http_address = f"http://{build_address(ip, http_port)}"
|
||||
self._agents[head_node_id] = JobAgentSubmissionClient(agent_http_address)
|
||||
|
||||
return self._agents[head_node_id]
|
||||
|
||||
async def _get_head_node_agent(self, timeout_s: float) -> JobAgentSubmissionClient:
|
||||
"""Retrieves HTTP client for `JobAgent` running on the Head node. If the head
|
||||
node does not have an agent, it will retry every
|
||||
`TRY_TO_GET_AGENT_INFO_INTERVAL_SECONDS` seconds indefinitely.
|
||||
|
||||
Args:
|
||||
timeout_s: The timeout for the operation.
|
||||
|
||||
Returns:
|
||||
A `JobAgentSubmissionClient` for interacting with jobs via the head node's agent process.
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the operation times out.
|
||||
"""
|
||||
timeout_point = time.time() + timeout_s
|
||||
exception = None
|
||||
while time.time() < timeout_point:
|
||||
try:
|
||||
return await self._get_head_node_agent_once()
|
||||
except Exception as e:
|
||||
exception = e
|
||||
logger.exception(
|
||||
f"Failed to get head node agent, retrying in {TRY_TO_GET_AGENT_INFO_INTERVAL_SECONDS} seconds..."
|
||||
)
|
||||
await asyncio.sleep(TRY_TO_GET_AGENT_INFO_INTERVAL_SECONDS)
|
||||
raise TimeoutError(
|
||||
f"Failed to get head node agent within {timeout_s} seconds. The last exception is {exception}"
|
||||
)
|
||||
|
||||
async def _fetch_agent_info(self, target_node_id: NodeID) -> Tuple[str, int, int]:
|
||||
"""
|
||||
Fetches agent info by the Node ID. May raise exception if there's network error or the
|
||||
agent info is not found.
|
||||
|
||||
Returns: (ip, http_port, grpc_port)
|
||||
"""
|
||||
key = f"{DASHBOARD_AGENT_ADDR_NODE_ID_PREFIX}{target_node_id.hex()}"
|
||||
value = await self.gcs_client.async_internal_kv_get(
|
||||
key,
|
||||
namespace=KV_NAMESPACE_DASHBOARD,
|
||||
timeout=GCS_RPC_TIMEOUT_SECONDS,
|
||||
)
|
||||
if not value:
|
||||
raise KeyError(
|
||||
f"Agent info not found in internal KV for node {target_node_id}. "
|
||||
"It's possible that the agent didn't launch successfully due to "
|
||||
"port conflicts or other issues. Please check `dashboard_agent.log` "
|
||||
"for more details."
|
||||
)
|
||||
return json.loads(value.decode())
|
||||
|
||||
@routes.get("/api/version")
|
||||
async def get_version(self, req: Request) -> Response:
|
||||
# NOTE(edoakes): CURRENT_VERSION should be bumped and checked on the
|
||||
# client when we have backwards-incompatible changes.
|
||||
resp = VersionResponse(
|
||||
version=CURRENT_VERSION,
|
||||
ray_version=ray.__version__,
|
||||
ray_commit=ray.__commit__,
|
||||
session_name=self.session_name,
|
||||
)
|
||||
return Response(
|
||||
text=json.dumps(dataclasses.asdict(resp)),
|
||||
content_type="application/json",
|
||||
status=aiohttp.web.HTTPOk.status_code,
|
||||
)
|
||||
|
||||
@routes.get("/api/packages/{protocol}/{package_name}")
|
||||
async def get_package(self, req: Request) -> Response:
|
||||
package_uri = http_uri_components_to_uri(
|
||||
protocol=req.match_info["protocol"],
|
||||
package_name=req.match_info["package_name"],
|
||||
)
|
||||
|
||||
logger.debug(f"Adding temporary reference to package {package_uri}.")
|
||||
try:
|
||||
pin_runtime_env_uri(package_uri)
|
||||
except Exception:
|
||||
return Response(
|
||||
text=traceback.format_exc(),
|
||||
status=aiohttp.web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
if not package_exists(package_uri):
|
||||
return Response(
|
||||
text=f"Package {package_uri} does not exist",
|
||||
status=aiohttp.web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
return Response()
|
||||
|
||||
@routes.put("/api/packages/{protocol}/{package_name}")
|
||||
async def upload_package(self, req: Request):
|
||||
package_uri = http_uri_components_to_uri(
|
||||
protocol=req.match_info["protocol"],
|
||||
package_name=req.match_info["package_name"],
|
||||
)
|
||||
logger.info(f"Uploading package {package_uri} to the GCS.")
|
||||
try:
|
||||
data = await req.read()
|
||||
await get_or_create_event_loop().run_in_executor(
|
||||
None,
|
||||
upload_package_to_gcs,
|
||||
package_uri,
|
||||
data,
|
||||
)
|
||||
except Exception:
|
||||
return Response(
|
||||
text=traceback.format_exc(),
|
||||
status=aiohttp.web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
return Response(status=aiohttp.web.HTTPOk.status_code)
|
||||
|
||||
@routes.post("/api/jobs/")
|
||||
async def submit_job(self, req: Request) -> Response:
|
||||
result = await parse_and_validate_request(req, JobSubmitRequest)
|
||||
# Request parsing failed, returned with Response object.
|
||||
if isinstance(result, Response):
|
||||
return result
|
||||
else:
|
||||
submit_request: JobSubmitRequest = result
|
||||
|
||||
try:
|
||||
job_agent_client = await self.get_target_agent()
|
||||
resp = await job_agent_client.submit_job_internal(submit_request)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"Timed out waiting for an available job agent to submit the job."
|
||||
)
|
||||
return Response(
|
||||
text="No available agent to submit job, please try again later.",
|
||||
status=aiohttp.web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Failed to submit job due to an invalid request.")
|
||||
return Response(
|
||||
text=traceback.format_exc(),
|
||||
status=aiohttp.web.HTTPBadRequest.status_code,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to submit job due to unexpected exception.")
|
||||
return Response(
|
||||
text=traceback.format_exc(),
|
||||
status=aiohttp.web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
return Response(
|
||||
text=json.dumps(dataclasses.asdict(resp)),
|
||||
content_type="application/json",
|
||||
status=aiohttp.web.HTTPOk.status_code,
|
||||
)
|
||||
|
||||
@routes.post("/api/jobs/{job_or_submission_id}/stop")
|
||||
async def stop_job(self, req: Request) -> Response:
|
||||
job_or_submission_id = req.match_info["job_or_submission_id"]
|
||||
job = await find_job_by_ids(
|
||||
self.gcs_client,
|
||||
self._job_info_client,
|
||||
job_or_submission_id,
|
||||
)
|
||||
if not job:
|
||||
return Response(
|
||||
text=f"Job {job_or_submission_id} does not exist",
|
||||
status=aiohttp.web.HTTPNotFound.status_code,
|
||||
)
|
||||
if job.type is not JobType.SUBMISSION:
|
||||
return Response(
|
||||
text="Can only stop submission type jobs",
|
||||
status=aiohttp.web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
job_agent_client = await self.get_target_agent()
|
||||
resp = await job_agent_client.stop_job_internal(job.submission_id)
|
||||
except Exception:
|
||||
return Response(
|
||||
text=traceback.format_exc(),
|
||||
status=aiohttp.web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
return Response(
|
||||
text=json.dumps(dataclasses.asdict(resp)), content_type="application/json"
|
||||
)
|
||||
|
||||
@routes.delete("/api/jobs/{job_or_submission_id}")
|
||||
async def delete_job(self, req: Request) -> Response:
|
||||
job_or_submission_id = req.match_info["job_or_submission_id"]
|
||||
job = await find_job_by_ids(
|
||||
self.gcs_client,
|
||||
self._job_info_client,
|
||||
job_or_submission_id,
|
||||
)
|
||||
if not job:
|
||||
return Response(
|
||||
text=f"Job {job_or_submission_id} does not exist",
|
||||
status=aiohttp.web.HTTPNotFound.status_code,
|
||||
)
|
||||
if job.type is not JobType.SUBMISSION:
|
||||
return Response(
|
||||
text="Can only delete submission type jobs",
|
||||
status=aiohttp.web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
job_agent_client = await self.get_target_agent()
|
||||
resp = await job_agent_client.delete_job_internal(job.submission_id)
|
||||
except Exception:
|
||||
return Response(
|
||||
text=traceback.format_exc(),
|
||||
status=aiohttp.web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
return Response(
|
||||
text=json.dumps(dataclasses.asdict(resp)), content_type="application/json"
|
||||
)
|
||||
|
||||
@routes.get("/api/jobs/{job_or_submission_id}")
|
||||
async def get_job_info(self, req: Request) -> Response:
|
||||
job_or_submission_id = req.match_info["job_or_submission_id"]
|
||||
job = await find_job_by_ids(
|
||||
self.gcs_client,
|
||||
self._job_info_client,
|
||||
job_or_submission_id,
|
||||
)
|
||||
if not job:
|
||||
return Response(
|
||||
text=f"Job {job_or_submission_id} does not exist",
|
||||
status=aiohttp.web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
return Response(
|
||||
text=json.dumps(job.dict()),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
# TODO(rickyx): This endpoint's logic is also mirrored in state API's endpoint.
|
||||
# We should eventually unify the backend logic (and keep the logic in sync before
|
||||
# that).
|
||||
@routes.get("/api/jobs/")
|
||||
async def list_jobs(self, req: Request) -> Response:
|
||||
(driver_jobs, submission_job_drivers), submission_jobs = await asyncio.gather(
|
||||
get_driver_jobs(self.gcs_client), self._job_info_client.get_all_jobs()
|
||||
)
|
||||
|
||||
submission_jobs = [
|
||||
JobDetails(
|
||||
**dataclasses.asdict(job),
|
||||
submission_id=submission_id,
|
||||
job_id=submission_job_drivers.get(submission_id).id
|
||||
if submission_id in submission_job_drivers
|
||||
else None,
|
||||
driver_info=submission_job_drivers.get(submission_id),
|
||||
type=JobType.SUBMISSION,
|
||||
)
|
||||
for submission_id, job in submission_jobs.items()
|
||||
]
|
||||
return Response(
|
||||
text=json.dumps(
|
||||
[
|
||||
*[submission_job.dict() for submission_job in submission_jobs],
|
||||
*[job_info.dict() for job_info in driver_jobs.values()],
|
||||
]
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
@routes.get("/api/jobs/{job_or_submission_id}/logs")
|
||||
async def get_job_logs(self, req: Request) -> Response:
|
||||
job_or_submission_id = req.match_info["job_or_submission_id"]
|
||||
job = await find_job_by_ids(
|
||||
self.gcs_client,
|
||||
self._job_info_client,
|
||||
job_or_submission_id,
|
||||
)
|
||||
if not job:
|
||||
return Response(
|
||||
text=f"Job {job_or_submission_id} does not exist",
|
||||
status=aiohttp.web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
if job.type is not JobType.SUBMISSION:
|
||||
return Response(
|
||||
text="Can only get logs of submission type jobs",
|
||||
status=aiohttp.web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
job_agent_client = self.get_job_driver_agent_client(job)
|
||||
payload = (
|
||||
await job_agent_client.get_job_logs_internal(job.submission_id)
|
||||
if job_agent_client
|
||||
else JobLogsResponse("")
|
||||
)
|
||||
return Response(
|
||||
text=json.dumps(dataclasses.asdict(payload)),
|
||||
content_type="application/json",
|
||||
)
|
||||
except Exception:
|
||||
return Response(
|
||||
text=traceback.format_exc(),
|
||||
status=aiohttp.web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
@routes.get(
|
||||
"/api/jobs/{job_or_submission_id}/logs/tail", resp_type=ResponseType.WEBSOCKET
|
||||
)
|
||||
async def tail_job_logs(self, req: Request) -> StreamResponse:
|
||||
job_or_submission_id = req.match_info["job_or_submission_id"]
|
||||
job = await find_job_by_ids(
|
||||
self.gcs_client,
|
||||
self._job_info_client,
|
||||
job_or_submission_id,
|
||||
)
|
||||
if not job:
|
||||
return Response(
|
||||
text=f"Job {job_or_submission_id} does not exist",
|
||||
status=aiohttp.web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
if job.type is not JobType.SUBMISSION:
|
||||
return Response(
|
||||
text="Can only get logs of submission type jobs",
|
||||
status=aiohttp.web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
ws = aiohttp.web.WebSocketResponse()
|
||||
await ws.prepare(req)
|
||||
|
||||
driver_agent_http_address = None
|
||||
while driver_agent_http_address is None:
|
||||
job = await find_job_by_ids(
|
||||
self.gcs_client,
|
||||
self._job_info_client,
|
||||
job_or_submission_id,
|
||||
)
|
||||
driver_agent_http_address = job.driver_agent_http_address
|
||||
status = job.status
|
||||
if status.is_terminal() and driver_agent_http_address is None:
|
||||
# Job exited before supervisor actor started.
|
||||
return ws
|
||||
|
||||
await asyncio.sleep(self.WAIT_FOR_SUPERVISOR_ACTOR_INTERVAL_S)
|
||||
|
||||
job_agent_client = self.get_job_driver_agent_client(job)
|
||||
|
||||
async for lines in job_agent_client.tail_job_logs(job.submission_id):
|
||||
await ws.send_str(lines)
|
||||
|
||||
return ws
|
||||
|
||||
def get_job_driver_agent_client(
|
||||
self, job: JobDetails
|
||||
) -> Optional[JobAgentSubmissionClient]:
|
||||
if job.driver_agent_http_address is None:
|
||||
return None
|
||||
|
||||
driver_node_id = job.driver_node_id
|
||||
if driver_node_id not in self._agents:
|
||||
self._agents[driver_node_id] = JobAgentSubmissionClient(
|
||||
job.driver_agent_http_address
|
||||
)
|
||||
|
||||
return self._agents[driver_node_id]
|
||||
|
||||
@routes.get("/api/component_activities")
|
||||
async def get_component_activities(
|
||||
self, req: aiohttp.web.Request
|
||||
) -> aiohttp.web.Response:
|
||||
timeout = req.query.get("timeout", None)
|
||||
if timeout and timeout.isdigit():
|
||||
timeout = int(timeout)
|
||||
else:
|
||||
timeout = 30
|
||||
|
||||
# Get activity information for driver
|
||||
driver_activity_info = await self._get_job_activity_info(timeout=timeout)
|
||||
resp = {"driver": dict(driver_activity_info)}
|
||||
|
||||
if RAY_CLUSTER_ACTIVITY_HOOK in os.environ:
|
||||
try:
|
||||
cluster_activity_callable = load_class(
|
||||
os.environ[RAY_CLUSTER_ACTIVITY_HOOK]
|
||||
)
|
||||
external_activity_output = cluster_activity_callable()
|
||||
assert isinstance(external_activity_output, dict), (
|
||||
f"Output of hook {os.environ[RAY_CLUSTER_ACTIVITY_HOOK]} "
|
||||
"should be Dict[str, RayActivityResponse]. Got "
|
||||
f"output: {external_activity_output}"
|
||||
)
|
||||
for component_type in external_activity_output:
|
||||
try:
|
||||
component_activity_output = external_activity_output[
|
||||
component_type
|
||||
]
|
||||
# Parse and validate output to type RayActivityResponse
|
||||
component_activity_output = RayActivityResponse(
|
||||
**dict(component_activity_output)
|
||||
)
|
||||
resp[component_type] = dict(component_activity_output)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Failed to get activity status of {component_type} "
|
||||
f"from user hook {os.environ[RAY_CLUSTER_ACTIVITY_HOOK]}."
|
||||
)
|
||||
resp[component_type] = {
|
||||
"is_active": RayActivityStatus.ERROR,
|
||||
"reason": repr(e),
|
||||
"timestamp": datetime.now().timestamp(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Failed to get activity status from user "
|
||||
f"hook {os.environ[RAY_CLUSTER_ACTIVITY_HOOK]}."
|
||||
)
|
||||
resp["external_component"] = {
|
||||
"is_active": RayActivityStatus.ERROR,
|
||||
"reason": repr(e),
|
||||
"timestamp": datetime.now().timestamp(),
|
||||
}
|
||||
|
||||
return aiohttp.web.Response(
|
||||
text=json.dumps(resp),
|
||||
content_type="application/json",
|
||||
status=aiohttp.web.HTTPOk.status_code,
|
||||
)
|
||||
|
||||
async def _get_job_activity_info(self, timeout: int) -> RayActivityResponse:
|
||||
# Returns if there is Ray activity from drivers (job).
|
||||
# Drivers in namespaces that start with _ray_internal_ are not
|
||||
# considered activity.
|
||||
# This includes the _ray_internal_dashboard job that gets automatically
|
||||
# created with every cluster
|
||||
try:
|
||||
reply = await self.gcs_client.async_get_all_job_info(
|
||||
skip_submission_job_info_field=True,
|
||||
skip_is_running_tasks_field=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
num_active_drivers = 0
|
||||
latest_job_end_time = 0
|
||||
for job_table_entry in reply.values():
|
||||
is_dead = bool(job_table_entry.is_dead)
|
||||
in_internal_namespace = job_table_entry.config.ray_namespace.startswith(
|
||||
"_ray_internal_"
|
||||
)
|
||||
latest_job_end_time = (
|
||||
max(latest_job_end_time, job_table_entry.end_time)
|
||||
if job_table_entry.end_time
|
||||
else latest_job_end_time
|
||||
)
|
||||
if not is_dead and not in_internal_namespace:
|
||||
num_active_drivers += 1
|
||||
|
||||
current_timestamp = datetime.now().timestamp()
|
||||
# Latest job end time must be before or equal to the current timestamp.
|
||||
# Job end times may be provided in epoch milliseconds. Check if this
|
||||
# is true, and convert to seconds
|
||||
if latest_job_end_time > current_timestamp:
|
||||
latest_job_end_time = latest_job_end_time / 1000
|
||||
assert current_timestamp >= latest_job_end_time, (
|
||||
f"Most recent job end time {latest_job_end_time} must be "
|
||||
f"before or equal to the current timestamp {current_timestamp}"
|
||||
)
|
||||
|
||||
is_active = (
|
||||
RayActivityStatus.ACTIVE
|
||||
if num_active_drivers > 0
|
||||
else RayActivityStatus.INACTIVE
|
||||
)
|
||||
return RayActivityResponse(
|
||||
is_active=is_active,
|
||||
reason=f"Number of active drivers: {num_active_drivers}"
|
||||
if num_active_drivers
|
||||
else None,
|
||||
timestamp=current_timestamp,
|
||||
# If latest_job_end_time == 0, no jobs have finished yet so don't
|
||||
# populate last_activity_at
|
||||
last_activity_at=latest_job_end_time if latest_job_end_time else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to get activity status of Ray drivers.")
|
||||
return RayActivityResponse(
|
||||
is_active=RayActivityStatus.ERROR,
|
||||
reason=repr(e),
|
||||
timestamp=datetime.now().timestamp(),
|
||||
)
|
||||
|
||||
async def run(self):
|
||||
await super().run()
|
||||
if not self._job_info_client:
|
||||
self._job_info_client = JobInfoStorageClient(self.gcs_client)
|
||||
@@ -0,0 +1,57 @@
|
||||
import os
|
||||
from typing import AsyncIterator, List, Tuple
|
||||
|
||||
import ray
|
||||
from ray.dashboard.modules.job.common import JOB_LOGS_PATH_TEMPLATE
|
||||
from ray.dashboard.modules.job.utils import fast_tail_last_n_lines, file_tail_iterator
|
||||
|
||||
|
||||
class JobLogStorageClient:
|
||||
"""
|
||||
Disk storage for stdout / stderr of driver script logs.
|
||||
"""
|
||||
|
||||
# Number of last N lines to put in job message upon failure.
|
||||
NUM_LOG_LINES_ON_ERROR = 10
|
||||
# Maximum number of characters to print out of the logs to avoid
|
||||
# HUGE log outputs that bring down the api server
|
||||
MAX_LOG_SIZE = 20000
|
||||
|
||||
def get_logs(self, job_id: str) -> str:
|
||||
try:
|
||||
with open(self.get_log_file_path(job_id), "r") as f:
|
||||
return f.read()
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
|
||||
def tail_logs(self, job_id: str) -> AsyncIterator[List[str]]:
|
||||
return file_tail_iterator(self.get_log_file_path(job_id))
|
||||
|
||||
async def get_last_n_log_lines(
|
||||
self, job_id: str, num_log_lines: int = NUM_LOG_LINES_ON_ERROR
|
||||
) -> str:
|
||||
"""Returns the last MAX_LOG_SIZE (20000) characters in the last ``num_log_lines`` lines.
|
||||
|
||||
Args:
|
||||
job_id: The id of the job whose logs we want to return
|
||||
num_log_lines: The number of lines to return.
|
||||
|
||||
Returns:
|
||||
Up to ``MAX_LOG_SIZE`` characters drawn from the last
|
||||
``num_log_lines`` lines of the job's log file.
|
||||
"""
|
||||
return fast_tail_last_n_lines(
|
||||
path=self.get_log_file_path(job_id),
|
||||
num_lines=num_log_lines,
|
||||
max_chars=self.MAX_LOG_SIZE,
|
||||
)
|
||||
|
||||
def get_log_file_path(self, job_id: str) -> Tuple[str, str]:
|
||||
"""
|
||||
Get the file path to the logs of a given job. Example:
|
||||
/tmp/ray/session_date/logs/job-driver-{job_id}.log
|
||||
"""
|
||||
return os.path.join(
|
||||
ray._private.worker._global_node.get_logs_dir_path(),
|
||||
JOB_LOGS_PATH_TEMPLATE.format(submission_id=job_id),
|
||||
)
|
||||
@@ -0,0 +1,707 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
import traceback
|
||||
from typing import Any, AsyncIterator, Dict, Optional, Union
|
||||
|
||||
import ray
|
||||
import ray._private.ray_constants as ray_constants
|
||||
from ray._common.utils import Timer, run_background_task
|
||||
from ray._private.accelerators.npu import NOSET_ASCEND_RT_VISIBLE_DEVICES_ENV_VAR
|
||||
from ray._private.accelerators.nvidia_gpu import NOSET_CUDA_VISIBLE_DEVICES_ENV_VAR
|
||||
from ray._private.event.event_logger import get_event_logger
|
||||
from ray._private.label_utils import validate_label_selector
|
||||
from ray._raylet import GcsClient
|
||||
from ray.actor import ActorHandle
|
||||
from ray.core.generated.event_pb2 import Event
|
||||
from ray.dashboard.consts import (
|
||||
DEFAULT_JOB_START_TIMEOUT_SECONDS,
|
||||
RAY_JOB_ALLOW_DRIVER_ON_WORKER_NODES_ENV_VAR,
|
||||
RAY_JOB_START_TIMEOUT_SECONDS_ENV_VAR,
|
||||
RAY_STREAM_RUNTIME_ENV_LOG_TO_JOB_DRIVER_LOG_ENV_VAR,
|
||||
)
|
||||
from ray.dashboard.modules.job.common import (
|
||||
JOB_ACTOR_NAME_TEMPLATE,
|
||||
SUPERVISOR_ACTOR_RAY_NAMESPACE,
|
||||
JobInfo,
|
||||
JobInfoStorageClient,
|
||||
)
|
||||
from ray.dashboard.modules.job.job_log_storage_client import JobLogStorageClient
|
||||
from ray.dashboard.modules.job.job_supervisor import JobSupervisor
|
||||
from ray.dashboard.utils import close_logger_file_descriptor, get_head_node_id
|
||||
from ray.exceptions import ActorDiedError, ActorUnschedulableError, RuntimeEnvSetupError
|
||||
from ray.job_submission import JobErrorType, JobStatus
|
||||
from ray.runtime_env import RuntimeEnvConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_job_id() -> str:
|
||||
"""Returns a job_id of the form 'raysubmit_XYZ'.
|
||||
|
||||
Prefixed with 'raysubmit' to avoid confusion with Ray JobID (driver ID).
|
||||
"""
|
||||
rand = random.SystemRandom()
|
||||
possible_characters = list(
|
||||
set(string.ascii_letters + string.digits)
|
||||
- {"I", "l", "o", "O", "0"} # No confusing characters
|
||||
)
|
||||
id_part = "".join(rand.choices(possible_characters, k=16))
|
||||
return f"raysubmit_{id_part}"
|
||||
|
||||
|
||||
class JobManager:
|
||||
"""Provide python APIs for job submission and management.
|
||||
|
||||
It does not provide persistence, all info will be lost if the cluster
|
||||
goes down.
|
||||
"""
|
||||
|
||||
# Time that we will sleep while tailing logs if no new log line is
|
||||
# available.
|
||||
LOG_TAIL_SLEEP_S = 1
|
||||
JOB_MONITOR_LOOP_PERIOD_S = 1
|
||||
WAIT_FOR_ACTOR_DEATH_TIMEOUT_S = 0.1
|
||||
|
||||
def __init__(
|
||||
self, gcs_client: GcsClient, logs_dir: str, timeout_check_timer: Timer = None
|
||||
):
|
||||
self._gcs_client = gcs_client
|
||||
self._logs_dir = logs_dir
|
||||
self._job_info_client = JobInfoStorageClient(gcs_client, logs_dir)
|
||||
self._gcs_address = gcs_client.address
|
||||
self._cluster_id_hex = gcs_client.cluster_id.hex()
|
||||
self._log_client = JobLogStorageClient()
|
||||
self._supervisor_actor_cls = ray.remote(JobSupervisor)
|
||||
self._timeout_check_timer = timeout_check_timer or Timer()
|
||||
self.monitored_jobs = set()
|
||||
try:
|
||||
self.event_logger = get_event_logger(Event.SourceType.JOBS, logs_dir)
|
||||
except Exception:
|
||||
self.event_logger = None
|
||||
|
||||
self._recover_running_jobs_event = asyncio.Event()
|
||||
run_background_task(self._recover_running_jobs())
|
||||
|
||||
def _get_job_driver_logger(self, job_id: str) -> logging.Logger:
|
||||
"""Return job driver logger to log messages to the job driver log file.
|
||||
|
||||
If this function is called for the first time, configure the logger.
|
||||
"""
|
||||
job_driver_logger = logging.getLogger(f"{__name__}.driver-{job_id}")
|
||||
|
||||
# Configure the logger if it's not already configured.
|
||||
if not job_driver_logger.handlers:
|
||||
job_driver_log_path = self._log_client.get_log_file_path(job_id)
|
||||
job_driver_handler = logging.FileHandler(job_driver_log_path)
|
||||
job_driver_formatter = logging.Formatter(ray_constants.LOGGER_FORMAT)
|
||||
job_driver_handler.setFormatter(job_driver_formatter)
|
||||
job_driver_logger.addHandler(job_driver_handler)
|
||||
|
||||
return job_driver_logger
|
||||
|
||||
async def _recover_running_jobs(self):
|
||||
"""Recovers all running jobs from the status client.
|
||||
|
||||
For each job, we will spawn a coroutine to monitor it.
|
||||
Each will be added to self._running_jobs and reconciled.
|
||||
"""
|
||||
try:
|
||||
all_jobs = await self._job_info_client.get_all_jobs()
|
||||
for job_id, job_info in all_jobs.items():
|
||||
if not job_info.status.is_terminal():
|
||||
run_background_task(self._monitor_job(job_id))
|
||||
finally:
|
||||
# This event is awaited in `submit_job` to avoid race conditions between
|
||||
# recovery and new job submission, so it must always get set even if there
|
||||
# are exceptions.
|
||||
self._recover_running_jobs_event.set()
|
||||
|
||||
def _get_actor_for_job(self, job_id: str) -> Optional[ActorHandle]:
|
||||
try:
|
||||
return ray.get_actor(
|
||||
JOB_ACTOR_NAME_TEMPLATE.format(job_id=job_id),
|
||||
namespace=SUPERVISOR_ACTOR_RAY_NAMESPACE,
|
||||
)
|
||||
except ValueError: # Ray returns ValueError for nonexistent actor.
|
||||
return None
|
||||
|
||||
async def _monitor_job(
|
||||
self, job_id: str, job_supervisor: Optional[ActorHandle] = None
|
||||
):
|
||||
"""Monitors the specified job until it enters a terminal state.
|
||||
|
||||
This is necessary because we need to handle the case where the
|
||||
JobSupervisor dies unexpectedly.
|
||||
"""
|
||||
if job_id in self.monitored_jobs:
|
||||
logger.debug(f"Job {job_id} is already being monitored.")
|
||||
return
|
||||
|
||||
self.monitored_jobs.add(job_id)
|
||||
try:
|
||||
await self._monitor_job_internal(job_id, job_supervisor)
|
||||
except Exception as e:
|
||||
logger.error("Unhandled exception in job monitoring!", exc_info=e)
|
||||
raise e
|
||||
finally:
|
||||
self.monitored_jobs.remove(job_id)
|
||||
|
||||
async def _monitor_job_internal(
|
||||
self, job_id: str, job_supervisor: Optional[ActorHandle] = None
|
||||
):
|
||||
timeout = float(
|
||||
os.environ.get(
|
||||
RAY_JOB_START_TIMEOUT_SECONDS_ENV_VAR,
|
||||
DEFAULT_JOB_START_TIMEOUT_SECONDS,
|
||||
)
|
||||
)
|
||||
|
||||
job_status = None
|
||||
job_info = None
|
||||
ping_obj_ref = None
|
||||
|
||||
while True:
|
||||
try:
|
||||
# NOTE: Job monitoring loop sleeps before proceeding with monitoring
|
||||
# sequence to consolidate the control-flow of the pacing
|
||||
# in a single place, rather than having it spread across
|
||||
# many branches
|
||||
await asyncio.sleep(self.JOB_MONITOR_LOOP_PERIOD_S)
|
||||
|
||||
job_status = await self._job_info_client.get_status(
|
||||
job_id, timeout=None
|
||||
)
|
||||
if job_status == JobStatus.PENDING:
|
||||
# Compare the current time with the job start time.
|
||||
# If the job is still pending, we will set the status
|
||||
# to FAILED.
|
||||
if job_info is None:
|
||||
job_info = await self._job_info_client.get_info(
|
||||
job_id, timeout=None
|
||||
)
|
||||
|
||||
if (
|
||||
self._timeout_check_timer.time() - job_info.start_time / 1000
|
||||
> timeout
|
||||
):
|
||||
err_msg = (
|
||||
"Job supervisor actor failed to start within "
|
||||
f"{timeout} seconds. This timeout can be "
|
||||
f"configured by setting the environment "
|
||||
f"variable {RAY_JOB_START_TIMEOUT_SECONDS_ENV_VAR}."
|
||||
)
|
||||
resources_specified = (
|
||||
(
|
||||
job_info.entrypoint_num_cpus is not None
|
||||
and job_info.entrypoint_num_cpus > 0
|
||||
)
|
||||
or (
|
||||
job_info.entrypoint_num_gpus is not None
|
||||
and job_info.entrypoint_num_gpus > 0
|
||||
)
|
||||
or (
|
||||
job_info.entrypoint_memory is not None
|
||||
and job_info.entrypoint_memory > 0
|
||||
)
|
||||
or (
|
||||
job_info.entrypoint_resources is not None
|
||||
and len(job_info.entrypoint_resources) > 0
|
||||
)
|
||||
)
|
||||
if resources_specified:
|
||||
err_msg += (
|
||||
" This may be because the job entrypoint's specified "
|
||||
"resources (entrypoint_num_cpus, entrypoint_num_gpus, "
|
||||
"entrypoint_resources, entrypoint_memory)"
|
||||
"aren't available on the cluster."
|
||||
" Try checking the cluster's available resources with "
|
||||
"`ray status` and specifying fewer resources for the "
|
||||
"job entrypoint."
|
||||
)
|
||||
await self._job_info_client.put_status(
|
||||
job_id,
|
||||
JobStatus.FAILED,
|
||||
message=err_msg,
|
||||
error_type=JobErrorType.JOB_SUPERVISOR_ACTOR_START_TIMEOUT,
|
||||
timeout=None,
|
||||
)
|
||||
logger.error(err_msg)
|
||||
break
|
||||
|
||||
if job_supervisor is None:
|
||||
job_supervisor = self._get_actor_for_job(job_id)
|
||||
|
||||
if job_supervisor is None:
|
||||
if job_status == JobStatus.PENDING:
|
||||
# Maybe the job supervisor actor is not created yet.
|
||||
# We will wait for the next loop.
|
||||
continue
|
||||
else:
|
||||
# The job supervisor actor is not created, but the job
|
||||
# status is not PENDING. This means the job supervisor
|
||||
# actor is not created due to some unexpected errors.
|
||||
# We will set the job status to FAILED.
|
||||
logger.error(f"Failed to get job supervisor for job {job_id}.")
|
||||
await self._job_info_client.put_status(
|
||||
job_id,
|
||||
JobStatus.FAILED,
|
||||
message=(
|
||||
"Unexpected error occurred: "
|
||||
"failed to get job supervisor."
|
||||
),
|
||||
error_type=JobErrorType.JOB_SUPERVISOR_ACTOR_START_FAILURE,
|
||||
timeout=None,
|
||||
)
|
||||
break
|
||||
|
||||
# Check to see if `JobSupervisor` is alive and reachable
|
||||
if ping_obj_ref is None:
|
||||
ping_obj_ref = job_supervisor.ping.options(
|
||||
max_task_retries=-1
|
||||
).remote()
|
||||
ready, _ = ray.wait([ping_obj_ref], timeout=0)
|
||||
if ready:
|
||||
ray.get(ping_obj_ref)
|
||||
ping_obj_ref = None
|
||||
else:
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
job_status = await self._job_info_client.get_status(
|
||||
job_id, timeout=None
|
||||
)
|
||||
target_job_error_message = ""
|
||||
target_job_error_type: Optional[JobErrorType] = None
|
||||
if job_status is not None and job_status.is_terminal():
|
||||
# If the job is already in a terminal state, then the actor
|
||||
# exiting is expected.
|
||||
pass
|
||||
else:
|
||||
if isinstance(e, RuntimeEnvSetupError):
|
||||
logger.error(f"Failed to set up runtime_env for job {job_id}.")
|
||||
|
||||
target_job_error_message = f"runtime_env setup failed: {e}"
|
||||
target_job_error_type = JobErrorType.RUNTIME_ENV_SETUP_FAILURE
|
||||
|
||||
elif isinstance(e, ActorUnschedulableError):
|
||||
logger.error(
|
||||
f"Failed to schedule job {job_id} because the supervisor "
|
||||
f"actor could not be scheduled: {e}"
|
||||
)
|
||||
|
||||
target_job_error_message = (
|
||||
f"Job supervisor actor could not be scheduled: {e}"
|
||||
)
|
||||
target_job_error_type = (
|
||||
JobErrorType.JOB_SUPERVISOR_ACTOR_UNSCHEDULABLE
|
||||
)
|
||||
|
||||
elif isinstance(e, ActorDiedError):
|
||||
logger.error(f"Job supervisor actor for {job_id} died: {e}")
|
||||
target_job_error_message = f"Job supervisor actor died: {e}"
|
||||
target_job_error_type = JobErrorType.JOB_SUPERVISOR_ACTOR_DIED
|
||||
|
||||
else:
|
||||
logger.error(
|
||||
f"Job monitoring for job {job_id} failed "
|
||||
f"unexpectedly: {e}.",
|
||||
exc_info=e,
|
||||
)
|
||||
|
||||
target_job_error_message = f"Unexpected error occurred: {e}"
|
||||
target_job_error_type = (
|
||||
JobErrorType.JOB_SUPERVISOR_ACTOR_UNKNOWN_FAILURE
|
||||
)
|
||||
|
||||
job_status = JobStatus.FAILED
|
||||
await self._job_info_client.put_status(
|
||||
job_id,
|
||||
job_status,
|
||||
message=target_job_error_message,
|
||||
error_type=target_job_error_type
|
||||
or JobErrorType.JOB_SUPERVISOR_ACTOR_UNKNOWN_FAILURE,
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
# Log error message to the job driver file for easy access.
|
||||
if target_job_error_message:
|
||||
log_path = self._log_client.get_log_file_path(job_id)
|
||||
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
||||
with open(log_path, "a") as log_file:
|
||||
log_file.write(target_job_error_message)
|
||||
|
||||
# Log events
|
||||
if self.event_logger:
|
||||
event_log = (
|
||||
f"Completed a ray job {job_id} with a status {job_status}."
|
||||
)
|
||||
if target_job_error_message:
|
||||
event_log += f" {target_job_error_message}"
|
||||
self.event_logger.error(event_log, submission_id=job_id)
|
||||
else:
|
||||
self.event_logger.info(event_log, submission_id=job_id)
|
||||
|
||||
break
|
||||
|
||||
# Kill the actor defensively to avoid leaking actors in unexpected error cases.
|
||||
if job_supervisor is None:
|
||||
job_supervisor = self._get_actor_for_job(job_id)
|
||||
if job_supervisor is not None:
|
||||
ray.kill(job_supervisor, no_restart=True)
|
||||
|
||||
def _handle_supervisor_startup(self, job_id: str, result: Optional[Exception]):
|
||||
"""Handle the result of starting a job supervisor actor.
|
||||
|
||||
If started successfully, result should be None. Otherwise it should be
|
||||
an Exception.
|
||||
|
||||
On failure, the job will be marked failed with a relevant error
|
||||
message.
|
||||
"""
|
||||
if result is None:
|
||||
return
|
||||
|
||||
def _get_supervisor_runtime_env(
|
||||
self,
|
||||
user_runtime_env: Dict[str, Any],
|
||||
submission_id: str,
|
||||
resources_specified: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Configure and return the runtime_env for the supervisor actor.
|
||||
|
||||
Args:
|
||||
user_runtime_env: The runtime_env specified by the user.
|
||||
submission_id: The submission id of the job; used to derive the log
|
||||
file path piped into the runtime env config.
|
||||
resources_specified: Whether the user specified resources in the
|
||||
submit_job() call. If so, we will skip the workaround introduced
|
||||
in #24546 for GPU detection and just use the user's resource
|
||||
requests, so that the behavior matches that of the user specifying
|
||||
resources for any other actor.
|
||||
|
||||
Returns:
|
||||
The runtime_env for the supervisor actor.
|
||||
"""
|
||||
# Make a copy to avoid mutating passed runtime_env.
|
||||
runtime_env = (
|
||||
copy.deepcopy(user_runtime_env) if user_runtime_env is not None else {}
|
||||
)
|
||||
|
||||
# NOTE(edoakes): Can't use .get(, {}) here because we need to handle the case
|
||||
# where env_vars is explicitly set to `None`.
|
||||
env_vars = runtime_env.get("env_vars")
|
||||
if env_vars is None:
|
||||
env_vars = {}
|
||||
|
||||
env_vars[ray_constants.RAY_WORKER_NICENESS] = "0"
|
||||
|
||||
if not resources_specified:
|
||||
# Don't set CUDA_VISIBLE_DEVICES for the supervisor actor so the
|
||||
# driver can use GPUs if it wants to. This will be removed from
|
||||
# the driver's runtime_env so it isn't inherited by tasks & actors.
|
||||
env_vars[NOSET_CUDA_VISIBLE_DEVICES_ENV_VAR] = "1"
|
||||
env_vars[NOSET_ASCEND_RT_VISIBLE_DEVICES_ENV_VAR] = "1"
|
||||
|
||||
runtime_env["env_vars"] = env_vars
|
||||
|
||||
if os.getenv(RAY_STREAM_RUNTIME_ENV_LOG_TO_JOB_DRIVER_LOG_ENV_VAR, "0") == "1":
|
||||
config = runtime_env.get("config")
|
||||
# Empty fields may be set to None, so we need to check for None explicitly.
|
||||
if config is None:
|
||||
config = RuntimeEnvConfig()
|
||||
config["log_files"] = [self._log_client.get_log_file_path(submission_id)]
|
||||
runtime_env["config"] = config
|
||||
return runtime_env
|
||||
|
||||
async def _get_label_selector(self, resources_specified: bool) -> Dict:
|
||||
"""Determine the scheduling strategy for the job using a label selector.
|
||||
|
||||
If resources_specified is true, or if the environment variable is set to
|
||||
allow the job to run on worker nodes, we will not use any label constraints.
|
||||
Otherwise, we will force the job to use the head node via a label selector
|
||||
specifying the head node id.
|
||||
|
||||
Args:
|
||||
resources_specified: Whether the job specified any resources
|
||||
(CPUs, GPUs, or custom resources).
|
||||
|
||||
Returns:
|
||||
The label selector to use for the job.
|
||||
"""
|
||||
if resources_specified:
|
||||
return {}
|
||||
|
||||
if os.environ.get(RAY_JOB_ALLOW_DRIVER_ON_WORKER_NODES_ENV_VAR, "0") == "1":
|
||||
logger.info(
|
||||
f"{RAY_JOB_ALLOW_DRIVER_ON_WORKER_NODES_ENV_VAR} was set to 1. "
|
||||
"Using Ray's default actor scheduling strategy for the job "
|
||||
"driver instead of running it on the head node via a label selector."
|
||||
)
|
||||
return {}
|
||||
|
||||
# If the user did not specify any resources or set the driver on worker nodes
|
||||
# env var, we will run the driver on the head node.
|
||||
|
||||
head_node_id = await get_head_node_id(self._gcs_client)
|
||||
if head_node_id is None:
|
||||
logger.info(
|
||||
"Head node ID not found in GCS. Using Ray's default actor "
|
||||
"scheduling strategy for the job driver instead of running "
|
||||
"it on the head node via a label selector."
|
||||
)
|
||||
return {}
|
||||
|
||||
logger.info(
|
||||
"Head node ID found in GCS; scheduling job driver on "
|
||||
f"head node {head_node_id} using a label selector"
|
||||
)
|
||||
return {ray._raylet.RAY_NODE_ID_KEY: head_node_id}
|
||||
|
||||
async def submit_job(
|
||||
self,
|
||||
*,
|
||||
entrypoint: str,
|
||||
submission_id: Optional[str] = None,
|
||||
runtime_env: Optional[Dict[str, Any]] = None,
|
||||
metadata: Optional[Dict[str, str]] = None,
|
||||
entrypoint_num_cpus: Optional[Union[int, float]] = None,
|
||||
entrypoint_num_gpus: Optional[Union[int, float]] = None,
|
||||
entrypoint_memory: Optional[int] = None,
|
||||
entrypoint_resources: Optional[Dict[str, float]] = None,
|
||||
entrypoint_label_selector: Optional[Dict[str, str]] = None,
|
||||
_start_signal_actor: Optional[ActorHandle] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Job execution happens asynchronously.
|
||||
|
||||
1) Generate a new unique id for this job submission, each call of this
|
||||
method assumes they're independent submission with its own new
|
||||
ID, job supervisor actor, and child process.
|
||||
2) Create new detached actor with same runtime_env as job spec
|
||||
|
||||
Actual setting up runtime_env, subprocess group, driver command
|
||||
execution, subprocess cleaning up and running status update to GCS
|
||||
is all handled by job supervisor actor.
|
||||
|
||||
Args:
|
||||
entrypoint: Driver command to execute in subprocess shell.
|
||||
Represents the entrypoint to start user application.
|
||||
submission_id: Optional caller-provided submission id. When None, a
|
||||
new id is generated via ``generate_job_id()``.
|
||||
runtime_env: Runtime environment used to execute driver command,
|
||||
which could contain its own ray.init() to configure runtime
|
||||
env at ray cluster, task and actor level.
|
||||
metadata: Support passing arbitrary data to driver command in
|
||||
case needed.
|
||||
entrypoint_num_cpus: The quantity of CPU cores to reserve for the execution
|
||||
of the entrypoint command, separately from any tasks or actors launched
|
||||
by it. Defaults to 0.
|
||||
entrypoint_num_gpus: The quantity of GPUs to reserve for
|
||||
the entrypoint command, separately from any tasks or actors launched
|
||||
by it. Defaults to 0.
|
||||
entrypoint_memory: The amount of total available memory for workers
|
||||
requesting memory the entrypoint command, separately from any tasks
|
||||
or actors launched by it. Defaults to 0.
|
||||
entrypoint_resources: The quantity of various custom resources
|
||||
to reserve for the entrypoint command, separately from any tasks or
|
||||
actors launched by it.
|
||||
entrypoint_label_selector: Label selector for the entrypoint command.
|
||||
_start_signal_actor: Used in testing only to capture state
|
||||
transitions between PENDING -> RUNNING. Regular user shouldn't
|
||||
need this.
|
||||
|
||||
Returns:
|
||||
job_id: Generated uuid for further job management. Only valid
|
||||
within the same ray cluster.
|
||||
"""
|
||||
if entrypoint_num_cpus is None:
|
||||
entrypoint_num_cpus = 0
|
||||
if entrypoint_num_gpus is None:
|
||||
entrypoint_num_gpus = 0
|
||||
if entrypoint_memory is None:
|
||||
entrypoint_memory = 0
|
||||
if submission_id is None:
|
||||
submission_id = generate_job_id()
|
||||
|
||||
# Wait for `_recover_running_jobs` to run before accepting submissions to
|
||||
# avoid duplicate monitoring of the same job.
|
||||
await self._recover_running_jobs_event.wait()
|
||||
|
||||
logger.info(f"Starting job with submission_id: {submission_id}")
|
||||
if entrypoint_label_selector:
|
||||
error_message = validate_label_selector(entrypoint_label_selector)
|
||||
if error_message:
|
||||
raise ValueError(error_message)
|
||||
job_info = JobInfo(
|
||||
entrypoint=entrypoint,
|
||||
status=JobStatus.PENDING,
|
||||
start_time=int(time.time() * 1000),
|
||||
metadata=metadata,
|
||||
runtime_env=runtime_env,
|
||||
entrypoint_num_cpus=entrypoint_num_cpus,
|
||||
entrypoint_num_gpus=entrypoint_num_gpus,
|
||||
entrypoint_memory=entrypoint_memory,
|
||||
entrypoint_resources=entrypoint_resources,
|
||||
)
|
||||
new_key_added = await self._job_info_client.put_info(
|
||||
submission_id, job_info, overwrite=False
|
||||
)
|
||||
if not new_key_added:
|
||||
raise ValueError(
|
||||
f"Job with submission_id {submission_id} already exists. "
|
||||
"Please use a different submission_id."
|
||||
)
|
||||
|
||||
driver_logger = self._get_job_driver_logger(submission_id)
|
||||
# Wait for the actor to start up asynchronously so this call always
|
||||
# returns immediately and we can catch errors with the actor starting
|
||||
# up.
|
||||
try:
|
||||
resources_specified = any(
|
||||
[
|
||||
entrypoint_num_cpus is not None and entrypoint_num_cpus > 0,
|
||||
entrypoint_num_gpus is not None and entrypoint_num_gpus > 0,
|
||||
entrypoint_memory is not None and entrypoint_memory > 0,
|
||||
entrypoint_resources not in [None, {}],
|
||||
entrypoint_label_selector not in [None, {}],
|
||||
]
|
||||
)
|
||||
label_selector = await self._get_label_selector(resources_specified)
|
||||
if entrypoint_label_selector:
|
||||
label_selector = {**label_selector, **entrypoint_label_selector}
|
||||
|
||||
if self.event_logger:
|
||||
self.event_logger.info(
|
||||
f"Started a ray job {submission_id}.", submission_id=submission_id
|
||||
)
|
||||
|
||||
driver_logger.info("Runtime env is setting up.")
|
||||
supervisor_options = dict(
|
||||
lifetime="detached",
|
||||
name=JOB_ACTOR_NAME_TEMPLATE.format(job_id=submission_id),
|
||||
num_cpus=entrypoint_num_cpus,
|
||||
num_gpus=entrypoint_num_gpus,
|
||||
memory=entrypoint_memory,
|
||||
resources=entrypoint_resources,
|
||||
label_selector=label_selector,
|
||||
runtime_env=self._get_supervisor_runtime_env(
|
||||
runtime_env, submission_id, resources_specified
|
||||
),
|
||||
namespace=SUPERVISOR_ACTOR_RAY_NAMESPACE,
|
||||
# Don't pollute task events with system actor tasks that users don't
|
||||
# know about.
|
||||
enable_task_events=False,
|
||||
)
|
||||
supervisor = self._supervisor_actor_cls.options(
|
||||
**supervisor_options
|
||||
).remote(
|
||||
submission_id,
|
||||
entrypoint,
|
||||
metadata or {},
|
||||
self._gcs_address,
|
||||
self._cluster_id_hex,
|
||||
self._logs_dir,
|
||||
)
|
||||
supervisor.run.remote(
|
||||
_start_signal_actor=_start_signal_actor,
|
||||
resources_specified=resources_specified,
|
||||
)
|
||||
|
||||
# Monitor the job in the background so we can detect errors without
|
||||
# requiring a client to poll.
|
||||
run_background_task(
|
||||
self._monitor_job(submission_id, job_supervisor=supervisor)
|
||||
)
|
||||
except Exception as e:
|
||||
tb_str = traceback.format_exc()
|
||||
driver_logger.warning(
|
||||
f"Failed to start supervisor actor for job {submission_id}: '{e}'"
|
||||
f". Full traceback:\n{tb_str}"
|
||||
)
|
||||
await self._job_info_client.put_status(
|
||||
submission_id,
|
||||
JobStatus.FAILED,
|
||||
message=(
|
||||
f"Failed to start supervisor actor {submission_id}: '{e}'"
|
||||
f". Full traceback:\n{tb_str}"
|
||||
),
|
||||
error_type=JobErrorType.JOB_SUPERVISOR_ACTOR_START_FAILURE,
|
||||
)
|
||||
finally:
|
||||
close_logger_file_descriptor(driver_logger)
|
||||
|
||||
return submission_id
|
||||
|
||||
def stop_job(self, job_id) -> bool:
|
||||
"""Request a job to exit, fire and forget.
|
||||
|
||||
Returns whether or not the job was running.
|
||||
"""
|
||||
job_supervisor_actor = self._get_actor_for_job(job_id)
|
||||
if job_supervisor_actor is not None:
|
||||
# Actor is still alive, signal it to stop the driver, fire and
|
||||
# forget
|
||||
job_supervisor_actor.stop.remote()
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
async def delete_job(self, job_id):
|
||||
"""Delete a job's info and metadata from the cluster."""
|
||||
job_status = await self._job_info_client.get_status(job_id)
|
||||
|
||||
if job_status is None or not job_status.is_terminal():
|
||||
raise RuntimeError(
|
||||
f"Attempted to delete job '{job_id}', "
|
||||
f"but it is in a non-terminal state {job_status}."
|
||||
)
|
||||
|
||||
await self._job_info_client.delete_info(job_id)
|
||||
return True
|
||||
|
||||
def job_info_client(self) -> JobInfoStorageClient:
|
||||
return self._job_info_client
|
||||
|
||||
async def get_job_status(self, job_id: str) -> Optional[JobStatus]:
|
||||
"""Get latest status of a job."""
|
||||
return await self._job_info_client.get_status(job_id)
|
||||
|
||||
async def get_job_info(self, job_id: str) -> Optional[JobInfo]:
|
||||
"""Get latest info of a job."""
|
||||
return await self._job_info_client.get_info(job_id)
|
||||
|
||||
async def list_jobs(self) -> Dict[str, JobInfo]:
|
||||
"""Get info for all jobs."""
|
||||
return await self._job_info_client.get_all_jobs()
|
||||
|
||||
def get_job_logs(self, job_id: str) -> str:
|
||||
"""Get all logs produced by a job."""
|
||||
return self._log_client.get_logs(job_id)
|
||||
|
||||
async def tail_job_logs(self, job_id: str) -> AsyncIterator[str]:
|
||||
"""Return an iterator following the logs of a job."""
|
||||
if await self.get_job_status(job_id) is None:
|
||||
raise RuntimeError(f"Job '{job_id}' does not exist.")
|
||||
|
||||
job_finished = False
|
||||
async for lines in self._log_client.tail_logs(job_id):
|
||||
if lines is None:
|
||||
if job_finished:
|
||||
# Job has already finished and we have read EOF afterwards,
|
||||
# it's guaranteed that we won't get any more logs.
|
||||
return
|
||||
else:
|
||||
status = await self.get_job_status(job_id)
|
||||
if status.is_terminal():
|
||||
job_finished = True
|
||||
# Continue tailing logs generated between the
|
||||
# last EOF read and the finish of the job.
|
||||
|
||||
await asyncio.sleep(self.LOG_TAIL_SLEEP_S)
|
||||
else:
|
||||
yield "".join(lines)
|
||||
@@ -0,0 +1,484 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import traceback
|
||||
from asyncio.tasks import FIRST_COMPLETED
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import ray
|
||||
import ray._private.ray_constants as ray_constants
|
||||
from ray._common.filters import CoreContextFilter
|
||||
from ray._common.formatters import JSONFormatter, TextFormatter
|
||||
from ray._common.network_utils import build_address
|
||||
from ray._private.accelerators.npu import NOSET_ASCEND_RT_VISIBLE_DEVICES_ENV_VAR
|
||||
from ray._private.accelerators.nvidia_gpu import NOSET_CUDA_VISIBLE_DEVICES_ENV_VAR
|
||||
from ray._private.runtime_env.constants import RAY_JOB_CONFIG_JSON_ENV_VAR
|
||||
from ray._private.utils import remove_ray_internal_flags_from_env
|
||||
from ray._raylet import GcsClient
|
||||
from ray.actor import ActorHandle
|
||||
from ray.dashboard.modules.job.common import (
|
||||
JOB_ID_METADATA_KEY,
|
||||
JOB_NAME_METADATA_KEY,
|
||||
JobInfoStorageClient,
|
||||
)
|
||||
from ray.dashboard.modules.job.job_log_storage_client import JobLogStorageClient
|
||||
from ray.job_submission import JobErrorType, JobStatus
|
||||
|
||||
import psutil
|
||||
|
||||
# asyncio python version compatibility
|
||||
try:
|
||||
create_task = asyncio.create_task
|
||||
except AttributeError:
|
||||
create_task = asyncio.ensure_future
|
||||
|
||||
# Windows requires additional packages for proper process control.
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
import win32api
|
||||
import win32con
|
||||
import win32job
|
||||
except (ModuleNotFoundError, ImportError) as e:
|
||||
win32api = None
|
||||
win32con = None
|
||||
win32job = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(
|
||||
"Failed to Import win32api. For best usage experience run "
|
||||
f"'conda install pywin32'. Import error: {e}"
|
||||
)
|
||||
|
||||
|
||||
class JobSupervisor:
|
||||
"""
|
||||
Ray actor created by JobManager for each submitted job, responsible to
|
||||
setup runtime_env, execute given shell command in subprocess, update job
|
||||
status, persist job logs and manage subprocess group cleaning.
|
||||
|
||||
One job supervisor actor maps to one subprocess, for one job_id.
|
||||
Job supervisor actor should fate share with subprocess it created.
|
||||
"""
|
||||
|
||||
DEFAULT_RAY_JOB_STOP_WAIT_TIME_S = 3
|
||||
SUBPROCESS_POLL_PERIOD_S = 0.1
|
||||
VALID_STOP_SIGNALS = ["SIGINT", "SIGTERM"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
job_id: str,
|
||||
entrypoint: str,
|
||||
user_metadata: Dict[str, str],
|
||||
gcs_address: str,
|
||||
cluster_id_hex: str,
|
||||
logs_dir: Optional[str] = None,
|
||||
):
|
||||
self._job_id = job_id
|
||||
gcs_client = GcsClient(address=gcs_address, cluster_id=cluster_id_hex)
|
||||
self._job_info_client = JobInfoStorageClient(gcs_client, logs_dir)
|
||||
self._log_client = JobLogStorageClient()
|
||||
self._entrypoint = entrypoint
|
||||
|
||||
# Default metadata if not passed by the user.
|
||||
self._metadata = {JOB_ID_METADATA_KEY: job_id, JOB_NAME_METADATA_KEY: job_id}
|
||||
self._metadata.update(user_metadata)
|
||||
|
||||
# Event used to signal that a job should be stopped.
|
||||
# Set in the `stop_job` method.
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
# Windows Job Object used to handle stopping the child processes.
|
||||
self._win32_job_object = None
|
||||
|
||||
# Logger object to persist JobSupervisor logs in separate file.
|
||||
self._logger = logging.getLogger(f"{__name__}.supervisor-{job_id}")
|
||||
self._configure_logger()
|
||||
|
||||
def _configure_logger(self) -> None:
|
||||
"""
|
||||
Configure self._logger object to write logs to file based on job
|
||||
submission ID and to console.
|
||||
"""
|
||||
supervisor_log_file_name = os.path.join(
|
||||
ray._private.worker._global_node.get_logs_dir_path(),
|
||||
f"jobs/supervisor-{self._job_id}.log",
|
||||
)
|
||||
os.makedirs(os.path.dirname(supervisor_log_file_name), exist_ok=True)
|
||||
self._logger.addFilter(CoreContextFilter())
|
||||
stream_handler = logging.StreamHandler()
|
||||
file_handler = logging.FileHandler(supervisor_log_file_name)
|
||||
formatter = TextFormatter()
|
||||
if ray_constants.env_bool(ray_constants.RAY_BACKEND_LOG_JSON_ENV_VAR, False):
|
||||
formatter = JSONFormatter()
|
||||
stream_handler.setFormatter(formatter)
|
||||
file_handler.setFormatter(formatter)
|
||||
self._logger.addHandler(stream_handler)
|
||||
self._logger.addHandler(file_handler)
|
||||
self._logger.propagate = False
|
||||
|
||||
def _get_driver_runtime_env(
|
||||
self, resources_specified: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Get the runtime env that should be set in the job driver.
|
||||
|
||||
Args:
|
||||
resources_specified: Whether the user specified resources (CPUs, GPUs,
|
||||
custom resources) in the submit_job request. If so, we will skip
|
||||
the workaround for GPU detection introduced in #24546, so that the
|
||||
behavior matches that of the user specifying resources for any
|
||||
other actor.
|
||||
|
||||
Returns:
|
||||
The runtime env that should be set in the job driver.
|
||||
"""
|
||||
# Get the runtime_env set for the supervisor actor.
|
||||
curr_runtime_env = dict(ray.get_runtime_context().runtime_env)
|
||||
if resources_specified:
|
||||
return curr_runtime_env
|
||||
# Allow CUDA_VISIBLE_DEVICES to be set normally for the driver's tasks
|
||||
# & actors.
|
||||
env_vars = curr_runtime_env.get("env_vars", {})
|
||||
env_vars.pop(NOSET_CUDA_VISIBLE_DEVICES_ENV_VAR)
|
||||
env_vars.pop(NOSET_ASCEND_RT_VISIBLE_DEVICES_ENV_VAR)
|
||||
env_vars.pop(ray_constants.RAY_WORKER_NICENESS)
|
||||
curr_runtime_env["env_vars"] = env_vars
|
||||
return curr_runtime_env
|
||||
|
||||
def ping(self):
|
||||
"""Used to check the health of the actor."""
|
||||
pass
|
||||
|
||||
def _exec_entrypoint(self, env: dict, logs_path: str) -> subprocess.Popen:
|
||||
"""
|
||||
Runs the entrypoint command as a child process, streaming stderr &
|
||||
stdout to given log files.
|
||||
|
||||
Unix systems:
|
||||
Meanwhile we start a demon process and group driver
|
||||
subprocess in same pgid, such that if job actor dies, entire process
|
||||
group also fate share with it.
|
||||
|
||||
Windows systems:
|
||||
A jobObject is created to enable fate sharing for the entire process group.
|
||||
|
||||
Args:
|
||||
env: Environment variables passed through to the driver subprocess.
|
||||
logs_path: File path on head node's local disk to store driver
|
||||
command's stdout & stderr.
|
||||
Returns:
|
||||
child_process: Child process that runs the driver command. Can be
|
||||
terminated or killed upon user calling stop().
|
||||
"""
|
||||
# Open in append mode to avoid overwriting runtime_env setup logs for the
|
||||
# supervisor actor, which are also written to the same file.
|
||||
with open(logs_path, "a") as logs_file:
|
||||
logs_file.write(
|
||||
f"Running entrypoint for job {self._job_id}: {self._entrypoint}\n"
|
||||
)
|
||||
child_process = subprocess.Popen(
|
||||
self._entrypoint,
|
||||
shell=True,
|
||||
start_new_session=True,
|
||||
stdout=logs_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=env,
|
||||
# Ray intentionally blocks SIGINT in all processes, so if the user wants
|
||||
# to stop job through SIGINT, we need to unblock it in the child process
|
||||
preexec_fn=(
|
||||
(
|
||||
lambda: signal.pthread_sigmask(
|
||||
signal.SIG_UNBLOCK, {signal.SIGINT}
|
||||
)
|
||||
)
|
||||
if sys.platform != "win32"
|
||||
and os.environ.get("RAY_JOB_STOP_SIGNAL") == "SIGINT"
|
||||
else None
|
||||
),
|
||||
)
|
||||
parent_pid = os.getpid()
|
||||
child_pid = child_process.pid
|
||||
# Create new pgid with new subprocess to execute driver command
|
||||
|
||||
if sys.platform != "win32":
|
||||
try:
|
||||
child_pgid = os.getpgid(child_pid)
|
||||
except ProcessLookupError:
|
||||
# Process died before we could get its pgid.
|
||||
return child_process
|
||||
|
||||
# Open a new subprocess to kill the child process when the parent
|
||||
# process dies kill -s 0 parent_pid will succeed if the parent is
|
||||
# alive. If it fails, SIGKILL the child process group and exit
|
||||
subprocess.Popen(
|
||||
f"while kill -s 0 {parent_pid}; do sleep 1; done; kill -9 -{child_pgid}", # noqa: E501
|
||||
shell=True,
|
||||
# Suppress output
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
elif sys.platform == "win32" and win32api:
|
||||
# Create a JobObject to which the child process (and its children)
|
||||
# will be connected. This job object can be used to kill the child
|
||||
# processes explicitly or when the jobObject gets deleted during
|
||||
# garbage collection.
|
||||
self._win32_job_object = win32job.CreateJobObject(None, "")
|
||||
win32_job_info = win32job.QueryInformationJobObject(
|
||||
self._win32_job_object, win32job.JobObjectExtendedLimitInformation
|
||||
)
|
||||
win32_job_info["BasicLimitInformation"][
|
||||
"LimitFlags"
|
||||
] = win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
||||
win32job.SetInformationJobObject(
|
||||
self._win32_job_object,
|
||||
win32job.JobObjectExtendedLimitInformation,
|
||||
win32_job_info,
|
||||
)
|
||||
child_handle = win32api.OpenProcess(
|
||||
win32con.PROCESS_TERMINATE | win32con.PROCESS_SET_QUOTA,
|
||||
False,
|
||||
child_pid,
|
||||
)
|
||||
win32job.AssignProcessToJobObject(self._win32_job_object, child_handle)
|
||||
|
||||
return child_process
|
||||
|
||||
def _get_driver_env_vars(self, resources_specified: bool) -> Dict[str, str]:
|
||||
"""Returns environment variables that should be set in the driver."""
|
||||
# RAY_ADDRESS may be the dashboard URL but not the gcs address,
|
||||
# so when the environment variable is not empty, we force set RAY_ADDRESS
|
||||
# to "auto" to avoid function `canonicalize_bootstrap_address_or_die` returning
|
||||
# the wrong GCS address.
|
||||
# TODO(Jialing He, Archit Kulkarni): Definition of Specification RAY_ADDRESS
|
||||
if ray_constants.RAY_ADDRESS_ENVIRONMENT_VARIABLE in os.environ:
|
||||
os.environ[ray_constants.RAY_ADDRESS_ENVIRONMENT_VARIABLE] = "auto"
|
||||
ray_addr = ray._private.services.canonicalize_bootstrap_address_or_die(
|
||||
"auto", ray._private.worker._global_node._ray_params.temp_dir
|
||||
)
|
||||
assert ray_addr is not None
|
||||
return {
|
||||
# Set JobConfig for the child process (runtime_env, metadata).
|
||||
RAY_JOB_CONFIG_JSON_ENV_VAR: json.dumps(
|
||||
{
|
||||
"runtime_env": self._get_driver_runtime_env(resources_specified),
|
||||
"metadata": self._metadata,
|
||||
}
|
||||
),
|
||||
# Always set RAY_ADDRESS as find_bootstrap_address address for
|
||||
# job submission. In case of local development, prevent user from
|
||||
# re-using http://{address}:{dashboard_port} to interact with
|
||||
# jobs SDK.
|
||||
# TODO:(mwtian) Check why "auto" does not work in entrypoint script
|
||||
ray_constants.RAY_ADDRESS_ENVIRONMENT_VARIABLE: ray_addr,
|
||||
# Set PYTHONUNBUFFERED=1 to stream logs during the job instead of
|
||||
# only streaming them upon completion of the job.
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
}
|
||||
|
||||
async def _polling(self, child_process: subprocess.Popen) -> int:
|
||||
while child_process is not None:
|
||||
return_code = child_process.poll()
|
||||
if return_code is not None:
|
||||
# subprocess finished with return code
|
||||
return return_code
|
||||
else:
|
||||
# still running, yield control, 0.1s by default
|
||||
await asyncio.sleep(self.SUBPROCESS_POLL_PERIOD_S)
|
||||
|
||||
async def _poll_all(self, processes: List[psutil.Process]):
|
||||
"""Poll processes until all are completed."""
|
||||
while True:
|
||||
(_, alive) = psutil.wait_procs(processes, timeout=0)
|
||||
if len(alive) == 0:
|
||||
return
|
||||
else:
|
||||
await asyncio.sleep(self.SUBPROCESS_POLL_PERIOD_S)
|
||||
|
||||
def _kill_processes(self, processes: List[psutil.Process], sig: signal.Signals):
|
||||
"""Ensure each process is already finished or send a kill signal."""
|
||||
for proc in processes:
|
||||
try:
|
||||
os.kill(proc.pid, sig)
|
||||
except ProcessLookupError:
|
||||
# Process is already dead
|
||||
pass
|
||||
|
||||
async def run(
|
||||
self,
|
||||
# Signal actor used in testing to capture PENDING -> RUNNING cases
|
||||
_start_signal_actor: Optional[ActorHandle] = None,
|
||||
resources_specified: bool = False,
|
||||
):
|
||||
"""
|
||||
Stop and start both happen asynchronously, coordinated by asyncio event
|
||||
and coroutine, respectively.
|
||||
|
||||
1) Sets job status as running
|
||||
2) Pass runtime env and metadata to subprocess as serialized env
|
||||
variables.
|
||||
3) Handle concurrent events of driver execution and
|
||||
"""
|
||||
curr_info = await self._job_info_client.get_info(self._job_id)
|
||||
if curr_info is None:
|
||||
raise RuntimeError(f"Status could not be retrieved for job {self._job_id}.")
|
||||
curr_status = curr_info.status
|
||||
curr_message = curr_info.message
|
||||
if curr_status == JobStatus.RUNNING:
|
||||
raise RuntimeError(
|
||||
f"Job {self._job_id} is already in RUNNING state. "
|
||||
f"JobSupervisor.run() should only be called once. "
|
||||
)
|
||||
if curr_status != JobStatus.PENDING:
|
||||
raise RuntimeError(
|
||||
f"Job {self._job_id} is not in PENDING state. "
|
||||
f"Current status is {curr_status} with message {curr_message}."
|
||||
)
|
||||
|
||||
if _start_signal_actor:
|
||||
# Block in PENDING state until start signal received.
|
||||
await _start_signal_actor.wait.remote()
|
||||
|
||||
node = ray._private.worker.global_worker.node
|
||||
driver_agent_http_address = f"http://{build_address(node.node_ip_address, node.dashboard_agent_listen_port)}"
|
||||
driver_node_id = ray.get_runtime_context().get_node_id()
|
||||
|
||||
await self._job_info_client.put_status(
|
||||
self._job_id,
|
||||
JobStatus.RUNNING,
|
||||
jobinfo_replace_kwargs={
|
||||
"driver_agent_http_address": driver_agent_http_address,
|
||||
"driver_node_id": driver_node_id,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
# Configure environment variables for the child process.
|
||||
env = os.environ.copy()
|
||||
# Remove internal Ray flags. They present because JobSuperVisor itself is
|
||||
# a Ray worker process but we don't want to pass them to the driver.
|
||||
remove_ray_internal_flags_from_env(env)
|
||||
# These will *not* be set in the runtime_env, so they apply to the driver
|
||||
# only, not its tasks & actors.
|
||||
env.update(self._get_driver_env_vars(resources_specified))
|
||||
|
||||
self._logger.info(
|
||||
"Submitting job with RAY_ADDRESS = "
|
||||
f"{env[ray_constants.RAY_ADDRESS_ENVIRONMENT_VARIABLE]}"
|
||||
)
|
||||
log_path = self._log_client.get_log_file_path(self._job_id)
|
||||
child_process = self._exec_entrypoint(env, log_path)
|
||||
child_pid = child_process.pid
|
||||
|
||||
polling_task = create_task(self._polling(child_process))
|
||||
finished, _ = await asyncio.wait(
|
||||
[polling_task, create_task(self._stop_event.wait())],
|
||||
return_when=FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
if self._stop_event.is_set():
|
||||
polling_task.cancel()
|
||||
if sys.platform == "win32" and self._win32_job_object:
|
||||
win32job.TerminateJobObject(self._win32_job_object, -1)
|
||||
elif sys.platform != "win32":
|
||||
stop_signal = os.environ.get("RAY_JOB_STOP_SIGNAL", "SIGTERM")
|
||||
if stop_signal not in self.VALID_STOP_SIGNALS:
|
||||
self._logger.warning(
|
||||
f"{stop_signal} not a valid stop signal. Terminating "
|
||||
"job with SIGTERM."
|
||||
)
|
||||
stop_signal = "SIGTERM"
|
||||
|
||||
job_process = psutil.Process(child_pid)
|
||||
proc_to_kill = [job_process] + job_process.children(recursive=True)
|
||||
|
||||
# Send stop signal and wait for job to terminate gracefully,
|
||||
# otherwise SIGKILL job forcefully after timeout.
|
||||
self._kill_processes(proc_to_kill, getattr(signal, stop_signal))
|
||||
try:
|
||||
stop_job_wait_time = int(
|
||||
os.environ.get(
|
||||
"RAY_JOB_STOP_WAIT_TIME_S",
|
||||
self.DEFAULT_RAY_JOB_STOP_WAIT_TIME_S,
|
||||
)
|
||||
)
|
||||
poll_job_stop_task = create_task(self._poll_all(proc_to_kill))
|
||||
await asyncio.wait_for(poll_job_stop_task, stop_job_wait_time)
|
||||
self._logger.info(
|
||||
f"Job {self._job_id} has been terminated gracefully "
|
||||
f"with {stop_signal}."
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
self._logger.warning(
|
||||
f"Attempt to gracefully terminate job {self._job_id} "
|
||||
f"through {stop_signal} has timed out after "
|
||||
f"{stop_job_wait_time} seconds. Job is now being "
|
||||
"force-killed with SIGKILL."
|
||||
)
|
||||
self._kill_processes(proc_to_kill, signal.SIGKILL)
|
||||
|
||||
await self._job_info_client.put_status(self._job_id, JobStatus.STOPPED)
|
||||
else:
|
||||
# Child process finished execution and no stop event is set
|
||||
# at the same time
|
||||
assert len(finished) == 1, "Should have only one coroutine done"
|
||||
[child_process_task] = finished
|
||||
return_code = child_process_task.result()
|
||||
self._logger.info(
|
||||
f"Job {self._job_id} entrypoint command "
|
||||
f"exited with code {return_code}"
|
||||
)
|
||||
if return_code == 0:
|
||||
await self._job_info_client.put_status(
|
||||
self._job_id,
|
||||
JobStatus.SUCCEEDED,
|
||||
driver_exit_code=return_code,
|
||||
)
|
||||
else:
|
||||
log_tail = await self._log_client.get_last_n_log_lines(self._job_id)
|
||||
if log_tail is not None and log_tail != "":
|
||||
message = (
|
||||
"Job entrypoint command "
|
||||
f"failed with exit code {return_code}, "
|
||||
"last available logs (truncated to 20,000 chars):\n"
|
||||
+ log_tail
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
"Job entrypoint command "
|
||||
f"failed with exit code {return_code}. No logs available."
|
||||
)
|
||||
await self._job_info_client.put_status(
|
||||
self._job_id,
|
||||
JobStatus.FAILED,
|
||||
message=message,
|
||||
driver_exit_code=return_code,
|
||||
error_type=JobErrorType.JOB_ENTRYPOINT_COMMAND_ERROR,
|
||||
)
|
||||
except Exception:
|
||||
self._logger.error(
|
||||
"Got unexpected exception while trying to execute driver "
|
||||
f"command. {traceback.format_exc()}"
|
||||
)
|
||||
try:
|
||||
await self._job_info_client.put_status(
|
||||
self._job_id,
|
||||
JobStatus.FAILED,
|
||||
message=traceback.format_exc(),
|
||||
error_type=JobErrorType.JOB_ENTRYPOINT_COMMAND_START_ERROR,
|
||||
)
|
||||
except Exception:
|
||||
self._logger.error(
|
||||
"Failed to update job status to FAILED. "
|
||||
f"Exception: {traceback.format_exc()}"
|
||||
)
|
||||
finally:
|
||||
# clean up actor after tasks are finished
|
||||
ray.actor.exit_actor()
|
||||
|
||||
def stop(self):
|
||||
"""Set step_event and let run() handle the rest in its asyncio.wait()."""
|
||||
self._stop_event.set()
|
||||
@@ -0,0 +1,110 @@
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ray._common.pydantic_compat import PYDANTIC_INSTALLED, BaseModel, Field
|
||||
from ray.dashboard.modules.job.common import JobStatus
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
# Pydantic is not part of the minimal Ray installation.
|
||||
if PYDANTIC_INSTALLED:
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class DriverInfo(BaseModel):
|
||||
"""A class for recording information about the driver related to the job."""
|
||||
|
||||
id: str = Field(..., description="The id of the driver")
|
||||
node_ip_address: str = Field(
|
||||
..., description="The IP address of the node the driver is running on."
|
||||
)
|
||||
pid: str = Field(
|
||||
..., description="The PID of the worker process the driver is using."
|
||||
)
|
||||
# TODO(aguo): Add node_id as a field.
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class JobType(str, Enum):
|
||||
"""An enumeration for describing the different job types.
|
||||
|
||||
NOTE:
|
||||
This field is still experimental and may change in the future.
|
||||
"""
|
||||
|
||||
#: A job that was initiated by the Ray Jobs API.
|
||||
SUBMISSION = "SUBMISSION"
|
||||
#: A job that was initiated by a driver script.
|
||||
DRIVER = "DRIVER"
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class JobDetails(BaseModel):
|
||||
"""
|
||||
Job data with extra details about its driver and its submission.
|
||||
"""
|
||||
|
||||
type: JobType = Field(..., description="The type of job.")
|
||||
job_id: Optional[str] = Field(
|
||||
None,
|
||||
description="The job ID. An ID that is created for every job that is "
|
||||
"launched in Ray. This can be used to fetch data about jobs using Ray "
|
||||
"Core APIs.",
|
||||
)
|
||||
submission_id: Optional[str] = Field(
|
||||
None,
|
||||
description="A submission ID is an ID created for every job submitted via"
|
||||
"the Ray Jobs API. It can "
|
||||
"be used to fetch data about jobs using the Ray Jobs API.",
|
||||
)
|
||||
driver_info: Optional[DriverInfo] = Field(
|
||||
None,
|
||||
description="The driver related to this job. For jobs submitted via "
|
||||
"the Ray Jobs API, "
|
||||
"it is the last driver launched by that job submission, "
|
||||
"or None if there is no driver.",
|
||||
)
|
||||
|
||||
# The following fields are copied from JobInfo.
|
||||
# TODO(aguo): Inherit from JobInfo once it's migrated to pydantic.
|
||||
status: JobStatus = Field(..., description="The status of the job.")
|
||||
entrypoint: str = Field(..., description="The entrypoint command for this job.")
|
||||
message: Optional[str] = Field(
|
||||
None, description="A message describing the status in more detail."
|
||||
)
|
||||
error_type: Optional[str] = Field(
|
||||
None, description="Internal error or user script error."
|
||||
)
|
||||
start_time: Optional[int] = Field(
|
||||
None,
|
||||
description="The time when the job was started. A Unix timestamp in ms.",
|
||||
)
|
||||
end_time: Optional[int] = Field(
|
||||
None,
|
||||
description="The time when the job moved into a terminal state. "
|
||||
"A Unix timestamp in ms.",
|
||||
)
|
||||
metadata: Optional[Dict[str, str]] = Field(
|
||||
None, description="Arbitrary user-provided metadata for the job."
|
||||
)
|
||||
runtime_env: Optional[Dict[str, Any]] = Field(
|
||||
None, description="The runtime environment for the job."
|
||||
)
|
||||
# the node info where the driver running on.
|
||||
# - driver_agent_http_address: this node's agent http address
|
||||
# - driver_node_id: this node's id.
|
||||
driver_agent_http_address: Optional[str] = Field(
|
||||
None,
|
||||
description="The HTTP address of the JobAgent on the node the job "
|
||||
"entrypoint command is running on.",
|
||||
)
|
||||
driver_node_id: Optional[str] = Field(
|
||||
None,
|
||||
description="The ID of the node the job entrypoint command is running on.",
|
||||
)
|
||||
driver_exit_code: Optional[int] = Field(
|
||||
None,
|
||||
description="The driver process exit code after the driver executed. "
|
||||
"Return None if driver doesn't finish executing.",
|
||||
)
|
||||
|
||||
else:
|
||||
DriverInfo = None
|
||||
JobType = None
|
||||
JobDetails = None
|
||||
@@ -0,0 +1,542 @@
|
||||
import copy
|
||||
import dataclasses
|
||||
import logging
|
||||
from typing import Any, AsyncIterator, Dict, List, Optional, Union
|
||||
|
||||
import packaging.version
|
||||
|
||||
import ray
|
||||
from ray.dashboard.modules.dashboard_sdk import SubmissionClient
|
||||
from ray.dashboard.modules.job.common import (
|
||||
JobDeleteResponse,
|
||||
JobLogsResponse,
|
||||
JobStatus,
|
||||
JobStopResponse,
|
||||
JobSubmitRequest,
|
||||
JobSubmitResponse,
|
||||
)
|
||||
from ray.dashboard.modules.job.pydantic_models import JobDetails
|
||||
from ray.dashboard.modules.job.utils import strip_keys_with_value_none
|
||||
from ray.dashboard.utils import get_address_for_submission_client
|
||||
from ray.runtime_env import RuntimeEnv
|
||||
from ray.runtime_env.runtime_env import _validate_no_local_paths
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
import requests
|
||||
except ImportError:
|
||||
aiohttp = None
|
||||
requests = None
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
class JobSubmissionClient(SubmissionClient):
|
||||
"""A local client for submitting and interacting with jobs on a remote cluster.
|
||||
|
||||
Submits requests over HTTP to the job server on the cluster using the REST API.
|
||||
|
||||
|
||||
Args:
|
||||
address: Either (1) the address of the Ray cluster, or (2) the HTTP address
|
||||
of the dashboard server on the head node, e.g. "http://<head-node-ip>:8265".
|
||||
In case (1) it must be specified as an address that can be passed to
|
||||
ray.init(), e.g. a Ray Client address (ray://<head_node_host>:10001),
|
||||
or "auto", or "localhost:<port>". If unspecified, will try to connect to
|
||||
a running local Ray cluster. This argument is always overridden by the
|
||||
RAY_API_SERVER_ADDRESS or RAY_ADDRESS environment variable.
|
||||
create_cluster_if_needed: Indicates whether the cluster at the specified
|
||||
address needs to already be running. Ray doesn't start a cluster
|
||||
before interacting with jobs, but third-party job managers may do so.
|
||||
cookies: Cookies to use when sending requests to the HTTP job server.
|
||||
metadata: Arbitrary metadata to store along with all jobs. New metadata
|
||||
specified per job will be merged with the global metadata provided here
|
||||
via a simple dict update.
|
||||
headers: Headers to use when sending requests to the HTTP job server, used
|
||||
for cases like authentication to a remote cluster.
|
||||
verify: Boolean indication to verify the server's TLS certificate or a path to
|
||||
a file or directory of trusted certificates. Default: True.
|
||||
**kwargs: Additional keyword arguments forwarded to the cluster info
|
||||
resolution function. For external module addresses (e.g.,
|
||||
``anyscale://``), these are passed through to the module's
|
||||
``get_job_submission_client_cluster_info()`` implementation.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
address: Optional[str] = None,
|
||||
create_cluster_if_needed: bool = False,
|
||||
cookies: Optional[Dict[str, Any]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, Any]] = None,
|
||||
verify: Optional[Union[str, bool]] = True,
|
||||
**kwargs,
|
||||
):
|
||||
self._client_ray_version = ray.__version__
|
||||
"""Initialize a JobSubmissionClient and check the connection to the cluster."""
|
||||
if requests is None:
|
||||
raise RuntimeError(
|
||||
"The Ray jobs CLI & SDK require the ray[default] "
|
||||
"installation: `pip install 'ray[default]'`"
|
||||
)
|
||||
# Check types of arguments
|
||||
if address is not None and not isinstance(address, str):
|
||||
raise TypeError(f"address must be a string, got {type(address)}")
|
||||
if not isinstance(create_cluster_if_needed, bool):
|
||||
raise TypeError(
|
||||
f"create_cluster_if_needed must be a bool, got"
|
||||
f" {type(create_cluster_if_needed)}"
|
||||
)
|
||||
if cookies is not None and not isinstance(cookies, dict):
|
||||
raise TypeError(f"cookies must be a dict, got {type(cookies)}")
|
||||
if metadata is not None and not isinstance(metadata, dict):
|
||||
raise TypeError(f"metadata must be a dict, got {type(metadata)}")
|
||||
if headers is not None and not isinstance(headers, dict):
|
||||
raise TypeError(f"headers must be a dict, got {type(headers)}")
|
||||
if not (isinstance(verify, str) or isinstance(verify, bool)):
|
||||
raise TypeError(f"verify must be a str or bool, got {type(verify)}")
|
||||
|
||||
api_server_url = get_address_for_submission_client(address)
|
||||
|
||||
super().__init__(
|
||||
address=api_server_url,
|
||||
create_cluster_if_needed=create_cluster_if_needed,
|
||||
cookies=cookies,
|
||||
metadata=metadata,
|
||||
headers=headers,
|
||||
verify=verify,
|
||||
**kwargs,
|
||||
)
|
||||
self._check_connection_and_version(
|
||||
min_version="1.9",
|
||||
version_error_message="Jobs API is not supported on the Ray "
|
||||
"cluster. Please ensure the cluster is "
|
||||
"running Ray 1.9 or higher.",
|
||||
)
|
||||
|
||||
# In ray>=2.0, the client sends the new kwarg `submission_id` to the server
|
||||
# upon every job submission, which causes servers with ray<2.0 to error.
|
||||
if packaging.version.parse(self._client_ray_version) > packaging.version.parse(
|
||||
"2.0"
|
||||
):
|
||||
self._check_connection_and_version(
|
||||
min_version="2.0",
|
||||
version_error_message=f"Client Ray version {self._client_ray_version} "
|
||||
"is not compatible with the Ray cluster. Please ensure the cluster is "
|
||||
"running Ray 2.0 or higher or downgrade the client Ray version.",
|
||||
)
|
||||
|
||||
@PublicAPI(stability="stable")
|
||||
def submit_job(
|
||||
self,
|
||||
*,
|
||||
entrypoint: str,
|
||||
job_id: Optional[str] = None,
|
||||
runtime_env: Optional[Dict[str, Any]] = None,
|
||||
metadata: Optional[Dict[str, str]] = None,
|
||||
submission_id: Optional[str] = None,
|
||||
entrypoint_num_cpus: Optional[Union[int, float]] = None,
|
||||
entrypoint_num_gpus: Optional[Union[int, float]] = None,
|
||||
entrypoint_memory: Optional[int] = None,
|
||||
entrypoint_resources: Optional[Dict[str, float]] = None,
|
||||
entrypoint_label_selector: Optional[Dict[str, str]] = None,
|
||||
) -> str:
|
||||
"""Submit and execute a job asynchronously.
|
||||
|
||||
When a job is submitted, it runs once to completion or failure. Retries or
|
||||
different runs with different parameters should be handled by the
|
||||
submitter. Jobs are bound to the lifetime of a Ray cluster, so if the
|
||||
cluster goes down, all running jobs on that cluster will be terminated.
|
||||
|
||||
Example:
|
||||
>>> from ray.job_submission import JobSubmissionClient
|
||||
>>> client = JobSubmissionClient("http://127.0.0.1:8265") # doctest: +SKIP
|
||||
>>> client.submit_job( # doctest: +SKIP
|
||||
... entrypoint="python script.py",
|
||||
... runtime_env={
|
||||
... "working_dir": "./",
|
||||
... "pip": ["requests==2.26.0"]
|
||||
... }
|
||||
... ) # doctest: +SKIP
|
||||
'raysubmit_4LamXRuQpYdSMg7J'
|
||||
|
||||
Args:
|
||||
entrypoint: The shell command to run for this job.
|
||||
job_id: DEPRECATED. This has been renamed to submission_id.
|
||||
runtime_env: The runtime environment to install and run this job in.
|
||||
metadata: Arbitrary data to store along with this job.
|
||||
submission_id: A unique ID for this job.
|
||||
entrypoint_num_cpus: The quantity of CPU cores to reserve for the execution
|
||||
of the entrypoint command, separately from any tasks or actors launched
|
||||
by it. Defaults to 0.
|
||||
entrypoint_num_gpus: The quantity of GPUs to reserve for the execution
|
||||
of the entrypoint command, separately from any tasks or actors launched
|
||||
by it. Defaults to 0.
|
||||
entrypoint_memory: The quantity of memory to reserve for the
|
||||
execution of the entrypoint command, separately from any tasks or
|
||||
actors launched by it. Defaults to 0.
|
||||
entrypoint_resources: The quantity of custom resources to reserve for the
|
||||
execution of the entrypoint command, separately from any tasks or
|
||||
actors launched by it.
|
||||
entrypoint_label_selector: Label selector for the entrypoint command.
|
||||
|
||||
Returns:
|
||||
The submission ID of the submitted job. If not specified,
|
||||
this is a randomly generated unique ID.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the request to the job server fails, or if the specified
|
||||
submission_id has already been used by a job on this cluster.
|
||||
"""
|
||||
if job_id:
|
||||
logger.warning(
|
||||
"job_id kwarg is deprecated. Please use submission_id instead."
|
||||
)
|
||||
|
||||
if (
|
||||
entrypoint_num_cpus
|
||||
or entrypoint_num_gpus
|
||||
or entrypoint_resources
|
||||
or entrypoint_label_selector
|
||||
):
|
||||
self._check_connection_and_version(
|
||||
min_version="2.2",
|
||||
version_error_message="`entrypoint_num_cpus`, `entrypoint_num_gpus`, "
|
||||
"`entrypoint_resources`, and `entrypoint_label_selector` kwargs "
|
||||
"are not supported on the Ray cluster. Please ensure the cluster is "
|
||||
"running Ray 2.2 or higher.",
|
||||
)
|
||||
|
||||
if entrypoint_memory:
|
||||
self._check_connection_and_version(
|
||||
min_version="2.8",
|
||||
version_error_message="`entrypoint_memory` kwarg "
|
||||
"is not supported on the Ray cluster. Please ensure the cluster is "
|
||||
"running Ray 2.8 or higher.",
|
||||
)
|
||||
|
||||
runtime_env = copy.deepcopy(runtime_env or {})
|
||||
metadata = metadata or {}
|
||||
metadata.update(self._default_metadata)
|
||||
|
||||
self._upload_working_dir_if_needed(runtime_env)
|
||||
self._upload_py_modules_if_needed(runtime_env)
|
||||
|
||||
# Verify worker_process_setup_hook type.
|
||||
setup_hook = runtime_env.get("worker_process_setup_hook")
|
||||
if setup_hook and not isinstance(setup_hook, str):
|
||||
raise ValueError(
|
||||
f"Invalid type {type(setup_hook)} for `worker_process_setup_hook`. "
|
||||
"When a job submission API is used, `worker_process_setup_hook` "
|
||||
"only allows a string type (module name). "
|
||||
"Specify `worker_process_setup_hook` via "
|
||||
"ray.init within a driver to use a `Callable` type. "
|
||||
)
|
||||
|
||||
# Run the RuntimeEnv constructor to parse local pip/conda requirements files.
|
||||
runtime_env = RuntimeEnv(**runtime_env)
|
||||
_validate_no_local_paths(runtime_env)
|
||||
runtime_env = runtime_env.to_dict()
|
||||
|
||||
submission_id = submission_id or job_id
|
||||
req = JobSubmitRequest(
|
||||
entrypoint=entrypoint,
|
||||
submission_id=submission_id,
|
||||
runtime_env=runtime_env,
|
||||
metadata=metadata,
|
||||
entrypoint_num_cpus=entrypoint_num_cpus,
|
||||
entrypoint_num_gpus=entrypoint_num_gpus,
|
||||
entrypoint_memory=entrypoint_memory,
|
||||
entrypoint_resources=entrypoint_resources,
|
||||
entrypoint_label_selector=entrypoint_label_selector,
|
||||
)
|
||||
|
||||
# Remove keys with value None so that new clients with new optional fields
|
||||
# are still compatible with older servers. This is also done on the server,
|
||||
# but we do it here as well to be extra defensive.
|
||||
json_data = strip_keys_with_value_none(dataclasses.asdict(req))
|
||||
|
||||
logger.debug(f"Submitting job with submission_id={submission_id}.")
|
||||
r = self._do_request("POST", "/api/jobs/", json_data=json_data)
|
||||
|
||||
if r.status_code == 200:
|
||||
return JobSubmitResponse(**r.json()).submission_id
|
||||
else:
|
||||
self._raise_error(r)
|
||||
|
||||
@PublicAPI(stability="stable")
|
||||
def stop_job(
|
||||
self,
|
||||
job_id: str,
|
||||
) -> bool:
|
||||
"""Request a job to exit asynchronously.
|
||||
|
||||
Attempts to terminate process first, then kills process after timeout.
|
||||
|
||||
Example:
|
||||
>>> from ray.job_submission import JobSubmissionClient
|
||||
>>> client = JobSubmissionClient("http://127.0.0.1:8265") # doctest: +SKIP
|
||||
>>> sub_id = client.submit_job(entrypoint="sleep 10") # doctest: +SKIP
|
||||
>>> client.stop_job(sub_id) # doctest: +SKIP
|
||||
True
|
||||
|
||||
Args:
|
||||
job_id: The job ID or submission ID for the job to be stopped.
|
||||
|
||||
Returns:
|
||||
True if the job was running, otherwise False.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the job does not exist or if the request to the
|
||||
job server fails.
|
||||
"""
|
||||
logger.debug(f"Stopping job with job_id={job_id}.")
|
||||
r = self._do_request("POST", f"/api/jobs/{job_id}/stop")
|
||||
|
||||
if r.status_code == 200:
|
||||
return JobStopResponse(**r.json()).stopped
|
||||
else:
|
||||
self._raise_error(r)
|
||||
|
||||
@PublicAPI(stability="stable")
|
||||
def delete_job(
|
||||
self,
|
||||
job_id: str,
|
||||
) -> bool:
|
||||
"""Delete a job in a terminal state and all of its associated data.
|
||||
|
||||
If the job is not already in a terminal state, raises an error.
|
||||
This does not delete the job logs from disk.
|
||||
Submitting a job with the same submission ID as a previously
|
||||
deleted job is not supported and may lead to unexpected behavior.
|
||||
|
||||
Example:
|
||||
>>> from ray.job_submission import JobSubmissionClient
|
||||
>>> client = JobSubmissionClient() # doctest: +SKIP
|
||||
>>> job_id = client.submit_job(entrypoint="echo hello") # doctest: +SKIP
|
||||
>>> client.delete_job(job_id) # doctest: +SKIP
|
||||
True
|
||||
|
||||
Args:
|
||||
job_id: submission ID for the job to be deleted.
|
||||
|
||||
Returns:
|
||||
True if the job was deleted, otherwise False.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the job does not exist, if the request to the
|
||||
job server fails, or if the job is not in a terminal state.
|
||||
"""
|
||||
logger.debug(f"Deleting job with job_id={job_id}.")
|
||||
r = self._do_request("DELETE", f"/api/jobs/{job_id}")
|
||||
|
||||
if r.status_code == 200:
|
||||
return JobDeleteResponse(**r.json()).deleted
|
||||
else:
|
||||
self._raise_error(r)
|
||||
|
||||
@PublicAPI(stability="stable")
|
||||
def get_job_info(
|
||||
self,
|
||||
job_id: str,
|
||||
) -> JobDetails:
|
||||
"""Get the latest status and other information associated with a job.
|
||||
|
||||
Example:
|
||||
>>> from ray.job_submission import JobSubmissionClient
|
||||
>>> client = JobSubmissionClient("http://127.0.0.1:8265") # doctest: +SKIP
|
||||
>>> submission_id = client.submit_job(entrypoint="sleep 1") # doctest: +SKIP
|
||||
>>> client.get_job_info(submission_id) # doctest: +SKIP
|
||||
JobDetails(status='SUCCEEDED',
|
||||
job_id='03000000', type='submission',
|
||||
submission_id='raysubmit_4LamXRuQpYdSMg7J',
|
||||
message='Job finished successfully.', error_type=None,
|
||||
start_time=1647388711, end_time=1647388712, metadata={}, runtime_env={})
|
||||
|
||||
Args:
|
||||
job_id: The job ID or submission ID of the job whose information
|
||||
is being requested.
|
||||
|
||||
Returns:
|
||||
The JobDetails for the job.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the job does not exist or if the request to the
|
||||
job server fails.
|
||||
"""
|
||||
r = self._do_request("GET", f"/api/jobs/{job_id}")
|
||||
|
||||
if r.status_code == 200:
|
||||
if JobDetails is None:
|
||||
raise RuntimeError(
|
||||
"The Ray jobs CLI & SDK require the ray[default] "
|
||||
"installation: `pip install 'ray[default]'`"
|
||||
)
|
||||
else:
|
||||
return JobDetails(**r.json())
|
||||
else:
|
||||
self._raise_error(r)
|
||||
|
||||
@PublicAPI(stability="stable")
|
||||
def list_jobs(self) -> List[JobDetails]:
|
||||
"""List all jobs along with their status and other information.
|
||||
|
||||
Lists all jobs that have ever run on the cluster, including jobs that are
|
||||
currently running and jobs that are no longer running.
|
||||
|
||||
Example:
|
||||
>>> from ray.job_submission import JobSubmissionClient
|
||||
>>> client = JobSubmissionClient("http://127.0.0.1:8265") # doctest: +SKIP
|
||||
>>> client.submit_job(entrypoint="echo hello") # doctest: +SKIP
|
||||
>>> client.submit_job(entrypoint="sleep 2") # doctest: +SKIP
|
||||
>>> client.list_jobs() # doctest: +SKIP
|
||||
[JobDetails(status='SUCCEEDED',
|
||||
job_id='03000000', type='submission',
|
||||
submission_id='raysubmit_4LamXRuQpYdSMg7J',
|
||||
message='Job finished successfully.', error_type=None,
|
||||
start_time=1647388711, end_time=1647388712, metadata={}, runtime_env={}),
|
||||
JobDetails(status='RUNNING',
|
||||
job_id='04000000', type='submission',
|
||||
submission_id='raysubmit_1dxCeNvG1fCMVNHG',
|
||||
message='Job is currently running.', error_type=None,
|
||||
start_time=1647454832, end_time=None, metadata={}, runtime_env={})]
|
||||
|
||||
Returns:
|
||||
A list of JobDetails containing the job status and other information.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the request to the job server fails.
|
||||
"""
|
||||
r = self._do_request("GET", "/api/jobs/")
|
||||
|
||||
if r.status_code == 200:
|
||||
jobs_info_json = r.json()
|
||||
jobs_info = [
|
||||
JobDetails(**job_info_json) for job_info_json in jobs_info_json
|
||||
]
|
||||
return jobs_info
|
||||
else:
|
||||
self._raise_error(r)
|
||||
|
||||
@PublicAPI(stability="stable")
|
||||
def get_job_status(self, job_id: str) -> JobStatus:
|
||||
"""Get the most recent status of a job.
|
||||
|
||||
Example:
|
||||
>>> from ray.job_submission import JobSubmissionClient
|
||||
>>> client = JobSubmissionClient("http://127.0.0.1:8265") # doctest: +SKIP
|
||||
>>> client.submit_job(entrypoint="echo hello") # doctest: +SKIP
|
||||
>>> client.get_job_status("raysubmit_4LamXRuQpYdSMg7J") # doctest: +SKIP
|
||||
'SUCCEEDED'
|
||||
|
||||
Args:
|
||||
job_id: The job ID or submission ID of the job whose status is being
|
||||
requested.
|
||||
|
||||
Returns:
|
||||
The JobStatus of the job.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the job does not exist or if the request to the
|
||||
job server fails.
|
||||
"""
|
||||
return self.get_job_info(job_id).status
|
||||
|
||||
@PublicAPI(stability="stable")
|
||||
def get_job_logs(self, job_id: str) -> str:
|
||||
"""Get all logs produced by a job.
|
||||
|
||||
Example:
|
||||
>>> from ray.job_submission import JobSubmissionClient
|
||||
>>> client = JobSubmissionClient("http://127.0.0.1:8265") # doctest: +SKIP
|
||||
>>> sub_id = client.submit_job(entrypoint="echo hello") # doctest: +SKIP
|
||||
>>> client.get_job_logs(sub_id) # doctest: +SKIP
|
||||
'hello\\n'
|
||||
|
||||
Args:
|
||||
job_id: The job ID or submission ID of the job whose logs are being
|
||||
requested.
|
||||
|
||||
Returns:
|
||||
A string containing the full logs of the job.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the job does not exist or if the request to the
|
||||
job server fails.
|
||||
"""
|
||||
r = self._do_request("GET", f"/api/jobs/{job_id}/logs")
|
||||
|
||||
if r.status_code == 200:
|
||||
return JobLogsResponse(**r.json()).logs
|
||||
else:
|
||||
self._raise_error(r)
|
||||
|
||||
@PublicAPI(stability="stable")
|
||||
async def tail_job_logs(self, job_id: str) -> AsyncIterator[str]:
|
||||
"""Get an iterator that follows the logs of a job.
|
||||
|
||||
Example:
|
||||
>>> from ray.job_submission import JobSubmissionClient
|
||||
>>> client = JobSubmissionClient("http://127.0.0.1:8265") # doctest: +SKIP
|
||||
>>> submission_id = client.submit_job( # doctest: +SKIP
|
||||
... entrypoint="echo hi && sleep 5 && echo hi2")
|
||||
>>> async for lines in client.tail_job_logs( # doctest: +SKIP
|
||||
... 'raysubmit_Xe7cvjyGJCyuCvm2'):
|
||||
... print(lines, end="") # doctest: +SKIP
|
||||
hi
|
||||
hi2
|
||||
|
||||
Args:
|
||||
job_id: The job ID or submission ID of the job whose logs are being
|
||||
requested.
|
||||
|
||||
Yields:
|
||||
str: Successive chunks of the job's stdout/stderr as the driver
|
||||
process produces them.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the job does not exist, if the request to the
|
||||
job server fails, or if the connection closes unexpectedly
|
||||
before the job reaches a terminal state.
|
||||
"""
|
||||
async with aiohttp.ClientSession(
|
||||
cookies=self._cookies, headers=self._headers
|
||||
) as session:
|
||||
ws = await session.ws_connect(
|
||||
f"{self._address}/api/jobs/{job_id}/logs/tail",
|
||||
headers=self._headers,
|
||||
ssl=self._ssl_context,
|
||||
)
|
||||
|
||||
while True:
|
||||
msg = await ws.receive()
|
||||
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
yield msg.data
|
||||
elif msg.type == aiohttp.WSMsgType.CLOSED:
|
||||
logger.info(
|
||||
f"WebSocket closed for job {job_id} with close code "
|
||||
f"{ws.close_code}"
|
||||
)
|
||||
if ws.close_code == aiohttp.WSCloseCode.ABNORMAL_CLOSURE:
|
||||
raise RuntimeError(
|
||||
f"WebSocket connection closed unexpectedly with close code {ws.close_code}"
|
||||
)
|
||||
break
|
||||
elif msg.type == aiohttp.WSMsgType.ERROR:
|
||||
# Old Ray versions (<=2.0.1) may send ERROR on connection close
|
||||
if self._server_ray_version is not None and packaging.version.parse(
|
||||
self._server_ray_version
|
||||
) > packaging.version.parse("2.0.1"):
|
||||
raise RuntimeError(
|
||||
f"WebSocket error for job {job_id}: {ws.exception()}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"WebSocket error for job {job_id}, treating as "
|
||||
f"normal close. Err: {ws.exception()!r}"
|
||||
)
|
||||
break
|
||||
@@ -0,0 +1,26 @@
|
||||
import requests
|
||||
|
||||
import ray
|
||||
|
||||
ray.init()
|
||||
|
||||
|
||||
@ray.remote
|
||||
class Counter:
|
||||
def __init__(self):
|
||||
self.counter = 0
|
||||
|
||||
def inc(self):
|
||||
self.counter += 1
|
||||
|
||||
def get_counter(self):
|
||||
return self.counter
|
||||
|
||||
|
||||
counter = Counter.remote()
|
||||
|
||||
for _ in range(5):
|
||||
ray.get(counter.inc.remote())
|
||||
print(ray.get(counter.get_counter.remote()))
|
||||
|
||||
print(requests.__version__)
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -ex
|
||||
|
||||
unset RAY_ADDRESS
|
||||
|
||||
if ! [ -x "$(command -v conda)" ]; then
|
||||
echo "conda doesn't exist. Please download conda for this machine"
|
||||
exit 1
|
||||
else
|
||||
echo "conda exists"
|
||||
fi
|
||||
|
||||
# This is required to use conda activate
|
||||
source "$(conda info --base)/etc/profile.d/conda.sh"
|
||||
|
||||
PYTHON_VERSION=$(python -c"from platform import python_version; print(python_version())")
|
||||
|
||||
RAY_VERSIONS=("2.0.1")
|
||||
|
||||
for RAY_VERSION in "${RAY_VERSIONS[@]}"
|
||||
do
|
||||
env_name=${JOB_COMPATIBILITY_TEST_TEMP_ENV}
|
||||
|
||||
# Check if the conda env exists
|
||||
if conda env list | grep -q "${env_name}"; then
|
||||
# Clean up if env name is already taken from previous leaking runs
|
||||
conda env remove --name="${env_name}"
|
||||
fi
|
||||
|
||||
printf "\n\n\n"
|
||||
echo "========================================================================================="
|
||||
printf "Creating new conda environment with python %s for ray %s \n" "${PYTHON_VERSION}" "${RAY_VERSION}"
|
||||
echo "========================================================================================="
|
||||
printf "\n\n\n"
|
||||
|
||||
# Include `pip` explicitly: conda-forge's `python` package stopped
|
||||
# bundling pip as a dep, and without it `conda activate` puts us in
|
||||
# an env with python but no pip, so subsequent `pip install` falls
|
||||
# back to the base miniforge env's pip. That clobbers the editable
|
||||
# ray 3.0.0.dev0 in base with ray 2.0.1, and every subsequent
|
||||
# dashboard test that imports `ray._common` fails because 2.0.1
|
||||
# predates that module.
|
||||
conda create -y -n "${env_name}" python="${PYTHON_VERSION}" pip=25.2
|
||||
conda activate "${env_name}"
|
||||
python -m pip install --upgrade pip
|
||||
|
||||
# Pin pydantic version due to: https://github.com/ray-project/ray/issues/36990.
|
||||
# ray<2.9 is only compatible with pydantic<2 and setuptools < 70.
|
||||
python -m pip install -U "pydantic<2" ray=="${RAY_VERSION}" ray[default]=="${RAY_VERSION}" setuptools==69.5.1
|
||||
|
||||
printf "\n\n\n"
|
||||
echo "========================================================="
|
||||
printf "Installed ray job server version: "
|
||||
SERVER_RAY_VERSION=$(python -c "import ray; print(ray.__version__)")
|
||||
printf "%s \n" "${SERVER_RAY_VERSION}"
|
||||
echo "========================================================="
|
||||
printf "\n\n\n"
|
||||
ray stop --force
|
||||
ray start --head
|
||||
|
||||
conda deactivate
|
||||
|
||||
CLIENT_RAY_VERSION=$(python -c "import ray; print(ray.__version__)")
|
||||
CLIENT_RAY_COMMIT=$(python -c "import ray; print(ray.__commit__)")
|
||||
printf "\n\n\n"
|
||||
echo "========================================================================================="
|
||||
printf "Using Ray %s on %s as job client \n" "${CLIENT_RAY_VERSION}" "${CLIENT_RAY_COMMIT}"
|
||||
echo "========================================================================================="
|
||||
printf "\n\n\n"
|
||||
|
||||
export RAY_ADDRESS="http://127.0.0.1:8265"
|
||||
|
||||
cleanup () {
|
||||
unset RAY_ADDRESS
|
||||
ray stop --force
|
||||
conda remove -y --name "${env_name}" --all
|
||||
}
|
||||
|
||||
JOB_ID=$(python -c "import uuid; print(uuid.uuid4().hex)")
|
||||
|
||||
# Get directory of current file. https://stackoverflow.com/questions/59895/
|
||||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
||||
|
||||
if ! ray job submit --job-id="${JOB_ID}" --working-dir="${DIR}" --runtime-env-json='{"pip": ["requests==2.26.0", "setuptools==69.5.1"]}' -- python script.py; then
|
||||
cleanup
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! ray job status "${JOB_ID}"; then
|
||||
cleanup
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! ray job logs "${JOB_ID}"; then
|
||||
cleanup
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! pytest -vs "${DIR}"/../test_backwards_compatibility.py::test_error_message; then
|
||||
cleanup
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup
|
||||
done
|
||||
@@ -0,0 +1,30 @@
|
||||
import os
|
||||
|
||||
import ray
|
||||
from ray._raylet import GcsClient
|
||||
from ray.dashboard.modules.job.job_manager import JobManager
|
||||
|
||||
TEST_NAMESPACE = "jobs_test_namespace"
|
||||
|
||||
|
||||
def create_ray_cluster(_tracing_startup_hook=None):
|
||||
return ray.init(
|
||||
num_cpus=16,
|
||||
num_gpus=1,
|
||||
resources={"Custom": 1},
|
||||
namespace=TEST_NAMESPACE,
|
||||
log_to_driver=True,
|
||||
_tracing_startup_hook=_tracing_startup_hook,
|
||||
)
|
||||
|
||||
|
||||
def create_job_manager(ray_cluster, tmp_path):
|
||||
address_info = ray_cluster
|
||||
gcs_client = GcsClient(address=address_info["gcs_address"])
|
||||
return JobManager(gcs_client, tmp_path)
|
||||
|
||||
|
||||
def _driver_script_path(file_name: str) -> str:
|
||||
return os.path.join(
|
||||
os.path.dirname(__file__), "subprocess_driver_scripts", file_name
|
||||
)
|
||||
Binary file not shown.
+31
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
A dummy ray driver script that executes in subprocess.
|
||||
Prints global worker's `load_code_from_local` property that ought to be set
|
||||
whenever `JobConfig.code_search_path` is specified
|
||||
"""
|
||||
|
||||
|
||||
def run():
|
||||
import ray
|
||||
from ray.job_config import JobConfig
|
||||
|
||||
ray.init(job_config=JobConfig(code_search_path=["/home/code/"]))
|
||||
|
||||
@ray.remote
|
||||
def foo() -> bool:
|
||||
return ray._private.worker.global_worker.load_code_from_local
|
||||
|
||||
load_code_from_local = ray.get(foo.remote())
|
||||
|
||||
statement = "propagated" if load_code_from_local else "NOT propagated"
|
||||
|
||||
# Step 1: Print the statement indicating that the code_search_path have been
|
||||
# properly respected
|
||||
print(f"Code search path is {statement}")
|
||||
# Step 2: Print the whole runtime_env to validate that it's been passed
|
||||
# appropriately from submit_job API
|
||||
print(ray.get_runtime_context().runtime_env)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import os
|
||||
|
||||
import ray
|
||||
|
||||
cuda_env = ray._private.accelerators.nvidia_gpu.NOSET_CUDA_VISIBLE_DEVICES_ENV_VAR
|
||||
if os.environ.get("RAY_TEST_RESOURCES_SPECIFIED") == "1":
|
||||
assert cuda_env not in os.environ
|
||||
if os.environ.get("RAY_TEST_GPUS_SPECIFIED") == "1":
|
||||
assert "CUDA_VISIBLE_DEVICES" in os.environ
|
||||
else:
|
||||
assert "CUDA_VISIBLE_DEVICES" not in os.environ
|
||||
else:
|
||||
assert os.environ[cuda_env] == "1"
|
||||
|
||||
|
||||
@ray.remote
|
||||
def f():
|
||||
assert cuda_env not in os.environ
|
||||
|
||||
|
||||
# Will raise if task fails.
|
||||
ray.get(f.remote())
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
A dummy ray driver script that executes in subprocess.
|
||||
Checks that job manager's environment variable is different.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import ray
|
||||
|
||||
|
||||
def run():
|
||||
ray.init()
|
||||
|
||||
@ray.remote
|
||||
def foo():
|
||||
print("worker", os.nice(0))
|
||||
|
||||
ray.get(foo.remote())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("driver", os.nice(0))
|
||||
run()
|
||||
@@ -0,0 +1,13 @@
|
||||
import ray
|
||||
|
||||
ray.init()
|
||||
|
||||
|
||||
@ray.remote(num_cpus=1)
|
||||
def f():
|
||||
pass
|
||||
|
||||
|
||||
print("Hanging...")
|
||||
ray.get(f.remote())
|
||||
print("Success!")
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
|
||||
import ray
|
||||
|
||||
# This prefix is used to identify the output log line that contains the runtime env.
|
||||
RUNTIME_ENV_LOG_LINE_PREFIX = "ray_job_test_runtime_env_output:"
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Dashboard agent.")
|
||||
parser.add_argument(
|
||||
"--conflict",
|
||||
type=str,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--worker-process-setup-hook",
|
||||
type=str,
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.worker_process_setup_hook:
|
||||
ray.init(
|
||||
runtime_env={
|
||||
"worker_process_setup_hook": lambda: print(
|
||||
args.worker_process_setup_hook
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@ray.remote
|
||||
def f():
|
||||
pass
|
||||
|
||||
ray.get(f.remote())
|
||||
time.sleep(5)
|
||||
sys.exit(0)
|
||||
|
||||
if args.conflict == "pip":
|
||||
ray.init(runtime_env={"pip": ["numpy"]})
|
||||
print(
|
||||
RUNTIME_ENV_LOG_LINE_PREFIX + ray._private.worker.global_worker.runtime_env
|
||||
)
|
||||
elif args.conflict == "env_vars":
|
||||
ray.init(runtime_env={"env_vars": {"A": "1"}})
|
||||
print(
|
||||
RUNTIME_ENV_LOG_LINE_PREFIX + ray._private.worker.global_worker.runtime_env
|
||||
)
|
||||
else:
|
||||
ray.init(
|
||||
runtime_env={
|
||||
"env_vars": {"C": "1"},
|
||||
}
|
||||
)
|
||||
print(
|
||||
RUNTIME_ENV_LOG_LINE_PREFIX + ray._private.worker.global_worker.runtime_env
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Test script that attempts to set its own runtime_env, but we should ensure
|
||||
we ended up using job submission API call's runtime_env instead of scripts
|
||||
"""
|
||||
|
||||
|
||||
def run():
|
||||
import os
|
||||
|
||||
import ray
|
||||
|
||||
ray.init(
|
||||
runtime_env={
|
||||
"env_vars": {"TEST_SUBPROCESS_JOB_CONFIG_ENV_VAR": "SHOULD_BE_OVERRIDEN"}
|
||||
},
|
||||
)
|
||||
|
||||
@ray.remote
|
||||
def foo():
|
||||
return "bar"
|
||||
|
||||
ray.get(foo.remote())
|
||||
print(os.environ.get("TEST_SUBPROCESS_JOB_CONFIG_ENV_VAR", None))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import os
|
||||
|
||||
import ray
|
||||
|
||||
|
||||
def run():
|
||||
ray.init()
|
||||
|
||||
@ray.remote(runtime_env={"env_vars": {"FOO": "bar"}})
|
||||
def get_task_working_dir():
|
||||
# Check behavior of working_dir: The cwd should contain the
|
||||
# current file, which is being used as a job entrypoint script.
|
||||
assert os.path.exists("per_task_runtime_env.py")
|
||||
|
||||
return ray.get_runtime_context().runtime_env.working_dir()
|
||||
|
||||
driver_working_dir = ray.get_runtime_context().runtime_env.working_dir()
|
||||
task_working_dir = ray.get(get_task_working_dir.remote())
|
||||
assert driver_working_dir == task_working_dir, (
|
||||
driver_working_dir,
|
||||
task_working_dir,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
A dummy ray driver script that executes in subprocess. Prints namespace
|
||||
from ray's runtime context for job submission API testing.
|
||||
"""
|
||||
|
||||
import ray
|
||||
|
||||
|
||||
def run():
|
||||
ray.init()
|
||||
|
||||
@ray.remote
|
||||
def foo():
|
||||
return "bar"
|
||||
|
||||
ray.get(foo.remote())
|
||||
|
||||
print(ray.get_runtime_context().namespace)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
A dummy ray driver script that executes in subprocess. Prints runtime_env
|
||||
from ray's runtime context for job submission API testing.
|
||||
"""
|
||||
|
||||
import ray
|
||||
|
||||
|
||||
def run():
|
||||
ray.init()
|
||||
|
||||
@ray.remote
|
||||
def foo():
|
||||
return "bar"
|
||||
|
||||
ray.get(foo.remote())
|
||||
|
||||
print(ray.get_runtime_context().runtime_env)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Tests that Ray Tune works with the working_dir set in Jobs.
|
||||
|
||||
Ray Tune internally sets environment variables using runtime_env.
|
||||
If the inherited internal runtime environment overwrites the working_dir
|
||||
from jobs with an empty working_dir, this test will fail. See #25484"""
|
||||
from ray_tune_dependency import foo
|
||||
|
||||
from ray import tune
|
||||
|
||||
|
||||
def objective(*args):
|
||||
foo()
|
||||
|
||||
|
||||
tune.run(objective)
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
"""A file dependency for testing working_dir behavior with Ray Tune."""
|
||||
|
||||
|
||||
def foo():
|
||||
pass
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
def run():
|
||||
raise Exception("Script failed with exception !")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,124 @@
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
|
||||
from ray.job_submission import JobStatus, JobSubmissionClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def conda_env(env_name):
|
||||
# Set env name for shell script
|
||||
os.environ["JOB_COMPATIBILITY_TEST_TEMP_ENV"] = env_name
|
||||
# Delete conda env if it already exists
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Clean up created conda env upon test exit to prevent leaking
|
||||
del os.environ["JOB_COMPATIBILITY_TEST_TEMP_ENV"]
|
||||
subprocess.run(
|
||||
f"conda env remove -y --name {env_name}", shell=True, stdout=subprocess.PIPE
|
||||
)
|
||||
|
||||
|
||||
def _compatibility_script_path(file_name: str) -> str:
|
||||
return os.path.join(
|
||||
os.path.dirname(__file__), "backwards_compatibility_scripts", file_name
|
||||
)
|
||||
|
||||
|
||||
class TestBackwardsCompatibility:
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "darwin",
|
||||
reason="ray 2.0.1 runs differently on apple silicon than today's.",
|
||||
)
|
||||
def test_cli(self):
|
||||
"""
|
||||
Test that the current commit's CLI works with old server-side Ray versions.
|
||||
|
||||
1) Create a new conda environment with old ray version X installed;
|
||||
inherits same env as current conda envionment except ray version
|
||||
2) (Server) Start head node and dashboard with old ray version X
|
||||
3) (Client) Use current commit's CLI code to do sample job submission flow
|
||||
4) Deactivate the new conda environment and back to original place
|
||||
"""
|
||||
# Shell script creates and cleans up tmp conda environment regardless
|
||||
# of the outcome
|
||||
env_name = f"jobs-backwards-compatibility-{uuid.uuid4().hex}"
|
||||
with conda_env(env_name):
|
||||
shell_cmd = f"{_compatibility_script_path('test_backwards_compatibility.sh')}" # noqa: E501
|
||||
|
||||
try:
|
||||
subprocess.check_output(shell_cmd, shell=True, stderr=subprocess.STDOUT)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(str(e))
|
||||
logger.error(e.stdout.decode())
|
||||
raise e
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("JOB_COMPATIBILITY_TEST_TEMP_ENV") is None,
|
||||
reason="This test is only meant to be run from the "
|
||||
"test_backwards_compatibility.sh shell script.",
|
||||
)
|
||||
def test_error_message():
|
||||
"""
|
||||
Check that we get a good error message when running against an old server version.
|
||||
"""
|
||||
# Import lazily so the module still loads when the compatibility script
|
||||
# installs an older Ray that does not expose `ray._common`.
|
||||
from ray._common.test_utils import wait_for_condition
|
||||
|
||||
client = JobSubmissionClient("http://127.0.0.1:8265")
|
||||
|
||||
# Check that a basic job successfully runs.
|
||||
job_id = client.submit_job(
|
||||
entrypoint="echo 'hello world'",
|
||||
)
|
||||
wait_for_condition(lambda: client.get_job_status(job_id) == JobStatus.SUCCEEDED)
|
||||
|
||||
# `entrypoint_num_cpus`, `entrypoint_num_gpus`, `entrypoint_resources`, and
|
||||
# `entrypoint_label_selector`
|
||||
# are not supported in ray<2.2.0.
|
||||
for unsupported_submit_kwargs in [
|
||||
{"entrypoint_num_cpus": 1},
|
||||
{"entrypoint_num_gpus": 1},
|
||||
{"entrypoint_resources": {"custom": 1}},
|
||||
{"entrypoint_label_selector": {"fragile_node": "!1"}},
|
||||
]:
|
||||
with pytest.raises(
|
||||
Exception,
|
||||
match="Ray version 2.0.1 is running on the cluster. "
|
||||
"`entrypoint_num_cpus`, `entrypoint_num_gpus`, "
|
||||
"`entrypoint_resources`, and `entrypoint_label_selector` kwargs"
|
||||
" are not supported on the Ray cluster. Please ensure the cluster is "
|
||||
"running Ray 2.2 or higher.",
|
||||
):
|
||||
client.submit_job(
|
||||
entrypoint="echo hello",
|
||||
**unsupported_submit_kwargs,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
Exception,
|
||||
match="Ray version 2.0.1 is running on the cluster. "
|
||||
"`entrypoint_memory` kwarg"
|
||||
" is not supported on the Ray cluster. Please ensure the cluster is "
|
||||
"running Ray 2.8 or higher.",
|
||||
):
|
||||
client.submit_job(
|
||||
entrypoint="echo hello",
|
||||
entrypoint_memory=4,
|
||||
)
|
||||
|
||||
assert True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,695 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from subprocess import list2cmdline
|
||||
from typing import Optional
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from click.testing import CliRunner
|
||||
|
||||
from ray.dashboard.modules.job.cli import job_cli_group
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sdk_client():
|
||||
class AsyncIterator:
|
||||
def __init__(self, seq):
|
||||
self._seq = seq
|
||||
self.iter = iter(self._seq)
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
try:
|
||||
return next(self.iter)
|
||||
except StopIteration:
|
||||
self.iter = iter(self._seq)
|
||||
raise StopAsyncIteration
|
||||
|
||||
if "RAY_ADDRESS" in os.environ:
|
||||
del os.environ["RAY_ADDRESS"]
|
||||
with mock.patch("ray.dashboard.modules.job.cli.JobSubmissionClient") as mock_client:
|
||||
# In python 3.6 it will fail with error
|
||||
# 'async for' requires an object with __aiter__ method, got MagicMock"
|
||||
mock_client().tail_job_logs.return_value = AsyncIterator(range(10))
|
||||
|
||||
# We need to return a string for the address and not a MagicMock
|
||||
mock_client().get_address.return_value = ""
|
||||
|
||||
yield mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runtime_env_formats():
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
path = Path(tmp_dir)
|
||||
|
||||
test_env = {
|
||||
"working_dir": "s3://bogus.zip",
|
||||
"conda": "conda_env",
|
||||
"pip": ["pip-install-test"],
|
||||
"env_vars": {"hi": "hi2"},
|
||||
}
|
||||
|
||||
yaml_file = path / "env.yaml"
|
||||
with yaml_file.open(mode="w") as f:
|
||||
yaml.dump(test_env, f)
|
||||
|
||||
yield test_env, json.dumps(test_env), yaml_file
|
||||
|
||||
|
||||
@contextmanager
|
||||
def set_env_var(key: str, val: Optional[str] = None):
|
||||
old_val = os.environ.get(key, None)
|
||||
if val is not None:
|
||||
os.environ[key] = val
|
||||
elif key in os.environ:
|
||||
del os.environ[key]
|
||||
|
||||
yield
|
||||
|
||||
if key in os.environ:
|
||||
del os.environ[key]
|
||||
if old_val is not None:
|
||||
os.environ[key] = old_val
|
||||
|
||||
|
||||
def check_exit_code(result, exit_code):
|
||||
assert result.exit_code == exit_code, result.output
|
||||
|
||||
|
||||
def _expected_entrypoint(*args):
|
||||
"""Return the expected entrypoint string for the current platform.
|
||||
|
||||
On Windows, the CLI uses subprocess.list2cmdline (double quotes).
|
||||
On POSIX, it uses shlex.join (single quotes).
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
return list2cmdline(args)
|
||||
return shlex.join(args)
|
||||
|
||||
|
||||
def _job_cli_group_test_address(mock_sdk_client, cmd, *args):
|
||||
runner = CliRunner()
|
||||
|
||||
create_cluster_if_needed = True if cmd == "submit" else False
|
||||
# Test passing address via command line.
|
||||
result = runner.invoke(job_cli_group, [cmd, "--address=arg_addr", *args])
|
||||
mock_sdk_client.assert_called_with(
|
||||
"arg_addr", create_cluster_if_needed, headers=None, verify=True
|
||||
)
|
||||
with pytest.raises(AssertionError):
|
||||
mock_sdk_client.assert_called_with(
|
||||
"some_other_addr", True, headers=None, verify=True
|
||||
)
|
||||
check_exit_code(result, 0)
|
||||
# Test passing address via env var.
|
||||
with set_env_var("RAY_ADDRESS", "env_addr"):
|
||||
result = runner.invoke(job_cli_group, [cmd, *args])
|
||||
check_exit_code(result, 0)
|
||||
# RAY_ADDRESS is read inside the SDK client.
|
||||
mock_sdk_client.assert_called_with(
|
||||
None, create_cluster_if_needed, headers=None, verify=True
|
||||
)
|
||||
# Test passing no address.
|
||||
result = runner.invoke(job_cli_group, [cmd, *args])
|
||||
check_exit_code(result, 0)
|
||||
mock_sdk_client.assert_called_with(
|
||||
None, create_cluster_if_needed, headers=None, verify=True
|
||||
)
|
||||
|
||||
|
||||
class TestList:
|
||||
def test_address(self, mock_sdk_client):
|
||||
_job_cli_group_test_address(mock_sdk_client, "list")
|
||||
|
||||
def test_list(self, mock_sdk_client):
|
||||
runner = CliRunner()
|
||||
with set_env_var("RAY_ADDRESS", "env_addr"):
|
||||
result = runner.invoke(
|
||||
job_cli_group,
|
||||
["list"],
|
||||
)
|
||||
check_exit_code(result, 0)
|
||||
|
||||
result = runner.invoke(job_cli_group, ["submit", "--", "echo hello"])
|
||||
check_exit_code(result, 0)
|
||||
|
||||
result = runner.invoke(
|
||||
job_cli_group,
|
||||
["list"],
|
||||
)
|
||||
check_exit_code(result, 0)
|
||||
|
||||
|
||||
class TestSubmit:
|
||||
def test_address(self, mock_sdk_client):
|
||||
_job_cli_group_test_address(mock_sdk_client, "submit", "--", "echo", "hello")
|
||||
|
||||
def test_working_dir(self, mock_sdk_client):
|
||||
runner = CliRunner()
|
||||
mock_client_instance = mock_sdk_client.return_value
|
||||
|
||||
with set_env_var("RAY_ADDRESS", "env_addr"):
|
||||
result = runner.invoke(job_cli_group, ["submit", "--", "echo hello"])
|
||||
check_exit_code(result, 0)
|
||||
mock_client_instance.submit_job.assert_called_with(
|
||||
entrypoint=_expected_entrypoint("echo hello"),
|
||||
submission_id=None,
|
||||
runtime_env={},
|
||||
metadata=None,
|
||||
entrypoint_num_cpus=None,
|
||||
entrypoint_num_gpus=None,
|
||||
entrypoint_memory=None,
|
||||
entrypoint_resources=None,
|
||||
entrypoint_label_selector=None,
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
job_cli_group,
|
||||
["submit", "--working-dir", "blah", "--", "echo hello"],
|
||||
)
|
||||
check_exit_code(result, 0)
|
||||
mock_client_instance.submit_job.assert_called_with(
|
||||
entrypoint=_expected_entrypoint("echo hello"),
|
||||
submission_id=None,
|
||||
runtime_env={"working_dir": "blah"},
|
||||
metadata=None,
|
||||
entrypoint_num_cpus=None,
|
||||
entrypoint_num_gpus=None,
|
||||
entrypoint_memory=None,
|
||||
entrypoint_resources=None,
|
||||
entrypoint_label_selector=None,
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
job_cli_group, ["submit", "--working-dir='.'", "--", "echo hello"]
|
||||
)
|
||||
check_exit_code(result, 0)
|
||||
mock_client_instance.submit_job.assert_called_with(
|
||||
entrypoint=_expected_entrypoint("echo hello"),
|
||||
submission_id=None,
|
||||
runtime_env={"working_dir": "'.'"},
|
||||
metadata=None,
|
||||
entrypoint_num_cpus=None,
|
||||
entrypoint_num_gpus=None,
|
||||
entrypoint_memory=None,
|
||||
entrypoint_resources=None,
|
||||
entrypoint_label_selector=None,
|
||||
)
|
||||
|
||||
def test_runtime_env(self, mock_sdk_client, runtime_env_formats):
|
||||
runner = CliRunner()
|
||||
mock_client_instance = mock_sdk_client.return_value
|
||||
env_dict, env_json, env_yaml = runtime_env_formats
|
||||
|
||||
with set_env_var("RAY_ADDRESS", "env_addr"):
|
||||
# Test passing via file.
|
||||
result = runner.invoke(
|
||||
job_cli_group, ["submit", "--runtime-env", env_yaml, "--", "echo hello"]
|
||||
)
|
||||
check_exit_code(result, 0)
|
||||
mock_client_instance.submit_job.assert_called_with(
|
||||
entrypoint=_expected_entrypoint("echo hello"),
|
||||
submission_id=None,
|
||||
runtime_env=env_dict,
|
||||
metadata=None,
|
||||
entrypoint_num_cpus=None,
|
||||
entrypoint_num_gpus=None,
|
||||
entrypoint_memory=None,
|
||||
entrypoint_resources=None,
|
||||
entrypoint_label_selector=None,
|
||||
)
|
||||
|
||||
# Test passing via json.
|
||||
result = runner.invoke(
|
||||
job_cli_group,
|
||||
["submit", "--runtime-env-json", env_json, "--", "echo hello"],
|
||||
)
|
||||
check_exit_code(result, 0)
|
||||
mock_client_instance.submit_job.assert_called_with(
|
||||
entrypoint=_expected_entrypoint("echo hello"),
|
||||
submission_id=None,
|
||||
runtime_env=env_dict,
|
||||
metadata=None,
|
||||
entrypoint_num_cpus=None,
|
||||
entrypoint_num_gpus=None,
|
||||
entrypoint_memory=None,
|
||||
entrypoint_resources=None,
|
||||
entrypoint_label_selector=None,
|
||||
)
|
||||
|
||||
# Test passing both throws an error.
|
||||
result = runner.invoke(
|
||||
job_cli_group,
|
||||
[
|
||||
"submit",
|
||||
"--runtime-env",
|
||||
env_yaml,
|
||||
"--runtime-env-json",
|
||||
env_json,
|
||||
"--",
|
||||
"echo hello",
|
||||
],
|
||||
)
|
||||
check_exit_code(result, 1)
|
||||
assert "Only one of" in str(result.exception)
|
||||
|
||||
# Test overriding working_dir.
|
||||
env_dict.update(working_dir=".")
|
||||
result = runner.invoke(
|
||||
job_cli_group,
|
||||
[
|
||||
"submit",
|
||||
"--runtime-env",
|
||||
env_yaml,
|
||||
"--working-dir",
|
||||
".",
|
||||
"--",
|
||||
"echo hello",
|
||||
],
|
||||
)
|
||||
check_exit_code(result, 0)
|
||||
mock_client_instance.submit_job.assert_called_with(
|
||||
entrypoint=_expected_entrypoint("echo hello"),
|
||||
submission_id=None,
|
||||
runtime_env=env_dict,
|
||||
metadata=None,
|
||||
entrypoint_num_cpus=None,
|
||||
entrypoint_num_gpus=None,
|
||||
entrypoint_memory=None,
|
||||
entrypoint_resources=None,
|
||||
entrypoint_label_selector=None,
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
job_cli_group,
|
||||
[
|
||||
"submit",
|
||||
"--runtime-env-json",
|
||||
env_json,
|
||||
"--working-dir",
|
||||
".",
|
||||
"--",
|
||||
"echo hello",
|
||||
],
|
||||
)
|
||||
check_exit_code(result, 0)
|
||||
mock_client_instance.submit_job.assert_called_with(
|
||||
entrypoint=_expected_entrypoint("echo hello"),
|
||||
submission_id=None,
|
||||
runtime_env=env_dict,
|
||||
metadata=None,
|
||||
entrypoint_num_cpus=None,
|
||||
entrypoint_num_gpus=None,
|
||||
entrypoint_memory=None,
|
||||
entrypoint_resources=None,
|
||||
entrypoint_label_selector=None,
|
||||
)
|
||||
|
||||
def test_job_id(self, mock_sdk_client):
|
||||
runner = CliRunner()
|
||||
mock_client_instance = mock_sdk_client.return_value
|
||||
|
||||
with set_env_var("RAY_ADDRESS", "env_addr"):
|
||||
result = runner.invoke(job_cli_group, ["submit", "--", "echo hello"])
|
||||
check_exit_code(result, 0)
|
||||
mock_client_instance.submit_job.assert_called_with(
|
||||
entrypoint=_expected_entrypoint("echo hello"),
|
||||
submission_id=None,
|
||||
runtime_env={},
|
||||
metadata=None,
|
||||
entrypoint_num_cpus=None,
|
||||
entrypoint_num_gpus=None,
|
||||
entrypoint_memory=None,
|
||||
entrypoint_resources=None,
|
||||
entrypoint_label_selector=None,
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
job_cli_group,
|
||||
["submit", "--submission-id=my_job_id", "--", "echo hello"],
|
||||
)
|
||||
check_exit_code(result, 0)
|
||||
mock_client_instance.submit_job.assert_called_with(
|
||||
entrypoint=_expected_entrypoint("echo hello"),
|
||||
submission_id="my_job_id",
|
||||
runtime_env={},
|
||||
metadata=None,
|
||||
entrypoint_num_cpus=None,
|
||||
entrypoint_num_gpus=None,
|
||||
entrypoint_memory=None,
|
||||
entrypoint_resources=None,
|
||||
entrypoint_label_selector=None,
|
||||
)
|
||||
|
||||
def test_entrypoint_num_cpus(self, mock_sdk_client):
|
||||
runner = CliRunner()
|
||||
mock_client_instance = mock_sdk_client.return_value
|
||||
|
||||
with set_env_var("RAY_ADDRESS", "env_addr"):
|
||||
result = runner.invoke(
|
||||
job_cli_group,
|
||||
["submit", "--entrypoint-num-cpus=2", "--", "echo hello"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_client_instance.submit_job.assert_called_with(
|
||||
entrypoint=_expected_entrypoint("echo hello"),
|
||||
submission_id=None,
|
||||
runtime_env={},
|
||||
metadata=None,
|
||||
entrypoint_num_cpus=2,
|
||||
entrypoint_num_gpus=None,
|
||||
entrypoint_memory=None,
|
||||
entrypoint_resources=None,
|
||||
entrypoint_label_selector=None,
|
||||
)
|
||||
|
||||
def test_entrypoint_num_gpus(self, mock_sdk_client):
|
||||
runner = CliRunner()
|
||||
mock_client_instance = mock_sdk_client.return_value
|
||||
|
||||
with set_env_var("RAY_ADDRESS", "env_addr"):
|
||||
result = runner.invoke(
|
||||
job_cli_group,
|
||||
["submit", "--entrypoint-num-gpus=2", "--", "echo hello"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_client_instance.submit_job.assert_called_with(
|
||||
entrypoint=_expected_entrypoint("echo hello"),
|
||||
submission_id=None,
|
||||
runtime_env={},
|
||||
metadata=None,
|
||||
entrypoint_num_cpus=None,
|
||||
entrypoint_num_gpus=2,
|
||||
entrypoint_memory=None,
|
||||
entrypoint_resources=None,
|
||||
entrypoint_label_selector=None,
|
||||
)
|
||||
|
||||
def test_entrypoint_memory(self, mock_sdk_client):
|
||||
runner = CliRunner()
|
||||
mock_client_instance = mock_sdk_client.return_value
|
||||
|
||||
with set_env_var("RAY_ADDRESS", "env_addr"):
|
||||
result = runner.invoke(
|
||||
job_cli_group,
|
||||
["submit", "--entrypoint-memory=4", "--", "echo hello"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_client_instance.submit_job.assert_called_with(
|
||||
entrypoint=_expected_entrypoint("echo hello"),
|
||||
submission_id=None,
|
||||
runtime_env={},
|
||||
metadata=None,
|
||||
entrypoint_num_cpus=None,
|
||||
entrypoint_num_gpus=None,
|
||||
entrypoint_memory=4,
|
||||
entrypoint_resources=None,
|
||||
entrypoint_label_selector=None,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"resources",
|
||||
[
|
||||
("--entrypoint-num-cpus=2", {"entrypoint_num_cpus": 2}),
|
||||
("--entrypoint-num-gpus=2", {"entrypoint_num_gpus": 2}),
|
||||
(
|
||||
"""--entrypoint-resources={"Custom":3}""",
|
||||
{"entrypoint_resources": {"Custom": 3}},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_entrypoint_resources(self, mock_sdk_client, resources):
|
||||
runner = CliRunner()
|
||||
mock_client_instance = mock_sdk_client.return_value
|
||||
|
||||
with set_env_var("RAY_ADDRESS", "env_addr"):
|
||||
result = runner.invoke(
|
||||
job_cli_group,
|
||||
["submit", resources[0], "--", "echo hello"],
|
||||
)
|
||||
print(result.output)
|
||||
assert result.exit_code == 0
|
||||
expected_kwargs = {
|
||||
"entrypoint": _expected_entrypoint("echo hello"),
|
||||
"submission_id": None,
|
||||
"runtime_env": {},
|
||||
"metadata": None,
|
||||
"entrypoint_num_cpus": None,
|
||||
"entrypoint_num_gpus": None,
|
||||
"entrypoint_memory": None,
|
||||
"entrypoint_resources": None,
|
||||
"entrypoint_label_selector": None,
|
||||
}
|
||||
expected_kwargs.update(resources[1])
|
||||
mock_client_instance.submit_job.assert_called_with(**expected_kwargs)
|
||||
|
||||
def test_entrypoint_resources_invalid_json(self, mock_sdk_client):
|
||||
runner = CliRunner()
|
||||
|
||||
with set_env_var("RAY_ADDRESS", "env_addr"):
|
||||
result = runner.invoke(
|
||||
job_cli_group,
|
||||
[
|
||||
"submit",
|
||||
"""--entrypoint-resources={"Custom":3""",
|
||||
"--",
|
||||
"echo hello world",
|
||||
],
|
||||
)
|
||||
print(result.output)
|
||||
assert result.exit_code == 1
|
||||
assert "not a valid JSON string" in result.output
|
||||
|
||||
def test_entrypoint_label_selector(self, mock_sdk_client):
|
||||
runner = CliRunner()
|
||||
mock_client_instance = mock_sdk_client.return_value
|
||||
|
||||
with set_env_var("RAY_ADDRESS", "env_addr"):
|
||||
result = runner.invoke(
|
||||
job_cli_group,
|
||||
[
|
||||
"submit",
|
||||
"""--entrypoint-label-selector={"fragile_node":"!1"}""",
|
||||
"--",
|
||||
"echo hello",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_client_instance.submit_job.assert_called_with(
|
||||
entrypoint=_expected_entrypoint("echo hello"),
|
||||
submission_id=None,
|
||||
runtime_env={},
|
||||
metadata=None,
|
||||
entrypoint_num_cpus=None,
|
||||
entrypoint_num_gpus=None,
|
||||
entrypoint_memory=None,
|
||||
entrypoint_resources=None,
|
||||
entrypoint_label_selector={"fragile_node": "!1"},
|
||||
)
|
||||
|
||||
def test_metadata(self, mock_sdk_client):
|
||||
runner = CliRunner()
|
||||
mock_client_instance = mock_sdk_client.return_value
|
||||
|
||||
with set_env_var("RAY_ADDRESS", "env_addr"):
|
||||
result = runner.invoke(
|
||||
job_cli_group,
|
||||
[
|
||||
"submit",
|
||||
"--metadata-json",
|
||||
'{"key": "value"}',
|
||||
"--",
|
||||
"echo hello",
|
||||
],
|
||||
)
|
||||
check_exit_code(result, 0)
|
||||
mock_client_instance.submit_job.assert_called_with(
|
||||
entrypoint=_expected_entrypoint("echo hello"),
|
||||
submission_id=None,
|
||||
runtime_env={},
|
||||
entrypoint_num_cpus=None,
|
||||
entrypoint_num_gpus=None,
|
||||
entrypoint_memory=None,
|
||||
entrypoint_resources=None,
|
||||
entrypoint_label_selector=None,
|
||||
metadata={"key": "value"},
|
||||
)
|
||||
|
||||
def test_metadata_invalid_json(self, mock_sdk_client):
|
||||
runner = CliRunner()
|
||||
|
||||
with set_env_var("RAY_ADDRESS", "env_addr"):
|
||||
result = runner.invoke(
|
||||
job_cli_group,
|
||||
[
|
||||
"submit",
|
||||
"--metadata-json",
|
||||
'{"key": "value"',
|
||||
"--",
|
||||
"echo hello",
|
||||
],
|
||||
)
|
||||
print(result.output)
|
||||
check_exit_code(result, 1)
|
||||
assert "not a valid JSON string" in result.output
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cli_val, verify_param",
|
||||
[
|
||||
("True", True),
|
||||
("true", True),
|
||||
("1", True),
|
||||
("False", False),
|
||||
("false", False),
|
||||
("0", False),
|
||||
("a/rel/path", "a/rel/path"),
|
||||
("/an/abs/path", "/an/abs/path"),
|
||||
],
|
||||
)
|
||||
def test_entrypoint_verify(self, mock_sdk_client, cli_val, verify_param):
|
||||
runner = CliRunner()
|
||||
with set_env_var("RAY_ADDRESS", "env_addr"):
|
||||
result = runner.invoke(
|
||||
job_cli_group,
|
||||
["submit", f"--verify={cli_val}", "--", "echo hello"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_sdk_client.assert_called_with(
|
||||
None, True, headers=None, verify=verify_param
|
||||
)
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_address(self, mock_sdk_client):
|
||||
_job_cli_group_test_address(mock_sdk_client, "delete", "fake_job_id")
|
||||
|
||||
def test_delete(self, mock_sdk_client):
|
||||
runner = CliRunner()
|
||||
mock_client_instance = mock_sdk_client.return_value
|
||||
|
||||
with set_env_var("RAY_ADDRESS", "env_addr"):
|
||||
result = runner.invoke(job_cli_group, ["delete", "job_id"])
|
||||
check_exit_code(result, 0)
|
||||
mock_client_instance.delete_job.assert_called_with("job_id")
|
||||
|
||||
|
||||
class TestStatus:
|
||||
def test_address(self, mock_sdk_client):
|
||||
_job_cli_group_test_address(mock_sdk_client, "status", "fake_job_id")
|
||||
|
||||
def test_status(self, mock_sdk_client):
|
||||
runner = CliRunner()
|
||||
mock_client_instance = mock_sdk_client.return_value
|
||||
|
||||
with set_env_var("RAY_ADDRESS", "env_addr"):
|
||||
result = runner.invoke(job_cli_group, ["status", "job_id"])
|
||||
check_exit_code(result, 0)
|
||||
mock_client_instance.get_job_info.assert_called_with("job_id")
|
||||
|
||||
|
||||
class TestEntrypointShellQuoting:
|
||||
"""Regression test for https://github.com/ray-project/ray/issues/56232.
|
||||
|
||||
`ray job submit` previously used `subprocess.list2cmdline` unconditionally
|
||||
to join entrypoint arguments. That function wraps arguments in double
|
||||
quotes, which causes POSIX shells on the server to expand $VAR references.
|
||||
|
||||
The fix uses `shlex.join` on POSIX platforms (which single-quotes
|
||||
arguments to prevent expansion) and `list2cmdline` on Windows (which
|
||||
double-quotes arguments as expected by cmd.exe).
|
||||
"""
|
||||
|
||||
def test_entrypoint_preserves_shell_variables(self, mock_sdk_client):
|
||||
"""Ensure $VAR in entrypoint is single-quoted on POSIX, not double-quoted."""
|
||||
runner = CliRunner()
|
||||
mock_client_instance = mock_sdk_client.return_value
|
||||
|
||||
with set_env_var("RAY_ADDRESS", "env_addr"):
|
||||
with mock.patch("ray.dashboard.modules.job.cli.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
result = runner.invoke(
|
||||
job_cli_group,
|
||||
[
|
||||
"submit",
|
||||
"--",
|
||||
"python",
|
||||
"-m",
|
||||
"launcher",
|
||||
"--config",
|
||||
"$CONFIG_PATH",
|
||||
],
|
||||
)
|
||||
check_exit_code(result, 0)
|
||||
call_kwargs = mock_client_instance.submit_job.call_args
|
||||
entrypoint = call_kwargs.kwargs["entrypoint"]
|
||||
|
||||
# shlex.join must single-quote the $VAR argument so that
|
||||
# the server-side POSIX shell does NOT expand it.
|
||||
assert "'$CONFIG_PATH'" in entrypoint, (
|
||||
f"Expected single-quoted $CONFIG_PATH in entrypoint, "
|
||||
f"got: {entrypoint!r}"
|
||||
)
|
||||
# Double quotes around $CONFIG_PATH would cause expansion.
|
||||
assert '"$CONFIG_PATH"' not in entrypoint, (
|
||||
f"Double-quoted $CONFIG_PATH would be expanded by the "
|
||||
f"server shell, got: {entrypoint!r}"
|
||||
)
|
||||
|
||||
def test_entrypoint_uses_list2cmdline_on_windows(self, mock_sdk_client):
|
||||
"""On Windows, entrypoint should use list2cmdline (double quotes)."""
|
||||
runner = CliRunner()
|
||||
mock_client_instance = mock_sdk_client.return_value
|
||||
|
||||
with set_env_var("RAY_ADDRESS", "env_addr"):
|
||||
with mock.patch("ray.dashboard.modules.job.cli.sys") as mock_sys:
|
||||
mock_sys.platform = "win32"
|
||||
result = runner.invoke(
|
||||
job_cli_group,
|
||||
[
|
||||
"submit",
|
||||
"--",
|
||||
"echo",
|
||||
"hello world",
|
||||
],
|
||||
)
|
||||
check_exit_code(result, 0)
|
||||
call_kwargs = mock_client_instance.submit_job.call_args
|
||||
entrypoint = call_kwargs.kwargs["entrypoint"]
|
||||
|
||||
# list2cmdline wraps args with spaces in double quotes
|
||||
assert (
|
||||
entrypoint == 'echo "hello world"'
|
||||
), f"Expected list2cmdline output on Windows, got: {entrypoint!r}"
|
||||
|
||||
def test_entrypoint_simple_args_not_over_quoted(self, mock_sdk_client):
|
||||
"""Simple arguments without special chars should not be quoted."""
|
||||
runner = CliRunner()
|
||||
mock_client_instance = mock_sdk_client.return_value
|
||||
|
||||
with set_env_var("RAY_ADDRESS", "env_addr"):
|
||||
result = runner.invoke(
|
||||
job_cli_group,
|
||||
["submit", "--", "echo", "hello"],
|
||||
)
|
||||
check_exit_code(result, 0)
|
||||
call_kwargs = mock_client_instance.submit_job.call_args
|
||||
entrypoint = call_kwargs.kwargs["entrypoint"]
|
||||
assert entrypoint == "echo hello"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,356 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shutdown_only():
|
||||
yield None
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
# Delete the cluster address just in case.
|
||||
ray._common.utils.reset_ray_address()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def set_env_var(key: str, val: Optional[str] = None):
|
||||
old_val = os.environ.get(key, None)
|
||||
if val is not None:
|
||||
os.environ[key] = val
|
||||
elif key in os.environ:
|
||||
del os.environ[key]
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if key in os.environ:
|
||||
del os.environ[key]
|
||||
if old_val is not None:
|
||||
os.environ[key] = old_val
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_stop():
|
||||
subprocess.check_output(["ray", "start", "--head"])
|
||||
try:
|
||||
with set_env_var("RAY_ADDRESS", "http://127.0.0.1:8265"):
|
||||
yield
|
||||
finally:
|
||||
subprocess.check_output(["ray", "stop", "--force"])
|
||||
|
||||
|
||||
@contextmanager
|
||||
def ray_cluster_manager():
|
||||
"""
|
||||
Used not as fixture in case we want to set RAY_ADDRESS first.
|
||||
"""
|
||||
subprocess.check_output(["ray", "start", "--head"])
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
subprocess.check_output(["ray", "stop", "--force"])
|
||||
|
||||
|
||||
def _run_cmd(cmd: str, should_fail=False) -> Tuple[str, str]:
|
||||
"""Convenience wrapper for subprocess.run.
|
||||
|
||||
We always run with shell=True to simulate the CLI.
|
||||
|
||||
Asserts that the process succeeds/fails depending on should_fail.
|
||||
|
||||
Returns (stdout, stderr).
|
||||
"""
|
||||
print(f"Running command: '{cmd}'")
|
||||
p: subprocess.CompletedProcess = subprocess.run(
|
||||
cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
if p.returncode == 0:
|
||||
print("Command succeeded.")
|
||||
if should_fail:
|
||||
raise RuntimeError(
|
||||
f"Expected command to fail, but got exit code: {p.returncode}."
|
||||
)
|
||||
else:
|
||||
print(f"Command failed with exit code: {p.returncode}.")
|
||||
if not should_fail:
|
||||
raise RuntimeError(
|
||||
f"Expected command to succeed, but got exit code: {p.returncode}."
|
||||
)
|
||||
|
||||
return p.stdout.decode("utf-8"), p.stderr.decode("utf-8")
|
||||
|
||||
|
||||
class TestJobSubmitHook:
|
||||
"""Tests the RAY_JOB_SUBMIT_HOOK env var."""
|
||||
|
||||
def test_hook(self, ray_start_stop):
|
||||
with set_env_var("RAY_JOB_SUBMIT_HOOK", "ray._private.test_utils.job_hook"):
|
||||
stdout, _ = _run_cmd("ray job submit -- echo hello")
|
||||
assert "hook intercepted: echo hello" in stdout
|
||||
|
||||
|
||||
class TestRayJobHeaders:
|
||||
"""
|
||||
Integration version of job CLI test that ensures interaction with the
|
||||
following components are working as expected:
|
||||
1) Ray client: use of RAY_JOB_HEADERS and ray.init() in job_head.py
|
||||
2) Ray dashboard: `ray start --head`
|
||||
"""
|
||||
|
||||
def test_empty_ray_job_headers(self, ray_start_stop):
|
||||
with set_env_var("RAY_JOB_HEADERS", None):
|
||||
stdout, _ = _run_cmd("ray job submit -- echo hello")
|
||||
assert "hello" in stdout
|
||||
assert "succeeded" in stdout
|
||||
|
||||
@pytest.mark.parametrize("ray_job_headers", ['{"key": "value"}'])
|
||||
def test_ray_job_headers(self, ray_start_stop, ray_job_headers: str):
|
||||
with set_env_var("RAY_JOB_HEADERS", ray_job_headers):
|
||||
_run_cmd("ray job submit -- echo hello", should_fail=False)
|
||||
|
||||
@pytest.mark.parametrize("ray_job_headers", ["{key value}"])
|
||||
def test_ray_incorrectly_formatted_job_headers(
|
||||
self, ray_start_stop, ray_job_headers: str
|
||||
):
|
||||
with set_env_var("RAY_JOB_HEADERS", ray_job_headers):
|
||||
_run_cmd("ray job submit -- echo hello", should_fail=True)
|
||||
|
||||
|
||||
class TestRayAddress:
|
||||
"""
|
||||
Integration version of job CLI test that ensures interaction with the
|
||||
following components are working as expected:
|
||||
|
||||
1) Ray client: use of RAY_ADDRESS and ray.init() in job_head.py
|
||||
2) Ray dashboard: `ray start --head`
|
||||
"""
|
||||
|
||||
def test_empty_ray_address(self, ray_start_stop):
|
||||
with set_env_var("RAY_ADDRESS", None):
|
||||
stdout, _ = _run_cmd("ray job submit -- echo hello")
|
||||
assert "hello" in stdout
|
||||
assert "succeeded" in stdout
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ray_api_server_address,should_fail",
|
||||
[
|
||||
("http://127.0.0.1:8265", False), # correct API server
|
||||
("127.0.0.1:8265", True), # wrong format without http
|
||||
("http://127.0.0.1:9999", True), # wrong port
|
||||
],
|
||||
)
|
||||
def test_ray_api_server_address(
|
||||
self,
|
||||
ray_start_stop,
|
||||
ray_api_server_address: str,
|
||||
should_fail: bool,
|
||||
):
|
||||
# Set a `RAY_ADDRESS` that would not work with the `ray job submit` CLI because it uses the `ray://` prefix.
|
||||
# This verifies that the `RAY_API_SERVER_ADDRESS` env var takes precedence.
|
||||
with set_env_var("RAY_ADDRESS", "ray://127.0.0.1:8265"):
|
||||
with set_env_var("RAY_API_SERVER_ADDRESS", ray_api_server_address):
|
||||
_run_cmd("ray job submit -- echo hello", should_fail=should_fail)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ray_client_address,should_fail",
|
||||
[
|
||||
("127.0.0.1:8265", True),
|
||||
("ray://127.0.0.1:8265", True),
|
||||
("http://127.0.0.1:8265", False),
|
||||
],
|
||||
)
|
||||
def test_ray_client_address(
|
||||
self, ray_start_stop, ray_client_address: str, should_fail: bool
|
||||
):
|
||||
with set_env_var("RAY_ADDRESS", ray_client_address):
|
||||
_run_cmd("ray job submit -- echo hello", should_fail=should_fail)
|
||||
|
||||
def test_valid_http_ray_address(self, ray_start_stop):
|
||||
stdout, _ = _run_cmd("ray job submit -- echo hello")
|
||||
assert "hello" in stdout
|
||||
assert "succeeded" in stdout
|
||||
|
||||
|
||||
class TestJobSubmit:
|
||||
def test_basic_submit(self, ray_start_stop):
|
||||
"""Should tail logs and wait for process to exit."""
|
||||
cmd = "sleep 1 && echo hello && sleep 1 && echo hello"
|
||||
stdout, _ = _run_cmd(f"ray job submit -- bash -c '{cmd}'")
|
||||
|
||||
# 'hello' should appear four times: twice when we print the entrypoint, then
|
||||
# two more times in the logs from the `echo`.
|
||||
assert stdout.count("hello") == 4
|
||||
assert "succeeded" in stdout
|
||||
|
||||
def test_submit_no_wait(self, ray_start_stop):
|
||||
"""Should exit immediately w/o printing logs."""
|
||||
cmd = "echo hello && sleep 1000"
|
||||
stdout, _ = _run_cmd(f"ray job submit --no-wait -- bash -c '{cmd}'")
|
||||
assert "hello" not in stdout
|
||||
assert "Tailing logs until the job exits" not in stdout
|
||||
|
||||
def test_submit_with_logs_instant_job(self, ray_start_stop):
|
||||
"""Should exit immediately and print logs even if job returns instantly."""
|
||||
cmd = "echo hello"
|
||||
stdout, _ = _run_cmd(f"ray job submit -- bash -c '{cmd}'")
|
||||
|
||||
# 'hello' should appear twice: once when we print the entrypoint, then
|
||||
# again from the `echo`.
|
||||
assert stdout.count("hello") == 2
|
||||
|
||||
def test_multiple_ray_init(self, ray_start_stop):
|
||||
cmd = (
|
||||
"python -c 'import ray; ray.init(); ray.shutdown(); "
|
||||
"ray.init(); ray.shutdown();'"
|
||||
)
|
||||
stdout, _ = _run_cmd(f"ray job submit -- {cmd}")
|
||||
assert "succeeded" in stdout
|
||||
|
||||
def test_metadata(self, ray_start_stop):
|
||||
cmd = "echo hello"
|
||||
stdout, _ = _run_cmd(
|
||||
f'ray job submit --metadata-json=\'{{"key": "value"}}\' -- {cmd}'
|
||||
)
|
||||
assert "hello" in stdout
|
||||
assert "succeeded" in stdout
|
||||
|
||||
def test_job_failed(self, ray_start_stop):
|
||||
cmd = "python -c 'import ray; ray.init(); assert 1 == 2;'"
|
||||
_run_cmd(f"ray job submit -- {cmd}", should_fail=True)
|
||||
|
||||
|
||||
class TestRuntimeEnv:
|
||||
def test_bad_runtime_env(self, ray_start_stop):
|
||||
"""Should fail with helpful error if runtime env setup fails."""
|
||||
stdout, _ = _run_cmd(
|
||||
'ray job submit --runtime-env-json=\'{"pip": '
|
||||
'["does-not-exist"]}\' -- echo hi',
|
||||
should_fail=True,
|
||||
)
|
||||
assert "Tailing logs until the job exits" in stdout
|
||||
assert "runtime_env setup failed" in stdout
|
||||
assert "No matching distribution found for does-not-exist" in stdout
|
||||
|
||||
|
||||
class TestJobStop:
|
||||
def test_basic_stop(self, ray_start_stop):
|
||||
"""Should wait until the job is stopped."""
|
||||
cmd = "sleep 1000"
|
||||
job_id = "test_basic_stop"
|
||||
_run_cmd(f"ray job submit --no-wait --job-id={job_id} -- {cmd}")
|
||||
|
||||
stdout, _ = _run_cmd(f"ray job stop {job_id}")
|
||||
assert "Waiting for job" in stdout
|
||||
assert f"Job '{job_id}' was stopped" in stdout
|
||||
|
||||
def test_stop_no_wait(self, ray_start_stop):
|
||||
"""Should not wait until the job is stopped."""
|
||||
cmd = "echo hello && sleep 1000"
|
||||
job_id = "test_stop_no_wait"
|
||||
_run_cmd(f"ray job submit --no-wait --job-id={job_id} -- bash -c '{cmd}'")
|
||||
|
||||
stdout, _ = _run_cmd(f"ray job stop --no-wait {job_id}")
|
||||
assert "Waiting for job" not in stdout
|
||||
assert f"Job '{job_id}' was stopped" not in stdout
|
||||
|
||||
|
||||
class TestJobList:
|
||||
def test_empty(self, ray_start_stop):
|
||||
stdout, _ = _run_cmd("ray job list")
|
||||
assert "[]" in stdout
|
||||
|
||||
def test_list(self, ray_start_stop):
|
||||
_run_cmd("ray job submit --job-id='hello_id' -- echo hello")
|
||||
|
||||
runtime_env = {"env_vars": {"TEST": "123"}}
|
||||
_run_cmd(
|
||||
"ray job submit --job-id='hi_id' "
|
||||
f"--runtime-env-json='{json.dumps(runtime_env)}' -- echo hi"
|
||||
)
|
||||
stdout, _ = _run_cmd("ray job list")
|
||||
assert "123" in stdout
|
||||
assert "hello_id" in stdout
|
||||
assert "hi_id" in stdout
|
||||
|
||||
|
||||
class TestJobDelete:
|
||||
def test_basic_delete(self, ray_start_stop):
|
||||
cmd = "sleep 1000"
|
||||
job_id = "test_basic_delete"
|
||||
_run_cmd(f"ray job submit --no-wait --submission-id={job_id} -- {cmd}")
|
||||
|
||||
# Job shouldn't be able to be deleted because it is not in a terminal state.
|
||||
stdout, stderr = _run_cmd(f"ray job delete {job_id}", should_fail=True)
|
||||
assert "it is in a non-terminal state" in stderr
|
||||
|
||||
# Submit a job that finishes quickly.
|
||||
cmd = "echo hello"
|
||||
job_id = "test_basic_delete_quick"
|
||||
_run_cmd(f"ray job submit --submission-id={job_id} -- bash -c '{cmd}'")
|
||||
|
||||
# Job should be able to be deleted because it is finished.
|
||||
stdout, _ = _run_cmd(f"ray job delete {job_id}")
|
||||
assert f"Job '{job_id}' deleted successfully" in stdout
|
||||
|
||||
|
||||
class TestJobStatus:
|
||||
# `ray job status` should exit with 0 if the job exists and non-zero if it doesn't.
|
||||
# This is the contract between Ray and KubRay v1.3.0.
|
||||
def test_status_job_exists(self, ray_start_stop):
|
||||
cmd = "echo hello"
|
||||
job_id = "test_job_id"
|
||||
_run_cmd(
|
||||
f"ray job submit --submission-id={job_id} -- bash -c '{cmd}'",
|
||||
should_fail=False,
|
||||
)
|
||||
_run_cmd(f"ray job status {job_id}", should_fail=False)
|
||||
|
||||
def test_status_job_does_not_exist(self, ray_start_stop):
|
||||
job_id = "test_job_id"
|
||||
_run_cmd(f"ray job status {job_id}", should_fail=True)
|
||||
|
||||
|
||||
def test_quote_escaping(ray_start_stop):
|
||||
cmd = "echo \"hello 'world'\""
|
||||
job_id = "test_quote_escaping"
|
||||
stdout, _ = _run_cmd(
|
||||
f"ray job submit --job-id={job_id} -- {cmd}",
|
||||
)
|
||||
assert "hello 'world'" in stdout
|
||||
|
||||
|
||||
def test_resources(shutdown_only):
|
||||
ray.init(num_cpus=1, num_gpus=1, resources={"Custom": 1}, _memory=4)
|
||||
|
||||
# Check the case of too many resources.
|
||||
for id, arg in [
|
||||
("entrypoint_num_cpus", "--entrypoint-num-cpus=2"),
|
||||
("entrypoint_num_gpus", "--entrypoint-num-gpus=2"),
|
||||
("entrypoint_memory", "--entrypoint-memory=5"),
|
||||
("entrypoint_resources", "--entrypoint-resources='{\"Custom\": 2}'"),
|
||||
]:
|
||||
_run_cmd(f"ray job submit --submission-id={id} --no-wait {arg} -- echo hi")
|
||||
stdout, _ = _run_cmd(f"ray job status {id}")
|
||||
assert "waiting for resources" in stdout
|
||||
|
||||
# Check the case of sufficient resources.
|
||||
stdout, _ = _run_cmd(
|
||||
"ray job submit --entrypoint-num-cpus=1 "
|
||||
"--entrypoint-num-gpus=1 --entrypoint-memory=4 --entrypoint-resources='{"
|
||||
'"Custom": 1}\' -- echo hello',
|
||||
)
|
||||
assert "hello" in stdout
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,302 @@
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from google.protobuf.json_format import Parse
|
||||
|
||||
from ray.core.generated.gcs_pb2 import JobsAPIInfo
|
||||
from ray.dashboard.modules.job.common import (
|
||||
JobErrorType,
|
||||
JobInfo,
|
||||
JobInfoStorageClient,
|
||||
JobStatus,
|
||||
JobSubmitRequest,
|
||||
http_uri_components_to_uri,
|
||||
uri_to_http_components,
|
||||
validate_request_type,
|
||||
)
|
||||
|
||||
|
||||
class TestJobSubmitRequestValidation:
|
||||
def test_validate_entrypoint(self):
|
||||
r = validate_request_type({"entrypoint": "abc"}, JobSubmitRequest)
|
||||
assert r.entrypoint == "abc"
|
||||
|
||||
with pytest.raises(TypeError, match="required positional argument"):
|
||||
validate_request_type({}, JobSubmitRequest)
|
||||
|
||||
with pytest.raises(TypeError, match="must be a string"):
|
||||
validate_request_type({"entrypoint": 123}, JobSubmitRequest)
|
||||
|
||||
def test_validate_submission_id(self):
|
||||
r = validate_request_type({"entrypoint": "abc"}, JobSubmitRequest)
|
||||
assert r.entrypoint == "abc"
|
||||
assert r.submission_id is None
|
||||
|
||||
r = validate_request_type(
|
||||
{"entrypoint": "abc", "submission_id": "123"}, JobSubmitRequest
|
||||
)
|
||||
assert r.entrypoint == "abc"
|
||||
assert r.submission_id == "123"
|
||||
|
||||
with pytest.raises(TypeError, match="must be a string"):
|
||||
validate_request_type(
|
||||
{"entrypoint": 123, "submission_id": 1}, JobSubmitRequest
|
||||
)
|
||||
|
||||
def test_validate_runtime_env(self):
|
||||
r = validate_request_type({"entrypoint": "abc"}, JobSubmitRequest)
|
||||
assert r.entrypoint == "abc"
|
||||
assert r.runtime_env is None
|
||||
|
||||
r = validate_request_type(
|
||||
{"entrypoint": "abc", "runtime_env": {"hi": "hi2"}}, JobSubmitRequest
|
||||
)
|
||||
assert r.entrypoint == "abc"
|
||||
assert r.runtime_env == {"hi": "hi2"}
|
||||
|
||||
with pytest.raises(TypeError, match="must be a dict"):
|
||||
validate_request_type(
|
||||
{"entrypoint": "abc", "runtime_env": 123}, JobSubmitRequest
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError, match="keys must be strings"):
|
||||
validate_request_type(
|
||||
{"entrypoint": "abc", "runtime_env": {1: "hi"}}, JobSubmitRequest
|
||||
)
|
||||
|
||||
def test_validate_metadata(self):
|
||||
r = validate_request_type({"entrypoint": "abc"}, JobSubmitRequest)
|
||||
assert r.entrypoint == "abc"
|
||||
assert r.metadata is None
|
||||
|
||||
r = validate_request_type(
|
||||
{"entrypoint": "abc", "metadata": {"hi": "hi2"}}, JobSubmitRequest
|
||||
)
|
||||
assert r.entrypoint == "abc"
|
||||
assert r.metadata == {"hi": "hi2"}
|
||||
|
||||
with pytest.raises(TypeError, match="must be a dict"):
|
||||
validate_request_type(
|
||||
{"entrypoint": "abc", "metadata": 123}, JobSubmitRequest
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError, match="keys must be strings"):
|
||||
validate_request_type(
|
||||
{"entrypoint": "abc", "metadata": {1: "hi"}}, JobSubmitRequest
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError, match="values must be strings"):
|
||||
validate_request_type(
|
||||
{"entrypoint": "abc", "metadata": {"hi": 1}}, JobSubmitRequest
|
||||
)
|
||||
|
||||
def test_validate_entrypoint_label_selector(self):
|
||||
r = validate_request_type(
|
||||
{
|
||||
"entrypoint": "abc",
|
||||
"entrypoint_label_selector": {"fragile_node": "!1"},
|
||||
},
|
||||
JobSubmitRequest,
|
||||
)
|
||||
assert r.entrypoint_label_selector == {"fragile_node": "!1"}
|
||||
|
||||
with pytest.raises(TypeError, match="must be a dict"):
|
||||
validate_request_type(
|
||||
{"entrypoint": "abc", "entrypoint_label_selector": "bad"},
|
||||
JobSubmitRequest,
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError, match="keys must be strings"):
|
||||
validate_request_type(
|
||||
{"entrypoint": "abc", "entrypoint_label_selector": {1: "bad"}},
|
||||
JobSubmitRequest,
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError, match="values must be strings"):
|
||||
validate_request_type(
|
||||
{"entrypoint": "abc", "entrypoint_label_selector": {"k": 1}},
|
||||
JobSubmitRequest,
|
||||
)
|
||||
|
||||
def test_entrypoint_resources_disallow_strings(self):
|
||||
with pytest.raises(TypeError, match="values must be numbers"):
|
||||
validate_request_type(
|
||||
{"entrypoint": "abc", "entrypoint_resources": {"Custom": "1"}},
|
||||
JobSubmitRequest,
|
||||
)
|
||||
|
||||
|
||||
def test_uri_to_http_and_back():
|
||||
assert uri_to_http_components("gcs://hello.zip") == ("gcs", "hello.zip")
|
||||
assert uri_to_http_components("gcs://hello.whl") == ("gcs", "hello.whl")
|
||||
|
||||
with pytest.raises(ValueError, match="'blah' is not a valid Protocol"):
|
||||
uri_to_http_components("blah://halb.zip")
|
||||
|
||||
with pytest.raises(ValueError, match="does not end in .zip or .whl"):
|
||||
assert uri_to_http_components("gcs://hello.not_zip")
|
||||
|
||||
with pytest.raises(ValueError, match="does not end in .zip or .whl"):
|
||||
assert uri_to_http_components("gcs://hello")
|
||||
|
||||
assert http_uri_components_to_uri("gcs", "hello.zip") == "gcs://hello.zip"
|
||||
assert http_uri_components_to_uri("blah", "halb.zip") == "blah://halb.zip"
|
||||
assert http_uri_components_to_uri("blah", "halb.whl") == "blah://halb.whl"
|
||||
|
||||
for original_uri in ["gcs://hello.zip", "gcs://fasdf.whl"]:
|
||||
new_uri = http_uri_components_to_uri(*uri_to_http_components(original_uri))
|
||||
assert new_uri == original_uri
|
||||
|
||||
|
||||
def test_dynamic_status_message():
|
||||
info = JobInfo(
|
||||
status=JobStatus.PENDING, entrypoint="echo hi", entrypoint_num_cpus=1
|
||||
)
|
||||
assert "may be waiting for resources" in info.message
|
||||
|
||||
info = JobInfo(
|
||||
status=JobStatus.PENDING, entrypoint="echo hi", entrypoint_num_gpus=1
|
||||
)
|
||||
assert "may be waiting for resources" in info.message
|
||||
|
||||
info = JobInfo(status=JobStatus.PENDING, entrypoint="echo hi", entrypoint_memory=4)
|
||||
assert "may be waiting for resources" in info.message
|
||||
|
||||
info = JobInfo(
|
||||
status=JobStatus.PENDING,
|
||||
entrypoint="echo hi",
|
||||
entrypoint_resources={"Custom": 1},
|
||||
)
|
||||
assert "may be waiting for resources" in info.message
|
||||
|
||||
info = JobInfo(
|
||||
status=JobStatus.PENDING, entrypoint="echo hi", runtime_env={"conda": "env"}
|
||||
)
|
||||
assert "may be waiting for the runtime environment" in info.message
|
||||
|
||||
|
||||
def test_job_info_to_json():
|
||||
info = JobInfo(
|
||||
status=JobStatus.PENDING,
|
||||
entrypoint="echo hi",
|
||||
entrypoint_num_cpus=1,
|
||||
entrypoint_num_gpus=1,
|
||||
entrypoint_memory=4,
|
||||
entrypoint_resources={"Custom": 1},
|
||||
runtime_env={"pip": ["pkg"]},
|
||||
)
|
||||
expected_items = {
|
||||
"status": "PENDING",
|
||||
"message": (
|
||||
"Job has not started yet. It may be waiting for resources "
|
||||
"(CPUs, GPUs, memory, custom resources) to become available. "
|
||||
"It may be waiting for the runtime environment to be set up."
|
||||
),
|
||||
"entrypoint": "echo hi",
|
||||
"entrypoint_num_cpus": 1,
|
||||
"entrypoint_num_gpus": 1,
|
||||
"entrypoint_memory": 4,
|
||||
"entrypoint_resources": {"Custom": 1},
|
||||
"runtime_env_json": '{"pip": ["pkg"]}',
|
||||
}
|
||||
|
||||
# Check that the expected items are in the JSON.
|
||||
assert expected_items.items() <= info.to_json().items()
|
||||
|
||||
new_job_info = JobInfo.from_json(info.to_json())
|
||||
assert new_job_info == info
|
||||
|
||||
# If `status` is just a string, then operations like status.is_terminal()
|
||||
# would fail, so we should make sure that it's a JobStatus.
|
||||
assert isinstance(new_job_info.status, JobStatus)
|
||||
|
||||
|
||||
def test_job_info_json_to_proto():
|
||||
"""Test that JobInfo JSON can be converted to JobsAPIInfo protobuf."""
|
||||
info = JobInfo(
|
||||
status=JobStatus.PENDING,
|
||||
entrypoint="echo hi",
|
||||
error_type=JobErrorType.JOB_SUPERVISOR_ACTOR_UNSCHEDULABLE,
|
||||
start_time=123,
|
||||
end_time=456,
|
||||
metadata={"hi": "hi2"},
|
||||
entrypoint_num_cpus=1,
|
||||
entrypoint_num_gpus=1,
|
||||
entrypoint_memory=4,
|
||||
entrypoint_resources={"Custom": 1},
|
||||
runtime_env={"pip": ["pkg"]},
|
||||
driver_agent_http_address="http://localhost:1234",
|
||||
driver_node_id="node_id",
|
||||
)
|
||||
info_json = json.dumps(info.to_json())
|
||||
info_proto = Parse(info_json, JobsAPIInfo())
|
||||
assert info_proto.status == "PENDING"
|
||||
assert info_proto.entrypoint == "echo hi"
|
||||
assert info_proto.start_time == 123
|
||||
assert info_proto.end_time == 456
|
||||
assert info_proto.metadata == {"hi": "hi2"}
|
||||
assert info_proto.entrypoint_num_cpus == 1
|
||||
assert info_proto.entrypoint_num_gpus == 1
|
||||
assert info_proto.entrypoint_memory == 4
|
||||
assert info_proto.entrypoint_resources == {"Custom": 1}
|
||||
assert info_proto.runtime_env_json == '{"pip": ["pkg"]}'
|
||||
assert info_proto.message == (
|
||||
"Job has not started yet. It may be waiting for resources "
|
||||
"(CPUs, GPUs, memory, custom resources) to become available. "
|
||||
"It may be waiting for the runtime environment to be set up."
|
||||
)
|
||||
assert info_proto.error_type == "JOB_SUPERVISOR_ACTOR_UNSCHEDULABLE"
|
||||
assert info_proto.driver_agent_http_address == "http://localhost:1234"
|
||||
assert info_proto.driver_node_id == "node_id"
|
||||
|
||||
minimal_info = JobInfo(status=JobStatus.PENDING, entrypoint="echo hi")
|
||||
minimal_info_json = json.dumps(minimal_info.to_json())
|
||||
minimal_info_proto = Parse(minimal_info_json, JobsAPIInfo())
|
||||
assert minimal_info_proto.status == "PENDING"
|
||||
assert minimal_info_proto.entrypoint == "echo hi"
|
||||
for unset_optional_field in [
|
||||
"entrypoint_num_cpus",
|
||||
"entrypoint_num_gpus",
|
||||
"entrypoint_memory",
|
||||
"runtime_env_json",
|
||||
"error_type",
|
||||
"driver_agent_http_address",
|
||||
"driver_node_id",
|
||||
]:
|
||||
assert not minimal_info_proto.HasField(unset_optional_field)
|
||||
|
||||
|
||||
def test_get_all_jobs_filters_out_none_job_info():
|
||||
prefix = JobInfoStorageClient.JOB_DATA_KEY_PREFIX
|
||||
mock_gcs = MagicMock()
|
||||
mock_gcs.async_internal_kv_keys = AsyncMock(
|
||||
return_value=[
|
||||
(prefix + "job1").encode(),
|
||||
(prefix + "job2").encode(),
|
||||
]
|
||||
)
|
||||
|
||||
storage = JobInfoStorageClient(mock_gcs)
|
||||
job_info_1 = JobInfo(status=JobStatus.RUNNING, entrypoint="echo 1")
|
||||
|
||||
async def mock_get_info(job_id, timeout=30):
|
||||
if job_id == "job1":
|
||||
return job_info_1
|
||||
return None
|
||||
|
||||
storage.get_info = mock_get_info
|
||||
|
||||
result = asyncio.run(storage.get_all_jobs())
|
||||
|
||||
assert result == {"job1": job_info_1}
|
||||
for job_id, job_info in result.items():
|
||||
asdict(job_info) # This should not raise an exception
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,177 @@
|
||||
import json
|
||||
import os
|
||||
import pprint
|
||||
import sys
|
||||
|
||||
import jsonschema
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from ray._common.test_utils import (
|
||||
run_string_as_driver,
|
||||
wait_for_condition,
|
||||
)
|
||||
from ray._private.test_utils import (
|
||||
format_web_url,
|
||||
run_string_as_driver_nonblocking,
|
||||
)
|
||||
from ray.dashboard import dashboard
|
||||
from ray.dashboard.consts import RAY_CLUSTER_ACTIVITY_HOOK
|
||||
from ray.dashboard.modules.job.job_head import RayActivityResponse
|
||||
from ray.dashboard.tests.conftest import * # noqa
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def set_ray_cluster_activity_hook(request):
|
||||
"""
|
||||
Fixture that sets RAY_CLUSTER_ACTIVITY_HOOK environment variable
|
||||
for test_e2e_component_activities_hook.
|
||||
"""
|
||||
external_hook = request.param
|
||||
assert (
|
||||
external_hook
|
||||
), "Please pass value of RAY_CLUSTER_ACTIVITY_HOOK env var to this fixture"
|
||||
old_hook = os.environ.get(RAY_CLUSTER_ACTIVITY_HOOK)
|
||||
os.environ[RAY_CLUSTER_ACTIVITY_HOOK] = external_hook
|
||||
|
||||
yield external_hook
|
||||
|
||||
if old_hook is not None:
|
||||
os.environ[RAY_CLUSTER_ACTIVITY_HOOK] = old_hook
|
||||
else:
|
||||
del os.environ[RAY_CLUSTER_ACTIVITY_HOOK]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"set_ray_cluster_activity_hook",
|
||||
[
|
||||
"ray._private.test_utils.external_ray_cluster_activity_hook1",
|
||||
"ray._private.test_utils.external_ray_cluster_activity_hook2",
|
||||
"ray._private.test_utils.external_ray_cluster_activity_hook3",
|
||||
"ray._private.test_utils.external_ray_cluster_activity_hook4",
|
||||
"ray._private.test_utils.external_ray_cluster_activity_hook5",
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
def test_component_activities_hook(set_ray_cluster_activity_hook, call_ray_start):
|
||||
"""
|
||||
Tests /api/component_activities returns correctly for various
|
||||
responses of RAY_CLUSTER_ACTIVITY_HOOK.
|
||||
|
||||
Verify no active drivers are correctly reflected in response.
|
||||
"""
|
||||
external_hook = set_ray_cluster_activity_hook
|
||||
|
||||
response = requests.get("http://127.0.0.1:8265/api/component_activities")
|
||||
response.raise_for_status()
|
||||
|
||||
# Validate schema of response
|
||||
data = response.json()
|
||||
schema_path = os.path.join(
|
||||
os.path.dirname(dashboard.__file__),
|
||||
"modules/job/component_activities_schema.json",
|
||||
)
|
||||
pprint.pprint(data)
|
||||
jsonschema.validate(instance=data, schema=json.load(open(schema_path)))
|
||||
|
||||
# Validate driver response can be cast to RayActivityResponse object
|
||||
# and that there are no active drivers.
|
||||
driver_ray_activity_response = RayActivityResponse(**data["driver"])
|
||||
assert driver_ray_activity_response.is_active == "INACTIVE"
|
||||
assert driver_ray_activity_response.reason is None
|
||||
|
||||
# Validate external component response can be cast to RayActivityResponse object
|
||||
if external_hook[-1] == "5":
|
||||
external_activity_response = RayActivityResponse(**data["test_component5"])
|
||||
assert external_activity_response.is_active == "ACTIVE"
|
||||
assert external_activity_response.reason == "Counter: 1"
|
||||
elif external_hook[-1] == "4":
|
||||
external_activity_response = RayActivityResponse(**data["external_component"])
|
||||
assert external_activity_response.is_active == "ERROR"
|
||||
assert (
|
||||
"'Error in external cluster activity hook'"
|
||||
in external_activity_response.reason
|
||||
)
|
||||
elif external_hook[-1] == "3":
|
||||
external_activity_response = RayActivityResponse(**data["external_component"])
|
||||
assert external_activity_response.is_active == "ERROR"
|
||||
elif external_hook[-1] == "2":
|
||||
external_activity_response = RayActivityResponse(**data["test_component2"])
|
||||
assert external_activity_response.is_active == "ERROR"
|
||||
elif external_hook[-1] == "1":
|
||||
external_activity_response = RayActivityResponse(**data["test_component1"])
|
||||
assert external_activity_response.is_active == "ACTIVE"
|
||||
assert external_activity_response.reason == "Counter: 1"
|
||||
|
||||
# Call endpoint again to validate different response
|
||||
response = requests.get("http://127.0.0.1:8265/api/component_activities")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
jsonschema.validate(instance=data, schema=json.load(open(schema_path)))
|
||||
|
||||
external_activity_response = RayActivityResponse(**data["test_component1"])
|
||||
assert external_activity_response.is_active == "ACTIVE"
|
||||
assert external_activity_response.reason == "Counter: 2"
|
||||
|
||||
|
||||
def test_active_component_activities(ray_start_with_dashboard):
|
||||
# Verify drivers which don't have namespace starting with _ray_internal_
|
||||
# are considered active.
|
||||
|
||||
webui_url = ray_start_with_dashboard["webui_url"]
|
||||
webui_url = format_web_url(webui_url)
|
||||
|
||||
driver_template = """
|
||||
import ray
|
||||
|
||||
ray.init(address="auto", namespace="{namespace}")
|
||||
import time
|
||||
time.sleep({sleep_time_s})
|
||||
"""
|
||||
run_string_as_driver(
|
||||
driver_template.format(namespace="my_namespace", sleep_time_s=0)
|
||||
)
|
||||
|
||||
run_string_as_driver_nonblocking(
|
||||
driver_template.format(namespace="my_namespace", sleep_time_s=5)
|
||||
)
|
||||
run_string_as_driver_nonblocking(
|
||||
driver_template.format(namespace="_ray_internal_job_info_id1", sleep_time_s=5)
|
||||
)
|
||||
# Simulate the default driver that gets created by dashboard
|
||||
run_string_as_driver_nonblocking(
|
||||
driver_template.format(namespace="_ray_internal_dashboard", sleep_time_s=5)
|
||||
)
|
||||
|
||||
def verify_driver_response():
|
||||
# Verify drivers are considered active after running script
|
||||
response = requests.get(f"{webui_url}/api/component_activities")
|
||||
response.raise_for_status()
|
||||
|
||||
# Validate schema of response
|
||||
data = response.json()
|
||||
schema_path = os.path.join(
|
||||
os.path.dirname(dashboard.__file__),
|
||||
"modules/job/component_activities_schema.json",
|
||||
)
|
||||
|
||||
jsonschema.validate(instance=data, schema=json.load(open(schema_path)))
|
||||
|
||||
# Validate ray_activity_response field can be cast to RayActivityResponse object
|
||||
driver_ray_activity_response = RayActivityResponse(**data["driver"])
|
||||
print(driver_ray_activity_response)
|
||||
|
||||
assert driver_ray_activity_response.is_active == "ACTIVE"
|
||||
# Drivers with namespace starting with "_ray_internal" are not
|
||||
# considered active drivers. Two active drivers are the second one
|
||||
# run with namespace "my_namespace" and the one started
|
||||
# from ray_start_with_dashboard
|
||||
assert driver_ray_activity_response.reason == "Number of active drivers: 2"
|
||||
|
||||
return True
|
||||
|
||||
wait_for_condition(verify_driver_response)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,777 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
import ray
|
||||
from ray._common.test_utils import wait_for_condition
|
||||
from ray._private.runtime_env.packaging import (
|
||||
create_package,
|
||||
download_and_unpack_package,
|
||||
get_uri_for_file,
|
||||
)
|
||||
from ray._private.test_utils import (
|
||||
chdir,
|
||||
format_web_url,
|
||||
wait_until_server_available,
|
||||
)
|
||||
from ray.dashboard.modules.dashboard_sdk import ClusterInfo, parse_cluster_info
|
||||
from ray.dashboard.modules.job.common import uri_to_http_components
|
||||
from ray.dashboard.modules.job.pydantic_models import JobDetails
|
||||
from ray.dashboard.modules.job.tests.test_cli_integration import set_env_var
|
||||
from ray.dashboard.modules.version import CURRENT_VERSION
|
||||
from ray.dashboard.tests.conftest import * # noqa
|
||||
from ray.job_submission import JobStatus, JobSubmissionClient
|
||||
from ray.runtime_env.runtime_env import RuntimeEnv, RuntimeEnvConfig
|
||||
from ray.tests.conftest import _ray_start
|
||||
|
||||
# This test requires you have AWS credentials set up (any AWS credentials will
|
||||
# do, this test only accesses a public bucket).
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DRIVER_SCRIPT_DIR = os.path.join(os.path.dirname(__file__), "subprocess_driver_scripts")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def headers():
|
||||
return {"Connection": "keep-alive", "Authorization": "TOK:<MY_TOKEN>"}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ray_start_context():
|
||||
with _ray_start(include_dashboard=True, num_cpus=1) as ctx:
|
||||
yield ctx
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def job_sdk_client(headers, ray_start_context) -> JobSubmissionClient:
|
||||
address = ray_start_context.address_info["webui_url"]
|
||||
assert wait_until_server_available(address)
|
||||
yield JobSubmissionClient(format_web_url(address), headers=headers)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shutdown_only():
|
||||
yield None
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
def test_submit_job_with_resources(shutdown_only):
|
||||
ctx = ray.init(
|
||||
include_dashboard=True,
|
||||
num_cpus=1,
|
||||
num_gpus=1,
|
||||
resources={"Custom": 1},
|
||||
dashboard_port=8269,
|
||||
_memory=4,
|
||||
)
|
||||
address = ctx.address_info["webui_url"]
|
||||
client = JobSubmissionClient(format_web_url(address))
|
||||
# Check the case of too many resources.
|
||||
for kwargs in [
|
||||
{"entrypoint_num_cpus": 2},
|
||||
{"entrypoint_num_gpus": 2},
|
||||
{"entrypoint_memory": 4},
|
||||
{"entrypoint_resources": {"Custom": 2}},
|
||||
]:
|
||||
job_id = client.submit_job(entrypoint="echo hello", **kwargs)
|
||||
data = client.get_job_info(job_id)
|
||||
assert "waiting for resources" in data.message
|
||||
|
||||
# Check the case of sufficient resources.
|
||||
job_id = client.submit_job(
|
||||
entrypoint="echo hello",
|
||||
entrypoint_num_cpus=1,
|
||||
entrypoint_num_gpus=1,
|
||||
entrypoint_memory=4,
|
||||
entrypoint_resources={"Custom": 1},
|
||||
)
|
||||
wait_for_condition(_check_job_succeeded, client=client, job_id=job_id, timeout=10)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_sdk", [True, False])
|
||||
def test_list_jobs_empty(headers, use_sdk: bool):
|
||||
# Create a cluster using `ray start` instead of `ray.init` to avoid creating a job
|
||||
subprocess.check_output(["ray", "start", "--head"])
|
||||
address = "http://127.0.0.1:8265"
|
||||
try:
|
||||
with set_env_var("RAY_ADDRESS", address):
|
||||
client = JobSubmissionClient(format_web_url(address), headers=headers)
|
||||
|
||||
if use_sdk:
|
||||
assert client.list_jobs() == []
|
||||
else:
|
||||
r = client._do_request(
|
||||
"GET",
|
||||
"/api/jobs/",
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert json.loads(r.text) == []
|
||||
|
||||
finally:
|
||||
subprocess.check_output(["ray", "stop", "--force"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_sdk", [True, False])
|
||||
def test_list_jobs(job_sdk_client: JobSubmissionClient, use_sdk: bool):
|
||||
client = job_sdk_client
|
||||
|
||||
runtime_env = {"env_vars": {"TEST": "123"}}
|
||||
metadata = {"foo": "bar"}
|
||||
entrypoint = "echo hello"
|
||||
submission_id = client.submit_job(
|
||||
entrypoint=entrypoint, runtime_env=runtime_env, metadata=metadata
|
||||
)
|
||||
|
||||
wait_for_condition(_check_job_succeeded, client=client, job_id=submission_id)
|
||||
if use_sdk:
|
||||
info: JobDetails = next(
|
||||
job_info
|
||||
for job_info in client.list_jobs()
|
||||
if job_info.submission_id == submission_id
|
||||
)
|
||||
else:
|
||||
r = client._do_request(
|
||||
"GET",
|
||||
"/api/jobs/",
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
jobs_info_json = json.loads(r.text)
|
||||
info_json = next(
|
||||
job_info
|
||||
for job_info in jobs_info_json
|
||||
if job_info["submission_id"] == submission_id
|
||||
)
|
||||
info = JobDetails(**info_json)
|
||||
|
||||
assert info.entrypoint == entrypoint
|
||||
assert info.status == JobStatus.SUCCEEDED
|
||||
assert info.message is not None
|
||||
assert info.end_time >= info.start_time
|
||||
assert info.runtime_env == runtime_env
|
||||
assert info.metadata == metadata
|
||||
|
||||
# Test get job status by job / driver id
|
||||
status = client.get_job_status(info.submission_id)
|
||||
assert status == JobStatus.SUCCEEDED
|
||||
|
||||
|
||||
def _check_job_succeeded(client: JobSubmissionClient, job_id: str) -> bool:
|
||||
status = client.get_job_status(job_id)
|
||||
if status == JobStatus.FAILED:
|
||||
logs = client.get_job_logs(job_id)
|
||||
raise RuntimeError(
|
||||
f"Job failed\nlogs:\n{logs}, info: {client.get_job_info(job_id)}"
|
||||
)
|
||||
assert status == JobStatus.SUCCEEDED
|
||||
return True
|
||||
|
||||
|
||||
def _check_job_failed(client: JobSubmissionClient, job_id: str) -> bool:
|
||||
status = client.get_job_status(job_id)
|
||||
return status == JobStatus.FAILED
|
||||
|
||||
|
||||
def _check_job_stopped(client: JobSubmissionClient, job_id: str) -> bool:
|
||||
status = client.get_job_status(job_id)
|
||||
return status == JobStatus.STOPPED
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
scope="module",
|
||||
params=[
|
||||
"no_working_dir",
|
||||
"local_working_dir",
|
||||
"s3_working_dir",
|
||||
"local_py_modules",
|
||||
"working_dir_and_local_py_modules_whl",
|
||||
"local_working_dir_zip",
|
||||
"pip_txt",
|
||||
"conda_yaml",
|
||||
"local_py_modules",
|
||||
],
|
||||
)
|
||||
def runtime_env_option(request):
|
||||
import_in_task_script = """
|
||||
import ray
|
||||
ray.init(address="auto")
|
||||
|
||||
@ray.remote
|
||||
def f():
|
||||
import pip_install_test
|
||||
|
||||
ray.get(f.remote())
|
||||
"""
|
||||
if request.param == "no_working_dir":
|
||||
yield {
|
||||
"runtime_env": {},
|
||||
"entrypoint": "echo hello",
|
||||
"expected_logs": "hello\n",
|
||||
}
|
||||
elif request.param in {
|
||||
"local_working_dir",
|
||||
"local_working_dir_zip",
|
||||
"local_py_modules",
|
||||
"working_dir_and_local_py_modules_whl",
|
||||
}:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
path = Path(tmp_dir)
|
||||
|
||||
hello_file = path / "test.py"
|
||||
with hello_file.open(mode="w") as f:
|
||||
f.write("from test_module import run_test\n")
|
||||
f.write("print(run_test())")
|
||||
|
||||
module_path = path / "test_module"
|
||||
module_path.mkdir(parents=True)
|
||||
|
||||
test_file = module_path / "test.py"
|
||||
with test_file.open(mode="w") as f:
|
||||
f.write("def run_test():\n")
|
||||
f.write(" return 'Hello from test_module!'\n") # noqa: Q000
|
||||
|
||||
init_file = module_path / "__init__.py"
|
||||
with init_file.open(mode="w") as f:
|
||||
f.write("from test_module.test import run_test\n")
|
||||
|
||||
if request.param == "local_working_dir":
|
||||
yield {
|
||||
"runtime_env": {"working_dir": tmp_dir},
|
||||
"entrypoint": "python test.py",
|
||||
"expected_logs": "Hello from test_module!\n",
|
||||
}
|
||||
elif request.param == "local_working_dir_zip":
|
||||
local_zipped_dir = shutil.make_archive(
|
||||
os.path.join(tmp_dir, "test"), "zip", tmp_dir
|
||||
)
|
||||
yield {
|
||||
"runtime_env": {"working_dir": local_zipped_dir},
|
||||
"entrypoint": "python test.py",
|
||||
"expected_logs": "Hello from test_module!\n",
|
||||
}
|
||||
elif request.param == "local_py_modules":
|
||||
yield {
|
||||
"runtime_env": {"py_modules": [str(Path(tmp_dir) / "test_module")]},
|
||||
"entrypoint": (
|
||||
"python -c 'import test_module;print(test_module.run_test())'"
|
||||
),
|
||||
"expected_logs": "Hello from test_module!\n",
|
||||
}
|
||||
elif request.param == "working_dir_and_local_py_modules_whl":
|
||||
yield {
|
||||
"runtime_env": {
|
||||
"working_dir": "s3://runtime-env-test/script_runtime_env.zip",
|
||||
"py_modules": [
|
||||
Path(os.path.dirname(__file__))
|
||||
/ "pip_install_test-0.5-py3-none-any.whl"
|
||||
],
|
||||
},
|
||||
"entrypoint": (
|
||||
"python script.py && python -c 'import pip_install_test'"
|
||||
),
|
||||
"expected_logs": (
|
||||
"Executing main() from script.py !!\n"
|
||||
"Good job! You installed a pip module."
|
||||
),
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Unexpected pytest fixture option {request.param}")
|
||||
elif request.param == "s3_working_dir":
|
||||
yield {
|
||||
"runtime_env": {
|
||||
"working_dir": "s3://runtime-env-test/script_runtime_env.zip",
|
||||
},
|
||||
"entrypoint": "python script.py",
|
||||
"expected_logs": "Executing main() from script.py !!\n",
|
||||
}
|
||||
elif request.param == "pip_txt":
|
||||
with tempfile.TemporaryDirectory() as tmpdir, chdir(tmpdir):
|
||||
pip_list = ["pip-install-test==0.5"]
|
||||
relative_filepath = "requirements.txt"
|
||||
pip_file = Path(relative_filepath)
|
||||
pip_file.write_text("\n".join(pip_list))
|
||||
runtime_env = {"pip": {"packages": relative_filepath, "pip_check": False}}
|
||||
yield {
|
||||
"runtime_env": runtime_env,
|
||||
"entrypoint": (
|
||||
f"python -c 'import pip_install_test' && "
|
||||
f"python -c '{import_in_task_script}'"
|
||||
),
|
||||
"expected_logs": "Good job! You installed a pip module.",
|
||||
}
|
||||
elif request.param == "conda_yaml":
|
||||
with tempfile.TemporaryDirectory() as tmpdir, chdir(tmpdir):
|
||||
conda_dict = {"dependencies": ["pip", {"pip": ["pip-install-test==0.5"]}]}
|
||||
relative_filepath = "environment.yml"
|
||||
conda_file = Path(relative_filepath)
|
||||
conda_file.write_text(yaml.dump(conda_dict))
|
||||
runtime_env = {"conda": relative_filepath}
|
||||
|
||||
yield {
|
||||
"runtime_env": runtime_env,
|
||||
"entrypoint": f"python -c '{import_in_task_script}'",
|
||||
# TODO(architkulkarni): Uncomment after #22968 is fixed.
|
||||
# "entrypoint": "python -c 'import pip_install_test'",
|
||||
"expected_logs": "Good job! You installed a pip module.",
|
||||
}
|
||||
else:
|
||||
assert False, f"Unrecognized option: {request.param}."
|
||||
|
||||
|
||||
def test_submit_job(job_sdk_client, runtime_env_option, monkeypatch):
|
||||
# This flag allows for local testing of runtime env conda functionality
|
||||
# without needing a built Ray wheel. Rather than insert the link to the
|
||||
# wheel into the conda spec, it links to the current Python site.
|
||||
monkeypatch.setenv("RAY_RUNTIME_ENV_LOCAL_DEV_MODE", "1")
|
||||
|
||||
client = job_sdk_client
|
||||
|
||||
job_id = client.submit_job(
|
||||
entrypoint=runtime_env_option["entrypoint"],
|
||||
runtime_env=runtime_env_option["runtime_env"],
|
||||
)
|
||||
|
||||
try:
|
||||
job_start_time = time.time()
|
||||
wait_for_condition(
|
||||
_check_job_succeeded, client=client, job_id=job_id, timeout=300
|
||||
)
|
||||
job_duration = time.time() - job_start_time
|
||||
print(f"The job took {job_duration}s to succeed.")
|
||||
except RuntimeError as e:
|
||||
# If the job is still pending, include job logs and info in error.
|
||||
if client.get_job_status(job_id) == JobStatus.PENDING:
|
||||
logs = client.get_job_logs(job_id)
|
||||
info = client.get_job_info(job_id)
|
||||
raise RuntimeError(
|
||||
f"Job was stuck in PENDING.\nLogs: {logs}\nInfo: {info}"
|
||||
) from e
|
||||
|
||||
logs = client.get_job_logs(job_id)
|
||||
assert runtime_env_option["expected_logs"] in logs
|
||||
|
||||
|
||||
def test_timeout(job_sdk_client):
|
||||
client = job_sdk_client
|
||||
|
||||
job_id = client.submit_job(
|
||||
entrypoint="echo hello",
|
||||
# Assume pip packages take > 1s to download, or this test will spuriously fail.
|
||||
runtime_env=RuntimeEnv(
|
||||
pip={
|
||||
"packages": ["tensorflow", "requests", "botocore", "torch"],
|
||||
"pip_check": False,
|
||||
"pip_version": "==23.3.2;python_version=='3.9.16'",
|
||||
},
|
||||
config=RuntimeEnvConfig(setup_timeout_seconds=1),
|
||||
),
|
||||
)
|
||||
|
||||
wait_for_condition(_check_job_failed, client=client, job_id=job_id, timeout=10)
|
||||
data = client.get_job_info(job_id)
|
||||
assert "Failed to set up runtime environment" in data.message
|
||||
assert "Timeout" in data.message
|
||||
assert "setup_timeout_seconds" in data.message
|
||||
|
||||
|
||||
def test_per_task_runtime_env(job_sdk_client: JobSubmissionClient):
|
||||
run_cmd = "python per_task_runtime_env.py"
|
||||
job_id = job_sdk_client.submit_job(
|
||||
entrypoint=run_cmd,
|
||||
runtime_env={"working_dir": DRIVER_SCRIPT_DIR},
|
||||
)
|
||||
|
||||
wait_for_condition(_check_job_succeeded, client=job_sdk_client, job_id=job_id)
|
||||
|
||||
|
||||
def test_ray_tune_basic(job_sdk_client: JobSubmissionClient):
|
||||
run_cmd = "python ray_tune_basic.py"
|
||||
job_id = job_sdk_client.submit_job(
|
||||
entrypoint=run_cmd,
|
||||
runtime_env={"working_dir": DRIVER_SCRIPT_DIR},
|
||||
)
|
||||
wait_for_condition(
|
||||
_check_job_succeeded, timeout=30, client=job_sdk_client, job_id=job_id
|
||||
)
|
||||
|
||||
|
||||
def test_http_bad_request(job_sdk_client):
|
||||
"""
|
||||
Send bad requests to job http server and ensure right return code and
|
||||
error message is returned via http.
|
||||
"""
|
||||
client = job_sdk_client
|
||||
|
||||
# 400 - HTTPBadRequest
|
||||
r = client._do_request(
|
||||
"POST",
|
||||
"/api/jobs/",
|
||||
json_data={"key": "baaaad request"},
|
||||
)
|
||||
|
||||
assert r.status_code == 400
|
||||
assert "__init__() got an unexpected keyword argument" in r.text
|
||||
|
||||
|
||||
def test_invalid_runtime_env(job_sdk_client):
|
||||
client = job_sdk_client
|
||||
with pytest.raises(ValueError, match="Only .zip, .tar.gz, and .tgz files"):
|
||||
client.submit_job(
|
||||
entrypoint="echo hello", runtime_env={"working_dir": "s3://not_a_zip"}
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_env_setup_failure(job_sdk_client):
|
||||
client = job_sdk_client
|
||||
job_id = client.submit_job(
|
||||
entrypoint="echo hello", runtime_env={"working_dir": "s3://does_not_exist.zip"}
|
||||
)
|
||||
|
||||
wait_for_condition(_check_job_failed, client=client, job_id=job_id)
|
||||
data = client.get_job_info(job_id)
|
||||
assert "Failed to set up runtime environment" in data.message
|
||||
|
||||
|
||||
def test_submit_job_with_exception_in_driver(job_sdk_client):
|
||||
"""
|
||||
Submit a job that's expected to throw exception while executing.
|
||||
"""
|
||||
client = job_sdk_client
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
path = Path(tmp_dir)
|
||||
driver_script = """
|
||||
print('Hello !')
|
||||
raise RuntimeError('Intentionally failed.')
|
||||
"""
|
||||
test_script_file = path / "test_script.py"
|
||||
with open(test_script_file, "w+") as file:
|
||||
file.write(driver_script)
|
||||
|
||||
job_id = client.submit_job(
|
||||
entrypoint="python test_script.py", runtime_env={"working_dir": tmp_dir}
|
||||
)
|
||||
|
||||
wait_for_condition(_check_job_failed, client=client, job_id=job_id)
|
||||
logs = client.get_job_logs(job_id)
|
||||
assert "Hello !" in logs
|
||||
assert "RuntimeError: Intentionally failed." in logs
|
||||
|
||||
|
||||
def test_stop_long_running_job(job_sdk_client):
|
||||
"""
|
||||
Submit a job that runs for a while and stop it in the middle.
|
||||
"""
|
||||
client = job_sdk_client
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
path = Path(tmp_dir)
|
||||
driver_script = """
|
||||
print('Hello !')
|
||||
import time
|
||||
time.sleep(300) # This should never finish
|
||||
raise RuntimeError('Intentionally failed.')
|
||||
"""
|
||||
test_script_file = path / "test_script.py"
|
||||
with open(test_script_file, "w+") as file:
|
||||
file.write(driver_script)
|
||||
|
||||
job_id = client.submit_job(
|
||||
entrypoint="python test_script.py", runtime_env={"working_dir": tmp_dir}
|
||||
)
|
||||
assert client.stop_job(job_id) is True
|
||||
wait_for_condition(_check_job_stopped, client=client, job_id=job_id)
|
||||
|
||||
|
||||
def test_delete_job(job_sdk_client, capsys):
|
||||
"""
|
||||
Submit a job and delete it.
|
||||
"""
|
||||
client: JobSubmissionClient = job_sdk_client
|
||||
|
||||
job_id = client.submit_job(entrypoint="sleep 300 && echo hello")
|
||||
with pytest.raises(Exception, match="but it is in a non-terminal state"):
|
||||
# This should fail because the job is not in a terminal state.
|
||||
client.delete_job(job_id)
|
||||
|
||||
# Check that the job appears in list_jobs
|
||||
jobs = client.list_jobs()
|
||||
assert job_id in [job.submission_id for job in jobs]
|
||||
|
||||
finished_job_id = client.submit_job(entrypoint="echo hello")
|
||||
wait_for_condition(_check_job_succeeded, client=client, job_id=finished_job_id)
|
||||
deleted = client.delete_job(finished_job_id)
|
||||
assert deleted is True
|
||||
|
||||
# Check that the job no longer appears in list_jobs
|
||||
jobs = client.list_jobs()
|
||||
assert finished_job_id not in [job.submission_id for job in jobs]
|
||||
|
||||
|
||||
def test_job_metadata(job_sdk_client):
|
||||
client = job_sdk_client
|
||||
|
||||
print_metadata_cmd = (
|
||||
'python -c"'
|
||||
"import ray;"
|
||||
"ray.init();"
|
||||
"job_config=ray._private.worker.global_worker.core_worker.get_job_config();"
|
||||
"print(dict(sorted(job_config.metadata.items())))"
|
||||
'"'
|
||||
)
|
||||
|
||||
job_id = client.submit_job(
|
||||
entrypoint=print_metadata_cmd, metadata={"key1": "val1", "key2": "val2"}
|
||||
)
|
||||
|
||||
wait_for_condition(_check_job_succeeded, client=client, job_id=job_id)
|
||||
|
||||
assert str(
|
||||
{
|
||||
"job_name": job_id,
|
||||
"job_submission_id": job_id,
|
||||
"key1": "val1",
|
||||
"key2": "val2",
|
||||
}
|
||||
) in client.get_job_logs(job_id)
|
||||
|
||||
|
||||
def test_pass_job_id(job_sdk_client):
|
||||
client = job_sdk_client
|
||||
|
||||
job_id = "my_custom_id"
|
||||
returned_id = client.submit_job(entrypoint="echo hello", job_id=job_id)
|
||||
|
||||
assert returned_id == job_id
|
||||
wait_for_condition(_check_job_succeeded, client=client, job_id=returned_id)
|
||||
|
||||
# Test that a duplicate job_id is rejected.
|
||||
with pytest.raises(Exception, match=f"{job_id} already exists"):
|
||||
returned_id = client.submit_job(entrypoint="echo hello", job_id=job_id)
|
||||
|
||||
|
||||
def test_nonexistent_job(job_sdk_client):
|
||||
client = job_sdk_client
|
||||
|
||||
with pytest.raises(RuntimeError, match="nonexistent_job does not exist"):
|
||||
client.get_job_status("nonexistent_job")
|
||||
|
||||
|
||||
def test_submit_optional_args(job_sdk_client):
|
||||
"""Check that job_id, runtime_env, and metadata are optional."""
|
||||
client = job_sdk_client
|
||||
|
||||
r = client._do_request(
|
||||
"POST",
|
||||
"/api/jobs/",
|
||||
json_data={"entrypoint": "ls"},
|
||||
)
|
||||
|
||||
wait_for_condition(
|
||||
_check_job_succeeded, client=client, job_id=r.json()["submission_id"]
|
||||
)
|
||||
|
||||
|
||||
def test_submit_still_accepts_job_id_or_submission_id(job_sdk_client):
|
||||
"""Check that job_id, runtime_env, and metadata are optional."""
|
||||
client = job_sdk_client
|
||||
|
||||
client._do_request(
|
||||
"POST",
|
||||
"/api/jobs/",
|
||||
json_data={"entrypoint": "ls", "job_id": "raysubmit_12345"},
|
||||
)
|
||||
|
||||
wait_for_condition(_check_job_succeeded, client=client, job_id="raysubmit_12345")
|
||||
|
||||
client._do_request(
|
||||
"POST",
|
||||
"/api/jobs/",
|
||||
json_data={"entrypoint": "ls", "submission_id": "raysubmit_23456"},
|
||||
)
|
||||
|
||||
wait_for_condition(_check_job_succeeded, client=client, job_id="raysubmit_23456")
|
||||
|
||||
|
||||
def test_missing_resources(job_sdk_client):
|
||||
"""Check that 404s are raised for resources that don't exist."""
|
||||
client = job_sdk_client
|
||||
|
||||
conditions = [
|
||||
("GET", "/api/jobs/fake_job_id"),
|
||||
("GET", "/api/jobs/fake_job_id/logs"),
|
||||
("POST", "/api/jobs/fake_job_id/stop"),
|
||||
("GET", "/api/packages/fake_package_uri"),
|
||||
]
|
||||
|
||||
for method, route in conditions:
|
||||
assert client._do_request(method, route).status_code == 404
|
||||
|
||||
|
||||
def test_version_endpoint(job_sdk_client):
|
||||
client = job_sdk_client
|
||||
|
||||
r = client._do_request("GET", "/api/version")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body == {
|
||||
"version": CURRENT_VERSION,
|
||||
"ray_version": ray.__version__,
|
||||
"ray_commit": ray.__commit__,
|
||||
"session_name": body["session_name"],
|
||||
}
|
||||
|
||||
|
||||
def test_request_headers(job_sdk_client):
|
||||
client = job_sdk_client
|
||||
with patch("requests.request") as mock_request:
|
||||
_ = client._do_request(
|
||||
"POST",
|
||||
"/api/jobs/",
|
||||
json_data={"entrypoint": "ls"},
|
||||
)
|
||||
mock_request.assert_called_with(
|
||||
"POST",
|
||||
"http://127.0.0.1:8265/api/jobs/",
|
||||
cookies=None,
|
||||
data=None,
|
||||
json={"entrypoint": "ls"},
|
||||
headers={"Connection": "keep-alive", "Authorization": "TOK:<MY_TOKEN>"},
|
||||
verify=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scheme", ["http", "https", "fake_module"])
|
||||
@pytest.mark.parametrize("host", ["127.0.0.1", "localhost", "fake.dns.name"])
|
||||
@pytest.mark.parametrize("port", [None, 8265, 10000])
|
||||
def test_parse_cluster_info(scheme: str, host: str, port: Optional[int]):
|
||||
address = f"{scheme}://{host}"
|
||||
if port is not None:
|
||||
address += f":{port}"
|
||||
|
||||
if scheme in {"http", "https"}:
|
||||
assert parse_cluster_info(address, False) == ClusterInfo(
|
||||
address=address,
|
||||
cookies=None,
|
||||
metadata=None,
|
||||
headers=None,
|
||||
)
|
||||
else:
|
||||
with pytest.raises(RuntimeError):
|
||||
parse_cluster_info(address, False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tail_job_logs(job_sdk_client):
|
||||
client = job_sdk_client
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
path = Path(tmp_dir)
|
||||
driver_script = """
|
||||
import time
|
||||
for i in range(100):
|
||||
print("Hello", i)
|
||||
time.sleep(0.1)
|
||||
"""
|
||||
test_script_file = path / "test_script.py"
|
||||
with open(test_script_file, "w+") as f:
|
||||
f.write(driver_script)
|
||||
|
||||
job_id = client.submit_job(
|
||||
entrypoint="python test_script.py", runtime_env={"working_dir": tmp_dir}
|
||||
)
|
||||
|
||||
st = time.time()
|
||||
while time.time() - st <= 10:
|
||||
try:
|
||||
i = 0
|
||||
async for lines in client.tail_job_logs(job_id):
|
||||
print(lines, end="")
|
||||
for line in lines.strip().split("\n"):
|
||||
assert line.split(" ") == ["Hello", str(i)]
|
||||
i += 1
|
||||
except Exception as ex:
|
||||
print("Exception:", ex)
|
||||
|
||||
wait_for_condition(_check_job_succeeded, client=client, job_id=job_id)
|
||||
|
||||
|
||||
def _hook(env):
|
||||
with open(env["env_vars"]["TEMPPATH"], "w+") as f:
|
||||
f.write(env["env_vars"]["TOKEN"])
|
||||
return env
|
||||
|
||||
|
||||
def test_jobs_env_hook(job_sdk_client: JobSubmissionClient):
|
||||
client = job_sdk_client
|
||||
|
||||
_, path = tempfile.mkstemp()
|
||||
runtime_env = {"env_vars": {"TEMPPATH": path, "TOKEN": "Ray rocks!"}}
|
||||
run_job_script = """
|
||||
import os
|
||||
import ray
|
||||
os.environ["RAY_RUNTIME_ENV_HOOK"] =\
|
||||
"ray.dashboard.modules.job.tests.test_http_job_server._hook"
|
||||
ray.init(address="auto")
|
||||
"""
|
||||
entrypoint = f"python -c '{run_job_script}'"
|
||||
job_id = client.submit_job(entrypoint=entrypoint, runtime_env=runtime_env)
|
||||
|
||||
wait_for_condition(_check_job_succeeded, client=client, job_id=job_id)
|
||||
|
||||
with open(path) as f:
|
||||
assert f.read().strip() == "Ray rocks!"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_upload_package(ray_start_context, tmp_path):
|
||||
assert wait_until_server_available(ray_start_context["webui_url"])
|
||||
webui_url = format_web_url(ray_start_context["webui_url"])
|
||||
gcs_client = ray._private.worker.global_worker.gcs_client
|
||||
url = webui_url + "/api/packages/{protocol}/{package_name}"
|
||||
|
||||
pkg_dir = tmp_path / "pkg"
|
||||
pkg_dir.mkdir()
|
||||
filename = "task.py"
|
||||
|
||||
file_content = b"Hello world"
|
||||
with (pkg_dir / filename).open("wb") as f:
|
||||
f.write(file_content)
|
||||
|
||||
package_uri = get_uri_for_file(str(pkg_dir / filename))
|
||||
protocol, package_name = uri_to_http_components(package_uri)
|
||||
package_file = tmp_path / package_name
|
||||
create_package(str(pkg_dir), package_file, include_gitignore=True)
|
||||
|
||||
resp = requests.get(url.format(protocol=protocol, package_name=package_name))
|
||||
assert resp.status_code == 404
|
||||
|
||||
resp = requests.put(
|
||||
url.format(protocol=protocol, package_name=package_name),
|
||||
data=package_file.read_bytes(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = requests.get(url.format(protocol=protocol, package_name=package_name))
|
||||
assert resp.status_code == 200
|
||||
|
||||
await download_and_unpack_package(package_uri, str(tmp_path), gcs_client)
|
||||
assert (package_file.with_suffix("") / filename).read_bytes() == file_content
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,47 @@
|
||||
import ssl
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import trustme
|
||||
|
||||
import ray
|
||||
from ray.job_submission import JobSubmissionClient
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ca():
|
||||
return trustme.CA()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def httpserver_ssl_context(ca):
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
localhost_cert = ca.issue_cert("localhost")
|
||||
localhost_cert.configure_cert(context)
|
||||
return context
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def httpclient_ssl_context(ca):
|
||||
with ca.cert_pem.tempfile() as ca_temp_path:
|
||||
return ssl.create_default_context(cafile=ca_temp_path)
|
||||
|
||||
|
||||
def test_mock_https_connection(httpserver, ca):
|
||||
"""Test connections to a mock HTTPS job submission server."""
|
||||
httpserver.expect_request("/api/version").respond_with_json(
|
||||
{"ray_version": ray.__version__}
|
||||
)
|
||||
mock_url = httpserver.url_for("/")
|
||||
# Connection without SSL certificate should fail
|
||||
with pytest.raises(ConnectionError):
|
||||
JobSubmissionClient(mock_url)
|
||||
# Connecton with SSL verification skipped should succeed
|
||||
JobSubmissionClient(mock_url, verify=False)
|
||||
# Connection with SSL verification should succeed
|
||||
with ca.cert_pem.tempfile() as ca_temp_path:
|
||||
JobSubmissionClient(mock_url, verify=ca_temp_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,692 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
import ray
|
||||
from ray._common.network_utils import build_address
|
||||
from ray._common.test_utils import async_wait_for_condition, wait_for_condition
|
||||
from ray._common.utils import get_or_create_event_loop
|
||||
from ray._private.ray_constants import DEFAULT_DASHBOARD_AGENT_LISTEN_PORT
|
||||
from ray._private.runtime_env.py_modules import upload_py_modules_if_needed
|
||||
from ray._private.runtime_env.working_dir import upload_working_dir_if_needed
|
||||
from ray._private.test_utils import (
|
||||
chdir,
|
||||
format_web_url,
|
||||
get_current_unused_port,
|
||||
run_string_as_driver_nonblocking,
|
||||
wait_until_server_available,
|
||||
)
|
||||
from ray.dashboard.modules.job.common import (
|
||||
JOB_ACTOR_NAME_TEMPLATE,
|
||||
SUPERVISOR_ACTOR_RAY_NAMESPACE,
|
||||
JobSubmitRequest,
|
||||
validate_request_type,
|
||||
)
|
||||
from ray.dashboard.modules.job.job_head import JobAgentSubmissionClient
|
||||
from ray.dashboard.tests.conftest import * # noqa
|
||||
from ray.job_submission import JobStatus, JobSubmissionClient
|
||||
from ray.runtime_env.runtime_env import RuntimeEnv, RuntimeEnvConfig
|
||||
from ray.tests.conftest import _ray_start
|
||||
from ray.util.state import get_node, list_actors, list_nodes
|
||||
|
||||
# This test requires you have AWS credentials set up (any AWS credentials will
|
||||
# do, this test only accesses a public bucket).
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DRIVER_SCRIPT_DIR = os.path.join(os.path.dirname(__file__), "subprocess_driver_scripts")
|
||||
EVENT_LOOP = get_or_create_event_loop()
|
||||
|
||||
|
||||
def get_node_id_for_supervisor_actor_for_job(
|
||||
address: str, job_submission_id: str
|
||||
) -> str:
|
||||
actors = list_actors(
|
||||
address=address,
|
||||
filters=[("ray_namespace", "=", SUPERVISOR_ACTOR_RAY_NAMESPACE)],
|
||||
)
|
||||
for actor in actors:
|
||||
if actor.name == JOB_ACTOR_NAME_TEMPLATE.format(job_id=job_submission_id):
|
||||
return actor.node_id
|
||||
raise ValueError(f"actor not found for job_submission_id {job_submission_id}")
|
||||
|
||||
|
||||
def get_node_ip_by_id(node_id: str) -> str:
|
||||
node = get_node(id=node_id)
|
||||
return node.node_ip
|
||||
|
||||
|
||||
class JobAgentSubmissionBrowserClient(JobAgentSubmissionClient):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._session.headers[
|
||||
"User-Agent"
|
||||
] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" # noqa: E501
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def job_sdk_client(make_sure_dashboard_http_port_unused):
|
||||
with _ray_start(include_dashboard=True, num_cpus=1) as ctx:
|
||||
node_ip = ctx.address_info["node_ip_address"]
|
||||
agent_address = build_address(node_ip, DEFAULT_DASHBOARD_AGENT_LISTEN_PORT)
|
||||
assert wait_until_server_available(agent_address)
|
||||
head_address = ctx.address_info["webui_url"]
|
||||
assert wait_until_server_available(head_address)
|
||||
yield (
|
||||
JobAgentSubmissionClient(format_web_url(agent_address)),
|
||||
JobSubmissionClient(format_web_url(head_address)),
|
||||
)
|
||||
|
||||
|
||||
def _check_job(
|
||||
client: JobSubmissionClient, job_id: str, status: JobStatus, timeout: int = 10
|
||||
) -> bool:
|
||||
res_status = client.get_job_status(job_id)
|
||||
assert res_status == status
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
scope="module",
|
||||
params=[
|
||||
"no_working_dir",
|
||||
"local_working_dir",
|
||||
"s3_working_dir",
|
||||
"local_py_modules",
|
||||
"working_dir_and_local_py_modules_whl",
|
||||
"local_working_dir_zip",
|
||||
"pip_txt",
|
||||
"conda_yaml",
|
||||
"local_py_modules",
|
||||
],
|
||||
)
|
||||
def runtime_env_option(request):
|
||||
import_in_task_script = """
|
||||
import ray
|
||||
ray.init(address="auto")
|
||||
|
||||
@ray.remote
|
||||
def f():
|
||||
import pip_install_test
|
||||
|
||||
ray.get(f.remote())
|
||||
"""
|
||||
if request.param == "no_working_dir":
|
||||
yield {
|
||||
"runtime_env": {},
|
||||
"entrypoint": "echo hello",
|
||||
"expected_logs": "hello\n",
|
||||
}
|
||||
elif request.param in {
|
||||
"local_working_dir",
|
||||
"local_working_dir_zip",
|
||||
"local_py_modules",
|
||||
"working_dir_and_local_py_modules_whl",
|
||||
}:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
path = Path(tmp_dir)
|
||||
|
||||
hello_file = path / "test.py"
|
||||
with hello_file.open(mode="w") as f:
|
||||
f.write("from test_module import run_test\n")
|
||||
f.write("print(run_test())")
|
||||
|
||||
module_path = path / "test_module"
|
||||
module_path.mkdir(parents=True)
|
||||
|
||||
test_file = module_path / "test.py"
|
||||
with test_file.open(mode="w") as f:
|
||||
f.write("def run_test():\n")
|
||||
f.write(" return 'Hello from test_module!'\n") # noqa: Q000
|
||||
|
||||
init_file = module_path / "__init__.py"
|
||||
with init_file.open(mode="w") as f:
|
||||
f.write("from test_module.test import run_test\n")
|
||||
|
||||
if request.param == "local_working_dir":
|
||||
yield {
|
||||
"runtime_env": {"working_dir": tmp_dir},
|
||||
"entrypoint": "python test.py",
|
||||
"expected_logs": "Hello from test_module!\n",
|
||||
}
|
||||
elif request.param == "local_working_dir_zip":
|
||||
local_zipped_dir = shutil.make_archive(
|
||||
os.path.join(tmp_dir, "test"), "zip", tmp_dir
|
||||
)
|
||||
yield {
|
||||
"runtime_env": {"working_dir": local_zipped_dir},
|
||||
"entrypoint": "python test.py",
|
||||
"expected_logs": "Hello from test_module!\n",
|
||||
}
|
||||
elif request.param == "local_py_modules":
|
||||
yield {
|
||||
"runtime_env": {"py_modules": [str(Path(tmp_dir) / "test_module")]},
|
||||
"entrypoint": (
|
||||
"python -c 'import test_module;print(test_module.run_test())'"
|
||||
),
|
||||
"expected_logs": "Hello from test_module!\n",
|
||||
}
|
||||
elif request.param == "working_dir_and_local_py_modules_whl":
|
||||
yield {
|
||||
"runtime_env": {
|
||||
"working_dir": "s3://runtime-env-test/script_runtime_env.zip",
|
||||
"py_modules": [
|
||||
Path(os.path.dirname(__file__))
|
||||
/ "pip_install_test-0.5-py3-none-any.whl"
|
||||
],
|
||||
},
|
||||
"entrypoint": (
|
||||
"python script.py && python -c 'import pip_install_test'"
|
||||
),
|
||||
"expected_logs": (
|
||||
"Executing main() from script.py !!\n"
|
||||
"Good job! You installed a pip module."
|
||||
),
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Unexpected pytest fixture option {request.param}")
|
||||
elif request.param == "s3_working_dir":
|
||||
yield {
|
||||
"runtime_env": {
|
||||
"working_dir": "s3://runtime-env-test/script_runtime_env.zip",
|
||||
},
|
||||
"entrypoint": "python script.py",
|
||||
"expected_logs": "Executing main() from script.py !!\n",
|
||||
}
|
||||
elif request.param == "pip_txt":
|
||||
with tempfile.TemporaryDirectory() as tmpdir, chdir(tmpdir):
|
||||
pip_list = ["pip-install-test==0.5"]
|
||||
relative_filepath = "requirements.txt"
|
||||
pip_file = Path(relative_filepath)
|
||||
pip_file.write_text("\n".join(pip_list))
|
||||
runtime_env = {"pip": {"packages": relative_filepath, "pip_check": False}}
|
||||
yield {
|
||||
"runtime_env": runtime_env,
|
||||
"entrypoint": (
|
||||
f"python -c 'import pip_install_test' && "
|
||||
f"python -c '{import_in_task_script}'"
|
||||
),
|
||||
"expected_logs": "Good job! You installed a pip module.",
|
||||
}
|
||||
elif request.param == "conda_yaml":
|
||||
with tempfile.TemporaryDirectory() as tmpdir, chdir(tmpdir):
|
||||
conda_dict = {"dependencies": ["pip", {"pip": ["pip-install-test==0.5"]}]}
|
||||
relative_filepath = "environment.yml"
|
||||
conda_file = Path(relative_filepath)
|
||||
conda_file.write_text(yaml.dump(conda_dict))
|
||||
runtime_env = {"conda": relative_filepath}
|
||||
|
||||
yield {
|
||||
"runtime_env": runtime_env,
|
||||
"entrypoint": f"python -c '{import_in_task_script}'",
|
||||
# TODO(architkulkarni): Uncomment after #22968 is fixed.
|
||||
# "entrypoint": "python -c 'import pip_install_test'",
|
||||
"expected_logs": "Good job! You installed a pip module.",
|
||||
}
|
||||
else:
|
||||
assert False, f"Unrecognized option: {request.param}."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_job(job_sdk_client, runtime_env_option, monkeypatch):
|
||||
# This flag allows for local testing of runtime env conda functionality
|
||||
# without needing a built Ray wheel. Rather than insert the link to the
|
||||
# wheel into the conda spec, it links to the current Python site.
|
||||
monkeypatch.setenv("RAY_RUNTIME_ENV_LOCAL_DEV_MODE", "1")
|
||||
|
||||
agent_client, head_client = job_sdk_client
|
||||
|
||||
runtime_env = runtime_env_option["runtime_env"]
|
||||
runtime_env = upload_working_dir_if_needed(
|
||||
runtime_env, include_gitignore=True, logger=logger
|
||||
)
|
||||
runtime_env = upload_py_modules_if_needed(
|
||||
runtime_env, include_gitignore=True, logger=logger
|
||||
)
|
||||
runtime_env = RuntimeEnv(**runtime_env_option["runtime_env"]).to_dict()
|
||||
request = validate_request_type(
|
||||
{"runtime_env": runtime_env, "entrypoint": runtime_env_option["entrypoint"]},
|
||||
JobSubmitRequest,
|
||||
)
|
||||
submit_result = await agent_client.submit_job_internal(request)
|
||||
job_id = submit_result.submission_id
|
||||
|
||||
try:
|
||||
job_start_time = time.time()
|
||||
wait_for_condition(
|
||||
partial(
|
||||
_check_job,
|
||||
client=head_client,
|
||||
job_id=job_id,
|
||||
status=JobStatus.SUCCEEDED,
|
||||
),
|
||||
timeout=300,
|
||||
)
|
||||
job_duration = time.time() - job_start_time
|
||||
print(f"The job took {job_duration}s to succeed.")
|
||||
except RuntimeError as e:
|
||||
# If the job is still pending, include job logs and info in error.
|
||||
if head_client.get_job_status(job_id) == JobStatus.PENDING:
|
||||
logs = head_client.get_job_logs(job_id)
|
||||
info = head_client.get_job_info(job_id)
|
||||
raise RuntimeError(
|
||||
f"Job was stuck in PENDING.\nLogs: {logs}\nInfo: {info}"
|
||||
) from e
|
||||
|
||||
# There is only one node, so there is no need to replace the client of the JobAgent
|
||||
resp = await agent_client.get_job_logs_internal(job_id)
|
||||
assert runtime_env_option["expected_logs"] in resp.logs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_job_rejects_browsers(
|
||||
job_sdk_client, runtime_env_option, monkeypatch
|
||||
):
|
||||
# This flag allows for local testing of runtime env conda functionality
|
||||
# without needing a built Ray wheel. Rather than insert the link to the
|
||||
# wheel into the conda spec, it links to the current Python site.
|
||||
monkeypatch.setenv("RAY_RUNTIME_ENV_LOCAL_DEV_MODE", "1")
|
||||
|
||||
agent_client, head_client = job_sdk_client
|
||||
agent_address = agent_client._agent_address
|
||||
agent_client = JobAgentSubmissionBrowserClient(agent_address)
|
||||
|
||||
runtime_env = runtime_env_option["runtime_env"]
|
||||
runtime_env = upload_working_dir_if_needed(
|
||||
runtime_env, include_gitignore=True, logger=logger
|
||||
)
|
||||
runtime_env = upload_py_modules_if_needed(
|
||||
runtime_env, include_gitignore=True, logger=logger
|
||||
)
|
||||
runtime_env = RuntimeEnv(**runtime_env_option["runtime_env"]).to_dict()
|
||||
request = validate_request_type(
|
||||
{"runtime_env": runtime_env, "entrypoint": runtime_env_option["entrypoint"]},
|
||||
JobSubmitRequest,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc:
|
||||
_ = await agent_client.submit_job_internal(request)
|
||||
|
||||
assert "status code 403" in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_job_rejects_browsers(job_sdk_client, monkeypatch):
|
||||
"""Test that DELETE job requests from browsers are rejected."""
|
||||
monkeypatch.setenv("RAY_RUNTIME_ENV_LOCAL_DEV_MODE", "1")
|
||||
|
||||
agent_client, head_client = job_sdk_client
|
||||
|
||||
# First, submit a job using the normal client
|
||||
runtime_env = RuntimeEnv().to_dict()
|
||||
request = validate_request_type(
|
||||
{"runtime_env": runtime_env, "entrypoint": "echo hello"},
|
||||
JobSubmitRequest,
|
||||
)
|
||||
submit_result = await agent_client.submit_job_internal(request)
|
||||
job_id = submit_result.submission_id
|
||||
|
||||
# Now try to delete the job using browser-like headers
|
||||
agent_address = agent_client._agent_address
|
||||
browser_client = JobAgentSubmissionBrowserClient(agent_address)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc:
|
||||
_ = await browser_client.delete_job_internal(job_id)
|
||||
|
||||
assert "status code 403" in str(exc.value)
|
||||
|
||||
await browser_client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout(job_sdk_client):
|
||||
agent_client, head_client = job_sdk_client
|
||||
|
||||
runtime_env = RuntimeEnv(
|
||||
pip={
|
||||
"packages": ["tensorflow", "requests", "botocore", "torch"],
|
||||
"pip_check": False,
|
||||
"pip_version": "==23.3.2;python_version=='3.9.16'",
|
||||
},
|
||||
config=RuntimeEnvConfig(setup_timeout_seconds=1),
|
||||
).to_dict()
|
||||
request = validate_request_type(
|
||||
{"runtime_env": runtime_env, "entrypoint": "echo hello"},
|
||||
JobSubmitRequest,
|
||||
)
|
||||
|
||||
submit_result = await agent_client.submit_job_internal(request)
|
||||
job_id = submit_result.submission_id
|
||||
|
||||
wait_for_condition(
|
||||
partial(_check_job, client=head_client, job_id=job_id, status=JobStatus.FAILED),
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
data = head_client.get_job_info(job_id)
|
||||
assert "Failed to set up runtime environment" in data.message
|
||||
assert "Timeout" in data.message
|
||||
assert "setup_timeout_seconds" in data.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_env_setup_failure(job_sdk_client):
|
||||
agent_client, head_client = job_sdk_client
|
||||
|
||||
runtime_env = RuntimeEnv(working_dir="s3://does_not_exist.zip").to_dict()
|
||||
request = validate_request_type(
|
||||
{"runtime_env": runtime_env, "entrypoint": "echo hello"},
|
||||
JobSubmitRequest,
|
||||
)
|
||||
|
||||
submit_result = await agent_client.submit_job_internal(request)
|
||||
job_id = submit_result.submission_id
|
||||
|
||||
wait_for_condition(
|
||||
partial(_check_job, client=head_client, job_id=job_id, status=JobStatus.FAILED),
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
data = head_client.get_job_info(job_id)
|
||||
assert "Failed to set up runtime environment" in data.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_long_running_job(job_sdk_client):
|
||||
"""
|
||||
Submit a job that runs for a while and stop it in the middle.
|
||||
"""
|
||||
agent_client, head_client = job_sdk_client
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
path = Path(tmp_dir)
|
||||
driver_script = """
|
||||
print('Hello !')
|
||||
import time
|
||||
time.sleep(300) # This should never finish
|
||||
raise RuntimeError('Intentionally failed.')
|
||||
"""
|
||||
test_script_file = path / "test_script.py"
|
||||
with open(test_script_file, "w+") as file:
|
||||
file.write(driver_script)
|
||||
|
||||
runtime_env = {"working_dir": tmp_dir}
|
||||
runtime_env = upload_working_dir_if_needed(
|
||||
runtime_env, include_gitignore=True, scratch_dir=tmp_dir, logger=logger
|
||||
)
|
||||
runtime_env = RuntimeEnv(**runtime_env).to_dict()
|
||||
|
||||
request = validate_request_type(
|
||||
{"runtime_env": runtime_env, "entrypoint": "python test_script.py"},
|
||||
JobSubmitRequest,
|
||||
)
|
||||
submit_result = await agent_client.submit_job_internal(request)
|
||||
job_id = submit_result.submission_id
|
||||
|
||||
resp = await agent_client.stop_job_internal(job_id)
|
||||
assert resp.stopped is True
|
||||
|
||||
wait_for_condition(
|
||||
partial(
|
||||
_check_job, client=head_client, job_id=job_id, status=JobStatus.STOPPED
|
||||
),
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tail_job_logs_with_echo(job_sdk_client):
|
||||
agent_client, head_client = job_sdk_client
|
||||
|
||||
runtime_env = RuntimeEnv().to_dict()
|
||||
entrypoint = "python -c \"import time; [(print('Hello', i), time.sleep(0.1)) for i in range(100)]\"" # noqa: E501
|
||||
request = validate_request_type(
|
||||
{
|
||||
"runtime_env": runtime_env,
|
||||
"entrypoint": entrypoint,
|
||||
},
|
||||
JobSubmitRequest,
|
||||
)
|
||||
|
||||
submit_result = await agent_client.submit_job_internal(request)
|
||||
job_id = submit_result.submission_id
|
||||
|
||||
i = 0
|
||||
async for lines in agent_client.tail_job_logs(job_id):
|
||||
print(lines, end="")
|
||||
for line in lines.strip().split("\n"):
|
||||
if (
|
||||
"Runtime env is setting up." in line
|
||||
or "Running entrypoint for job" in line
|
||||
):
|
||||
continue
|
||||
assert line.split(" ") == ["Hello", str(i)]
|
||||
i += 1
|
||||
|
||||
wait_for_condition(
|
||||
partial(
|
||||
_check_job, client=head_client, job_id=job_id, status=JobStatus.SUCCEEDED
|
||||
),
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"ray_start_cluster_head",
|
||||
[
|
||||
{
|
||||
"include_dashboard": True,
|
||||
"dashboard_agent_listen_port": DEFAULT_DASHBOARD_AGENT_LISTEN_PORT,
|
||||
}
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_job_log_in_multiple_node(
|
||||
make_sure_dashboard_http_port_unused,
|
||||
enable_test_module,
|
||||
disable_aiohttp_cache,
|
||||
ray_start_cluster_head,
|
||||
):
|
||||
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(
|
||||
dashboard_agent_listen_port=DEFAULT_DASHBOARD_AGENT_LISTEN_PORT + 1
|
||||
)
|
||||
cluster.add_node(
|
||||
dashboard_agent_listen_port=DEFAULT_DASHBOARD_AGENT_LISTEN_PORT + 2
|
||||
)
|
||||
|
||||
node_ip = cluster.head_node.node_ip_address
|
||||
agent_address = build_address(node_ip, DEFAULT_DASHBOARD_AGENT_LISTEN_PORT)
|
||||
assert wait_until_server_available(agent_address)
|
||||
client = JobAgentSubmissionClient(format_web_url(agent_address))
|
||||
|
||||
def _check_nodes():
|
||||
try:
|
||||
assert len(list_nodes()) == 3
|
||||
return True
|
||||
except Exception as ex:
|
||||
logger.info(ex)
|
||||
return False
|
||||
|
||||
wait_for_condition(_check_nodes, timeout=15)
|
||||
|
||||
job_ids = []
|
||||
job_check_status = []
|
||||
JOB_NUM = 10
|
||||
job_agent_ports = [
|
||||
DEFAULT_DASHBOARD_AGENT_LISTEN_PORT,
|
||||
DEFAULT_DASHBOARD_AGENT_LISTEN_PORT + 1,
|
||||
DEFAULT_DASHBOARD_AGENT_LISTEN_PORT + 2,
|
||||
]
|
||||
for index in range(JOB_NUM):
|
||||
runtime_env = RuntimeEnv().to_dict()
|
||||
request = validate_request_type(
|
||||
{
|
||||
"runtime_env": runtime_env,
|
||||
"entrypoint": f"while true; do echo hello index-{index}"
|
||||
" && sleep 3600; done",
|
||||
},
|
||||
JobSubmitRequest,
|
||||
)
|
||||
|
||||
submit_result = await client.submit_job_internal(request)
|
||||
job_ids.append(submit_result.submission_id)
|
||||
job_check_status.append(False)
|
||||
|
||||
async def _check_all_jobs_log():
|
||||
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"]
|
||||
|
||||
for index, job_id in enumerate(job_ids):
|
||||
if job_check_status[index]:
|
||||
continue
|
||||
result_log = f"hello index-{index}"
|
||||
# Try to get the node id which supervisor actor running in.
|
||||
node_id = get_node_id_for_supervisor_actor_for_job(cluster.address, job_id)
|
||||
for node_info in summary:
|
||||
if node_info["raylet"]["nodeId"] == node_id:
|
||||
break
|
||||
assert node_info["raylet"]["nodeId"] == node_id, f"node id: {node_id}"
|
||||
|
||||
# Try to get the agent HTTP port by node id.
|
||||
for agent_port in job_agent_ports:
|
||||
if f"--listen-port={agent_port}" in " ".join(node_info["cmdline"]):
|
||||
break
|
||||
assert f"--listen-port={agent_port}" in " ".join(
|
||||
node_info["cmdline"]
|
||||
), f"port: {agent_port}"
|
||||
|
||||
# Finally, we got the whole agent address, and try to get the job log.
|
||||
ip = get_node_ip_by_id(node_id)
|
||||
agent_address = f"{ip}:{agent_port}"
|
||||
assert wait_until_server_available(agent_address)
|
||||
client = JobAgentSubmissionClient(format_web_url(agent_address))
|
||||
resp = await client.get_job_logs_internal(job_id)
|
||||
assert result_log in resp.logs, f"logs: {resp.logs}"
|
||||
|
||||
job_check_status[index] = True
|
||||
return True
|
||||
|
||||
st = time.time()
|
||||
while time.time() - st <= 30:
|
||||
try:
|
||||
await _check_all_jobs_log()
|
||||
break
|
||||
except Exception as ex:
|
||||
print("error:", ex)
|
||||
time.sleep(1)
|
||||
assert all(job_check_status), job_check_status
|
||||
|
||||
|
||||
def test_agent_logs_not_streamed_to_drivers():
|
||||
"""Ensure when the job submission is used,
|
||||
(ray.init is called from an agent), the agent logs are
|
||||
not streamed to drivers.
|
||||
|
||||
Related: https://github.com/ray-project/ray/issues/29944
|
||||
"""
|
||||
script = """
|
||||
import ray
|
||||
from ray.job_submission import JobSubmissionClient, JobStatus
|
||||
from ray._private.test_utils import format_web_url
|
||||
from ray._common.test_utils import wait_for_condition
|
||||
|
||||
ray.init()
|
||||
address = ray._private.worker._global_node.webui_url
|
||||
address = format_web_url(address)
|
||||
client = JobSubmissionClient(address)
|
||||
submission_id = client.submit_job(entrypoint="ls")
|
||||
wait_for_condition(
|
||||
lambda: client.get_job_status(submission_id) == JobStatus.SUCCEEDED
|
||||
)
|
||||
"""
|
||||
|
||||
proc = run_string_as_driver_nonblocking(script)
|
||||
out_str = proc.stdout.read().decode("ascii")
|
||||
err_str = proc.stderr.read().decode("ascii")
|
||||
|
||||
print(out_str, err_str)
|
||||
|
||||
assert "(raylet)" not in out_str
|
||||
assert "(raylet)" not in err_str
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_default_dashboard_agent_http_port(tmp_path):
|
||||
"""Test that we can connect to the dashboard agent with a non-default
|
||||
http port.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
dashboard_agent_port = get_current_unused_port()
|
||||
cmd = f"ray start --head --dashboard-agent-listen-port {dashboard_agent_port}"
|
||||
subprocess.check_output(cmd, shell=True)
|
||||
|
||||
try:
|
||||
# We will need to wait for the ray to be started in the subprocess.
|
||||
address_info = ray.init("auto", ignore_reinit_error=True).address_info
|
||||
|
||||
node_ip = address_info["node_ip_address"]
|
||||
|
||||
dashboard_agent_listen_port = address_info["dashboard_agent_listen_port"]
|
||||
agent_address = build_address(node_ip, dashboard_agent_listen_port)
|
||||
print("agent address = ", agent_address)
|
||||
|
||||
agent_client = JobAgentSubmissionClient(format_web_url(agent_address))
|
||||
head_client = JobSubmissionClient(format_web_url(address_info["webui_url"]))
|
||||
|
||||
assert wait_until_server_available(agent_address)
|
||||
|
||||
# Submit a job through the agent.
|
||||
runtime_env = RuntimeEnv().to_dict()
|
||||
request = validate_request_type(
|
||||
{
|
||||
"runtime_env": runtime_env,
|
||||
"entrypoint": "echo hello",
|
||||
},
|
||||
JobSubmitRequest,
|
||||
)
|
||||
submit_result = await agent_client.submit_job_internal(request)
|
||||
job_id = submit_result.submission_id
|
||||
|
||||
async def verify():
|
||||
# Wait for job finished.
|
||||
wait_for_condition(
|
||||
partial(
|
||||
_check_job,
|
||||
client=head_client,
|
||||
job_id=job_id,
|
||||
status=JobStatus.SUCCEEDED,
|
||||
),
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
resp = await agent_client.get_job_logs_internal(job_id)
|
||||
assert "hello" in resp.logs, resp.logs
|
||||
|
||||
return True
|
||||
|
||||
await async_wait_for_condition(verify, retry_interval_ms=2000)
|
||||
finally:
|
||||
subprocess.check_output("ray stop --force", shell=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,182 @@
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from ray.cluster_utils import Cluster
|
||||
from ray.dashboard.consts import RAY_STREAM_RUNTIME_ENV_LOG_TO_JOB_DRIVER_LOG_ENV_VAR
|
||||
from ray.dashboard.modules.job.tests.conftest import _driver_script_path
|
||||
from ray.dashboard.modules.job.tests.subprocess_driver_scripts.driver_runtime_env_inheritance import ( # noqa: E501
|
||||
RUNTIME_ENV_LOG_LINE_PREFIX,
|
||||
)
|
||||
from ray.job_submission import JobStatus, JobSubmissionClient
|
||||
|
||||
|
||||
def wait_until_status(client, job_id, status_to_wait_for, timeout_seconds=20):
|
||||
start = time.time()
|
||||
while time.time() - start <= timeout_seconds:
|
||||
status = client.get_job_status(job_id)
|
||||
print(f"status: {status}")
|
||||
if status in status_to_wait_for:
|
||||
return
|
||||
time.sleep(1)
|
||||
raise Exception
|
||||
|
||||
|
||||
def wait(client, job_id):
|
||||
wait_until_status(
|
||||
client,
|
||||
job_id,
|
||||
{JobStatus.SUCCEEDED, JobStatus.STOPPED, JobStatus.FAILED},
|
||||
timeout_seconds=60,
|
||||
)
|
||||
|
||||
|
||||
def get_runtime_env_from_logs(client, job_id):
|
||||
wait(client, job_id)
|
||||
logs = client.get_job_logs(job_id)
|
||||
print(logs)
|
||||
assert client.get_job_status(job_id) == JobStatus.SUCCEEDED
|
||||
# Split logs by line, find the unique line that starts with
|
||||
# RUNTIME_ENV_LOG_LINE_PREFIX, strip it and parse it as JSON.
|
||||
lines = logs.strip().split("\n")
|
||||
assert len(lines) > 0
|
||||
for line in lines:
|
||||
if line.startswith(RUNTIME_ENV_LOG_LINE_PREFIX):
|
||||
return json.loads(line[len(RUNTIME_ENV_LOG_LINE_PREFIX) :])
|
||||
|
||||
|
||||
def test_job_driver_inheritance():
|
||||
try:
|
||||
c = Cluster()
|
||||
c.add_node(num_cpus=1)
|
||||
# If using a remote cluster, replace 127.0.0.1 with the head node's IP address.
|
||||
client = JobSubmissionClient("http://127.0.0.1:8265")
|
||||
driver_script_path = _driver_script_path("driver_runtime_env_inheritance.py")
|
||||
job_id = client.submit_job(
|
||||
entrypoint=f"python {driver_script_path}",
|
||||
runtime_env={
|
||||
"env_vars": {"A": "1", "B": "2"},
|
||||
"pip": ["requests"],
|
||||
},
|
||||
)
|
||||
|
||||
# Test key is merged
|
||||
print("Test key merged")
|
||||
runtime_env = get_runtime_env_from_logs(client, job_id)
|
||||
assert runtime_env["env_vars"] == {"A": "1", "B": "2", "C": "1"}
|
||||
assert runtime_env["pip"] == {"packages": ["requests"], "pip_check": False}
|
||||
|
||||
# Test worker process setuphook works.
|
||||
print("Test key setup hook")
|
||||
expected_str = "HELLOWORLD"
|
||||
job_id = client.submit_job(
|
||||
entrypoint=(
|
||||
f"python {driver_script_path} "
|
||||
f"--worker-process-setup-hook {expected_str}"
|
||||
),
|
||||
runtime_env={
|
||||
"env_vars": {"A": "1", "B": "2"},
|
||||
},
|
||||
)
|
||||
wait(client, job_id)
|
||||
logs = client.get_job_logs(job_id)
|
||||
assert expected_str in logs
|
||||
|
||||
# Test raise an exception upon key conflict
|
||||
print("Test conflicting pip")
|
||||
job_id = client.submit_job(
|
||||
entrypoint=f"python {driver_script_path} --conflict=pip",
|
||||
runtime_env={"pip": ["numpy"]},
|
||||
)
|
||||
wait(client, job_id)
|
||||
status = client.get_job_status(job_id)
|
||||
logs = client.get_job_logs(job_id)
|
||||
assert status == JobStatus.FAILED
|
||||
assert "Failed to merge the Job's runtime env" in logs
|
||||
|
||||
# Test raise an exception upon env var conflict
|
||||
print("Test conflicting env vars")
|
||||
job_id = client.submit_job(
|
||||
entrypoint=f"python {driver_script_path} --conflict=env_vars",
|
||||
runtime_env={
|
||||
"env_vars": {"A": "1"},
|
||||
},
|
||||
)
|
||||
wait(client, job_id)
|
||||
status = client.get_job_status(job_id)
|
||||
logs = client.get_job_logs(job_id)
|
||||
assert status == JobStatus.FAILED
|
||||
assert "Failed to merge the Job's runtime env" in logs
|
||||
finally:
|
||||
c.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stream_runtime_env_log", ["1", "0"])
|
||||
def test_runtime_env_logs_streamed_to_job_driver_log(
|
||||
monkeypatch, stream_runtime_env_log
|
||||
):
|
||||
monkeypatch.setenv(
|
||||
RAY_STREAM_RUNTIME_ENV_LOG_TO_JOB_DRIVER_LOG_ENV_VAR, stream_runtime_env_log
|
||||
)
|
||||
try:
|
||||
c = Cluster()
|
||||
c.add_node(num_cpus=1)
|
||||
client = JobSubmissionClient("http://127.0.0.1:8265")
|
||||
job_id = client.submit_job(
|
||||
entrypoint="echo hello world",
|
||||
runtime_env={"pip": ["requests==2.25.1"]},
|
||||
)
|
||||
wait(client, job_id)
|
||||
logs = client.get_job_logs(job_id)
|
||||
if stream_runtime_env_log == "0":
|
||||
assert "Creating virtualenv at" not in logs
|
||||
else:
|
||||
assert "Creating virtualenv at" in logs
|
||||
finally:
|
||||
c.shutdown()
|
||||
|
||||
|
||||
def test_job_driver_inheritance_override(monkeypatch):
|
||||
monkeypatch.setenv("RAY_OVERRIDE_JOB_RUNTIME_ENV", "1")
|
||||
|
||||
try:
|
||||
c = Cluster()
|
||||
c.add_node(num_cpus=1)
|
||||
# If using a remote cluster, replace 127.0.0.1 with the head node's IP address.
|
||||
client = JobSubmissionClient("http://127.0.0.1:8265")
|
||||
driver_script_path = _driver_script_path("driver_runtime_env_inheritance.py")
|
||||
job_id = client.submit_job(
|
||||
entrypoint=f"python {driver_script_path}",
|
||||
runtime_env={
|
||||
"env_vars": {"A": "1", "B": "2"},
|
||||
"pip": ["requests"],
|
||||
},
|
||||
)
|
||||
|
||||
# Test conflict resolution regular field
|
||||
job_id = client.submit_job(
|
||||
entrypoint=f"python {driver_script_path} --conflict=pip",
|
||||
runtime_env={"pip": ["pip-install-test==0.5"]},
|
||||
)
|
||||
runtime_env = get_runtime_env_from_logs(client, job_id)
|
||||
print(runtime_env)
|
||||
assert runtime_env["pip"] == {"packages": ["numpy"], "pip_check": False}
|
||||
|
||||
# Test raise an exception upon env var conflict
|
||||
job_id = client.submit_job(
|
||||
entrypoint=f"python {driver_script_path} --conflict=env_vars",
|
||||
runtime_env={
|
||||
"env_vars": {"A": "2"},
|
||||
},
|
||||
)
|
||||
runtime_env = get_runtime_env_from_logs(client, job_id)
|
||||
print(runtime_env)
|
||||
assert runtime_env["env_vars"]["A"] == "1"
|
||||
finally:
|
||||
c.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from ray._common.test_utils import async_wait_for_condition
|
||||
from ray.dashboard.modules.job.tests.conftest import (
|
||||
_driver_script_path,
|
||||
create_job_manager,
|
||||
create_ray_cluster,
|
||||
)
|
||||
from ray.dashboard.modules.job.tests.test_job_manager import check_job_succeeded
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestRuntimeEnvStandalone:
|
||||
"""NOTE: PLEASE READ CAREFULLY BEFORE MODIFYING
|
||||
This test is extracted into a standalone module such that it can bootstrap its own
|
||||
(standalone) Ray cluster while avoiding affecting the shared one used by other
|
||||
JobManager tests
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tracing_enabled",
|
||||
[
|
||||
False,
|
||||
# TODO(issues/38633): local code loading is broken when tracing is enabled
|
||||
# True,
|
||||
],
|
||||
)
|
||||
async def test_user_provided_job_config_honored_by_worker(
|
||||
self, tracing_enabled, tmp_path
|
||||
):
|
||||
"""Ensures that the JobConfig instance injected into ray.init in the driver
|
||||
script is honored even in case when job is submitted via JobManager.submit_job
|
||||
API (involving RAY_JOB_CONFIG_JSON_ENV_VAR being set in child process env)
|
||||
|
||||
"""
|
||||
|
||||
if tracing_enabled:
|
||||
tracing_startup_hook = (
|
||||
"ray.util.tracing.setup_local_tmp_tracing:setup_tracing"
|
||||
)
|
||||
else:
|
||||
tracing_startup_hook = None
|
||||
|
||||
with create_ray_cluster(_tracing_startup_hook=tracing_startup_hook) as cluster:
|
||||
job_manager = create_job_manager(cluster, tmp_path)
|
||||
|
||||
driver_script_path = _driver_script_path(
|
||||
"check_code_search_path_is_propagated.py"
|
||||
)
|
||||
|
||||
job_id = await job_manager.submit_job(
|
||||
entrypoint=f"python {driver_script_path}",
|
||||
# NOTE: We inject runtime_env in here, but also specify the JobConfig in
|
||||
# the driver script: settings to JobConfig (other than the
|
||||
# runtime_env) passed in via ray.init(...) have to be respected
|
||||
# along with the runtime_env passed from submit_job API
|
||||
runtime_env={"env_vars": {"TEST_SUBPROCESS_RANDOM_VAR": "0xDEEDDEED"}},
|
||||
)
|
||||
|
||||
await async_wait_for_condition(
|
||||
check_job_succeeded, job_manager=job_manager, job_id=job_id
|
||||
)
|
||||
|
||||
logs = job_manager.get_job_logs(job_id)
|
||||
|
||||
assert "Code search path is propagated" in logs, logs
|
||||
assert "0xDEEDDEED" in logs, logs
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,435 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
|
||||
import ray.experimental.internal_kv as kv
|
||||
from ray._common.test_utils import wait_for_condition
|
||||
from ray._private import worker
|
||||
from ray._private.ray_constants import (
|
||||
KV_NAMESPACE_DASHBOARD,
|
||||
PROCESS_TYPE_DASHBOARD,
|
||||
)
|
||||
from ray._private.test_utils import (
|
||||
format_web_url,
|
||||
wait_until_server_available,
|
||||
)
|
||||
from ray._raylet import GcsClient
|
||||
from ray.dashboard.consts import (
|
||||
DASHBOARD_AGENT_ADDR_NODE_ID_PREFIX,
|
||||
GCS_RPC_TIMEOUT_SECONDS,
|
||||
RAY_JOB_ALLOW_DRIVER_ON_WORKER_NODES_ENV_VAR,
|
||||
)
|
||||
from ray.dashboard.modules.dashboard_sdk import (
|
||||
DEFAULT_DASHBOARD_ADDRESS,
|
||||
ClusterInfo,
|
||||
parse_cluster_info,
|
||||
)
|
||||
from ray.dashboard.modules.job.pydantic_models import JobType
|
||||
from ray.dashboard.modules.job.sdk import JobStatus, JobSubmissionClient
|
||||
from ray.dashboard.tests.conftest import * # noqa
|
||||
from ray.runtime_env.runtime_env import RuntimeEnv
|
||||
from ray.tests.conftest import _ray_start
|
||||
from ray.util.state import list_nodes
|
||||
|
||||
import psutil
|
||||
|
||||
|
||||
def _check_job_succeeded(client: JobSubmissionClient, job_id: str) -> bool:
|
||||
status = client.get_job_status(job_id)
|
||||
if status == JobStatus.FAILED:
|
||||
logs = client.get_job_logs(job_id)
|
||||
raise RuntimeError(f"Job failed\nlogs:\n{logs}")
|
||||
return status == JobStatus.SUCCEEDED
|
||||
|
||||
|
||||
def check_internal_kv_gced():
|
||||
return len(kv._internal_kv_list("gcs://")) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address_param",
|
||||
[
|
||||
("ray://1.2.3.4:10001", "ray", "1.2.3.4:10001"),
|
||||
("other_module://", "other_module", ""),
|
||||
("other_module://address", "other_module", "address"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("create_cluster_if_needed", [True, False])
|
||||
@pytest.mark.parametrize("cookies", [None, {"test_cookie_key": "test_cookie_val"}])
|
||||
@pytest.mark.parametrize("metadata", [None, {"test_metadata_key": "test_metadata_val"}])
|
||||
@pytest.mark.parametrize("headers", [None, {"test_headers_key": "test_headers_val"}])
|
||||
@pytest.mark.parametrize("extra_kwargs", [{}, {"cloud": "my-cloud"}])
|
||||
def test_parse_cluster_info(
|
||||
address_param: Tuple[str, str, str],
|
||||
create_cluster_if_needed: bool,
|
||||
cookies: Optional[Dict[str, str]],
|
||||
metadata: Optional[Dict[str, str]],
|
||||
headers: Optional[Dict[str, str]],
|
||||
extra_kwargs: Dict[str, str],
|
||||
):
|
||||
"""
|
||||
Test ray.dashboard.modules.dashboard_sdk.parse_cluster_info for different
|
||||
format of addresses.
|
||||
"""
|
||||
mock_get_job_submission_client_cluster = Mock(return_value="Ray ClusterInfo")
|
||||
mock_module = Mock()
|
||||
mock_module.get_job_submission_client_cluster_info = Mock(
|
||||
return_value="Other module ClusterInfo"
|
||||
)
|
||||
mock_import_module = Mock(return_value=mock_module)
|
||||
|
||||
address, module_string, inner_address = address_param
|
||||
|
||||
with (
|
||||
patch.multiple(
|
||||
"ray.dashboard.modules.dashboard_sdk",
|
||||
get_job_submission_client_cluster_info=mock_get_job_submission_client_cluster,
|
||||
),
|
||||
patch.multiple("importlib", import_module=mock_import_module),
|
||||
):
|
||||
if module_string == "ray":
|
||||
with pytest.raises(ValueError, match="ray://"):
|
||||
parse_cluster_info(
|
||||
address,
|
||||
create_cluster_if_needed=create_cluster_if_needed,
|
||||
cookies=cookies,
|
||||
metadata=metadata,
|
||||
headers=headers,
|
||||
**extra_kwargs,
|
||||
)
|
||||
elif module_string == "other_module":
|
||||
assert (
|
||||
parse_cluster_info(
|
||||
address,
|
||||
create_cluster_if_needed=create_cluster_if_needed,
|
||||
cookies=cookies,
|
||||
metadata=metadata,
|
||||
headers=headers,
|
||||
**extra_kwargs,
|
||||
)
|
||||
== "Other module ClusterInfo"
|
||||
)
|
||||
mock_import_module.assert_called_once_with(module_string)
|
||||
mock_module.get_job_submission_client_cluster_info.assert_called_once_with(
|
||||
inner_address,
|
||||
create_cluster_if_needed=create_cluster_if_needed,
|
||||
cookies=cookies,
|
||||
metadata=metadata,
|
||||
headers=headers,
|
||||
**extra_kwargs,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_cluster_info_default_address():
|
||||
assert parse_cluster_info(
|
||||
address=None,
|
||||
) == ClusterInfo(address=DEFAULT_DASHBOARD_ADDRESS)
|
||||
|
||||
|
||||
def test_submit_job_does_not_mutate_runtime_env():
|
||||
class TestClient(JobSubmissionClient):
|
||||
def __init__(self):
|
||||
self._default_metadata = {}
|
||||
|
||||
def _upload_working_dir_if_needed(self, runtime_env):
|
||||
runtime_env["working_dir"] = "gcs://test.zip"
|
||||
|
||||
def _upload_py_modules_if_needed(self, runtime_env):
|
||||
runtime_env["py_modules"] = ["gcs://test_module.zip"]
|
||||
|
||||
def _do_request(self, method, endpoint, **kwargs):
|
||||
return MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"job_id": "test_job", "submission_id": "test_job"},
|
||||
)
|
||||
|
||||
runtime_env = {"working_dir": "/tmp/test", "py_modules": ["/tmp/test_module"]}
|
||||
original_runtime_env = {
|
||||
"working_dir": runtime_env["working_dir"],
|
||||
"py_modules": list(runtime_env["py_modules"]),
|
||||
}
|
||||
|
||||
assert (
|
||||
TestClient().submit_job(entrypoint="echo hi", runtime_env=runtime_env)
|
||||
== "test_job"
|
||||
)
|
||||
assert runtime_env == original_runtime_env
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expiration_s", [0, 10])
|
||||
def test_temporary_uri_reference(monkeypatch, expiration_s):
|
||||
"""Test that temporary GCS URI references are deleted after expiration_s."""
|
||||
monkeypatch.setenv(
|
||||
"RAY_RUNTIME_ENV_TEMPORARY_REFERENCE_EXPIRATION_S", str(expiration_s)
|
||||
)
|
||||
# We can't use a fixture with a shared Ray runtime because we need to set the
|
||||
# expiration_s env var before Ray starts.
|
||||
with _ray_start(include_dashboard=True, num_cpus=1) as ctx:
|
||||
headers = {"Connection": "keep-alive", "Authorization": "TOK:<MY_TOKEN>"}
|
||||
address = ctx.address_info["webui_url"]
|
||||
assert wait_until_server_available(address)
|
||||
client = JobSubmissionClient(format_web_url(address), headers=headers)
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
path = Path(tmp_dir)
|
||||
|
||||
hello_file = path / "hi.txt"
|
||||
with hello_file.open(mode="w") as f:
|
||||
f.write("hi\n")
|
||||
|
||||
start = time.time()
|
||||
|
||||
runtime_env = {"working_dir": tmp_dir}
|
||||
job_id = client.submit_job(entrypoint="echo hi", runtime_env=runtime_env)
|
||||
assert runtime_env == {"working_dir": tmp_dir}
|
||||
wait_for_condition(
|
||||
_check_job_succeeded, client=client, job_id=job_id, timeout=30
|
||||
)
|
||||
|
||||
# Give time for deletion to occur if expiration_s is 0.
|
||||
time.sleep(2)
|
||||
# Need to connect to Ray to check internal_kv.
|
||||
# ray.init(address="auto")
|
||||
|
||||
print("Starting Internal KV checks at time ", time.time() - start)
|
||||
if expiration_s > 0:
|
||||
assert not check_internal_kv_gced()
|
||||
wait_for_condition(check_internal_kv_gced, timeout=2 * expiration_s)
|
||||
assert expiration_s < time.time() - start < 2 * expiration_s
|
||||
print("Internal KV was GC'ed at time ", time.time() - start)
|
||||
else:
|
||||
wait_for_condition(check_internal_kv_gced)
|
||||
print("Internal KV was GC'ed at time ", time.time() - start)
|
||||
|
||||
# Regression test for #46625: reusing the same runtime_env after
|
||||
# the package has been GC'ed should re-upload the local working_dir.
|
||||
job_id = client.submit_job(
|
||||
entrypoint="echo hi", runtime_env=runtime_env
|
||||
)
|
||||
wait_for_condition(
|
||||
_check_job_succeeded, client=client, job_id=job_id, timeout=30
|
||||
)
|
||||
|
||||
|
||||
def get_register_agents_number(gcs_client):
|
||||
keys = gcs_client.internal_kv_keys(
|
||||
prefix=DASHBOARD_AGENT_ADDR_NODE_ID_PREFIX,
|
||||
namespace=KV_NAMESPACE_DASHBOARD,
|
||||
timeout=GCS_RPC_TIMEOUT_SECONDS,
|
||||
)
|
||||
return len(keys)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ray_start_cluster_head_with_env_vars",
|
||||
[
|
||||
{
|
||||
"include_dashboard": True,
|
||||
"env_vars": {RAY_JOB_ALLOW_DRIVER_ON_WORKER_NODES_ENV_VAR: "1"},
|
||||
},
|
||||
{
|
||||
"include_dashboard": True,
|
||||
"env_vars": {RAY_JOB_ALLOW_DRIVER_ON_WORKER_NODES_ENV_VAR: "0"},
|
||||
},
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
def test_jobs_run_on_head_by_default_E2E(ray_start_cluster_head_with_env_vars):
|
||||
allow_driver_on_worker_nodes = (
|
||||
os.environ.get(RAY_JOB_ALLOW_DRIVER_ON_WORKER_NODES_ENV_VAR) == "1"
|
||||
)
|
||||
# Cluster setup
|
||||
cluster = ray_start_cluster_head_with_env_vars
|
||||
cluster.add_node(dashboard_agent_listen_port=52366)
|
||||
cluster.add_node(dashboard_agent_listen_port=52367)
|
||||
assert wait_until_server_available(cluster.webui_url) is True
|
||||
webui_url = cluster.webui_url
|
||||
webui_url = format_web_url(webui_url)
|
||||
client = JobSubmissionClient(webui_url)
|
||||
gcs_client = GcsClient(address=cluster.gcs_address)
|
||||
|
||||
def _check_nodes(num_nodes):
|
||||
try:
|
||||
assert len(list_nodes()) == num_nodes
|
||||
return True
|
||||
except Exception as ex:
|
||||
print(ex)
|
||||
return False
|
||||
|
||||
wait_for_condition(lambda: _check_nodes(num_nodes=3), timeout=15)
|
||||
wait_for_condition(lambda: get_register_agents_number(gcs_client) == 3, timeout=20)
|
||||
|
||||
# Submit 20 simple jobs.
|
||||
for i in range(20):
|
||||
client.submit_job(entrypoint="echo hi", submission_id=f"job_{i}")
|
||||
import pprint
|
||||
|
||||
def check_all_jobs_succeeded():
|
||||
submission_jobs = [
|
||||
job for job in client.list_jobs() if job.type == JobType.SUBMISSION
|
||||
]
|
||||
for job in submission_jobs:
|
||||
pprint.pprint(job)
|
||||
if job.status != JobStatus.SUCCEEDED:
|
||||
return False
|
||||
return True
|
||||
|
||||
# Wait until all jobs have finished.
|
||||
wait_for_condition(check_all_jobs_succeeded, timeout=60, retry_interval_ms=1000)
|
||||
|
||||
# Check driver_node_id of all jobs.
|
||||
submission_jobs = [
|
||||
job for job in client.list_jobs() if job.type == JobType.SUBMISSION
|
||||
]
|
||||
driver_node_ids = [job.driver_node_id for job in submission_jobs]
|
||||
|
||||
# Spuriously fails with probability (1/3)^20.
|
||||
pprint.pprint(driver_node_ids)
|
||||
num_ids = len(set(driver_node_ids))
|
||||
assert (num_ids > 1) if allow_driver_on_worker_nodes else (num_ids == 1), [
|
||||
id[:5] for id in driver_node_ids
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runtime_env_working_dir():
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
path = Path(tmp_dir)
|
||||
working_dir = path / "working_dir"
|
||||
working_dir.mkdir(parents=True)
|
||||
yield working_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def py_module_whl():
|
||||
with tempfile.NamedTemporaryFile(suffix=".whl") as tmp_file:
|
||||
yield tmp_file.name
|
||||
|
||||
|
||||
def test_job_submission_with_runtime_env_as_dict(
|
||||
runtime_env_working_dir, py_module_whl
|
||||
):
|
||||
working_dir_str = str(runtime_env_working_dir)
|
||||
with _ray_start(num_cpus=1):
|
||||
client = JobSubmissionClient()
|
||||
runtime_env = {"working_dir": working_dir_str, "py_modules": [py_module_whl]}
|
||||
job_id = client.submit_job(entrypoint="echo hi", runtime_env=runtime_env)
|
||||
job_details = client.get_job_info(job_id)
|
||||
parsed_runtime_env = job_details.runtime_env
|
||||
assert "gcs://" in parsed_runtime_env["working_dir"]
|
||||
assert len(parsed_runtime_env["py_modules"]) == 1
|
||||
assert "gcs://" in parsed_runtime_env["py_modules"][0]
|
||||
|
||||
|
||||
def test_job_submission_with_runtime_env_as_object(
|
||||
runtime_env_working_dir, py_module_whl
|
||||
):
|
||||
working_dir_str = str(runtime_env_working_dir)
|
||||
with _ray_start(num_cpus=1):
|
||||
client = JobSubmissionClient()
|
||||
runtime_env = RuntimeEnv(
|
||||
working_dir=working_dir_str, py_modules=[py_module_whl]
|
||||
)
|
||||
job_id = client.submit_job(entrypoint="echo hi", runtime_env=runtime_env)
|
||||
job_details = client.get_job_info(job_id)
|
||||
parsed_runtime_env = job_details.runtime_env
|
||||
assert "gcs://" in parsed_runtime_env["working_dir"]
|
||||
assert len(parsed_runtime_env["py_modules"]) == 1
|
||||
assert "gcs://" in parsed_runtime_env["py_modules"][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tail_job_logs_passes_headers_to_websocket(ray_start_regular):
|
||||
"""
|
||||
Test that authentication headers are passed to WebSocket connections.
|
||||
|
||||
This test verifies that headers provided to JobSubmissionClient are
|
||||
explicitly passed to the ws_connect() method, not just to the ClientSession.
|
||||
This is required because aiohttp's ClientSession does not automatically
|
||||
include session headers in WebSocket upgrade requests.
|
||||
"""
|
||||
dashboard_url = ray_start_regular.dashboard_url
|
||||
test_headers = {"Authorization": "Bearer test-token"}
|
||||
client = JobSubmissionClient(format_web_url(dashboard_url), headers=test_headers)
|
||||
|
||||
# Submit a simple job
|
||||
job_id = client.submit_job(entrypoint="echo hello")
|
||||
|
||||
# Mock the aiohttp ClientSession and WebSocket
|
||||
mock_ws = AsyncMock()
|
||||
mock_ws.receive = AsyncMock()
|
||||
mock_ws.receive.side_effect = [
|
||||
# First call returns a text message
|
||||
MagicMock(type=aiohttp.WSMsgType.TEXT, data="test log line\n"),
|
||||
# Second call indicates WebSocket is closed
|
||||
MagicMock(type=aiohttp.WSMsgType.CLOSED),
|
||||
]
|
||||
mock_ws.close_code = 1000 # Normal closure
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.ws_connect = AsyncMock(return_value=mock_ws)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
# Patch ClientSession to use our mock
|
||||
with patch("aiohttp.ClientSession", return_value=mock_session):
|
||||
# Tail logs
|
||||
log_lines = []
|
||||
async for lines in client.tail_job_logs(job_id):
|
||||
log_lines.append(lines)
|
||||
|
||||
# Verify ws_connect was called with headers
|
||||
mock_session.ws_connect.assert_called_once()
|
||||
call_args = mock_session.ws_connect.call_args
|
||||
|
||||
assert "headers" in call_args.kwargs
|
||||
assert call_args.kwargs["headers"] == test_headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tail_job_logs_websocket_abnormal_closure(ray_start_regular):
|
||||
"""
|
||||
Test that ABNORMAL_CLOSURE raises RuntimeError when tailing logs.
|
||||
|
||||
This test uses its own Ray cluster and kills the dashboard while tailing logs
|
||||
to simulate an abnormal WebSocket closure.
|
||||
"""
|
||||
dashboard_url = ray_start_regular.dashboard_url
|
||||
client = JobSubmissionClient(format_web_url(dashboard_url))
|
||||
|
||||
# Submit a long-running job
|
||||
driver_script = """
|
||||
import time
|
||||
for i in range(100):
|
||||
print("Hello", i)
|
||||
time.sleep(0.5)
|
||||
"""
|
||||
entrypoint = f"python -c '{driver_script}'"
|
||||
job_id = client.submit_job(entrypoint=entrypoint)
|
||||
|
||||
# Start tailing logs and stop Ray while tailing
|
||||
# Expect RuntimeError when WebSocket closes abnormally
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="WebSocket connection closed unexpectedly with close code",
|
||||
):
|
||||
i = 0
|
||||
async for lines in client.tail_job_logs(job_id):
|
||||
print(lines, end="")
|
||||
i += 1
|
||||
|
||||
# Kill the dashboard after receiving a few log lines
|
||||
if i == 3:
|
||||
print("\nKilling the dashboard to close websocket abnormally...")
|
||||
dash_info = worker._global_node.all_processes[PROCESS_TYPE_DASHBOARD][0]
|
||||
psutil.Process(dash_info.process.pid).kill()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,303 @@
|
||||
import os
|
||||
import sys
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
import pytest
|
||||
|
||||
from ray.dashboard.modules.job.common import JobSubmitRequest
|
||||
from ray.dashboard.modules.job.utils import (
|
||||
fast_tail_last_n_lines,
|
||||
file_tail_iterator,
|
||||
parse_and_validate_request,
|
||||
redact_url_password,
|
||||
strip_keys_with_value_none,
|
||||
)
|
||||
|
||||
|
||||
# Polyfill anext() function for Python 3.9 compatibility
|
||||
# May raise StopAsyncIteration.
|
||||
async def anext_polyfill(iterator):
|
||||
return await iterator.__anext__()
|
||||
|
||||
|
||||
# Use the built-in anext() for Python 3.10+, otherwise use our polyfilled function
|
||||
if sys.version_info < (3, 10):
|
||||
anext = anext_polyfill
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp():
|
||||
with NamedTemporaryFile() as f:
|
||||
yield f.name
|
||||
|
||||
|
||||
def test_strip_keys_with_value_none():
|
||||
d = {"a": 1, "b": None, "c": 3}
|
||||
assert strip_keys_with_value_none(d) == {"a": 1, "c": 3}
|
||||
d = {"a": 1, "b": 2, "c": 3}
|
||||
assert strip_keys_with_value_none(d) == d
|
||||
d = {"a": 1, "b": None, "c": None}
|
||||
assert strip_keys_with_value_none(d) == {"a": 1}
|
||||
|
||||
|
||||
def test_redact_url_password():
|
||||
url = "http://user:password@host:port"
|
||||
assert redact_url_password(url) == "http://user:<redacted>@host:port"
|
||||
url = "http://user:password@host:port?query=1"
|
||||
assert redact_url_password(url) == "http://user:<redacted>@host:port?query=1"
|
||||
url = "http://user:password@host:port?query=1&password=2"
|
||||
assert (
|
||||
redact_url_password(url)
|
||||
== "http://user:<redacted>@host:port?query=1&password=2"
|
||||
)
|
||||
url = "https://user:password@127.0.0.1:8080"
|
||||
assert redact_url_password(url) == "https://user:<redacted>@127.0.0.1:8080"
|
||||
url = "https://user:password@host:port?query=1"
|
||||
assert redact_url_password(url) == "https://user:<redacted>@host:port?query=1"
|
||||
url = "https://user:password@host:port?query=1&password=2"
|
||||
assert (
|
||||
redact_url_password(url)
|
||||
== "https://user:<redacted>@host:port?query=1&password=2"
|
||||
)
|
||||
|
||||
|
||||
# Mock for aiohttp.web.Request, which should not be constructed directly.
|
||||
class MockRequest:
|
||||
def __init__(self, **kwargs):
|
||||
self._json = kwargs
|
||||
|
||||
async def json(self):
|
||||
return self._json
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mock_request():
|
||||
request = MockRequest(a=1, b=2)
|
||||
assert await request.json() == {"a": 1, "b": 2}
|
||||
request = MockRequest(a=1, b=None)
|
||||
assert await request.json() == {"a": 1, "b": None}
|
||||
|
||||
|
||||
# async test
|
||||
@pytest.mark.asyncio
|
||||
class TestParseAndValidateRequest:
|
||||
async def test_basic(self):
|
||||
request = MockRequest(entrypoint="echo hi")
|
||||
expected = JobSubmitRequest(entrypoint="echo hi")
|
||||
assert await parse_and_validate_request(request, JobSubmitRequest) == expected
|
||||
|
||||
async def test_forward_compatibility(self):
|
||||
request = MockRequest(entrypoint="echo hi", new_client_field=None)
|
||||
expected = JobSubmitRequest(entrypoint="echo hi")
|
||||
assert await parse_and_validate_request(request, JobSubmitRequest) == expected
|
||||
|
||||
|
||||
class TestIterLine:
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_type(self):
|
||||
with pytest.raises(TypeError, match="path must be a string"):
|
||||
await anext(file_tail_iterator(1))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_not_created(self, tmp):
|
||||
it = file_tail_iterator(tmp)
|
||||
assert await anext(it) is None
|
||||
f = open(tmp, "w")
|
||||
f.write("hi\n")
|
||||
f.flush()
|
||||
assert await anext(it) is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_newline(self, tmp):
|
||||
it = file_tail_iterator(tmp)
|
||||
assert await anext(it) is None
|
||||
|
||||
f = open(tmp, "w")
|
||||
f.write("no_newline_yet")
|
||||
assert await anext(it) is None
|
||||
f.write("\n")
|
||||
f.flush()
|
||||
assert await anext(it) == ["no_newline_yet\n"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_lines(self, tmp):
|
||||
it = file_tail_iterator(tmp)
|
||||
assert await anext(it) is None
|
||||
|
||||
f = open(tmp, "w")
|
||||
|
||||
num_lines = 10
|
||||
for i in range(num_lines):
|
||||
s = f"{i}\n"
|
||||
f.write(s)
|
||||
f.flush()
|
||||
assert await anext(it) == [s]
|
||||
|
||||
assert await anext(it) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batching(self, tmp):
|
||||
it = file_tail_iterator(tmp)
|
||||
assert await anext(it) is None
|
||||
|
||||
f = open(tmp, "w")
|
||||
|
||||
# Write lines in batches of 10, check that we get them back in batches.
|
||||
for _ in range(100):
|
||||
num_lines = 10
|
||||
for i in range(num_lines):
|
||||
f.write(f"{i}\n")
|
||||
f.flush()
|
||||
|
||||
assert await anext(it) == [f"{i}\n" for i in range(10)]
|
||||
|
||||
assert await anext(it) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_line_batching(self, tmp):
|
||||
it = file_tail_iterator(tmp)
|
||||
assert await anext(it) is None
|
||||
|
||||
f = open(tmp, "w")
|
||||
|
||||
# Write lines in batches of 50, check that we get them back in batches of 10.
|
||||
for _ in range(100):
|
||||
num_lines = 50
|
||||
for i in range(num_lines):
|
||||
f.write(f"{i}\n")
|
||||
f.flush()
|
||||
|
||||
assert await anext(it) == [f"{i}\n" for i in range(10)]
|
||||
assert await anext(it) == [f"{i}\n" for i in range(10, 20)]
|
||||
assert await anext(it) == [f"{i}\n" for i in range(20, 30)]
|
||||
assert await anext(it) == [f"{i}\n" for i in range(30, 40)]
|
||||
assert await anext(it) == [f"{i}\n" for i in range(40, 50)]
|
||||
|
||||
assert await anext(it) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_char_batching(self, tmp):
|
||||
it = file_tail_iterator(tmp)
|
||||
assert await anext(it) is None
|
||||
|
||||
f = open(tmp, "w")
|
||||
|
||||
# Write a single line that is 60k characters
|
||||
f.write(f"{'1234567890' * 6000}\n")
|
||||
# Write a 4 lines that are 10k characters each
|
||||
for _ in range(4):
|
||||
f.write(f"{'1234567890' * 500}\n")
|
||||
f.flush()
|
||||
|
||||
# First line will come in a batch of its own
|
||||
assert await anext(it) == [f"{'1234567890' * 6000}\n"]
|
||||
# Other 4 lines will be batched together
|
||||
assert (
|
||||
await anext(it)
|
||||
== [
|
||||
f"{'1234567890' * 500}\n",
|
||||
]
|
||||
* 4
|
||||
)
|
||||
assert await anext(it) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_file(self):
|
||||
with NamedTemporaryFile() as tmp:
|
||||
it = file_tail_iterator(tmp.name)
|
||||
f = open(tmp.name, "w")
|
||||
|
||||
assert await anext(it) is None
|
||||
|
||||
f.write("hi\n")
|
||||
f.flush()
|
||||
|
||||
assert await anext(it) == ["hi\n"]
|
||||
|
||||
# Calls should continue returning None after file deleted.
|
||||
assert await anext(it) is None
|
||||
|
||||
|
||||
class TestFastTailLastNLines:
|
||||
def test_nonexistent_path(self, tmp):
|
||||
missing = tmp + ".missing"
|
||||
assert not os.path.exists(missing)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
fast_tail_last_n_lines(missing, num_lines=10, max_chars=1000)
|
||||
|
||||
def test_basic_last_n(self, tmp):
|
||||
# Write 100 lines, check that we get the last 10 lines.
|
||||
with open(tmp, "w") as f:
|
||||
for i in range(100):
|
||||
f.write(f"line-{i}\n")
|
||||
out = fast_tail_last_n_lines(tmp, num_lines=10, max_chars=1000)
|
||||
expected = "".join([f"line-{i}\n" for i in range(90, 100)])
|
||||
assert out == expected
|
||||
|
||||
def test_truncate_max_chars(self, tmp):
|
||||
# Construct a log file with two lines, each over max_chars,
|
||||
# check that we truncate to max_chars.
|
||||
with open(tmp, "w") as f:
|
||||
f.write("x" * 5000 + "\n")
|
||||
f.write("y" * 5000 + "\n")
|
||||
out = fast_tail_last_n_lines(tmp, num_lines=2, max_chars=3000)
|
||||
assert len(out) == 3000
|
||||
# Check that we truncate to max_chars, and include the last line.
|
||||
assert out.endswith("\n")
|
||||
|
||||
def test_partial_last_line(self, tmp):
|
||||
# Write a log file with a partial last line, check that we include it.
|
||||
with open(tmp, "w") as f:
|
||||
f.write("a\n")
|
||||
f.write("b\n")
|
||||
f.write("partial_last_line") # No newline at end
|
||||
out = fast_tail_last_n_lines(tmp, num_lines=3, max_chars=1000)
|
||||
assert out == "a\nb\npartial_last_line"
|
||||
|
||||
def test_small_block_size(self, tmp):
|
||||
# Write 30 lines, check that we can read a small block size and get the last N lines.
|
||||
with open(tmp, "w") as f:
|
||||
for i in range(30):
|
||||
f.write(f"{i}\n")
|
||||
out = fast_tail_last_n_lines(tmp, num_lines=5, max_chars=1000, block_size=16)
|
||||
expected = "".join([f"{i}\n" for i in range(25, 30)])
|
||||
assert out == expected
|
||||
|
||||
def test_mixed_long_lines(self, tmp):
|
||||
# Write a log file with a mix of short and long lines, check that we get the last N lines.
|
||||
with open(tmp, "w") as f:
|
||||
f.write("short-1\n")
|
||||
f.write("short-2\n")
|
||||
f.write("long-" + ("Z" * 10000) + "\n")
|
||||
f.write("short-3\n")
|
||||
f.write("short-4\n")
|
||||
out = fast_tail_last_n_lines(tmp, num_lines=3, max_chars=20000)
|
||||
# Check that we get the last 3 lines, including the long line.
|
||||
assert out.splitlines()[-1] == "short-4"
|
||||
assert out.splitlines()[-2] == "short-3"
|
||||
assert out.splitlines()[-3].startswith("long-Z")
|
||||
|
||||
def test_sparse_large_file_tail_max_chars(self, tmp):
|
||||
"""Simulate ~8 GiB sparse file tail and verify max_chars=20000 truncation."""
|
||||
size_8g = 8 * 1024 * 1024 * 1024
|
||||
# Build tail of two extremely long lines
|
||||
tail = "\n" + ("Q" * 25000 + "\n") + ("R" * 25000 + "\n")
|
||||
tail_bytes = tail.encode("utf-8")
|
||||
|
||||
print("Start writing sparse file tail...")
|
||||
# Create a sparse file: seek to near EOF then write only the tail.
|
||||
with open(tmp, "wb") as f:
|
||||
f.seek(size_8g - len(tail_bytes))
|
||||
f.write(tail_bytes)
|
||||
f.flush()
|
||||
|
||||
print("Finish writing sparse file tail.")
|
||||
out = fast_tail_last_n_lines(tmp, num_lines=2, max_chars=20000)
|
||||
print("Finish reading sparse file tail.")
|
||||
assert len(out) == 20000
|
||||
assert out.endswith("\n")
|
||||
assert "R" * 100 in out # sampling check for last line content
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,361 @@
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, AsyncIterator, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from ray._raylet import RAY_INTERNAL_NAMESPACE_PREFIX, GcsClient
|
||||
from ray.dashboard.modules.job.common import (
|
||||
JOB_ID_METADATA_KEY,
|
||||
JobInfoStorageClient,
|
||||
JobStatus,
|
||||
validate_request_type,
|
||||
)
|
||||
from ray.dashboard.modules.job.pydantic_models import DriverInfo, JobDetails, JobType
|
||||
from ray.runtime_env import RuntimeEnv
|
||||
|
||||
try:
|
||||
# package `aiohttp` is not in ray's minimal dependencies
|
||||
import aiohttp
|
||||
from aiohttp.web import Request, Response
|
||||
except Exception:
|
||||
aiohttp = None
|
||||
Request = None
|
||||
Response = None
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_CHUNK_LINE_LENGTH = 10
|
||||
MAX_CHUNK_CHAR_LENGTH = 20000
|
||||
|
||||
|
||||
def strip_keys_with_value_none(d: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Strip keys with value None from a dictionary."""
|
||||
return {k: v for k, v in d.items() if v is not None}
|
||||
|
||||
|
||||
def redact_url_password(url: str) -> str:
|
||||
"""Redact any passwords in a URL."""
|
||||
secret = re.findall(r"https?:\/\/.*:(.*)@.*", url)
|
||||
if len(secret) > 0:
|
||||
url = url.replace(f":{secret[0]}@", ":<redacted>@")
|
||||
|
||||
return url
|
||||
|
||||
|
||||
async def file_tail_iterator(path: str) -> AsyncIterator[Optional[List[str]]]:
|
||||
"""Yield lines from a file as it's written.
|
||||
|
||||
Returns lines in batches of up to 10 lines or 20000 characters,
|
||||
whichever comes first. If it's a chunk of 20000 characters, then
|
||||
the last line that is yielded could be an incomplete line.
|
||||
New line characters are kept in the line string.
|
||||
|
||||
Returns None until the file exists or if no new line has been written.
|
||||
"""
|
||||
if not isinstance(path, str):
|
||||
raise TypeError(f"path must be a string, got {type(path)}.")
|
||||
|
||||
while not os.path.exists(path):
|
||||
logger.debug(f"Path {path} doesn't exist yet.")
|
||||
yield None
|
||||
|
||||
EOF = ""
|
||||
|
||||
with open(path, "r") as f:
|
||||
lines = []
|
||||
|
||||
chunk_char_count = 0
|
||||
curr_line = None
|
||||
|
||||
while True:
|
||||
# We want to flush current chunk in following cases:
|
||||
# - We accumulated 10 lines
|
||||
# - We accumulated at least MAX_CHUNK_CHAR_LENGTH total chars
|
||||
# - We reached EOF
|
||||
if (
|
||||
len(lines) >= 10
|
||||
or chunk_char_count > MAX_CHUNK_CHAR_LENGTH
|
||||
or curr_line == EOF
|
||||
):
|
||||
# Too many lines, return 10 lines in this chunk, and then
|
||||
# continue reading the file.
|
||||
yield lines or None
|
||||
|
||||
lines = []
|
||||
chunk_char_count = 0
|
||||
|
||||
# Read next line
|
||||
curr_line = f.readline()
|
||||
|
||||
# `readline` will return
|
||||
# - '' for EOF
|
||||
# - '\n' for an empty line in the file
|
||||
if curr_line != EOF:
|
||||
# Add line to current chunk
|
||||
lines.append(curr_line)
|
||||
chunk_char_count += len(curr_line)
|
||||
else:
|
||||
# If EOF is reached sleep for 1s before continuing
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def parse_and_validate_request(
|
||||
req: Request, request_type: dataclass
|
||||
) -> Union[dataclass, Response]:
|
||||
"""Parse request and cast to request type.
|
||||
|
||||
Remove keys with value None to allow newer client versions with new optional fields
|
||||
to work with older servers.
|
||||
|
||||
If parsing failed, return a Response object with status 400 and stacktrace instead.
|
||||
|
||||
Args:
|
||||
req: aiohttp request object.
|
||||
request_type: dataclass type to cast request to.
|
||||
|
||||
Returns:
|
||||
Parsed request object or Response object with status 400 and stacktrace.
|
||||
"""
|
||||
import aiohttp
|
||||
|
||||
json_data = strip_keys_with_value_none(await req.json())
|
||||
try:
|
||||
return validate_request_type(json_data, request_type)
|
||||
except Exception as e:
|
||||
logger.info(f"Got invalid request type: {e}")
|
||||
return Response(
|
||||
text=traceback.format_exc(),
|
||||
status=aiohttp.web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
|
||||
async def get_driver_jobs(
|
||||
gcs_client: GcsClient,
|
||||
job_or_submission_id: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
) -> Tuple[Dict[str, JobDetails], Dict[str, DriverInfo]]:
|
||||
"""Returns a tuple of dictionaries related to drivers.
|
||||
|
||||
The first dictionary contains all driver jobs and is keyed by the job's id.
|
||||
The second dictionary contains drivers that belong to submission jobs.
|
||||
It's keyed by the submission job's submission id.
|
||||
Only the last driver of a submission job is returned.
|
||||
|
||||
An optional job_or_submission_id filter can be provided to only return
|
||||
jobs with the job id or submission id.
|
||||
"""
|
||||
job_infos = await gcs_client.async_get_all_job_info(
|
||||
job_or_submission_id=job_or_submission_id,
|
||||
skip_submission_job_info_field=True,
|
||||
skip_is_running_tasks_field=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
# Sort jobs from GCS to follow convention of returning only last driver
|
||||
# of submission job.
|
||||
sorted_job_infos = sorted(
|
||||
job_infos.values(), key=lambda job_table_entry: job_table_entry.job_id.hex()
|
||||
)
|
||||
|
||||
jobs = {}
|
||||
submission_job_drivers = {}
|
||||
for job_table_entry in sorted_job_infos:
|
||||
if job_table_entry.config.ray_namespace.startswith(
|
||||
RAY_INTERNAL_NAMESPACE_PREFIX
|
||||
):
|
||||
# Skip jobs in any _ray_internal_ namespace
|
||||
continue
|
||||
job_id = job_table_entry.job_id.hex()
|
||||
metadata = dict(job_table_entry.config.metadata)
|
||||
job_submission_id = metadata.get(JOB_ID_METADATA_KEY)
|
||||
if not job_submission_id:
|
||||
driver = DriverInfo(
|
||||
id=job_id,
|
||||
node_ip_address=job_table_entry.driver_address.ip_address,
|
||||
pid=str(job_table_entry.driver_pid),
|
||||
)
|
||||
job = JobDetails(
|
||||
job_id=job_id,
|
||||
type=JobType.DRIVER,
|
||||
status=JobStatus.SUCCEEDED
|
||||
if job_table_entry.is_dead
|
||||
else JobStatus.RUNNING,
|
||||
entrypoint=job_table_entry.entrypoint,
|
||||
start_time=job_table_entry.start_time,
|
||||
end_time=job_table_entry.end_time,
|
||||
metadata=metadata,
|
||||
runtime_env=RuntimeEnv.deserialize(
|
||||
job_table_entry.config.runtime_env_info.serialized_runtime_env
|
||||
).to_dict(),
|
||||
driver_info=driver,
|
||||
)
|
||||
jobs[job_id] = job
|
||||
else:
|
||||
driver = DriverInfo(
|
||||
id=job_id,
|
||||
node_ip_address=job_table_entry.driver_address.ip_address,
|
||||
pid=str(job_table_entry.driver_pid),
|
||||
)
|
||||
submission_job_drivers[job_submission_id] = driver
|
||||
|
||||
return jobs, submission_job_drivers
|
||||
|
||||
|
||||
async def find_job_by_ids(
|
||||
gcs_client: GcsClient,
|
||||
job_info_client: JobInfoStorageClient,
|
||||
job_or_submission_id: str,
|
||||
) -> Optional[JobDetails]:
|
||||
"""
|
||||
Attempts to find the job with a given submission_id or job id.
|
||||
"""
|
||||
# First try to find by job_id
|
||||
driver_jobs, submission_job_drivers = await get_driver_jobs(
|
||||
gcs_client, job_or_submission_id=job_or_submission_id
|
||||
)
|
||||
job = driver_jobs.get(job_or_submission_id)
|
||||
if job:
|
||||
return job
|
||||
# Try to find a driver with the given id
|
||||
submission_id = next(
|
||||
(
|
||||
id
|
||||
for id, driver in submission_job_drivers.items()
|
||||
if driver.id == job_or_submission_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if not submission_id:
|
||||
# If we didn't find a driver with the given id,
|
||||
# then lets try to search for a submission with given id
|
||||
submission_id = job_or_submission_id
|
||||
|
||||
job_info = await job_info_client.get_info(submission_id)
|
||||
if job_info:
|
||||
driver = submission_job_drivers.get(submission_id)
|
||||
job = JobDetails(
|
||||
**dataclasses.asdict(job_info),
|
||||
submission_id=submission_id,
|
||||
job_id=driver.id if driver else None,
|
||||
driver_info=driver,
|
||||
type=JobType.SUBMISSION,
|
||||
)
|
||||
return job
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def find_jobs_by_job_ids(
|
||||
gcs_client: GcsClient,
|
||||
job_info_client: JobInfoStorageClient,
|
||||
job_ids: List[str],
|
||||
) -> Dict[str, JobDetails]:
|
||||
"""
|
||||
Returns a dictionary of submission jobs with the given job ids, keyed by the job id.
|
||||
|
||||
This only accepts job ids and not submission ids.
|
||||
"""
|
||||
driver_jobs, submission_job_drivers = await get_driver_jobs(gcs_client)
|
||||
|
||||
# Filter down to the request job_ids
|
||||
driver_jobs = {key: job for key, job in driver_jobs.items() if key in job_ids}
|
||||
submission_job_drivers = {
|
||||
key: job for key, job in submission_job_drivers.items() if job.id in job_ids
|
||||
}
|
||||
|
||||
# Fetch job details for each job
|
||||
job_submission_ids = submission_job_drivers.keys()
|
||||
job_infos = await asyncio.gather(
|
||||
*[
|
||||
job_info_client.get_info(submission_id)
|
||||
for submission_id in job_submission_ids
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
**driver_jobs,
|
||||
**{
|
||||
submission_job_drivers.get(submission_id).id: JobDetails(
|
||||
**dataclasses.asdict(job_info),
|
||||
submission_id=submission_id,
|
||||
job_id=submission_job_drivers.get(submission_id).id,
|
||||
driver_info=submission_job_drivers.get(submission_id),
|
||||
type=JobType.SUBMISSION,
|
||||
)
|
||||
for job_info, submission_id in zip(job_infos, job_submission_ids)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def fast_tail_last_n_lines(
|
||||
path: str,
|
||||
num_lines: int,
|
||||
max_chars: int,
|
||||
block_size: int = 8192,
|
||||
) -> str:
|
||||
"""Return the last ``num_lines`` lines from a large log file efficiently.
|
||||
|
||||
This function avoids scanning the entire file. It seeks to the end of
|
||||
the file and reads backwards in fixed-size blocks until enough lines are
|
||||
collected. This is much faster for large files compared to using
|
||||
``file_tail_iterator()``, which performs a full sequential scan.
|
||||
|
||||
Args:
|
||||
path: The file path to read.
|
||||
num_lines: Number of lines to return.
|
||||
max_chars: Maximum number of characters in the returned string.
|
||||
block_size: Read size for each backward block.
|
||||
|
||||
Returns:
|
||||
A string containing at most ``num_lines`` of the last lines in the file,
|
||||
truncated to ``max_chars`` characters.
|
||||
"""
|
||||
if num_lines < 0:
|
||||
raise ValueError(f"num_lines must be non-negative, got {num_lines}")
|
||||
if num_lines == 0:
|
||||
return ""
|
||||
if max_chars < 0:
|
||||
raise ValueError(f"max_chars must be non-negative, got {max_chars}")
|
||||
if max_chars == 0:
|
||||
return ""
|
||||
if block_size <= 0:
|
||||
raise ValueError(f"block_size must be positive, got {block_size}")
|
||||
|
||||
logger.debug(
|
||||
f"Start reading log file {path} with num_lines={num_lines} max_chars={max_chars} block_size={block_size}"
|
||||
)
|
||||
with open(path, "rb") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
file_size = f.tell()
|
||||
if file_size == 0:
|
||||
return ""
|
||||
|
||||
chunks = []
|
||||
position = file_size
|
||||
newlines_found = 0
|
||||
|
||||
# We read backwards in chunks until we have enough newlines for num_lines.
|
||||
# We may need one more newline to capture the content before the first newline.
|
||||
while position > 0 and newlines_found < num_lines + 1:
|
||||
read_size = min(block_size, position)
|
||||
position -= read_size
|
||||
f.seek(position)
|
||||
|
||||
chunk = f.read(read_size)
|
||||
newlines_found += chunk.count(b"\n")
|
||||
chunks.insert(0, chunk)
|
||||
|
||||
buffer = b"".join(chunks)
|
||||
lines = buffer.decode("utf-8", errors="replace").splitlines(keepends=True)
|
||||
|
||||
if len(lines) <= num_lines:
|
||||
result = "".join(lines)
|
||||
else:
|
||||
result = "".join(lines[-num_lines:])
|
||||
|
||||
return result[-max_chars:]
|
||||
@@ -0,0 +1,405 @@
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
import grpc
|
||||
|
||||
import ray.dashboard.modules.log.log_consts as log_consts
|
||||
import ray.dashboard.modules.log.log_utils as log_utils
|
||||
import ray.dashboard.optional_utils as dashboard_optional_utils
|
||||
import ray.dashboard.utils as dashboard_utils
|
||||
from ray._private.ray_constants import env_integer
|
||||
from ray.core.generated import reporter_pb2, reporter_pb2_grpc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
routes = dashboard_optional_utils.DashboardAgentRouteTable
|
||||
|
||||
# 64 KB
|
||||
BLOCK_SIZE = 1 << 16
|
||||
|
||||
# Keep-alive interval for reading the file
|
||||
DEFAULT_KEEP_ALIVE_INTERVAL_SEC = 1
|
||||
|
||||
RAY_DASHBOARD_LOG_TASK_LOG_SEARCH_MAX_WORKER_COUNT = env_integer(
|
||||
"RAY_DASHBOARD_LOG_TASK_LOG_SEARCH_MAX_WORKER_COUNT", default=2
|
||||
)
|
||||
|
||||
|
||||
def find_offset_of_content_in_file(
|
||||
file: io.BufferedIOBase, content: bytes, start_offset: int = 0
|
||||
) -> int:
|
||||
"""Find the offset of the first occurrence of content in a file.
|
||||
|
||||
Args:
|
||||
file: File object
|
||||
content: Content to find
|
||||
start_offset: Start offset to read from, inclusive.
|
||||
|
||||
Returns:
|
||||
Offset of the first occurrence of content in a file.
|
||||
"""
|
||||
logger.debug(f"Finding offset of content {content} in file")
|
||||
file.seek(start_offset, io.SEEK_SET) # move file pointer to start of file
|
||||
offset = start_offset
|
||||
while True:
|
||||
# Read in block
|
||||
block_data = file.read(BLOCK_SIZE)
|
||||
if block_data == b"":
|
||||
# Stop reading
|
||||
return -1
|
||||
# Find the offset of the first occurrence of content in the block
|
||||
block_offset = block_data.find(content)
|
||||
if block_offset != -1:
|
||||
# Found the offset in the block
|
||||
return offset + block_offset
|
||||
# Continue reading
|
||||
offset += len(block_data)
|
||||
|
||||
|
||||
def find_end_offset_file(file: io.BufferedIOBase) -> int:
|
||||
"""
|
||||
Find the offset of the end of a file without changing the file pointer.
|
||||
|
||||
Args:
|
||||
file: File object
|
||||
|
||||
Returns:
|
||||
Offset of the end of a file.
|
||||
"""
|
||||
old_pos = file.tell() # store old position
|
||||
file.seek(0, io.SEEK_END) # move file pointer to end of file
|
||||
end = file.tell() # return end of file offset
|
||||
file.seek(old_pos, io.SEEK_SET)
|
||||
return end
|
||||
|
||||
|
||||
def find_end_offset_next_n_lines_from_offset(
|
||||
file: io.BufferedIOBase, start_offset: int, n: int
|
||||
) -> int:
|
||||
"""
|
||||
Find the offsets of next n lines from a start offset.
|
||||
|
||||
Args:
|
||||
file: File object
|
||||
start_offset: Start offset to read from, inclusive.
|
||||
n: Number of lines to find.
|
||||
|
||||
Returns:
|
||||
Offset of the end of the next n line (exclusive)
|
||||
"""
|
||||
file.seek(start_offset) # move file pointer to start offset
|
||||
end_offset = None
|
||||
for _ in range(n): # loop until we find n lines or reach end of file
|
||||
line = file.readline() # read a line and consume new line character
|
||||
if not line: # end of file
|
||||
break
|
||||
end_offset = file.tell() # end offset.
|
||||
|
||||
logger.debug(f"Found next {n} lines from {start_offset} offset")
|
||||
return (
|
||||
end_offset if end_offset is not None else file.seek(0, io.SEEK_END)
|
||||
) # return last line offset or end of file offset if no lines found
|
||||
|
||||
|
||||
def find_start_offset_last_n_lines_from_offset(
|
||||
file: io.BufferedIOBase, offset: int, n: int, block_size: int = BLOCK_SIZE
|
||||
) -> int:
|
||||
"""
|
||||
Find the offset of the beginning of the line of the last X lines from an offset.
|
||||
|
||||
Args:
|
||||
file: File object
|
||||
offset: Start offset from which to find last X lines, -1 means end of file.
|
||||
The offset is exclusive, i.e. data at the offset is not included
|
||||
in the result.
|
||||
n: Number of lines to find
|
||||
block_size: Block size to read from file
|
||||
|
||||
Returns:
|
||||
Offset of the beginning of the line of the last X lines from a start offset.
|
||||
"""
|
||||
logger.debug(f"Finding last {n} lines from {offset} offset")
|
||||
if offset == -1:
|
||||
offset = file.seek(0, io.SEEK_END) # move file pointer to end of file
|
||||
else:
|
||||
file.seek(offset, io.SEEK_SET) # move file pointer to start offset
|
||||
|
||||
if n == 0:
|
||||
return offset
|
||||
nbytes_from_end = (
|
||||
0 # Number of bytes that should be tailed from the end of the file
|
||||
)
|
||||
# Non new line terminating offset, adjust the line count and treat the non-newline
|
||||
# terminated line as the last line. e.g. line 1\nline 2
|
||||
file.seek(max(0, offset - 1), os.SEEK_SET)
|
||||
if file.read(1) != b"\n":
|
||||
n -= 1
|
||||
|
||||
# Remaining number of lines to tail
|
||||
lines_more = n
|
||||
read_offset = max(0, offset - block_size)
|
||||
# So that we know how much to read on the last block (the block 0)
|
||||
prev_offset = offset
|
||||
|
||||
while lines_more >= 0 and read_offset >= 0:
|
||||
# Seek to the current block start
|
||||
file.seek(read_offset, 0)
|
||||
# Read the current block (or less than block) data
|
||||
block_data = file.read(min(block_size, prev_offset - read_offset))
|
||||
num_lines = block_data.count(b"\n")
|
||||
if num_lines > lines_more:
|
||||
# This is the last block to read.
|
||||
# Need to find the offset of exact number of lines to tail
|
||||
# in the block.
|
||||
# Use `split` here to split away the extra lines, i.e.
|
||||
# first `num_lines - lines_more` lines.
|
||||
lines = block_data.split(b"\n", num_lines - lines_more)
|
||||
# Added the len of those lines that at the end of the block.
|
||||
nbytes_from_end += len(lines[-1])
|
||||
break
|
||||
|
||||
# Need to read more blocks.
|
||||
lines_more -= num_lines
|
||||
nbytes_from_end += len(block_data)
|
||||
|
||||
if read_offset == 0:
|
||||
# We have read all blocks (since the start)
|
||||
break
|
||||
# Continuing with the previous block
|
||||
prev_offset = read_offset
|
||||
read_offset = max(0, read_offset - block_size)
|
||||
|
||||
offset_read_start = offset - nbytes_from_end
|
||||
assert (
|
||||
offset_read_start >= 0
|
||||
), f"Read start offset({offset_read_start}) should be non-negative"
|
||||
return offset_read_start
|
||||
|
||||
|
||||
async def _stream_log_in_chunk(
|
||||
context: grpc.aio.ServicerContext,
|
||||
file: io.BufferedIOBase,
|
||||
start_offset: int,
|
||||
end_offset: int = -1,
|
||||
keep_alive_interval_sec: int = -1,
|
||||
block_size: int = BLOCK_SIZE,
|
||||
) -> AsyncIterator[reporter_pb2.StreamLogReply]:
|
||||
"""Streaming log in chunk from start to end offset.
|
||||
|
||||
Stream binary file content in chunks from start offset to an end
|
||||
offset if provided, else to the end of the file.
|
||||
|
||||
Args:
|
||||
context: gRPC server side context
|
||||
file: Binary file to stream
|
||||
start_offset: File offset where streaming starts
|
||||
end_offset: If -1, implying streaming til the EOF.
|
||||
keep_alive_interval_sec: Duration for which streaming will be
|
||||
retried when reaching the file end, -1 means no retry.
|
||||
block_size: Number of bytes per chunk, exposed for testing
|
||||
|
||||
Yields:
|
||||
reporter_pb2.StreamLogReply: Successive chunks of the file contents,
|
||||
one per block.
|
||||
"""
|
||||
assert "b" in file.mode, "Only binary file is supported."
|
||||
assert not (
|
||||
keep_alive_interval_sec >= 0 and end_offset != -1
|
||||
), "Keep-alive is not allowed when specifying an end offset"
|
||||
|
||||
file.seek(start_offset, 0)
|
||||
cur_offset = start_offset
|
||||
|
||||
# Until gRPC is done
|
||||
while not context.done():
|
||||
# Read in block
|
||||
if end_offset != -1:
|
||||
to_read = min(end_offset - cur_offset, block_size)
|
||||
else:
|
||||
to_read = block_size
|
||||
|
||||
bytes = file.read(to_read)
|
||||
|
||||
if bytes == b"":
|
||||
# Stop reading
|
||||
if keep_alive_interval_sec >= 0:
|
||||
await asyncio.sleep(keep_alive_interval_sec)
|
||||
# Try reading again
|
||||
continue
|
||||
|
||||
# Have read the entire file, done
|
||||
break
|
||||
logger.debug(f"Sending {len(bytes)} bytes at {cur_offset}")
|
||||
yield reporter_pb2.StreamLogReply(data=bytes)
|
||||
|
||||
# Have read the requested section [start_offset, end_offset), done
|
||||
cur_offset += len(bytes)
|
||||
if end_offset != -1 and cur_offset >= end_offset:
|
||||
break
|
||||
|
||||
|
||||
class LogAgent(dashboard_utils.DashboardAgentModule):
|
||||
def __init__(self, dashboard_agent):
|
||||
super().__init__(dashboard_agent)
|
||||
log_utils.register_mimetypes()
|
||||
routes.static("/logs", self._dashboard_agent.log_dir, show_index=True)
|
||||
|
||||
async def run(self, server):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def is_minimal_module():
|
||||
return False
|
||||
|
||||
|
||||
_task_log_search_worker_pool = concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=RAY_DASHBOARD_LOG_TASK_LOG_SEARCH_MAX_WORKER_COUNT
|
||||
)
|
||||
|
||||
|
||||
class LogAgentV1Grpc(dashboard_utils.DashboardAgentModule):
|
||||
def __init__(self, dashboard_agent):
|
||||
super().__init__(dashboard_agent)
|
||||
|
||||
async def run(self, server):
|
||||
if server:
|
||||
reporter_pb2_grpc.add_LogServiceServicer_to_server(self, server)
|
||||
|
||||
@property
|
||||
def node_id(self) -> Optional[str]:
|
||||
return self._dashboard_agent.get_node_id()
|
||||
|
||||
@staticmethod
|
||||
def is_minimal_module():
|
||||
# Dashboard is only available with non-minimal install now.
|
||||
return False
|
||||
|
||||
async def ListLogs(self, request, context):
|
||||
"""
|
||||
Lists all files in the active Ray logs directory.
|
||||
|
||||
Part of `LogService` gRPC.
|
||||
|
||||
NOTE: These RPCs are used by state_head.py, not log_head.py
|
||||
"""
|
||||
path = Path(self._dashboard_agent.log_dir)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Could not find log dir at path: {self._dashboard_agent.log_dir}"
|
||||
"It is unexpected. Please report an issue to Ray Github."
|
||||
)
|
||||
log_files = []
|
||||
for p in path.glob(request.glob_filter):
|
||||
log_files.append(str(p.relative_to(path)) + ("/" if p.is_dir() else ""))
|
||||
return reporter_pb2.ListLogsReply(log_files=log_files)
|
||||
|
||||
@classmethod
|
||||
def _resolve_filename(cls, root_log_dir: Path, filename: str) -> Path:
|
||||
"""
|
||||
Resolves the file path relative to the root log directory.
|
||||
|
||||
Args:
|
||||
root_log_dir: Root log directory.
|
||||
filename: File path relative to the root log directory.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the file path is invalid.
|
||||
|
||||
Returns:
|
||||
The absolute file path resolved from the root log directory.
|
||||
"""
|
||||
if not Path(filename).is_absolute():
|
||||
filepath = root_log_dir / filename
|
||||
else:
|
||||
filepath = Path(filename)
|
||||
|
||||
# We want to allow relative paths that include symlinks pointing outside of the
|
||||
# `root_log_dir`, so use `os.path.abspath` instead of `Path.resolve()` because
|
||||
# `os.path.abspath` does not resolve symlinks.
|
||||
filepath = Path(os.path.abspath(filepath))
|
||||
|
||||
if not filepath.is_file():
|
||||
raise FileNotFoundError(f"A file is not found at: {filepath}")
|
||||
|
||||
try:
|
||||
filepath.relative_to(root_log_dir)
|
||||
except ValueError as e:
|
||||
raise FileNotFoundError(f"{filepath} not in {root_log_dir}: {e}")
|
||||
|
||||
# Fully resolve the path before returning (including following symlinks).
|
||||
return filepath.resolve()
|
||||
|
||||
async def StreamLog(self, request, context):
|
||||
"""
|
||||
Streams the log in real time starting from `request.lines` number of lines from
|
||||
the end of the file if `request.keep_alive == True`. Else, it terminates the
|
||||
stream once there are no more bytes to read from the log file.
|
||||
|
||||
Part of `LogService` gRPC.
|
||||
|
||||
NOTE: These RPCs are used by state_head.py, not log_head.py
|
||||
"""
|
||||
# NOTE: If the client side connection is closed, this handler will
|
||||
# be automatically terminated.
|
||||
lines = request.lines if request.lines else 1000
|
||||
|
||||
try:
|
||||
filepath = self._resolve_filename(
|
||||
Path(self._dashboard_agent.log_dir), request.log_file_name
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
await context.send_initial_metadata([[log_consts.LOG_GRPC_ERROR, str(e)]])
|
||||
else:
|
||||
with open(filepath, "rb") as f:
|
||||
await context.send_initial_metadata([])
|
||||
|
||||
# Default stream entire file
|
||||
start_offset = (
|
||||
request.start_offset if request.HasField("start_offset") else 0
|
||||
)
|
||||
end_offset = (
|
||||
request.end_offset
|
||||
if request.HasField("end_offset")
|
||||
else find_end_offset_file(f)
|
||||
)
|
||||
|
||||
if lines != -1:
|
||||
# If specified tail line number, cap the start offset
|
||||
# with lines from the current end offset
|
||||
start_offset = max(
|
||||
find_start_offset_last_n_lines_from_offset(
|
||||
f, offset=end_offset, n=lines
|
||||
),
|
||||
start_offset,
|
||||
)
|
||||
|
||||
# If keep alive: following the log every 'interval'
|
||||
keep_alive_interval_sec = -1
|
||||
if request.keep_alive:
|
||||
keep_alive_interval_sec = (
|
||||
request.interval
|
||||
if request.interval
|
||||
else DEFAULT_KEEP_ALIVE_INTERVAL_SEC
|
||||
)
|
||||
|
||||
# When following (keep_alive), it will read beyond the end
|
||||
end_offset = -1
|
||||
|
||||
logger.info(
|
||||
f"Tailing logs from {start_offset} to {end_offset} for "
|
||||
f"lines={lines}, with keep_alive={keep_alive_interval_sec}"
|
||||
)
|
||||
|
||||
# Read and send the file data in chunk
|
||||
async for chunk_res in _stream_log_in_chunk(
|
||||
context=context,
|
||||
file=f,
|
||||
start_offset=start_offset,
|
||||
end_offset=end_offset,
|
||||
keep_alive_interval_sec=keep_alive_interval_sec,
|
||||
):
|
||||
yield chunk_res
|
||||
@@ -0,0 +1,8 @@
|
||||
MIME_TYPES = {
|
||||
"text/plain": [".err", ".out", ".log"],
|
||||
}
|
||||
|
||||
LOG_GRPC_ERROR = "log_grpc_status"
|
||||
|
||||
# 10 seconds
|
||||
GRPC_TIMEOUT = 10
|
||||
@@ -0,0 +1,478 @@
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from typing import AsyncIterable, Awaitable, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from ray import ActorID, NodeID, WorkerID
|
||||
from ray._common.pydantic_compat import BaseModel
|
||||
from ray.core.generated.gcs_pb2 import ActorTableData
|
||||
from ray.dashboard.modules.job.common import JOB_LOGS_PATH_TEMPLATE
|
||||
from ray.util.state.common import (
|
||||
DEFAULT_RPC_TIMEOUT,
|
||||
GetLogOptions,
|
||||
protobuf_to_task_state_dict,
|
||||
)
|
||||
from ray.util.state.state_manager import StateDataSourceClient
|
||||
|
||||
if BaseModel is None:
|
||||
raise ModuleNotFoundError("Please install pydantic via `pip install pydantic`.")
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WORKER_LOG_PATTERN = re.compile(r".*worker-([0-9a-f]+)-([0-9a-f]+)-(\d+).(out|err)")
|
||||
|
||||
|
||||
class ResolvedStreamFileInfo(BaseModel):
|
||||
# The node id where the log file is located.
|
||||
node_id: str
|
||||
|
||||
# The log file path name. Could be a relative path relative to ray's logging folder,
|
||||
# or an absolute path.
|
||||
filename: str
|
||||
|
||||
# Start offset in the log file to stream from. None to indicate beginning of
|
||||
# the file, or determined by last tail lines.
|
||||
start_offset: Optional[int] = None
|
||||
|
||||
# End offset in the log file to stream from. None to indicate the end of the file.
|
||||
end_offset: Optional[int] = None
|
||||
|
||||
|
||||
class LogsManager:
|
||||
def __init__(self, data_source_client: StateDataSourceClient):
|
||||
self.client = data_source_client
|
||||
|
||||
@property
|
||||
def data_source_client(self) -> StateDataSourceClient:
|
||||
return self.client
|
||||
|
||||
async def ip_to_node_id(self, node_ip: Optional[str]) -> Optional[str]:
|
||||
"""Resolve the node id in hex from a given node ip.
|
||||
|
||||
Args:
|
||||
node_ip: The node ip.
|
||||
|
||||
Returns:
|
||||
node_id if there's a node id that matches the given node ip and is alive.
|
||||
None otherwise.
|
||||
"""
|
||||
return await self.client.ip_to_node_id(node_ip)
|
||||
|
||||
async def list_logs(
|
||||
self, node_id: str, timeout: int, glob_filter: str = "*"
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Return a list of log files on a given node id filtered by the glob.
|
||||
|
||||
Args:
|
||||
node_id: The node id where log files present.
|
||||
timeout: The timeout of the API.
|
||||
glob_filter: The glob filter to filter out log files.
|
||||
|
||||
Returns:
|
||||
Dictionary of {component_name -> list of log files}
|
||||
|
||||
Raises:
|
||||
ValueError: If a source is unresponsive.
|
||||
"""
|
||||
reply = await self.client.list_logs(node_id, glob_filter, timeout=timeout)
|
||||
return self._categorize_log_files(reply.log_files)
|
||||
|
||||
async def stream_logs(
|
||||
self,
|
||||
options: GetLogOptions,
|
||||
get_actor_fn: Callable[[ActorID], Awaitable[Optional[ActorTableData]]],
|
||||
) -> AsyncIterable[bytes]:
|
||||
"""Generate a stream of logs in bytes.
|
||||
|
||||
Args:
|
||||
options: The option for streaming logs.
|
||||
get_actor_fn: Callable used to resolve actor metadata when the
|
||||
request targets an actor's logs.
|
||||
|
||||
Yields:
|
||||
bytes: Successive chunks of log content streamed from the agent.
|
||||
"""
|
||||
node_id = options.node_id
|
||||
if node_id is None:
|
||||
node_id = await self.ip_to_node_id(options.node_ip)
|
||||
|
||||
res = await self.resolve_filename(
|
||||
node_id=node_id,
|
||||
log_filename=options.filename,
|
||||
actor_id=options.actor_id,
|
||||
task_id=options.task_id,
|
||||
attempt_number=options.attempt_number,
|
||||
pid=options.pid,
|
||||
get_actor_fn=get_actor_fn,
|
||||
timeout=options.timeout,
|
||||
suffix=options.suffix,
|
||||
submission_id=options.submission_id,
|
||||
)
|
||||
|
||||
keep_alive = options.media_type == "stream"
|
||||
stream = await self.client.stream_log(
|
||||
node_id=res.node_id,
|
||||
log_file_name=res.filename,
|
||||
keep_alive=keep_alive,
|
||||
lines=options.lines,
|
||||
interval=options.interval,
|
||||
# If we keepalive logs connection, we shouldn't have timeout
|
||||
# otherwise the stream will be terminated forcefully
|
||||
# after the deadline is expired.
|
||||
timeout=options.timeout if not keep_alive else None,
|
||||
start_offset=res.start_offset,
|
||||
end_offset=res.end_offset,
|
||||
)
|
||||
|
||||
async for streamed_log in stream:
|
||||
yield streamed_log.data
|
||||
|
||||
async def _resolve_job_filename(self, sub_job_id: str) -> Tuple[str, str]:
|
||||
"""Return the log file name and node id for a given job submission id.
|
||||
|
||||
Args:
|
||||
sub_job_id: The job submission id.
|
||||
|
||||
Returns:
|
||||
The log file name and node id.
|
||||
"""
|
||||
job_infos = await self.client.get_job_info(timeout=DEFAULT_RPC_TIMEOUT)
|
||||
target_job = None
|
||||
for job_info in job_infos:
|
||||
if job_info.submission_id == sub_job_id:
|
||||
target_job = job_info
|
||||
break
|
||||
if target_job is None:
|
||||
logger.info(f"Submission job ID {sub_job_id} not found.")
|
||||
return None, None
|
||||
|
||||
node_id = job_info.driver_node_id
|
||||
if node_id is None:
|
||||
raise ValueError(
|
||||
f"Job {sub_job_id} has no driver node id info. "
|
||||
"This is likely a bug. Please file an issue."
|
||||
)
|
||||
|
||||
log_filename = JOB_LOGS_PATH_TEMPLATE.format(submission_id=sub_job_id)
|
||||
return node_id, log_filename
|
||||
|
||||
async def _resolve_worker_file(
|
||||
self,
|
||||
node_id_hex: str,
|
||||
worker_id_hex: Optional[str],
|
||||
pid: Optional[int],
|
||||
suffix: str,
|
||||
timeout: int,
|
||||
) -> Optional[str]:
|
||||
"""Resolve worker log file."""
|
||||
if worker_id_hex is not None and pid is not None:
|
||||
raise ValueError(
|
||||
f"Only one of worker id({worker_id_hex}) or pid({pid}) should be"
|
||||
"provided."
|
||||
)
|
||||
|
||||
if worker_id_hex is not None:
|
||||
log_files = await self.list_logs(
|
||||
node_id_hex, timeout, glob_filter=f"*{worker_id_hex}*{suffix}"
|
||||
)
|
||||
else:
|
||||
log_files = await self.list_logs(
|
||||
node_id_hex, timeout, glob_filter=f"*{pid}*{suffix}"
|
||||
)
|
||||
|
||||
# Find matching worker logs.
|
||||
for filename in [*log_files["worker_out"], *log_files["worker_err"]]:
|
||||
# Worker logs look like worker-[worker_id]-[job_id]-[pid].out
|
||||
if worker_id_hex is not None:
|
||||
worker_id_from_filename = WORKER_LOG_PATTERN.match(filename).group(1)
|
||||
if worker_id_from_filename == worker_id_hex:
|
||||
return filename
|
||||
else:
|
||||
worker_pid_from_filename = int(
|
||||
WORKER_LOG_PATTERN.match(filename).group(3)
|
||||
)
|
||||
if worker_pid_from_filename == pid:
|
||||
return filename
|
||||
return None
|
||||
|
||||
async def _resolve_actor_filename(
|
||||
self,
|
||||
actor_id: ActorID,
|
||||
get_actor_fn: Callable[[ActorID], Awaitable[Optional[ActorTableData]]],
|
||||
suffix: str,
|
||||
timeout: int,
|
||||
):
|
||||
"""Resolve actor log file.
|
||||
|
||||
Args:
|
||||
actor_id: The actor id.
|
||||
get_actor_fn: The function to get actor information.
|
||||
suffix: The suffix of the log file.
|
||||
timeout: Timeout in seconds.
|
||||
|
||||
Returns:
|
||||
The log file name and node id.
|
||||
|
||||
Raises:
|
||||
ValueError: If actor data is not found or get_actor_fn is not provided.
|
||||
"""
|
||||
if get_actor_fn is None:
|
||||
raise ValueError("get_actor_fn needs to be specified for actor_id")
|
||||
|
||||
actor_data = await get_actor_fn(actor_id)
|
||||
if actor_data is None:
|
||||
raise ValueError(f"Actor ID {actor_id} not found.")
|
||||
# TODO(sang): Only the latest worker id can be obtained from
|
||||
# actor information now. That means, if actors are restarted,
|
||||
# there's no way for us to get the past worker ids.
|
||||
worker_id_binary = actor_data.address.worker_id
|
||||
if not worker_id_binary:
|
||||
raise ValueError(
|
||||
f"Worker ID for Actor ID {actor_id} not found. "
|
||||
"Actor is not scheduled yet."
|
||||
)
|
||||
worker_id = WorkerID(worker_id_binary)
|
||||
node_id_binary = actor_data.address.node_id
|
||||
if not node_id_binary:
|
||||
raise ValueError(
|
||||
f"Node ID for Actor ID {actor_id} not found. "
|
||||
"Actor is not scheduled yet."
|
||||
)
|
||||
node_id = NodeID(node_id_binary)
|
||||
log_filename = await self._resolve_worker_file(
|
||||
node_id_hex=node_id.hex(),
|
||||
worker_id_hex=worker_id.hex(),
|
||||
pid=None,
|
||||
suffix=suffix,
|
||||
timeout=timeout,
|
||||
)
|
||||
return node_id.hex(), log_filename
|
||||
|
||||
async def _resolve_task_filename(
|
||||
self, task_id: str, attempt_number: int, suffix: str, timeout: int
|
||||
):
|
||||
"""Resolve log file for a task.
|
||||
|
||||
Args:
|
||||
task_id: The task id.
|
||||
attempt_number: The attempt number.
|
||||
suffix: The suffix of the log file, e.g. out or err.
|
||||
timeout: Timeout in seconds.
|
||||
|
||||
Returns:
|
||||
The log file name, node id, the start and end offsets of the
|
||||
corresponding task log in the file.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the log file is not found.
|
||||
ValueError: If the suffix is not out or err.
|
||||
"""
|
||||
log_filename = None
|
||||
node_id = None
|
||||
start_offset = None
|
||||
end_offset = None
|
||||
|
||||
if suffix not in ["out", "err"]:
|
||||
raise ValueError(f"Suffix {suffix} is not supported.")
|
||||
|
||||
reply = await self.client.get_all_task_info(
|
||||
filters=[("task_id", "=", task_id)], timeout=timeout
|
||||
)
|
||||
# Check if the task is found.
|
||||
if len(reply.events_by_task) == 0:
|
||||
raise FileNotFoundError(
|
||||
f"Could not find log file for task: {task_id}"
|
||||
f" (attempt {attempt_number}) with suffix: {suffix}"
|
||||
)
|
||||
task_event = None
|
||||
for t in reply.events_by_task:
|
||||
if t.attempt_number == attempt_number:
|
||||
task_event = t
|
||||
break
|
||||
|
||||
if task_event is None:
|
||||
raise FileNotFoundError(
|
||||
"Could not find log file for task attempt:"
|
||||
f"{task_id}({attempt_number})"
|
||||
)
|
||||
# Get the worker id and node id.
|
||||
task = protobuf_to_task_state_dict(task_event)
|
||||
|
||||
worker_id = task.get("worker_id", None)
|
||||
node_id = task.get("node_id", None)
|
||||
log_info = task.get("task_log_info", None)
|
||||
actor_id = task.get("actor_id", None)
|
||||
|
||||
if node_id is None:
|
||||
raise FileNotFoundError(
|
||||
"Could not find log file for task attempt."
|
||||
f"{task_id}({attempt_number}) due to missing node info."
|
||||
)
|
||||
|
||||
if log_info is None and actor_id is not None:
|
||||
# This is a concurrent actor task. The logs will be interleaved.
|
||||
# So we return the log file of the actor instead.
|
||||
raise FileNotFoundError(
|
||||
f"For actor task, please query actor log for "
|
||||
f"actor({actor_id}): e.g. ray logs actor --id {actor_id} . Or "
|
||||
"set RAY_ENABLE_RECORD_ACTOR_TASK_LOGGING=1 in actor's runtime env "
|
||||
"or when starting the cluster. Recording actor task's log could be "
|
||||
"expensive, so Ray turns it off by default."
|
||||
)
|
||||
elif log_info is None:
|
||||
raise FileNotFoundError(
|
||||
"Could not find log file for task attempt:"
|
||||
f"{task_id}({attempt_number})."
|
||||
f"Worker id = {worker_id}, node id = {node_id},"
|
||||
f"log_info = {log_info}"
|
||||
)
|
||||
|
||||
filename_key = "stdout_file" if suffix == "out" else "stderr_file"
|
||||
log_filename = log_info.get(filename_key, None)
|
||||
if log_filename is None:
|
||||
raise FileNotFoundError(
|
||||
f"Missing log filename info in {log_info} for task {task_id},"
|
||||
f"attempt {attempt_number}"
|
||||
)
|
||||
|
||||
start_offset = log_info.get(f"std{suffix}_start", None)
|
||||
end_offset = log_info.get(f"std{suffix}_end", None)
|
||||
|
||||
return node_id, log_filename, start_offset, end_offset
|
||||
|
||||
async def resolve_filename(
|
||||
self,
|
||||
*,
|
||||
node_id: Optional[str] = None,
|
||||
log_filename: Optional[str] = None,
|
||||
actor_id: Optional[str] = None,
|
||||
task_id: Optional[str] = None,
|
||||
attempt_number: Optional[int] = None,
|
||||
pid: Optional[str] = None,
|
||||
get_actor_fn: Optional[
|
||||
Callable[[ActorID], Awaitable[Optional[ActorTableData]]]
|
||||
] = None,
|
||||
timeout: int = DEFAULT_RPC_TIMEOUT,
|
||||
suffix: str = "out",
|
||||
submission_id: Optional[str] = None,
|
||||
) -> ResolvedStreamFileInfo:
|
||||
"""Return the file name given all options.
|
||||
|
||||
Args:
|
||||
node_id: The node's id from which logs are resolved.
|
||||
log_filename: Filename of the log file.
|
||||
actor_id: Id of the actor that generates the log file.
|
||||
task_id: Id of the task that generates the log file.
|
||||
attempt_number: The attempt number of the task. Used with
|
||||
``task_id`` to disambiguate retries.
|
||||
pid: Id of the worker process that generates the log file.
|
||||
get_actor_fn: Callback to get the actor's data by id.
|
||||
timeout: Timeout for the gRPC to listing logs on the node
|
||||
specified by `node_id`.
|
||||
suffix: Log suffix if no `log_filename` is provided, when
|
||||
resolving by other ids'. Default to "out".
|
||||
submission_id: The submission id for a submission job.
|
||||
|
||||
Returns:
|
||||
A ``ResolvedStreamFileInfo`` describing the resolved node id,
|
||||
filename, and (optional) byte offsets to stream.
|
||||
"""
|
||||
start_offset = None
|
||||
end_offset = None
|
||||
if suffix not in ["out", "err"]:
|
||||
raise ValueError(f"Suffix {suffix} is not supported. ")
|
||||
|
||||
# TODO(rickyx): We should make sure we do some sort of checking on the log
|
||||
# filename
|
||||
if actor_id:
|
||||
node_id, log_filename = await self._resolve_actor_filename(
|
||||
ActorID.from_hex(actor_id), get_actor_fn, suffix, timeout
|
||||
)
|
||||
|
||||
elif task_id:
|
||||
(
|
||||
node_id,
|
||||
log_filename,
|
||||
start_offset,
|
||||
end_offset,
|
||||
) = await self._resolve_task_filename(
|
||||
task_id, attempt_number, suffix, timeout
|
||||
)
|
||||
|
||||
elif submission_id:
|
||||
node_id, log_filename = await self._resolve_job_filename(submission_id)
|
||||
|
||||
elif pid:
|
||||
if node_id is None:
|
||||
raise ValueError(
|
||||
"Node id needs to be specified for resolving"
|
||||
f" filenames of pid {pid}"
|
||||
)
|
||||
log_filename = await self._resolve_worker_file(
|
||||
node_id_hex=node_id,
|
||||
worker_id_hex=None,
|
||||
pid=pid,
|
||||
suffix=suffix,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
if log_filename is None:
|
||||
raise FileNotFoundError(
|
||||
"Could not find a log file. Please make sure the given "
|
||||
"option exists in the cluster.\n"
|
||||
f"\tnode_id: {node_id}\n"
|
||||
f"\tfilename: {log_filename}\n"
|
||||
f"\tactor_id: {actor_id}\n"
|
||||
f"\ttask_id: {task_id}\n"
|
||||
f"\tpid: {pid}\n"
|
||||
f"\tsuffix: {suffix}\n"
|
||||
f"\tsubmission_id: {submission_id}\n"
|
||||
f"\tattempt_number: {attempt_number}\n"
|
||||
)
|
||||
|
||||
res = ResolvedStreamFileInfo(
|
||||
node_id=node_id,
|
||||
filename=log_filename,
|
||||
start_offset=start_offset,
|
||||
end_offset=end_offset,
|
||||
)
|
||||
logger.info(f"Resolved log file: {res}")
|
||||
return res
|
||||
|
||||
def _categorize_log_files(self, log_files: List[str]) -> Dict[str, List[str]]:
|
||||
"""Categorize the given log files after filterieng them out using a given glob.
|
||||
|
||||
Args:
|
||||
log_files: Filenames returned from a ``list_logs`` query, already
|
||||
filtered by the caller's glob.
|
||||
|
||||
Returns:
|
||||
Dictionary of {component_name -> list of log files}
|
||||
"""
|
||||
result = defaultdict(list)
|
||||
for log_file in log_files:
|
||||
if "worker" in log_file and (log_file.endswith(".out")):
|
||||
result["worker_out"].append(log_file)
|
||||
elif "worker" in log_file and (log_file.endswith(".err")):
|
||||
result["worker_err"].append(log_file)
|
||||
elif "core-worker" in log_file and log_file.endswith(".log"):
|
||||
result["core_worker"].append(log_file)
|
||||
elif "core-driver" in log_file and log_file.endswith(".log"):
|
||||
result["driver"].append(log_file)
|
||||
elif "raylet." in log_file:
|
||||
result["raylet"].append(log_file)
|
||||
elif "gcs_server." in log_file:
|
||||
result["gcs_server"].append(log_file)
|
||||
elif "log_monitor" in log_file:
|
||||
result["internal"].append(log_file)
|
||||
elif "monitor" in log_file:
|
||||
result["autoscaler"].append(log_file)
|
||||
elif "agent." in log_file:
|
||||
result["agent"].append(log_file)
|
||||
elif "dashboard." in log_file:
|
||||
result["dashboard"].append(log_file)
|
||||
else:
|
||||
result["internal"].append(log_file)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,9 @@
|
||||
import mimetypes
|
||||
|
||||
import ray.dashboard.modules.log.log_consts as log_consts
|
||||
|
||||
|
||||
def register_mimetypes():
|
||||
for _type, extensions in log_consts.MIME_TYPES.items():
|
||||
for ext in extensions:
|
||||
mimetypes.add_type(_type, ext)
|
||||
@@ -0,0 +1,615 @@
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
@dataclass
|
||||
class GridPos:
|
||||
x: int
|
||||
y: int
|
||||
w: int
|
||||
h: int
|
||||
|
||||
|
||||
GRAPH_TARGET_TEMPLATE = {
|
||||
"exemplar": True,
|
||||
"expr": "0",
|
||||
"interval": "",
|
||||
"legendFormat": "",
|
||||
"queryType": "randomWalk",
|
||||
"refId": "A",
|
||||
}
|
||||
|
||||
HEATMAP_TARGET_TEMPLATE = {
|
||||
"format": "heatmap",
|
||||
"fullMetaSearch": False,
|
||||
"includeNullMetadata": True,
|
||||
"instant": False,
|
||||
"range": True,
|
||||
"useBackend": False,
|
||||
}
|
||||
|
||||
HISTOGRAM_BAR_CHART_TARGET_TEMPLATE = {
|
||||
"exemplar": True,
|
||||
"format": "heatmap",
|
||||
"fullMetaSearch": False,
|
||||
"includeNullMetadata": True,
|
||||
"instant": True,
|
||||
"range": False,
|
||||
"useBackend": False,
|
||||
}
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class TargetTemplate(Enum):
|
||||
GRAPH = GRAPH_TARGET_TEMPLATE
|
||||
HEATMAP = HEATMAP_TARGET_TEMPLATE
|
||||
HISTOGRAM_BAR_CHART = HISTOGRAM_BAR_CHART_TARGET_TEMPLATE
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
@dataclass
|
||||
class Target:
|
||||
"""Defines a Grafana target (time-series query) within a panel.
|
||||
|
||||
A panel will have one or more targets. By default, all targets are rendered as
|
||||
stacked area charts, with the exception of legend="MAX", which is rendered as
|
||||
a blue dotted line. Any legend="FINISHED|FAILED|DEAD|REMOVED" series will also be
|
||||
rendered hidden by default.
|
||||
|
||||
Attributes:
|
||||
expr: The prometheus query to evaluate.
|
||||
legend: The legend string to format for each time-series.
|
||||
"""
|
||||
|
||||
expr: str
|
||||
legend: str
|
||||
template: Optional[TargetTemplate] = TargetTemplate.GRAPH
|
||||
|
||||
|
||||
HEATMAP_TEMPLATE = {
|
||||
"datasource": r"${datasource}",
|
||||
"description": "<Description>",
|
||||
"fieldConfig": {"defaults": {}, "overrides": []},
|
||||
"id": 12,
|
||||
"options": {
|
||||
"calculate": False,
|
||||
"cellGap": 1,
|
||||
"cellValues": {"unit": "none"},
|
||||
"color": {
|
||||
"exponent": 0.5,
|
||||
"fill": "dark-orange",
|
||||
"min": 0,
|
||||
"mode": "scheme",
|
||||
"reverse": False,
|
||||
"scale": "exponential",
|
||||
"scheme": "Spectral",
|
||||
"steps": 64,
|
||||
},
|
||||
"exemplars": {"color": "rgba(255,0,255,0.7)"},
|
||||
"filterValues": {"le": 1e-9},
|
||||
"legend": {"show": True},
|
||||
"rowsFrame": {"layout": "auto", "value": "Value"},
|
||||
"tooltip": {"mode": "single", "showColorScale": False, "yHistogram": True},
|
||||
"yAxis": {"axisPlacement": "left", "reverse": False, "unit": "none"},
|
||||
},
|
||||
"pluginVersion": "11.2.0",
|
||||
"targets": [],
|
||||
"title": "<Title>",
|
||||
"type": "heatmap",
|
||||
"yaxes": [
|
||||
{
|
||||
"$$hashKey": "object:628",
|
||||
"format": "units",
|
||||
"label": "",
|
||||
"logBase": 1,
|
||||
"max": None,
|
||||
"min": "0",
|
||||
"show": True,
|
||||
},
|
||||
{
|
||||
"$$hashKey": "object:629",
|
||||
"format": "short",
|
||||
"label": None,
|
||||
"logBase": 1,
|
||||
"max": None,
|
||||
"min": None,
|
||||
"show": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
GRAPH_PANEL_TEMPLATE = {
|
||||
"aliasColors": {},
|
||||
"bars": False,
|
||||
"dashLength": 10,
|
||||
"dashes": False,
|
||||
"datasource": r"${datasource}",
|
||||
"description": "<Description>",
|
||||
"fieldConfig": {"defaults": {}, "overrides": []},
|
||||
# Setting height and width is important here to ensure the default panel has some size to it.
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
|
||||
"fill": 10,
|
||||
"fillGradient": 0,
|
||||
"hiddenSeries": False,
|
||||
"id": 26,
|
||||
"legend": {
|
||||
"alignAsTable": True,
|
||||
"avg": True,
|
||||
"current": True,
|
||||
"hideEmpty": False,
|
||||
"hideZero": True,
|
||||
"max": True,
|
||||
"min": False,
|
||||
"rightSide": False,
|
||||
"show": True,
|
||||
"sort": "current",
|
||||
"sortDesc": True,
|
||||
"total": False,
|
||||
"values": True,
|
||||
},
|
||||
"lines": True,
|
||||
"linewidth": 1,
|
||||
"nullPointMode": None,
|
||||
"options": {"alertThreshold": True},
|
||||
"percentage": False,
|
||||
"pluginVersion": "7.5.17",
|
||||
"pointradius": 2,
|
||||
"points": False,
|
||||
"renderer": "flot",
|
||||
# These series overrides are necessary to make the "MAX" and "MAX + PENDING" dotted lines
|
||||
# instead of stacked filled areas.
|
||||
"seriesOverrides": [
|
||||
{
|
||||
"$$hashKey": "object:2987",
|
||||
"alias": "MAX",
|
||||
"dashes": True,
|
||||
"color": "#1F60C4",
|
||||
"fill": 0,
|
||||
"stack": False,
|
||||
},
|
||||
{
|
||||
"$$hashKey": "object:78",
|
||||
"alias": "/FINISHED|FAILED|DEAD|REMOVED|Failed Nodes:/",
|
||||
"hiddenSeries": True,
|
||||
},
|
||||
{
|
||||
"$$hashKey": "object:2987",
|
||||
"alias": "MAX + PENDING",
|
||||
"dashes": True,
|
||||
"color": "#777777",
|
||||
"fill": 0,
|
||||
"stack": False,
|
||||
},
|
||||
{
|
||||
"alias": "/Container/",
|
||||
"hiddenSeries": True,
|
||||
},
|
||||
{
|
||||
"alias": "Container MAX",
|
||||
"dashes": True,
|
||||
"color": "#73BF69",
|
||||
"fill": 0,
|
||||
"stack": False,
|
||||
"hiddenSeries": True,
|
||||
},
|
||||
],
|
||||
"spaceLength": 10,
|
||||
"stack": True,
|
||||
"steppedLine": False,
|
||||
"targets": [],
|
||||
"thresholds": [],
|
||||
"timeFrom": None,
|
||||
"timeRegions": [],
|
||||
"timeShift": None,
|
||||
"title": "<Title>",
|
||||
"tooltip": {"shared": True, "sort": 0, "value_type": "individual"},
|
||||
"type": "graph",
|
||||
"xaxis": {
|
||||
"buckets": None,
|
||||
"mode": "time",
|
||||
"name": None,
|
||||
"show": True,
|
||||
"values": [],
|
||||
},
|
||||
"yaxes": [
|
||||
{
|
||||
"$$hashKey": "object:628",
|
||||
"format": "units",
|
||||
"label": "",
|
||||
"logBase": 1,
|
||||
"max": None,
|
||||
"min": "0",
|
||||
"show": True,
|
||||
},
|
||||
{
|
||||
"$$hashKey": "object:629",
|
||||
"format": "short",
|
||||
"label": None,
|
||||
"logBase": 1,
|
||||
"max": None,
|
||||
"min": None,
|
||||
"show": True,
|
||||
},
|
||||
],
|
||||
"yaxis": {"align": False, "alignLevel": None},
|
||||
}
|
||||
|
||||
STAT_PANEL_TEMPLATE = {
|
||||
"datasource": r"${datasource}",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "thresholds"},
|
||||
"mappings": [],
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "percentage",
|
||||
"steps": [
|
||||
{"color": "super-light-yellow", "value": None},
|
||||
{"color": "super-light-green", "value": 50},
|
||||
{"color": "green", "value": 100},
|
||||
],
|
||||
},
|
||||
"unit": "short",
|
||||
},
|
||||
"overrides": [],
|
||||
},
|
||||
"id": 78,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": False},
|
||||
"text": {},
|
||||
"textMode": "auto",
|
||||
},
|
||||
"pluginVersion": "7.5.17",
|
||||
"targets": [],
|
||||
"timeFrom": None,
|
||||
"timeShift": None,
|
||||
"title": "<Title>",
|
||||
"type": "stat",
|
||||
"yaxes": [
|
||||
{
|
||||
"$$hashKey": "object:628",
|
||||
"format": "Tokens",
|
||||
"label": "",
|
||||
"logBase": 1,
|
||||
"max": None,
|
||||
"min": "0",
|
||||
"show": True,
|
||||
},
|
||||
{
|
||||
"$$hashKey": "object:629",
|
||||
"format": "short",
|
||||
"label": None,
|
||||
"logBase": 1,
|
||||
"max": None,
|
||||
"min": None,
|
||||
"show": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
GAUGE_PANEL_TEMPLATE = {
|
||||
"datasource": r"${datasource}",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "continuous-YlBl"},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "percentage",
|
||||
"steps": [{"color": "rgb(230, 230, 230)", "value": None}],
|
||||
},
|
||||
"unit": "short",
|
||||
},
|
||||
"overrides": [],
|
||||
},
|
||||
"id": 10,
|
||||
"options": {
|
||||
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": False},
|
||||
"showThresholdLabels": False,
|
||||
"showThresholdMarkers": False,
|
||||
"text": {"titleSize": 12},
|
||||
},
|
||||
"pluginVersion": "7.5.17",
|
||||
"targets": [],
|
||||
"title": "<Title>",
|
||||
"type": "gauge",
|
||||
"yaxes": [
|
||||
{
|
||||
"$$hashKey": "object:628",
|
||||
"format": "Tokens",
|
||||
"label": "",
|
||||
"logBase": 1,
|
||||
"max": None,
|
||||
"min": "0",
|
||||
"show": True,
|
||||
},
|
||||
{
|
||||
"$$hashKey": "object:629",
|
||||
"format": "short",
|
||||
"label": None,
|
||||
"logBase": 1,
|
||||
"max": None,
|
||||
"min": None,
|
||||
"show": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
PIE_CHART_TEMPLATE = {
|
||||
"datasource": r"${datasource}",
|
||||
"description": "<Description>",
|
||||
"fieldConfig": {"defaults": {}, "overrides": []},
|
||||
"id": 26,
|
||||
"options": {
|
||||
"displayLabels": [],
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"values": ["percent", "value"],
|
||||
},
|
||||
"pieType": "pie",
|
||||
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": False},
|
||||
"text": {},
|
||||
},
|
||||
"pluginVersion": "7.5.17",
|
||||
"targets": [],
|
||||
"timeFrom": None,
|
||||
"timeShift": None,
|
||||
"title": "<Title>",
|
||||
"type": "piechart",
|
||||
"yaxes": [
|
||||
{
|
||||
"$$hashKey": "object:628",
|
||||
"format": "units",
|
||||
"label": "",
|
||||
"logBase": 1,
|
||||
"max": None,
|
||||
"min": "0",
|
||||
"show": True,
|
||||
},
|
||||
{
|
||||
"$$hashKey": "object:629",
|
||||
"format": "short",
|
||||
"label": None,
|
||||
"logBase": 1,
|
||||
"max": None,
|
||||
"min": None,
|
||||
"show": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
BAR_CHART_PANEL_TEMPLATE = {
|
||||
"aliasColors": {},
|
||||
"dashLength": 10,
|
||||
"dashes": False,
|
||||
"datasource": r"${datasource}",
|
||||
"description": "<Description>",
|
||||
"fieldConfig": {"defaults": {}, "overrides": []},
|
||||
# Setting height and width is important here to ensure the default panel has some size to it.
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
|
||||
"hiddenSeries": False,
|
||||
"id": 26,
|
||||
"legend": {
|
||||
"alignAsTable": True,
|
||||
"avg": False,
|
||||
"current": True,
|
||||
"hideEmpty": False,
|
||||
"hideZero": True,
|
||||
"max": False,
|
||||
"min": False,
|
||||
"rightSide": False,
|
||||
"show": False,
|
||||
"sort": "current",
|
||||
"sortDesc": True,
|
||||
"total": False,
|
||||
"values": True,
|
||||
},
|
||||
"lines": False,
|
||||
"linewidth": 1,
|
||||
"bars": True,
|
||||
"nullPointMode": None,
|
||||
"options": {
|
||||
"alertThreshold": True,
|
||||
"legend": {
|
||||
"showLegend": False,
|
||||
"displayMode": "table",
|
||||
"placement": "bottom",
|
||||
},
|
||||
},
|
||||
"percentage": False,
|
||||
"pluginVersion": "7.5.17",
|
||||
"pointradius": 2,
|
||||
"points": False,
|
||||
"renderer": "flot",
|
||||
"spaceLength": 10,
|
||||
"stack": True,
|
||||
"steppedLine": False,
|
||||
"targets": [],
|
||||
"thresholds": [],
|
||||
"timeFrom": None,
|
||||
"timeRegions": [],
|
||||
"timeShift": None,
|
||||
"title": "<Title>",
|
||||
"tooltip": {"shared": True, "sort": 0, "value_type": "individual"},
|
||||
"type": "graph",
|
||||
"xaxis": {
|
||||
"buckets": None,
|
||||
"mode": "series",
|
||||
"name": None,
|
||||
"show": True,
|
||||
"values": [
|
||||
"total",
|
||||
],
|
||||
},
|
||||
"yaxes": [
|
||||
{
|
||||
"$$hashKey": "object:628",
|
||||
"format": "units",
|
||||
"label": "",
|
||||
"logBase": 1,
|
||||
"max": None,
|
||||
"min": "0",
|
||||
"show": True,
|
||||
},
|
||||
{
|
||||
"$$hashKey": "object:629",
|
||||
"format": "short",
|
||||
"label": None,
|
||||
"logBase": 1,
|
||||
"max": None,
|
||||
"min": None,
|
||||
"show": True,
|
||||
},
|
||||
],
|
||||
"yaxis": {"align": False, "alignLevel": None},
|
||||
}
|
||||
|
||||
TABLE_PANEL_TEMPLATE = {
|
||||
"datasource": r"${datasource}",
|
||||
"description": "<Description>",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {
|
||||
"align": "auto",
|
||||
"displayMode": "auto",
|
||||
},
|
||||
"mappings": [],
|
||||
},
|
||||
"overrides": [],
|
||||
},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
|
||||
"id": 26,
|
||||
"options": {
|
||||
"showHeader": True,
|
||||
"footer": {
|
||||
"show": False,
|
||||
"reducer": ["sum"],
|
||||
"fields": "",
|
||||
},
|
||||
},
|
||||
"pluginVersion": "7.5.17",
|
||||
"targets": [],
|
||||
"title": "<Title>",
|
||||
"type": "table",
|
||||
"transformations": [{"id": "organize", "options": {}}],
|
||||
}
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class PanelTemplate(Enum):
|
||||
GRAPH = GRAPH_PANEL_TEMPLATE
|
||||
HEATMAP = HEATMAP_TEMPLATE
|
||||
PIE_CHART = PIE_CHART_TEMPLATE
|
||||
STAT = STAT_PANEL_TEMPLATE
|
||||
GAUGE = GAUGE_PANEL_TEMPLATE
|
||||
BAR_CHART = BAR_CHART_PANEL_TEMPLATE
|
||||
TABLE = TABLE_PANEL_TEMPLATE
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
@dataclass
|
||||
class Panel:
|
||||
"""Defines a Grafana panel (graph) for the Ray dashboard page.
|
||||
|
||||
A panel contains one or more targets (time-series queries).
|
||||
|
||||
Attributes:
|
||||
title: Short name of the graph. Note: please keep this in sync with the title
|
||||
definitions in Metrics.tsx.
|
||||
description: Long form description of the graph.
|
||||
id: Integer id used to reference the graph from Metrics.tsx.
|
||||
unit: The unit to display on the y-axis of the graph.
|
||||
targets: List of query targets.
|
||||
fill: Whether or not the graph will be filled by a color.
|
||||
stack: Whether or not the lines in the graph will be stacked.
|
||||
linewidth: Width of the lines in the graph.
|
||||
grid_pos: Grid position of the panel.
|
||||
template: The panel template to use.
|
||||
hideXAxis: Whether to hide the x-axis.
|
||||
thresholds: Custom threshold configuration for stat/gauge panels.
|
||||
Example: [
|
||||
{"color": "green", "value": None},
|
||||
{"color": "yellow", "value": 70},
|
||||
{"color": "red", "value": 90}
|
||||
]
|
||||
value_mappings: Value mappings for displaying text instead of numbers.
|
||||
Used for status panels.
|
||||
color_mode: Color mode for stat panels ("value", "background", "none").
|
||||
legend_mode: Legend display mode ("list", "table", "hidden").
|
||||
min_val: Minimum value for gauge/graph y-axis.
|
||||
max_val: Maximum value for gauge/graph y-axis.
|
||||
reduce_calc: Reduce calculation method for stat panels (default: "lastNotNull").
|
||||
heatmap_color_scheme: Color scheme for heatmap panels (e.g., "Spectral", "RdYlGn").
|
||||
heatmap_color_reverse: Whether to reverse the heatmap color scheme.
|
||||
heatmap_yaxis_label: Y-axis label for heatmap panels.
|
||||
"""
|
||||
|
||||
title: str
|
||||
description: str
|
||||
id: int
|
||||
unit: str
|
||||
targets: List[Target]
|
||||
fill: int = 10
|
||||
stack: bool = True
|
||||
linewidth: int = 1
|
||||
grid_pos: Optional[GridPos] = None
|
||||
template: Optional[PanelTemplate] = PanelTemplate.GRAPH
|
||||
hideXAxis: bool = False
|
||||
thresholds: Optional[List[Dict[str, Any]]] = None
|
||||
value_mappings: Optional[List[Dict[str, Any]]] = None
|
||||
color_mode: Optional[str] = None
|
||||
legend_mode: Optional[str] = None
|
||||
min_val: Optional[float] = None
|
||||
max_val: Optional[float] = None
|
||||
reduce_calc: Optional[str] = None
|
||||
heatmap_color_scheme: Optional[str] = None
|
||||
heatmap_color_reverse: Optional[bool] = None
|
||||
heatmap_yaxis_label: Optional[str] = None
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
@dataclass
|
||||
class Row:
|
||||
"""Defines a Grafana row that can contain multiple panels.
|
||||
|
||||
Attributes:
|
||||
title: The title of the row
|
||||
panels: List of panels contained in this row
|
||||
collapsed: Whether the row should be collapsed by default
|
||||
"""
|
||||
|
||||
title: str
|
||||
id: int
|
||||
panels: List[Panel]
|
||||
collapsed: bool = False
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
@dataclass
|
||||
class DashboardConfig:
|
||||
# This dashboard name is an internal key used to determine which env vars
|
||||
# to check for customization
|
||||
name: str
|
||||
# The uid of the dashboard json if not overridden by a user
|
||||
default_uid: str
|
||||
# The global filters applied to all graphs in this dashboard. Users can
|
||||
# add additional global_filters on top of this.
|
||||
standard_global_filters: List[str]
|
||||
base_json_file_name: str
|
||||
# Panels can be specified in `panels`, or nested within `rows`.
|
||||
# If both are specified, panels will be rendered before rows.
|
||||
panels: List[Panel] = field(default_factory=list)
|
||||
rows: List[Row] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.panels and not self.rows:
|
||||
raise ValueError("At least one of panels or rows must be specified")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": "-- Grafana --",
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"gnetId": null,
|
||||
"graphTooltip": 1,
|
||||
"iteration": 1667344411089,
|
||||
"links": [],
|
||||
"panels": [],
|
||||
"refresh": false,
|
||||
"schemaVersion": 27,
|
||||
"style": "dark",
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {
|
||||
"selected": false
|
||||
},
|
||||
"description": "Filter queries of a specific Prometheus type.",
|
||||
"hide": 2,
|
||||
"includeAll": false,
|
||||
"multi": false,
|
||||
"name": "datasource",
|
||||
"options": [],
|
||||
"query": "prometheus",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "datasource"
|
||||
},
|
||||
{
|
||||
"allValue": ".+",
|
||||
"current": {
|
||||
"selected": false
|
||||
},
|
||||
"datasource": "${datasource}",
|
||||
"definition": "query_result(count by (SessionName)(last_over_time(ray_data_output_bytes{{{global_filters}}}[$__range])))",
|
||||
"description": "Filter queries to specific ray sessions.",
|
||||
"error": null,
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": null,
|
||||
"multi": false,
|
||||
"name": "SessionName",
|
||||
"options": [],
|
||||
"query": {
|
||||
"query": "query_result(count by (SessionName)(last_over_time(ray_data_output_bytes{{{global_filters}}}[$__range])))",
|
||||
"refId": "StandardVariableQuery"
|
||||
},
|
||||
"refresh": 2,
|
||||
"regex": "{SessionName=\"(?<value>.*)\".*",
|
||||
"skipUrlSync": false,
|
||||
"sort": 2,
|
||||
"tagValuesQuery": "",
|
||||
"tags": [],
|
||||
"tagsQuery": "",
|
||||
"type": "query",
|
||||
"useTags": false
|
||||
},
|
||||
{
|
||||
"allValue": ".+",
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": [
|
||||
"All"
|
||||
],
|
||||
"value": [
|
||||
"$__all"
|
||||
]
|
||||
},
|
||||
"datasource": "${datasource}",
|
||||
"definition": "query_result(count by (dataset)(last_over_time(ray_data_output_bytes{{SessionName=~\"$SessionName\",{global_filters}}}[$__range])))",
|
||||
"description": null,
|
||||
"error": null,
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": null,
|
||||
"multi": true,
|
||||
"name": "DatasetID",
|
||||
"options": [],
|
||||
"query": {
|
||||
"query": "query_result(count by (dataset)(last_over_time(ray_data_output_bytes{{SessionName=~\"$SessionName\",{global_filters}}}[$__range])))",
|
||||
"refId": "Prometheus-Dataset-Variable-Query"
|
||||
},
|
||||
"refresh": 2,
|
||||
"regex": "{dataset=\"(?<value>.*)\".*",
|
||||
"skipUrlSync": false,
|
||||
"sort": 0,
|
||||
"tagValuesQuery": "",
|
||||
"tags": [],
|
||||
"tagsQuery": "",
|
||||
"type": "query",
|
||||
"useTags": false
|
||||
},
|
||||
{
|
||||
"allValue": ".+",
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": [
|
||||
"All"
|
||||
],
|
||||
"value": [
|
||||
"$__all"
|
||||
]
|
||||
},
|
||||
"datasource": "${datasource}",
|
||||
"definition": "query_result(count by (operator)(last_over_time(ray_data_output_bytes{{SessionName=~\"$SessionName\",{global_filters}}}[$__range])))",
|
||||
"description": null,
|
||||
"error": null,
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": null,
|
||||
"multi": true,
|
||||
"name": "Operator",
|
||||
"options": [],
|
||||
"query": {
|
||||
"query": "query_result(count by (operator)(last_over_time(ray_data_output_bytes{{SessionName=~\"$SessionName\",{global_filters}}}[$__range])))",
|
||||
"refId": "Prometheus-Dataset-Variable-Query"
|
||||
},
|
||||
"refresh": 2,
|
||||
"regex": "{operator=\"(?<value>.*)\".*",
|
||||
"skipUrlSync": false,
|
||||
"sort": 0,
|
||||
"tagValuesQuery": "",
|
||||
"tags": [],
|
||||
"tagsQuery": "",
|
||||
"type": "query",
|
||||
"useTags": false
|
||||
},
|
||||
{
|
||||
"allValue": ".*",
|
||||
"current": {
|
||||
"selected": false
|
||||
},
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_node_network_receive_speed{{{global_filters}}}, ray_io_cluster)",
|
||||
"description": "Filter queries to specific Ray clusters for KubeRay. When ingesting metrics across multiple ray clusters, the ray_io_cluster label should be set per cluster. For KubeRay users, this is done automatically with Prometheus PodMonitor.",
|
||||
"error": null,
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": null,
|
||||
"multi": false,
|
||||
"name": "Cluster",
|
||||
"options": [],
|
||||
"query": {
|
||||
"query": "label_values(ray_node_network_receive_speed{{{global_filters}}}, ray_io_cluster)",
|
||||
"refId": "StandardVariableQuery"
|
||||
},
|
||||
"refresh": 2,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"sort": 2,
|
||||
"tagValuesQuery": "",
|
||||
"tags": [],
|
||||
"tagsQuery": "",
|
||||
"type": "query",
|
||||
"useTags": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"rayMeta": ["excludesSystemRoutes"],
|
||||
"time": {
|
||||
"from": "now-30m",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "Data Dashboard",
|
||||
"uid": "rayDataDashboard",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
# ruff: noqa: E501
|
||||
"""Ray Data LLM Dashboard panels for vLLM metrics visualization.
|
||||
|
||||
This dashboard provides visibility into vLLM engine metrics when using Ray Data LLM,
|
||||
including latency metrics (TTFT, TPOT, E2E), throughput, cache utilization,
|
||||
prefix cache hit rate, and scheduler state.
|
||||
"""
|
||||
|
||||
from ray.dashboard.modules.metrics.dashboards.common import (
|
||||
DashboardConfig,
|
||||
GridPos,
|
||||
Panel,
|
||||
Target,
|
||||
)
|
||||
|
||||
DATA_LLM_GRAFANA_PANELS = [
|
||||
Panel(
|
||||
id=1,
|
||||
title="vLLM: Token Throughput",
|
||||
description="Number of tokens processed per second",
|
||||
unit="tokens/s",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum by (model_name, WorkerId) (rate(ray_vllm_request_prompt_tokens_sum{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval]))',
|
||||
legend="Prompt Tokens/Sec - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='sum by (model_name, WorkerId) (rate(ray_vllm_generation_tokens_total{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval]))',
|
||||
legend="Generation Tokens/Sec - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 0, 12, 8),
|
||||
),
|
||||
Panel(
|
||||
id=2,
|
||||
title="vLLM: Time Per Output Token Latency",
|
||||
description="P50, P90, P95, P99, and Mean TPOT latency",
|
||||
unit="s",
|
||||
targets=[
|
||||
Target(
|
||||
expr='histogram_quantile(0.99, sum by(le, model_name, WorkerId) (rate(ray_vllm_request_time_per_output_token_seconds_bucket{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="P99 - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.95, sum by(le, model_name, WorkerId) (rate(ray_vllm_request_time_per_output_token_seconds_bucket{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="P95 - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.9, sum by(le, model_name, WorkerId) (rate(ray_vllm_request_time_per_output_token_seconds_bucket{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="P90 - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.5, sum by(le, model_name, WorkerId) (rate(ray_vllm_request_time_per_output_token_seconds_bucket{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="P50 - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='(sum by(model_name, WorkerId) (rate(ray_vllm_request_time_per_output_token_seconds_sum{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval]))\n/\nsum by(model_name, WorkerId) (rate(ray_vllm_request_time_per_output_token_seconds_count{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="Mean - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(12, 0, 12, 8),
|
||||
),
|
||||
Panel(
|
||||
id=3,
|
||||
title="vLLM: Cache Utilization",
|
||||
description="Percentage of used KV cache blocks by vLLM.",
|
||||
unit="percentunit",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum by (WorkerId) (ray_vllm_kv_cache_usage_perc{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}})',
|
||||
legend="GPU Cache Usage - {{WorkerId}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 8, 12, 8),
|
||||
),
|
||||
Panel(
|
||||
id=4,
|
||||
title="vLLM: Prefix Cache Hit Rate",
|
||||
description="Percentage of prefix cache hits. Higher is better for repeated prefixes.",
|
||||
unit="percent",
|
||||
targets=[
|
||||
Target(
|
||||
expr='max(100 * (sum by (WorkerId) (rate(ray_vllm_prefix_cache_hits_total{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])) / sum by (WorkerId) (rate(ray_vllm_prefix_cache_queries_total{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval]))))',
|
||||
legend="Max Hit Rate",
|
||||
),
|
||||
Target(
|
||||
expr='min(100 * (sum by (WorkerId) (rate(ray_vllm_prefix_cache_hits_total{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])) / sum by (WorkerId) (rate(ray_vllm_prefix_cache_queries_total{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval]))))',
|
||||
legend="Min Hit Rate",
|
||||
),
|
||||
Target(
|
||||
expr='100 * (sum by (WorkerId) (rate(ray_vllm_prefix_cache_hits_total{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])) / sum by (WorkerId) (rate(ray_vllm_prefix_cache_queries_total{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="Hit Rate: worker {{WorkerId}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=1,
|
||||
stack=False,
|
||||
grid_pos=GridPos(12, 8, 12, 8),
|
||||
),
|
||||
Panel(
|
||||
id=5,
|
||||
title="vLLM: Time To First Token Latency",
|
||||
description="P50, P90, P95, P99, and Mean TTFT latency",
|
||||
unit="s",
|
||||
targets=[
|
||||
Target(
|
||||
expr='(sum by(model_name, WorkerId) (rate(ray_vllm_time_to_first_token_seconds_sum{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval]))\n/\nsum by(model_name, WorkerId) (rate(ray_vllm_time_to_first_token_seconds_count{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="Average - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.5, sum by(le, model_name, WorkerId)(rate(ray_vllm_time_to_first_token_seconds_bucket{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="P50 - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.9, sum by(le, model_name, WorkerId)(rate(ray_vllm_time_to_first_token_seconds_bucket{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="P90 - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.95, sum by(le, model_name, WorkerId) (rate(ray_vllm_time_to_first_token_seconds_bucket{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="P95 - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.99, sum by(le, model_name, WorkerId)(rate(ray_vllm_time_to_first_token_seconds_bucket{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="P99 - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 16, 12, 8),
|
||||
),
|
||||
Panel(
|
||||
id=6,
|
||||
title="vLLM: E2E Request Latency",
|
||||
description="End-to-end request latency from arrival to completion.",
|
||||
unit="s",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum by(model_name, WorkerId) (rate(ray_vllm_e2e_request_latency_seconds_sum{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval]))\n/\nsum by(model_name, WorkerId) (rate(ray_vllm_e2e_request_latency_seconds_count{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval]))',
|
||||
legend="Average - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.5, sum by(le, model_name, WorkerId) (rate(ray_vllm_e2e_request_latency_seconds_bucket{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="P50 - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.9, sum by(le, model_name, WorkerId) (rate(ray_vllm_e2e_request_latency_seconds_bucket{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="P90 - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.95, sum by(le, model_name, WorkerId) (rate(ray_vllm_e2e_request_latency_seconds_bucket{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="P95 - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.99, sum by(le, model_name, WorkerId) (rate(ray_vllm_e2e_request_latency_seconds_bucket{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="P99 - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(12, 16, 12, 8),
|
||||
),
|
||||
Panel(
|
||||
id=7,
|
||||
title="vLLM: Scheduler State",
|
||||
description="Number of requests in RUNNING, WAITING, and SWAPPED state",
|
||||
unit="Requests",
|
||||
targets=[
|
||||
Target(
|
||||
expr='ray_vllm_num_requests_running{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}',
|
||||
legend="Num Running - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='ray_vllm_num_requests_swapped{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}',
|
||||
legend="Num Swapped - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='ray_vllm_num_requests_waiting{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}',
|
||||
legend="Num Waiting - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 24, 12, 8),
|
||||
),
|
||||
Panel(
|
||||
id=8,
|
||||
title="vLLM: Queue Time",
|
||||
description="P50, P90, P95, P99, and Mean time requests spend waiting in the queue.",
|
||||
unit="s",
|
||||
targets=[
|
||||
Target(
|
||||
expr='(sum by(model_name, WorkerId) (rate(ray_vllm_request_queue_time_seconds_sum{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval]))\n/\nsum by(model_name, WorkerId) (rate(ray_vllm_request_queue_time_seconds_count{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="Mean - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.5, sum by(le, model_name, WorkerId) (rate(ray_vllm_request_queue_time_seconds_bucket{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="P50 - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.9, sum by(le, model_name, WorkerId) (rate(ray_vllm_request_queue_time_seconds_bucket{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="P90 - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.95, sum by(le, model_name, WorkerId) (rate(ray_vllm_request_queue_time_seconds_bucket{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="P95 - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.99, sum by(le, model_name, WorkerId) (rate(ray_vllm_request_queue_time_seconds_bucket{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="P99 - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(12, 24, 12, 8),
|
||||
),
|
||||
Panel(
|
||||
id=9,
|
||||
title="vLLM: Prompt Length",
|
||||
description="Distribution of prompt token lengths.",
|
||||
unit="short",
|
||||
targets=[
|
||||
Target(
|
||||
expr='histogram_quantile(0.5, sum by(le, model_name, WorkerId) (rate(ray_vllm_request_prompt_tokens_bucket{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="P50-{{model_name}}-{{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.90, sum by(le, model_name, WorkerId) (rate(ray_vllm_request_prompt_tokens_bucket{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="P90-{{model_name}}-{{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='(sum by(model_name, WorkerId) (rate(ray_vllm_request_prompt_tokens_sum{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval]))\n/\nsum by(model_name, WorkerId) (rate(ray_vllm_request_prompt_tokens_count{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="Average-{{model_name}}-{{WorkerId}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=1,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 32, 12, 8),
|
||||
),
|
||||
Panel(
|
||||
id=10,
|
||||
title="vLLM: Generation Length",
|
||||
description="Distribution of generated token lengths.",
|
||||
unit="short",
|
||||
targets=[
|
||||
Target(
|
||||
expr='histogram_quantile(0.50, sum by(le, model_name, WorkerId) (rate(ray_vllm_request_generation_tokens_bucket{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="P50-{{model_name}}-{{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.90, sum by(le, model_name, WorkerId) (rate(ray_vllm_request_generation_tokens_bucket{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))',
|
||||
legend="P90-{{model_name}}-{{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr=(
|
||||
'(sum by(model_name, WorkerId) (rate(ray_vllm_request_generation_tokens_sum{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))'
|
||||
"\n/\n"
|
||||
'(sum by(model_name, WorkerId) (rate(ray_vllm_request_generation_tokens_count{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval])))'
|
||||
),
|
||||
legend="Average-{{model_name}}-{{WorkerId}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=1,
|
||||
stack=False,
|
||||
grid_pos=GridPos(12, 32, 12, 8),
|
||||
),
|
||||
Panel(
|
||||
id=11,
|
||||
title="vLLM: Finish Reason",
|
||||
description="Number of finished requests by their finish reason: EOS token or max length reached.",
|
||||
unit="Requests",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum by(finished_reason, model_name, WorkerId) (increase(ray_vllm_request_success_total{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval]))',
|
||||
legend="{{finished_reason}} - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 40, 12, 8),
|
||||
),
|
||||
Panel(
|
||||
id=12,
|
||||
title="vLLM: Prefill and Decode Time",
|
||||
description="Time spent in prefill vs decode phases.",
|
||||
unit="s",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum by(model_name, WorkerId) (rate(ray_vllm_request_prefill_time_seconds_sum{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval]))',
|
||||
legend="Prefill - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
Target(
|
||||
expr='sum by(model_name, WorkerId) (rate(ray_vllm_request_decode_time_seconds_sum{{model_name=~"$vllm_model_name", WorkerId=~"$workerid", ReplicaId=""}}[$interval]))',
|
||||
legend="Decode - {{model_name}} - {{WorkerId}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(12, 40, 12, 8),
|
||||
),
|
||||
]
|
||||
|
||||
data_llm_dashboard_config = DashboardConfig(
|
||||
name="DATA_LLM",
|
||||
default_uid="rayDataLlmDashboard",
|
||||
panels=DATA_LLM_GRAFANA_PANELS,
|
||||
standard_global_filters=[],
|
||||
base_json_file_name="data_llm_grafana_dashboard_base.json",
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": "-- Grafana --",
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"gnetId": null,
|
||||
"graphTooltip": 1,
|
||||
"iteration": 1667344411089,
|
||||
"links": [],
|
||||
"panels": [],
|
||||
"refresh": false,
|
||||
"schemaVersion": 27,
|
||||
"style": "dark",
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {
|
||||
"selected": false
|
||||
},
|
||||
"description": "Filter queries of a specific Prometheus type.",
|
||||
"hide": 2,
|
||||
"includeAll": false,
|
||||
"multi": false,
|
||||
"name": "datasource",
|
||||
"options": [],
|
||||
"query": "prometheus",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "datasource"
|
||||
},
|
||||
{
|
||||
"name": "vllm_model_name",
|
||||
"label": "vLLM Model Name",
|
||||
"type": "query",
|
||||
"hide": 0,
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_vllm_request_prompt_tokens_sum{{{global_filters}}}, model_name)",
|
||||
"query": {
|
||||
"query": "label_values(ray_vllm_request_prompt_tokens_sum{{{global_filters}}}, model_name)",
|
||||
"refId": "StandardVariableQuery"
|
||||
},
|
||||
"refresh": 1,
|
||||
"includeAll": true,
|
||||
"multi": false,
|
||||
"allValue": ".*",
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": [
|
||||
"All"
|
||||
],
|
||||
"value": [
|
||||
"$__all"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "workerid",
|
||||
"label": "Worker ID",
|
||||
"type": "query",
|
||||
"hide": 0,
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_vllm_request_prompt_tokens_sum{{{global_filters}}}, WorkerId)",
|
||||
"query": {
|
||||
"query": "label_values(ray_vllm_request_prompt_tokens_sum{{{global_filters}}}, WorkerId)",
|
||||
"refId": "StandardVariableQuery"
|
||||
},
|
||||
"refresh": 1,
|
||||
"includeAll": true,
|
||||
"multi": false,
|
||||
"allValue": ".*",
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": [
|
||||
"All"
|
||||
],
|
||||
"value": [
|
||||
"$__all"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "interval",
|
||||
"label": "Interval",
|
||||
"type": "custom",
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"multi": false,
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "30s",
|
||||
"value": "30s"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "1m",
|
||||
"value": "1m"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "5m",
|
||||
"value": "5m"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "10m",
|
||||
"value": "10m"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "15m",
|
||||
"value": "15m"
|
||||
}
|
||||
],
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": "5m",
|
||||
"value": "5m"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {
|
||||
"from": "now-30m",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "Data LLM Dashboard",
|
||||
"uid": "rayDataLlmDashboard",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,797 @@
|
||||
# ruff: noqa: E501
|
||||
|
||||
from ray.dashboard.modules.metrics.dashboards.common import (
|
||||
DashboardConfig,
|
||||
Panel,
|
||||
Row,
|
||||
Target,
|
||||
)
|
||||
|
||||
"""
|
||||
Queries for autoscaler resources.
|
||||
"""
|
||||
# Note: MAX & USED resources are reported from raylet to provide the most up to date information.
|
||||
# But MAX + PENDING data is coming from the autoscaler. That said, MAX + PENDING can be
|
||||
# more outdated. it is harmless because the actual MAX will catch up with MAX + PENDING
|
||||
# eventually.
|
||||
MAX_CPUS = 'sum(autoscaler_cluster_resources{{resource="CPU",{global_filters}}})'
|
||||
PENDING_CPUS = 'sum(autoscaler_pending_resources{{resource="CPU",{global_filters}}})'
|
||||
MAX_GPUS = 'sum(autoscaler_cluster_resources{{resource="GPU",{global_filters}}})'
|
||||
PENDING_GPUS = 'sum(autoscaler_pending_resources{{resource="GPU",{global_filters}}})'
|
||||
MAX_MEMORY = 'sum(autoscaler_cluster_resources{{resource="memory",{global_filters}}})'
|
||||
PENDING_MEMORY = (
|
||||
'sum(autoscaler_pending_resources{{resource="memory",{global_filters}}})'
|
||||
)
|
||||
|
||||
|
||||
def max_plus_pending(max_resource, pending_resource):
|
||||
return f"({max_resource} or vector(0)) + ({pending_resource} or vector(0))"
|
||||
|
||||
|
||||
MAX_PLUS_PENDING_CPUS = max_plus_pending(MAX_CPUS, PENDING_CPUS)
|
||||
MAX_PLUS_PENDING_GPUS = max_plus_pending(MAX_GPUS, PENDING_GPUS)
|
||||
MAX_PLUS_PENDING_MEMORY = max_plus_pending(MAX_MEMORY, PENDING_MEMORY)
|
||||
|
||||
MAX_PERCENTAGE_EXPRESSION = (
|
||||
"100" # To help draw the max limit line on percentage panels
|
||||
)
|
||||
|
||||
# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
# IMPORTANT: Please keep this in sync with Metrics.tsx and ray-metrics.rst
|
||||
# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
OVERVIEW_AND_HEALTH_PANELS = [
|
||||
Panel(
|
||||
id=24,
|
||||
title="Node Count",
|
||||
description='Note: not impacted by "Instance" variable.\n\nA total number of active failed, and pending nodes from the cluster. \n\nACTIVE: A node is alive and available.\n\nFAILED: A node is dead and not available. The node is considered dead when the raylet process on the node is terminated. The node will get into the failed state if it cannot be provided (e.g., there\'s no available node from the cloud provider) or failed to setup (e.g., setup_commands have errors). \n\nPending: A node is being started by the Ray cluster launcher. The node is unavailable now because it is being provisioned and initialized.',
|
||||
unit="nodes",
|
||||
targets=[
|
||||
Target(
|
||||
expr="sum(autoscaler_active_nodes{{{global_filters}}}) by (NodeType)",
|
||||
legend="Active Nodes: {{NodeType}}",
|
||||
),
|
||||
Target(
|
||||
expr="sum(autoscaler_recently_failed_nodes{{{global_filters}}}) by (NodeType)",
|
||||
legend="Failed Nodes: {{NodeType}}",
|
||||
),
|
||||
Target(
|
||||
expr="sum(autoscaler_pending_nodes{{{global_filters}}}) by (NodeType)",
|
||||
legend="Pending Nodes: {{NodeType}}",
|
||||
),
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=41,
|
||||
title="Cluster Utilization",
|
||||
description="Aggregated utilization of all physical resources (CPU, GPU, memory, disk, or etc.) across the cluster.",
|
||||
unit="%",
|
||||
targets=[
|
||||
# CPU
|
||||
Target(
|
||||
expr='avg(ray_node_cpu_utilization{{instance=~"$Instance",{global_filters}}})',
|
||||
legend="CPU (physical)",
|
||||
),
|
||||
# GPU
|
||||
Target(
|
||||
expr='sum(ray_node_gpus_utilization{{instance=~"$Instance",{global_filters}}}) / on() (sum(ray_node_gpus_available{{instance=~"$Instance",{global_filters}}}) or vector(0))',
|
||||
legend="GPU (physical)",
|
||||
),
|
||||
# Memory
|
||||
Target(
|
||||
expr='sum(ray_node_mem_used_host{{instance=~"$Instance",{global_filters}}}) / on() (sum(ray_node_mem_total_host{{instance=~"$Instance",{global_filters}}})) * 100',
|
||||
legend="Memory (RAM)",
|
||||
),
|
||||
# GRAM
|
||||
Target(
|
||||
expr='sum(ray_node_gram_used{{instance=~"$Instance",{global_filters}}}) / on() (sum(ray_node_gram_available{{instance=~"$Instance",{global_filters}}}) + sum(ray_node_gram_used{{instance=~"$Instance",{global_filters}}})) * 100',
|
||||
legend="GRAM",
|
||||
),
|
||||
# Object Store
|
||||
Target(
|
||||
expr='sum(ray_object_store_memory{{instance=~"$Instance",{global_filters}}}) / on() sum(ray_resources{{Name="object_store_memory",instance=~"$Instance",{global_filters}}}) * 100',
|
||||
legend="Object Store Memory",
|
||||
),
|
||||
# Disk
|
||||
Target(
|
||||
expr='sum(ray_node_disk_usage{{instance=~"$Instance",{global_filters}}}) / on() (sum(ray_node_disk_free{{instance=~"$Instance",{global_filters}}}) + sum(ray_node_disk_usage{{instance=~"$Instance",{global_filters}}})) * 100',
|
||||
legend="Disk",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
),
|
||||
Panel(
|
||||
id=44,
|
||||
title="Ray OOM Kills (Tasks and Actors)",
|
||||
description="The number of tasks and actors killed by the Ray Out of Memory killer due to high memory pressure. Metrics are broken down by IP and the name. https://docs.ray.io/en/master/ray-core/scheduling/ray-oom-prevention.html. Note: The RayNodeType filter does not work on this graph.",
|
||||
unit="failures",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_memory_manager_worker_eviction_total{{instance=~"$Instance", {global_filters}}}) by (Name, instance)',
|
||||
legend="OOM Killed: {{Name}}, {{instance}}",
|
||||
),
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=65,
|
||||
title="Unexpected System Level Worker Failures",
|
||||
description="The number of workers (potentially tasks or actors) that disconnected from the raylet unexpectedly. "
|
||||
"This typically indicates the worker process unexpectedly failed due to "
|
||||
"a Ray system error or a kernel kill (e.g. OOM, SIGKILL, Bad exit code). "
|
||||
"Note that this metric only includes OOM kills from the kernel and does not "
|
||||
"include OOM kills from Ray's memory monitor. "
|
||||
"If errors of this type is encountered when the node is under memory pressure, "
|
||||
"The failures are likely OOM kills.",
|
||||
unit="failures",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_manager_unexpected_worker_failure_total{{instance=~"$Instance", {global_filters}}}) by (Type, Name, instance)',
|
||||
legend="Unexpected worker failure: {{Name}}, {{Type}}, {{instance}}",
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
RAY_TASKS_ACTORS_PLACEMENT_GROUPS_PANELS = [
|
||||
Panel(
|
||||
id=26,
|
||||
title="All Tasks by State",
|
||||
description="Current count of tasks, grouped by scheduler state (e.g., pending, running, finished).\n\nState: the task state, as described by rpc::TaskStatus proto in common.proto. Task resubmissions due to failures or object reconstruction are shown with (retry) in the label.",
|
||||
unit="tasks",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(max_over_time(ray_tasks{{IsRetry="0",State=~"FINISHED|FAILED",instance=~"$Instance",{global_filters}}}[14d])) by (State) or clamp_min(sum(ray_tasks{{IsRetry="0",State!~"FINISHED|FAILED",instance=~"$Instance",{global_filters}}}) by (State), 0)',
|
||||
legend="{{State}}",
|
||||
),
|
||||
Target(
|
||||
expr='sum(max_over_time(ray_tasks{{IsRetry!="0",State=~"FINISHED|FAILED",instance=~"$Instance",{global_filters}}}[14d])) by (State) or clamp_min(sum(ray_tasks{{IsRetry!="0",State!~"FINISHED|FAILED",instance=~"$Instance",{global_filters}}}) by (State), 0)',
|
||||
legend="{{State}} (retry)",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
),
|
||||
Panel(
|
||||
id=35,
|
||||
title="Active Tasks by Name",
|
||||
description="Current count of active tasks (i.e. pending or running; not finished), grouped by task name. Task resubmissions due to failures or object reconstruction are shown with (retry) in the label.",
|
||||
unit="tasks",
|
||||
targets=[
|
||||
Target(
|
||||
expr='clamp_min(sum(ray_tasks{{IsRetry="0",State!~"FINISHED|FAILED",instance=~"$Instance",{global_filters}}}) by (Name), 0)',
|
||||
legend="{{Name}}",
|
||||
),
|
||||
Target(
|
||||
expr='clamp_min(sum(ray_tasks{{IsRetry!="0",State!~"FINISHED|FAILED",instance=~"$Instance",{global_filters}}}) by (Name), 0)',
|
||||
legend="{{Name}} (retry)",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
),
|
||||
Panel(
|
||||
id=38,
|
||||
title="Running Tasks by Name",
|
||||
description="Current count of tasks that are currently executing, grouped by task name. Task resubmissions due to failures or object reconstruction are shown with (retry) in the label.",
|
||||
unit="tasks",
|
||||
targets=[
|
||||
Target(
|
||||
expr='clamp_min(sum(ray_tasks{{IsRetry="0",State=~"RUNNING*",instance=~"$Instance",{global_filters}}}) by (Name), 0)',
|
||||
legend="{{Name}}",
|
||||
),
|
||||
Target(
|
||||
expr='clamp_min(sum(ray_tasks{{IsRetry!="0",State=~"RUNNING*",instance=~"$Instance",{global_filters}}}) by (Name), 0)',
|
||||
legend="{{Name}} (retry)",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
),
|
||||
Panel(
|
||||
id=64,
|
||||
title="Running Tasks by Node",
|
||||
description="Current count of tasks that are currently executing, grouped by node.",
|
||||
unit="tasks",
|
||||
targets=[
|
||||
Target(
|
||||
expr='clamp_min(sum(ray_tasks{{State=~"RUNNING*",Source="executor",instance=~"$Instance",{global_filters}}}) by (instance), 0)',
|
||||
legend="{{instance}}",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
),
|
||||
Panel(
|
||||
id=33,
|
||||
title="All Actors by State",
|
||||
description='Note: not impacted by "Instance" variable.\n\nCurrent count of actors, grouped by lifecycle state (e.g., alive, restarting, dead/terminated).\n\nState: the actor state, as described by rpc::ActorTableData proto in gcs.proto.',
|
||||
unit="actors",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_actors{{Source="gcs",{global_filters}}}) by (State)',
|
||||
legend="{{State}}",
|
||||
)
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=42,
|
||||
title="Alive Actors by State",
|
||||
description="Current count of alive actors (i.e. not dead/terminated), grouped by state.\n\nState: the actor state, as described by rpc::ActorTableData proto in gcs.proto.",
|
||||
unit="actors",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_actors{{Source="executor",NodeAddress=~"$Instance",{global_filters}}}) by (State)',
|
||||
legend="{{State}}",
|
||||
)
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=36,
|
||||
title="Alive Actors by Name",
|
||||
description="Current count of alive actors, grouped by actor name.",
|
||||
unit="actors",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_actors{{State!="DEAD",Source="executor",NodeAddress=~"$Instance",{global_filters}}}) by (Name)',
|
||||
legend="{{Name}}",
|
||||
)
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=40,
|
||||
title="All Placement Groups by State",
|
||||
description='Note: not impacted by "Instance" variable.\n\nCurrent count of placement groups, grouped by state.\n\nState: the placement group state, as described by the rpc::PlacementGroupTableData proto in gcs.proto.',
|
||||
unit="placement groups",
|
||||
targets=[
|
||||
Target(
|
||||
expr="sum(ray_placement_groups{{{global_filters}}}) by (State)",
|
||||
legend="{{State}}",
|
||||
)
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=29,
|
||||
title="Object Store Memory by Location",
|
||||
description="Object store memory usage by location. The dotted line indicates the object store memory capacity. This metric can go over the max capacity in case of spillage to disk.\n\nLocation: where the memory was allocated, which is MMAP_SHM or MMAP_DISK to indicate memory-mapped page, SPILLED to indicate spillage to disk, and WORKER_HEAP for objects small enough to be inlined in worker memory.",
|
||||
unit="bytes",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_object_store_memory{{instance=~"$Instance",{global_filters}}}) by (Location)',
|
||||
legend="{{Location}}",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_resources{{Name="object_store_memory",instance=~"$Instance",{global_filters}}})',
|
||||
legend="MAX",
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
RAY_RESOURCES_PANELS = [
|
||||
Panel(
|
||||
id=27,
|
||||
title="Logical CPUs Usage",
|
||||
description="Logical CPU usage of Ray. The dotted line indicates the total number of CPUs. The logical CPU is allocated by `num_cpus` arguments from tasks and actors. PENDING means the number of CPUs that will be available when new nodes are up after the autoscaler scales up.\n\nNOTE: Ray's logical CPU is different from physical CPU usage. Ray's logical CPU is allocated by `num_cpus` arguments.",
|
||||
unit="cores",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_resources{{Name="CPU",State="USED",instance=~"$Instance",{global_filters}}}) by (instance)',
|
||||
legend="CPU Usage: {{instance}}",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_resources{{Name="CPU",instance=~"$Instance",{global_filters}}})',
|
||||
legend="MAX",
|
||||
),
|
||||
# If max + pending > max, we display this value.
|
||||
# (A and predicate) means to return A when the predicate satisfies in PromSql.
|
||||
Target(
|
||||
expr=f"({MAX_PLUS_PENDING_CPUS} and {MAX_PLUS_PENDING_CPUS} > ({MAX_CPUS} or vector(0)))",
|
||||
legend="MAX + PENDING",
|
||||
),
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=28,
|
||||
title="Logical GPUs Usage",
|
||||
description="Logical GPU usage of Ray. The dotted line indicates the total number of GPUs. The logical GPU is allocated by `num_gpus` arguments from tasks and actors. PENDING means the number of GPUs that will be available when new nodes are up after the autoscaler scales up.",
|
||||
unit="GPUs",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_resources{{Name="GPU",State="USED",instance=~"$Instance",{global_filters}}}) by (instance)',
|
||||
legend="GPU Usage: {{instance}}",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_resources{{Name="GPU",instance=~"$Instance",{global_filters}}})',
|
||||
legend="MAX",
|
||||
),
|
||||
# If max + pending > max, we display this value.
|
||||
# (A and predicate) means to return A when the predicate satisfies in PromSql.
|
||||
Target(
|
||||
expr=f"({MAX_PLUS_PENDING_GPUS} and {MAX_PLUS_PENDING_GPUS} > ({MAX_GPUS} or vector(0)))",
|
||||
legend="MAX + PENDING",
|
||||
),
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=61,
|
||||
title="Logical Memory Usage",
|
||||
description="Logical memory usage of Ray by node. The dotted line indicates the total amount of memory available. Logical memory refers to Ray's view of memory resources allocated to tasks and actors. PENDING means the amount of memory that will be available when new nodes are up after the autoscaler scales up.",
|
||||
unit="bytes",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_resources{{Name="memory",State="USED",instance=~"$Instance",{global_filters}}}) by (instance)',
|
||||
legend="Memory Usage: {{instance}}",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_resources{{Name="memory",instance=~"$Instance",{global_filters}}})',
|
||||
legend="MAX",
|
||||
),
|
||||
Target(
|
||||
expr=f"({MAX_PLUS_PENDING_MEMORY} and {MAX_PLUS_PENDING_MEMORY} > ({MAX_MEMORY} or vector(0)))",
|
||||
legend="MAX + PENDING",
|
||||
),
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=58,
|
||||
title="Object Store Memory Usage",
|
||||
description="Object store memory usage by instance, including memory that has been spilled to disk. The dotted line indicates the object store memory capacity. This metric can go over the max capacity in case of spillage to disk.",
|
||||
unit="bytes",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_object_store_memory{{instance=~"$Instance",{global_filters}}}) by (instance)',
|
||||
legend="{{instance}}",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_resources{{Name="object_store_memory",instance=~"$Instance",{global_filters}}})',
|
||||
legend="MAX",
|
||||
),
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=59,
|
||||
title="Object Store Memory Usage %",
|
||||
description="Object store memory usage % by instance, including memory that has been spilled to disk. This metric can go over 100% in case of spillage to disk.",
|
||||
unit="%",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_object_store_memory{{instance=~"$Instance",{global_filters}}}) by (instance) * 100 / sum(ray_resources{{Name="object_store_memory",instance=~"$Instance",{global_filters}}}) by (instance)',
|
||||
legend="{{instance}}",
|
||||
),
|
||||
Target(
|
||||
expr=MAX_PERCENTAGE_EXPRESSION, # To show the memory limit visually
|
||||
legend="MAX",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
),
|
||||
Panel(
|
||||
id=60,
|
||||
title="Object Store Memory Spilled to Disk",
|
||||
description="Object store memory that has been spilled to disk, by instance.",
|
||||
unit="bytes",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_object_store_memory{{instance=~"$Instance",Location="SPILLED",{global_filters}}}) by (instance)',
|
||||
legend="{{instance}}",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
),
|
||||
]
|
||||
|
||||
NODE_HARDWARE_UTILIZATION_BY_RAY_COMPONENT_PANELS = [
|
||||
Panel(
|
||||
id=37,
|
||||
title="Node CPU Usage by Component",
|
||||
description="The physical (hardware) CPU usage across the cluster, broken down by component. This reports the summed CPU usage per Ray component. Ray components consist of system components (e.g., raylet, gcs, dashboard, or agent) and the process (that contains method names) names of running tasks/actors.",
|
||||
unit="cores",
|
||||
targets=[
|
||||
Target(
|
||||
# ray_component_cpu_percentage returns a percentage that can be > 100. It means that it uses more than 1 CPU.
|
||||
expr='sum(ray_component_cpu_percentage{{instance=~"$Instance",{global_filters}}}) by (Component) / 100',
|
||||
legend="{{Component}}",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_node_cpu_count{{instance=~"$Instance",{global_filters}}})',
|
||||
legend="MAX",
|
||||
),
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=34,
|
||||
title="Node Memory Usage by Component",
|
||||
description="The physical (hardware) memory usage across the cluster, broken down by component. This reports the summed RSS-SHM per Ray component, which corresponds to an approximate memory usage per proc. Ray components consist of system components (e.g., raylet, gcs, dashboard, or agent) and the process (that contains method names) names of running tasks/actors.",
|
||||
unit="bytes",
|
||||
targets=[
|
||||
Target(
|
||||
expr='(sum(ray_component_rss_bytes{{instance=~"$Instance",{global_filters}}}) by (Component)) - (sum(ray_component_shared_bytes{{instance=~"$Instance",{global_filters}}}) by (Component))',
|
||||
legend="{{Component}}",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_node_mem_shared_bytes{{instance=~"$Instance",{global_filters}}})',
|
||||
legend="shared_memory",
|
||||
),
|
||||
Target(
|
||||
expr='min(label_replace(sum(ray_node_mem_total_host{{instance=~"$Instance",{global_filters}}}), "mem_cap_source", "host", "", "") or label_replace(sum(ray_node_cgroup_mem_total{{instance=~"$Instance",{global_filters}}}), "mem_cap_source", "cgroup", "", ""))',
|
||||
legend="MAX",
|
||||
),
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=45,
|
||||
title="Node GPU Usage by Component",
|
||||
description="The physical (hardware) GPU usage across the cluster, broken down by component. This reports the summed GPU usage per Ray component.",
|
||||
unit="GPUs",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_component_gpu_percentage{{instance=~"$Instance",{global_filters}}} / 100) by (Component)',
|
||||
legend="{{Component}}",
|
||||
),
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=46,
|
||||
title="Node GPU Memory Usage by Component",
|
||||
description="The physical (hardware) GPU memory usage across the cluster, broken down by component. This reports the summed GPU memory usage per Ray component.",
|
||||
unit="bytes",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_component_gpu_memory_mb{{instance=~"$Instance",{global_filters}}} * 1024 * 1024) by (Component)',
|
||||
legend="{{Component}}",
|
||||
),
|
||||
Target(
|
||||
expr='(sum(ray_node_gram_available{{instance=~"$Instance",{global_filters}}}) + sum(ray_node_gram_used{{instance=~"$Instance",{global_filters}}})) * 1024 * 1024',
|
||||
legend="MAX",
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
NODE_HARDWARE_UTILIZATION_PANELS = [
|
||||
Panel(
|
||||
id=2,
|
||||
title="Node CPU Usage",
|
||||
description="The physical (hardware) CPU usage for each node.",
|
||||
unit="cores",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_cpu_utilization{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}} * ray_node_cpu_count{{instance=~"$Instance", RayNodeType=~"$RayNodeType",{global_filters}}} / 100) by (instance, RayNodeType)',
|
||||
legend="CPU Usage: {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_node_cpu_count{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}})',
|
||||
legend="MAX",
|
||||
),
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=54,
|
||||
title="Node CPU Usage %",
|
||||
description="The percentage of physical (hardware) CPU usage for each node.",
|
||||
unit="%",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_cpu_utilization{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="CPU Usage: {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
),
|
||||
Panel(
|
||||
id=4,
|
||||
title="Node Memory Usage (heap + object store)",
|
||||
description="The physical (hardware) memory usage for each node. The dotted line means the total amount of memory from the cluster. "
|
||||
"Node memory is a sum of object store memory (shared memory) and heap memory.\n\n"
|
||||
"Host targets reflect memory as reported by the host machine. "
|
||||
"Container targets reflect cgroup-limited memory and are only emitted when the ray node reside within a cgroup.",
|
||||
unit="bytes",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_mem_used_host{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="Memory Used (Host): {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_node_mem_total_host{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}})',
|
||||
legend="MAX",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_node_cgroup_mem_used{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="Memory Used (Container): {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_node_cgroup_mem_total{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}})',
|
||||
legend="Container MAX",
|
||||
),
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=48,
|
||||
title="Node Memory Usage % (heap + object store)",
|
||||
description="The percentage of physical (hardware) memory usage for each node.\n\n"
|
||||
"Host targets reflect memory as reported by the host machine. "
|
||||
"Container targets reflect cgroup-limited memory and are only emitted when the ray node reside within a cgroup.",
|
||||
unit="%",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_mem_used_host{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType) * 100 / sum(ray_node_mem_total_host{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="Memory Used (Host): {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_node_cgroup_mem_used{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType) * 100 / sum(ray_node_cgroup_mem_total{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="Memory Used (Container): {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
),
|
||||
Panel(
|
||||
id=6,
|
||||
title="Node Disk Usage",
|
||||
description="Node's physical (hardware) disk usage. The dotted line means the total amount of disk space from the cluster.\n\nNOTE: When Ray is deployed within a container, this shows the disk usage from the host machine. ",
|
||||
unit="bytes",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_disk_usage{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="Disk Used: {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_node_disk_free{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) + sum(ray_node_disk_usage{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}})',
|
||||
legend="MAX",
|
||||
),
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=57,
|
||||
title="Node Disk Usage %",
|
||||
description="Node's physical (hardware) disk usage. \n\nNOTE: When Ray is deployed within a container, this shows the disk usage from the host machine. ",
|
||||
unit="%",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_disk_usage{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType) * 100 / (sum(ray_node_disk_free{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType) + sum(ray_node_disk_usage{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType))',
|
||||
legend="Disk Used: {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
),
|
||||
Panel(
|
||||
id=8,
|
||||
title="Node GPU Usage",
|
||||
description="Node's physical (hardware) GPU usage. The dotted line means the total number of hardware GPUs from the cluster. ",
|
||||
unit="GPUs",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_gpus_utilization{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}} / 100) by (instance, RayNodeType, GpuIndex, GpuDeviceName)',
|
||||
legend="GPU Usage: {{instance}} ({{RayNodeType}}), gpu.{{GpuIndex}}, {{GpuDeviceName}}",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_node_gpus_available{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}})',
|
||||
legend="MAX",
|
||||
),
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=55,
|
||||
title="Node GPU Usage %",
|
||||
description="Node's physical (hardware) GPU usage.",
|
||||
unit="%",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_gpus_utilization{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType, GpuIndex, GpuDeviceName)',
|
||||
legend="GPU Usage: {{instance}} ({{RayNodeType}}), gpu.{{GpuIndex}}, {{GpuDeviceName}}",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
),
|
||||
Panel(
|
||||
id=18,
|
||||
title="Node GPU Memory Usage (GRAM)",
|
||||
description="The physical (hardware) GPU memory usage for each node. The dotted line means the total amount of GPU memory from the cluster.",
|
||||
unit="bytes",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_gram_used{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}} * 1024 * 1024) by (instance, RayNodeType, GpuIndex, GpuDeviceName)',
|
||||
legend="Used GRAM: {{instance}} ({{RayNodeType}}), gpu.{{GpuIndex}}, {{GpuDeviceName}}",
|
||||
),
|
||||
Target(
|
||||
expr='(sum(ray_node_gram_available{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) + sum(ray_node_gram_used{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}})) * 1024 * 1024',
|
||||
legend="MAX",
|
||||
),
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=56,
|
||||
title="Node GPU Memory Usage (GRAM) %",
|
||||
description="The percentage of physical (hardware) GPU memory usage for each node.",
|
||||
unit="%",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_gram_used{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType, GpuIndex, GpuDeviceName) * 100 / (sum(ray_node_gram_available{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType, GpuIndex, GpuDeviceName) + sum(ray_node_gram_used{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType, GpuIndex, GpuDeviceName))',
|
||||
legend="Used GRAM: {{instance}} ({{RayNodeType}}), gpu.{{GpuIndex}}, {{GpuDeviceName}}",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
),
|
||||
Panel(
|
||||
id=62,
|
||||
title="Node GPU Power",
|
||||
description="Current GPU power draw per node. Reported in milliwatts; displayed in watts. Supported on NVIDIA and AMD GPUs.",
|
||||
unit="mwatt",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_gpu_power_milliwatts{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType, GpuIndex, GpuDeviceName)',
|
||||
legend="Power: {{instance}} ({{RayNodeType}}), gpu.{{GpuIndex}}, {{GpuDeviceName}}",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
),
|
||||
Panel(
|
||||
id=63,
|
||||
title="Node GPU Temperature",
|
||||
description="Current GPU temperature per node in Celsius. Supported on NVIDIA GPUs.",
|
||||
unit="celsius",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_gpu_temperature_celsius{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType, GpuIndex, GpuDeviceName)',
|
||||
legend="Temperature: {{instance}} ({{RayNodeType}}), gpu.{{GpuIndex}}, {{GpuDeviceName}}",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
),
|
||||
Panel(
|
||||
id=32,
|
||||
title="Node Disk IO Speed",
|
||||
description="Disk IO per node.",
|
||||
unit="Bps",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_disk_io_write_speed{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="Write: {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_node_disk_io_read_speed{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="Read: {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=20,
|
||||
title="Node Network",
|
||||
description="Network speed per node",
|
||||
unit="Bps",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_network_receive_speed{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="Recv: {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_node_network_send_speed{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="Send: {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
NODE_TPU_UTILIZATION_PANELS = [
|
||||
Panel(
|
||||
id=50,
|
||||
title="Node TPU Tensorcore Utilization %",
|
||||
description="Percentage of tensorcore utilization for the TPUs on this node. Computed by dividing the number of tensorcore operations by the maximum supported number of operations during the sample period.",
|
||||
unit="%",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_tpu_tensorcore_utilization{{instance=~"$Instance",{global_filters}}}) by (instance, TpuIndex, TpuDeviceName, TpuType, TpuTopology)',
|
||||
legend="{{instance}}, tpu.{{TpuIndex}}, {{TpuType}}, {{TpuTopology}}",
|
||||
),
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=51,
|
||||
title="Node TPU High Bandwidth Memory Utilization %",
|
||||
description="Percentage of bandwidth memory utilization for the TPUs on this node. Computed by dividing the memory bandwidth used by the maximum supported memory bandwidth limit during the sample period.",
|
||||
unit="%",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_tpu_memory_bandwidth_utilization{{instance=~"$Instance",{global_filters}}}) by (instance, TpuIndex, TpuDeviceName, TpuType, TpuTopology)',
|
||||
legend="{{instance}}, tpu.{{TpuIndex}}, {{TpuType}}, {{TpuTopology}}",
|
||||
),
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=52,
|
||||
title="Node TPU Duty Cycle %",
|
||||
description="Percentage of time over the sample period during which the TPU is actively processing.",
|
||||
unit="%",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_tpu_duty_cycle{{instance=~"$Instance",{global_filters}}}) by (instance, TpuIndex, TpuDeviceName, TpuType, TpuTopology) or vector(0)',
|
||||
legend="{{instance}}, tpu.{{TpuIndex}}, {{TpuType}}, {{TpuTopology}}",
|
||||
),
|
||||
],
|
||||
),
|
||||
Panel(
|
||||
id=53,
|
||||
title="Node TPU Memory Used",
|
||||
description="Total memory used/allocated for the TPUs on this node.",
|
||||
unit="bytes",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_tpu_memory_used{{instance=~"$Instance",{global_filters}}}) by (instance, TpuIndex, TpuDeviceName, TpuType, TpuTopology) or vector(0)',
|
||||
legend="Memory Used: {{instance}}, tpu.{{TpuIndex}}, {{TpuType}}, {{TpuTopology}}",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_tpu_memory_total{{instance=~"$Instance",{global_filters}}}) by (instance, TpuIndex, TpuDeviceName, TpuType, TpuTopology) or vector(0)',
|
||||
legend="Memory Total: {{instance}}, tpu.{{TpuIndex}}, {{TpuType}}, {{TpuTopology}}",
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
DEFAULT_GRAFANA_ROWS = [
|
||||
Row(
|
||||
title="Overview and Health",
|
||||
id=1001,
|
||||
panels=OVERVIEW_AND_HEALTH_PANELS,
|
||||
collapsed=False,
|
||||
),
|
||||
Row(
|
||||
title="Hardware Utilization by Node",
|
||||
id=1005,
|
||||
panels=NODE_HARDWARE_UTILIZATION_PANELS,
|
||||
collapsed=False,
|
||||
),
|
||||
Row(
|
||||
title="Hardware Utilization by Ray Component",
|
||||
id=1004,
|
||||
panels=NODE_HARDWARE_UTILIZATION_BY_RAY_COMPONENT_PANELS,
|
||||
collapsed=False,
|
||||
),
|
||||
Row(
|
||||
title="Ray Resources by Node",
|
||||
id=1003,
|
||||
panels=RAY_RESOURCES_PANELS,
|
||||
collapsed=False,
|
||||
),
|
||||
Row(
|
||||
title="Ray Tasks, Actors and Placement Groups",
|
||||
id=1002,
|
||||
panels=RAY_TASKS_ACTORS_PLACEMENT_GROUPS_PANELS,
|
||||
collapsed=False,
|
||||
),
|
||||
Row(
|
||||
title="TPU Utilization by Node",
|
||||
id=1006,
|
||||
panels=NODE_TPU_UTILIZATION_PANELS,
|
||||
collapsed=True,
|
||||
),
|
||||
]
|
||||
|
||||
ids = []
|
||||
for row in DEFAULT_GRAFANA_ROWS:
|
||||
ids.append(row.id)
|
||||
ids.extend(panel.id for panel in row.panels)
|
||||
|
||||
ids.sort()
|
||||
|
||||
assert len(ids) == len(
|
||||
set(ids)
|
||||
), f"Duplicated id found. Use unique id for each panel. {ids}"
|
||||
|
||||
default_dashboard_config = DashboardConfig(
|
||||
name="DEFAULT",
|
||||
default_uid="rayDefaultDashboard",
|
||||
rows=DEFAULT_GRAFANA_ROWS,
|
||||
standard_global_filters=[
|
||||
'SessionName=~"$SessionName"',
|
||||
'ray_io_cluster=~"$Cluster"',
|
||||
],
|
||||
base_json_file_name="default_grafana_dashboard_base.json",
|
||||
)
|
||||
@@ -0,0 +1,177 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": "-- Grafana --",
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"gnetId": null,
|
||||
"graphTooltip": 1,
|
||||
"iteration": 1667344411089,
|
||||
"links": [],
|
||||
"panels": [],
|
||||
"refresh": false,
|
||||
"schemaVersion": 27,
|
||||
"style": "dark",
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {
|
||||
"selected": false
|
||||
},
|
||||
"description": "Filter queries of a specific Prometheus type.",
|
||||
"hide": 2,
|
||||
"includeAll": false,
|
||||
"multi": false,
|
||||
"name": "datasource",
|
||||
"options": [],
|
||||
"query": "prometheus",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "datasource"
|
||||
},
|
||||
{
|
||||
"allValue": ".+",
|
||||
"current": {
|
||||
"selected": false
|
||||
},
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_node_network_receive_speed{{{global_filters}}}, SessionName)",
|
||||
"description": "Filter queries to specific Ray sessions.",
|
||||
"error": null,
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": null,
|
||||
"multi": false,
|
||||
"name": "SessionName",
|
||||
"options": [],
|
||||
"query": {
|
||||
"query": "label_values(ray_node_network_receive_speed{{{global_filters}}}, SessionName)",
|
||||
"refId": "StandardVariableQuery"
|
||||
},
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"sort": 2,
|
||||
"tagValuesQuery": "",
|
||||
"tags": [],
|
||||
"tagsQuery": "",
|
||||
"type": "query",
|
||||
"useTags": false
|
||||
},
|
||||
{
|
||||
"allValue": ".+",
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": ["All"],
|
||||
"value": ["$__all"]
|
||||
},
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_node_network_receive_speed{{SessionName=~\"$SessionName\",{global_filters}}}, instance)",
|
||||
"description": "Filter queries to specific Ray nodes by their IP address.",
|
||||
"error": null,
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": null,
|
||||
"multi": true,
|
||||
"name": "Instance",
|
||||
"options": [],
|
||||
"query": {
|
||||
"query": "label_values(ray_node_network_receive_speed{{SessionName=~\"$SessionName\",{global_filters}}}, instance)",
|
||||
"refId": "Prometheus-Instance-Variable-Query"
|
||||
},
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"sort": 0,
|
||||
"tagValuesQuery": "",
|
||||
"tags": [],
|
||||
"tagsQuery": "",
|
||||
"type": "query",
|
||||
"useTags": false
|
||||
},
|
||||
{
|
||||
"allValue": ".*",
|
||||
"current": {
|
||||
"selected": false
|
||||
},
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_node_network_receive_speed{{{global_filters}}}, ray_io_cluster)",
|
||||
"description": "Filter queries to specific Ray clusters for KubeRay. When ingesting metrics across multiple Ray clusters, the ray_io_cluster label should be set per cluster. For KubeRay users, this is done automatically with Prometheus PodMonitor.",
|
||||
"error": null,
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": null,
|
||||
"multi": false,
|
||||
"name": "Cluster",
|
||||
"options": [],
|
||||
"query": {
|
||||
"query": "label_values(ray_node_network_receive_speed{{{global_filters}}}, ray_io_cluster)",
|
||||
"refId": "StandardVariableQuery"
|
||||
},
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"sort": 2,
|
||||
"tagValuesQuery": "",
|
||||
"tags": [],
|
||||
"tagsQuery": "",
|
||||
"type": "query",
|
||||
"useTags": false
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"text": [
|
||||
"All"
|
||||
],
|
||||
"value": [
|
||||
"$__all"
|
||||
]
|
||||
},
|
||||
"description": "Filter queries to specific Ray node types (head or worker).",
|
||||
"includeAll": true,
|
||||
"multi": true,
|
||||
"name": "RayNodeType",
|
||||
"options": [
|
||||
{
|
||||
"selected": false,
|
||||
"text": "All",
|
||||
"value": "$__all"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "Head Node",
|
||||
"value": "head"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "Worker Node",
|
||||
"value": "worker"
|
||||
}
|
||||
],
|
||||
"query": "head, worker",
|
||||
"type": "custom"
|
||||
}
|
||||
]
|
||||
},
|
||||
"rayMeta": ["supportsFullGrafanaView"],
|
||||
"time": {
|
||||
"from": "now-30m",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "Default Dashboard",
|
||||
"uid": "rayDefaultDashboard",
|
||||
"version": 4
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
# ruff: noqa: E501
|
||||
|
||||
from ray.dashboard.modules.metrics.dashboards.common import (
|
||||
DashboardConfig,
|
||||
GridPos,
|
||||
Panel,
|
||||
Target,
|
||||
)
|
||||
|
||||
SERVE_GRAFANA_PANELS = [
|
||||
Panel(
|
||||
id=5,
|
||||
title="Cluster Utilization",
|
||||
description="Aggregated utilization of all physical resources (CPU, GPU, memory, disk, or etc.) across the cluster. Ignores application variable.",
|
||||
unit="%",
|
||||
targets=[
|
||||
# CPU
|
||||
Target(
|
||||
expr="avg(ray_node_cpu_utilization{{{global_filters}}})",
|
||||
legend="CPU (physical)",
|
||||
),
|
||||
# GPU
|
||||
Target(
|
||||
expr="sum(ray_node_gpus_utilization{{{global_filters}}}) / on() (sum(autoscaler_cluster_resources{{resource='GPU',{global_filters}}}) or vector(0))",
|
||||
legend="GPU (physical)",
|
||||
),
|
||||
# Memory
|
||||
Target(
|
||||
expr="sum(ray_node_mem_used{{{global_filters}}}) / on() (sum(ray_node_mem_total{{{global_filters}}})) * 100",
|
||||
legend="Memory (RAM)",
|
||||
),
|
||||
# GRAM
|
||||
Target(
|
||||
expr="sum(ray_node_gram_used{{{global_filters}}}) / on() (sum(ray_node_gram_available{{{global_filters}}}) + sum(ray_node_gram_used{{{global_filters}}})) * 100",
|
||||
legend="GRAM",
|
||||
),
|
||||
# Object Store
|
||||
Target(
|
||||
expr='sum(ray_object_store_memory{{{global_filters}}}) / on() sum(ray_resources{{Name="object_store_memory",{global_filters}}}) * 100',
|
||||
legend="Object Store Memory",
|
||||
),
|
||||
# Disk
|
||||
Target(
|
||||
expr="sum(ray_node_disk_usage{{{global_filters}}}) / on() (sum(ray_node_disk_free{{{global_filters}}}) + sum(ray_node_disk_usage{{{global_filters}}})) * 100",
|
||||
legend="Disk",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 0, 12, 8),
|
||||
),
|
||||
Panel(
|
||||
id=7,
|
||||
title="QPS per application",
|
||||
description="QPS for each selected application.",
|
||||
unit="qps",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(rate(ray_serve_num_http_requests_total{{application=~"$Application",application!~"",route=~"$HTTP_Route",route!~"/-/.*",{global_filters}}}[5m])) by (application, route)',
|
||||
legend="{{application, route}}",
|
||||
),
|
||||
Target(
|
||||
expr='sum(rate(ray_serve_num_grpc_requests_total{{application=~"$Application",application!~"",method=~"$gRPC_Method",{global_filters}}}[5m])) by (application, method)',
|
||||
legend="{{application, method}}",
|
||||
),
|
||||
],
|
||||
grid_pos=GridPos(12, 0, 12, 8),
|
||||
),
|
||||
Panel(
|
||||
id=8,
|
||||
title="Error QPS per application",
|
||||
description="Error QPS for each selected application.",
|
||||
unit="qps",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(rate(ray_serve_num_http_error_requests_total{{application=~"$Application",application!~"",route=~"$HTTP_Route",route!~"/-/.*",{global_filters}}}[5m])) by (application, route)',
|
||||
legend="{{application, route}}",
|
||||
),
|
||||
Target(
|
||||
expr='sum(rate(ray_serve_num_grpc_error_requests_total{{application=~"$Application",application!~"",method=~"$gRPC_Method",{global_filters}}}[5m])) by (application, method)',
|
||||
legend="{{application, method}}",
|
||||
),
|
||||
],
|
||||
grid_pos=GridPos(0, 1, 12, 8),
|
||||
),
|
||||
Panel(
|
||||
id=17,
|
||||
title="Error QPS per application per error code",
|
||||
description="Error QPS for each selected application.",
|
||||
unit="qps",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(rate(ray_serve_num_http_error_requests_total{{application=~"$Application",application!~"",route=~"$HTTP_Route",route!~"/-/.*",{global_filters}}}[5m])) by (application, route, error_code)',
|
||||
legend="{{application, route, error_code}}",
|
||||
),
|
||||
Target(
|
||||
expr='sum(rate(ray_serve_num_grpc_error_requests_total{{application=~"$Application",application!~"",method=~"$gRPC_Method",{global_filters}}}[5m])) by (application, method, error_code)',
|
||||
legend="{{application, method, error_code}}",
|
||||
),
|
||||
],
|
||||
grid_pos=GridPos(12, 1, 12, 8),
|
||||
),
|
||||
Panel(
|
||||
id=12,
|
||||
title="P50 latency per application",
|
||||
description="P50 latency for selected applications.",
|
||||
unit="ms",
|
||||
targets=[
|
||||
Target(
|
||||
expr='histogram_quantile(0.5, sum(rate(ray_serve_http_request_latency_ms_bucket{{application=~"$Application",application!~"",route=~"$HTTP_Route",route!~"/-/.*",{global_filters}}}[5m])) by (application, route, le))',
|
||||
legend="{{application, route}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.5, sum(rate(ray_serve_grpc_request_latency_ms_bucket{{application=~"$Application",application!~"",method=~"$gRPC_Method",{global_filters}}}[5m])) by (application, method, le))',
|
||||
legend="{{application, method}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.5, sum(rate({{__name__=~ "ray_serve_(http|grpc)_request_latency_ms_bucket",application=~"$Application",application!~"",{global_filters}}}[5m])) by (le))',
|
||||
legend="Total",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 2, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=15,
|
||||
title="P90 latency per application",
|
||||
description="P90 latency for selected applications.",
|
||||
unit="ms",
|
||||
targets=[
|
||||
Target(
|
||||
expr='histogram_quantile(0.9, sum(rate(ray_serve_http_request_latency_ms_bucket{{application=~"$Application",application!~"",route=~"$HTTP_Route",route!~"/-/.*",{global_filters}}}[5m])) by (application, route, le))',
|
||||
legend="{{application, route}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.9, sum(rate(ray_serve_grpc_request_latency_ms_bucket{{application=~"$Application",application!~"",method=~"$gRPC_Method",{global_filters}}}[5m])) by (application, method, le))',
|
||||
legend="{{application, method}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.9, sum(rate({{__name__=~ "ray_serve_(http|grpc)_request_latency_ms_bucket|ray_serve_grpc_request_latency_ms_bucket",application=~"$Application",application!~"",{global_filters}}}[5m])) by (le))',
|
||||
legend="Total",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
grid_pos=GridPos(8, 2, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=16,
|
||||
title="P99 latency per application",
|
||||
description="P99 latency for selected applications.",
|
||||
unit="ms",
|
||||
targets=[
|
||||
Target(
|
||||
expr='histogram_quantile(0.99, sum(rate(ray_serve_http_request_latency_ms_bucket{{application=~"$Application",application!~"",route=~"$HTTP_Route",route!~"/-/.*",{global_filters}}}[5m])) by (application, route, le))',
|
||||
legend="{{application, route}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.99, sum(rate(ray_serve_grpc_request_latency_ms_bucket{{application=~"$Application",application!~"",method=~"$gRPC_Method",{global_filters}}}[5m])) by (application, method, le))',
|
||||
legend="{{application, method}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.99, sum(rate({{__name__=~ "ray_serve_(http|grpc)_request_latency_ms_bucket|ray_serve_grpc_request_latency_ms_bucket",application=~"$Application",application!~"",{global_filters}}}[5m])) by (le))',
|
||||
legend="Total",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
grid_pos=GridPos(16, 2, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=2,
|
||||
title="Replicas per deployment",
|
||||
description='Number of replicas per deployment. Ignores "Application" variable.',
|
||||
unit="replicas",
|
||||
targets=[
|
||||
Target(
|
||||
expr="sum(ray_serve_deployment_replica_healthy{{{global_filters}}}) by (application, deployment)",
|
||||
legend="{{application, deployment}}",
|
||||
),
|
||||
],
|
||||
grid_pos=GridPos(0, 3, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=13,
|
||||
title="QPS per deployment",
|
||||
description="QPS for each deployment.",
|
||||
unit="qps",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(rate(ray_serve_deployment_request_counter_total{{application=~"$Application",application!~"",{global_filters}}}[5m])) by (application, deployment)',
|
||||
legend="{{application, deployment}}",
|
||||
),
|
||||
],
|
||||
grid_pos=GridPos(8, 3, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=14,
|
||||
title="Error QPS per deployment",
|
||||
description="Error QPS for each deplyoment.",
|
||||
unit="qps",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(rate(ray_serve_deployment_error_counter_total{{application=~"$Application",application!~"",{global_filters}}}[5m])) by (application, deployment)',
|
||||
legend="{{application, deployment}}",
|
||||
),
|
||||
],
|
||||
grid_pos=GridPos(16, 3, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=9,
|
||||
title="P50 latency per deployment",
|
||||
description="P50 latency per deployment.",
|
||||
unit="ms",
|
||||
targets=[
|
||||
Target(
|
||||
expr='histogram_quantile(0.5, sum(rate(ray_serve_deployment_processing_latency_ms_bucket{{application=~"$Application",application!~"",{global_filters}}}[5m])) by (application, deployment, le))',
|
||||
legend="{{application, deployment}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.5, sum(rate(ray_serve_deployment_processing_latency_ms_bucket{{application=~"$Application",application!~"",{global_filters}}}[5m])) by (le))',
|
||||
legend="Total",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 4, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=10,
|
||||
title="P90 latency per deployment",
|
||||
description="P90 latency per deployment.",
|
||||
unit="ms",
|
||||
targets=[
|
||||
Target(
|
||||
expr='histogram_quantile(0.9, sum(rate(ray_serve_deployment_processing_latency_ms_bucket{{application=~"$Application",application!~"",{global_filters}}}[5m])) by (application, deployment, le))',
|
||||
legend="{{application, deployment}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.9, sum(rate(ray_serve_deployment_processing_latency_ms_bucket{{application=~"$Application",application!~"",{global_filters}}}[5m])) by (le))',
|
||||
legend="Total",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
grid_pos=GridPos(8, 4, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=11,
|
||||
title="P99 latency per deployment",
|
||||
description="P99 latency per deployment.",
|
||||
unit="ms",
|
||||
targets=[
|
||||
Target(
|
||||
expr='histogram_quantile(0.99, sum(rate(ray_serve_deployment_processing_latency_ms_bucket{{application=~"$Application",application!~"",{global_filters}}}[5m])) by (application, deployment, le))',
|
||||
legend="{{application, deployment}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.99, sum(rate(ray_serve_deployment_processing_latency_ms_bucket{{application=~"$Application",application!~"",{global_filters}}}[5m])) by (le))',
|
||||
legend="Total",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
grid_pos=GridPos(16, 4, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=3,
|
||||
title="Queue size per deployment",
|
||||
description='Number of requests queued per deployment. Ignores "Application" variable.',
|
||||
unit="requests",
|
||||
targets=[
|
||||
Target(
|
||||
expr="sum(ray_serve_deployment_queued_queries{{{global_filters}}}) by (application, deployment)",
|
||||
legend="{{application, deployment}}",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 5, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=4,
|
||||
title="Node count",
|
||||
description='Number of nodes in this cluster. Ignores "Application" variable.',
|
||||
unit="nodes",
|
||||
targets=[
|
||||
# TODO(aguo): Update this to use autoscaler metrics instead
|
||||
Target(
|
||||
expr="sum(autoscaler_active_nodes{{{global_filters}}}) by (NodeType)",
|
||||
legend="Active Nodes: {{NodeType}}",
|
||||
),
|
||||
Target(
|
||||
expr="sum(autoscaler_recently_failed_nodes{{{global_filters}}}) by (NodeType)",
|
||||
legend="Failed Nodes: {{NodeType}}",
|
||||
),
|
||||
Target(
|
||||
expr="sum(autoscaler_pending_nodes{{{global_filters}}}) by (NodeType)",
|
||||
legend="Pending Nodes: {{NodeType}}",
|
||||
),
|
||||
],
|
||||
grid_pos=GridPos(8, 5, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=6,
|
||||
title="Node network",
|
||||
description='Network speed per node. Ignores "Application" variable.',
|
||||
unit="Bps",
|
||||
targets=[
|
||||
Target(
|
||||
expr="sum(ray_node_network_receive_speed{{{global_filters}}}) by (instance)",
|
||||
legend="Recv: {{instance}}",
|
||||
),
|
||||
Target(
|
||||
expr="sum(ray_node_network_send_speed{{{global_filters}}}) by (instance)",
|
||||
legend="Send: {{instance}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(16, 5, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=20,
|
||||
title="Ongoing HTTP Requests",
|
||||
description="The number of ongoing requests in the HTTP Proxy.",
|
||||
unit="requests",
|
||||
targets=[
|
||||
Target(
|
||||
expr="ray_serve_num_ongoing_http_requests{{{global_filters}}}",
|
||||
legend="Ongoing HTTP Requests",
|
||||
),
|
||||
],
|
||||
grid_pos=GridPos(0, 6, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=21,
|
||||
title="Ongoing gRPC Requests",
|
||||
description="The number of ongoing requests in the gRPC Proxy.",
|
||||
unit="requests",
|
||||
targets=[
|
||||
Target(
|
||||
expr="ray_serve_num_ongoing_grpc_requests{{{global_filters}}}",
|
||||
legend="Ongoing gRPC Requests",
|
||||
),
|
||||
],
|
||||
grid_pos=GridPos(8, 6, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=22,
|
||||
title="Scheduling Tasks",
|
||||
description="The number of request scheduling tasks in the router.",
|
||||
unit="tasks",
|
||||
targets=[
|
||||
Target(
|
||||
expr="ray_serve_num_scheduling_tasks{{{global_filters}}}",
|
||||
legend="Scheduling Tasks",
|
||||
),
|
||||
],
|
||||
grid_pos=GridPos(16, 6, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=23,
|
||||
title="Scheduling Tasks in Backoff",
|
||||
description="The number of request scheduling tasks in the router that are undergoing backoff.",
|
||||
unit="tasks",
|
||||
targets=[
|
||||
Target(
|
||||
expr="ray_serve_num_scheduling_tasks_in_backoff{{{global_filters}}}",
|
||||
legend="Scheduling Tasks in Backoff",
|
||||
),
|
||||
],
|
||||
grid_pos=GridPos(0, 7, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=24,
|
||||
title="Controller Control Loop Duration",
|
||||
description="The duration of the last control loop.",
|
||||
unit="seconds",
|
||||
targets=[
|
||||
Target(
|
||||
expr="ray_serve_controller_control_loop_duration_s{{{global_filters}}}",
|
||||
legend="Control Loop Duration",
|
||||
),
|
||||
],
|
||||
grid_pos=GridPos(8, 7, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=25,
|
||||
title="Number of Control Loops",
|
||||
description="The number of control loops performed by the controller. Increases monotonically over the controller's lifetime.",
|
||||
unit="loops",
|
||||
targets=[
|
||||
Target(
|
||||
expr="ray_serve_controller_num_control_loops{{{global_filters}}}",
|
||||
legend="Control Loops",
|
||||
),
|
||||
],
|
||||
grid_pos=GridPos(16, 7, 8, 8),
|
||||
),
|
||||
]
|
||||
|
||||
ids = []
|
||||
for panel in SERVE_GRAFANA_PANELS:
|
||||
ids.append(panel.id)
|
||||
|
||||
ids.sort()
|
||||
|
||||
assert len(ids) == len(
|
||||
set(ids)
|
||||
), f"Duplicated id found. Use unique id for each panel. {ids}"
|
||||
|
||||
serve_dashboard_config = DashboardConfig(
|
||||
name="SERVE",
|
||||
default_uid="rayServeDashboard",
|
||||
panels=SERVE_GRAFANA_PANELS,
|
||||
standard_global_filters=[
|
||||
'ray_io_cluster=~"$Cluster"',
|
||||
],
|
||||
base_json_file_name="serve_grafana_dashboard_base.json",
|
||||
)
|
||||
@@ -0,0 +1,262 @@
|
||||
# ruff: noqa: E501
|
||||
|
||||
from ray.dashboard.modules.metrics.dashboards.common import (
|
||||
DashboardConfig,
|
||||
GridPos,
|
||||
Panel,
|
||||
Target,
|
||||
)
|
||||
|
||||
SERVE_DEPLOYMENT_GRAFANA_PANELS = [
|
||||
Panel(
|
||||
id=1,
|
||||
title="Replicas per deployment",
|
||||
description='Number of replicas per deployment. Ignores "Route" variable.',
|
||||
unit="replicas",
|
||||
targets=[
|
||||
Target(
|
||||
expr="sum(ray_serve_deployment_replica_healthy{{{global_filters}}}) by (application, deployment)",
|
||||
legend="{{application}}#{{deployment}}#{{replica}}",
|
||||
),
|
||||
],
|
||||
grid_pos=GridPos(0, 0, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=2,
|
||||
title="QPS per replica",
|
||||
description="QPS for each replica.",
|
||||
unit="qps",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(rate(ray_serve_deployment_request_counter_total{{route=~"$Route",route!~"/-/.*",{global_filters}}}[5m])) by (application, deployment, replica)',
|
||||
legend="{{application}}#{{deployment}}#{{replica}}",
|
||||
),
|
||||
],
|
||||
grid_pos=GridPos(8, 0, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=3,
|
||||
title="Error QPS per replica",
|
||||
description="Error QPS for each replica.",
|
||||
unit="qps",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(rate(ray_serve_deployment_error_counter_total{{route=~"$Route",route!~"/-/.*",{global_filters}}}[5m])) by (application, deployment, replica)',
|
||||
legend="{{application}}#{{deployment}}#{{replica}}",
|
||||
),
|
||||
],
|
||||
grid_pos=GridPos(16, 0, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=4,
|
||||
title="P50 latency per replica",
|
||||
description="P50 latency per replica.",
|
||||
unit="ms",
|
||||
targets=[
|
||||
Target(
|
||||
expr='histogram_quantile(0.5, sum(rate(ray_serve_deployment_processing_latency_ms_bucket{{route=~"$Route",route!~"/-/.*",{global_filters}}}[5m])) by (application, deployment, replica, le))',
|
||||
legend="{{application}}#{{deployment}}#{{replica}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.5, sum(rate(ray_serve_deployment_processing_latency_ms_bucket{{route=~"$Route",route!~"/-/.*",{global_filters}}}[5m])) by (le))',
|
||||
legend="Total",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 1, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=5,
|
||||
title="P90 latency per replica",
|
||||
description="P90 latency per replica.",
|
||||
unit="ms",
|
||||
targets=[
|
||||
Target(
|
||||
expr='histogram_quantile(0.9, sum(rate(ray_serve_deployment_processing_latency_ms_bucket{{route=~"$Route",route!~"/-/.*",{global_filters}}}[5m])) by (application, deployment, replica, le))',
|
||||
legend="{{application}}#{{deployment}}#{{replica}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.9, sum(rate(ray_serve_deployment_processing_latency_ms_bucket{{route=~"$Route",route!~"/-/.*",{global_filters}}}[5m])) by (le))',
|
||||
legend="Total",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
grid_pos=GridPos(8, 1, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=6,
|
||||
title="P99 latency per replica",
|
||||
description="P99 latency per replica.",
|
||||
unit="ms",
|
||||
targets=[
|
||||
Target(
|
||||
expr='histogram_quantile(0.99, sum(rate(ray_serve_deployment_processing_latency_ms_bucket{{route=~"$Route",route!~"/-/.*",{global_filters}}}[5m])) by (application, deployment, replica, le))',
|
||||
legend="{{application}}#{{deployment}}#{{replica}}",
|
||||
),
|
||||
Target(
|
||||
expr='histogram_quantile(0.99, sum(rate(ray_serve_deployment_processing_latency_ms_bucket{{route=~"$Route",{global_filters}}}[5m])) by (le))',
|
||||
legend="Total",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
grid_pos=GridPos(16, 1, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=7,
|
||||
title="Queue size per deployment",
|
||||
description='Number of requests queued per deployment. Ignores "Replica" and "Route" variable.',
|
||||
unit="requests",
|
||||
targets=[
|
||||
Target(
|
||||
expr="sum(ray_serve_deployment_queued_queries{{{global_filters}}}) by (application, deployment)",
|
||||
legend="{{application}}#{{deployment}}#{{replica}}",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 2, 12, 8),
|
||||
),
|
||||
Panel(
|
||||
id=8,
|
||||
title="Running requests per replica",
|
||||
description="Current running requests for each replica.",
|
||||
unit="requests",
|
||||
targets=[
|
||||
Target(
|
||||
expr="sum(ray_serve_replica_processing_queries{{{global_filters}}}) by (application, deployment, replica)",
|
||||
legend="{{application}}#{{deployment}}#{{replica}}",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
grid_pos=GridPos(12, 2, 12, 8),
|
||||
),
|
||||
Panel(
|
||||
id=9,
|
||||
title="Multiplexed models per replica",
|
||||
description="The number of multiplexed models for each replica.",
|
||||
unit="models",
|
||||
targets=[
|
||||
Target(
|
||||
expr="sum(ray_serve_num_multiplexed_models{{{global_filters}}}) by (application, deployment, replica)",
|
||||
legend="{{application}}#{{deployment}}#{{replica}}",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 3, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=10,
|
||||
title="Multiplexed model loads per replica",
|
||||
description="The number of times of multiplexed models loaded for each replica.",
|
||||
unit="times",
|
||||
targets=[
|
||||
Target(
|
||||
expr="sum(ray_serve_multiplexed_models_load_counter_total{{{global_filters}}}) by (application, deployment, replica)",
|
||||
legend="{{application}}#{{deployment}}#{{replica}}",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
grid_pos=GridPos(8, 3, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=11,
|
||||
title="Multiplexed model unloads per replica",
|
||||
description="The number of times of multiplexed models unloaded for each replica.",
|
||||
unit="times",
|
||||
targets=[
|
||||
Target(
|
||||
expr="sum(ray_serve_multiplexed_models_unload_counter_total{{{global_filters}}}) by (application, deployment, replica)",
|
||||
legend="{{application}}#{{deployment}}#{{replica}}",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
grid_pos=GridPos(16, 3, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=12,
|
||||
title="P99 latency of multiplexed model loads per replica",
|
||||
description="P99 latency of multiplexed model load per replica.",
|
||||
unit="ms",
|
||||
targets=[
|
||||
Target(
|
||||
expr="histogram_quantile(0.99, sum(rate(ray_serve_multiplexed_model_load_latency_ms_bucket{{{global_filters}}}[5m])) by (application, deployment, replica, le))",
|
||||
legend="{{application}}#{{deployment}}#{{replica}}",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 4, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=13,
|
||||
title="P99 latency of multiplexed model unloads per replica",
|
||||
description="P99 latency of multiplexed model unload per replica.",
|
||||
unit="ms",
|
||||
targets=[
|
||||
Target(
|
||||
expr="histogram_quantile(0.99, sum(rate(ray_serve_multiplexed_model_unload_latency_ms_bucket{{{global_filters}}}[5m])) by (application, deployment, replica, le))",
|
||||
legend="{{application}}#{{deployment}}#{{replica}}",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
grid_pos=GridPos(8, 4, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=14,
|
||||
title="Multiplexed model ids per replica",
|
||||
description="The ids of multiplexed models for each replica.",
|
||||
unit="model",
|
||||
targets=[
|
||||
Target(
|
||||
expr="ray_serve_registered_multiplexed_model_id{{{global_filters}}}",
|
||||
legend="{{replica}}:{{model_id}}",
|
||||
),
|
||||
],
|
||||
grid_pos=GridPos(16, 4, 8, 8),
|
||||
stack=False,
|
||||
),
|
||||
Panel(
|
||||
id=15,
|
||||
title="Multiplexed model cache hit rate",
|
||||
description="The cache hit rate of multiplexed models for the deployment.",
|
||||
unit="%",
|
||||
targets=[
|
||||
Target(
|
||||
expr="(1 - sum(rate(ray_serve_multiplexed_models_load_counter_total{{{global_filters}}}[5m]))/sum(rate(ray_serve_multiplexed_get_model_requests_counter_total{{{global_filters}}}[5m])))",
|
||||
legend="{{application}}#{{deployment}}#{{replica}}",
|
||||
),
|
||||
],
|
||||
grid_pos=GridPos(0, 5, 8, 8),
|
||||
),
|
||||
]
|
||||
|
||||
ids = []
|
||||
for panel in SERVE_DEPLOYMENT_GRAFANA_PANELS:
|
||||
ids.append(panel.id)
|
||||
|
||||
ids.sort()
|
||||
|
||||
assert len(ids) == len(
|
||||
set(ids)
|
||||
), f"Duplicated id found. Use unique id for each panel. {ids}"
|
||||
|
||||
serve_deployment_dashboard_config = DashboardConfig(
|
||||
name="SERVE_DEPLOYMENT",
|
||||
default_uid="rayServeDeploymentDashboard",
|
||||
panels=SERVE_DEPLOYMENT_GRAFANA_PANELS,
|
||||
standard_global_filters=[
|
||||
'application=~"$Application"',
|
||||
'deployment=~"$Deployment"',
|
||||
'replica=~"$Replica"',
|
||||
'ray_io_cluster=~"$Cluster"',
|
||||
],
|
||||
base_json_file_name="serve_deployment_grafana_dashboard_base.json",
|
||||
)
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": "-- Grafana --",
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"gnetId": null,
|
||||
"graphTooltip": 1,
|
||||
"iteration": 1667344411089,
|
||||
"links": [],
|
||||
"panels": [],
|
||||
"refresh": false,
|
||||
"schemaVersion": 27,
|
||||
"style": "dark",
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {
|
||||
"selected": false
|
||||
},
|
||||
"description": "Filter queries to specific prometheus type.",
|
||||
"hide": 2,
|
||||
"includeAll": false,
|
||||
"multi": false,
|
||||
"name": "datasource",
|
||||
"options": [],
|
||||
"query": "prometheus",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "datasource"
|
||||
},
|
||||
{
|
||||
"allValue": ".*",
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": ["All"],
|
||||
"value": ["$__all"]
|
||||
},
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_serve_deployment_replica_healthy{{{global_filters}}}, application)",
|
||||
"description": null,
|
||||
"error": null,
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": null,
|
||||
"multi": true,
|
||||
"name": "Application",
|
||||
"options": [],
|
||||
"query": {
|
||||
"query": "label_values(ray_serve_deployment_replica_healthy{{{global_filters}}}, application)",
|
||||
"refId": "Prometheus-Instance-Variable-Query"
|
||||
},
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"sort": 0,
|
||||
"tagValuesQuery": "",
|
||||
"tags": [],
|
||||
"tagsQuery": "",
|
||||
"type": "query",
|
||||
"useTags": false
|
||||
},
|
||||
{
|
||||
"allValue": ".*",
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": ["All"],
|
||||
"value": ["$__all"]
|
||||
},
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_serve_deployment_replica_healthy{{application=~\"$Application\",{global_filters}}}, deployment)",
|
||||
"description": null,
|
||||
"error": null,
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": null,
|
||||
"multi": true,
|
||||
"name": "Deployment",
|
||||
"options": [],
|
||||
"query": {
|
||||
"query": "label_values(ray_serve_deployment_replica_healthy{{application=~\"$Application\",{global_filters}}}, deployment)",
|
||||
"refId": "Prometheus-Instance-Variable-Query"
|
||||
},
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"sort": 0,
|
||||
"tagValuesQuery": "",
|
||||
"tags": [],
|
||||
"tagsQuery": "",
|
||||
"type": "query",
|
||||
"useTags": false
|
||||
},
|
||||
{
|
||||
"allValue": ".*",
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": ["All"],
|
||||
"value": ["$__all"]
|
||||
},
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_serve_deployment_replica_healthy{{application=~\"$Application\",deployment=~\"$Deployment\",{global_filters}}}, replica)",
|
||||
"description": null,
|
||||
"error": null,
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": null,
|
||||
"multi": true,
|
||||
"name": "Replica",
|
||||
"options": [],
|
||||
"query": {
|
||||
"query": "label_values(ray_serve_deployment_replica_healthy{{application=~\"$Application\",deployment=~\"$Deployment\",{global_filters}}}, replica)",
|
||||
"refId": "Prometheus-Instance-Variable-Query"
|
||||
},
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"sort": 0,
|
||||
"tagValuesQuery": "",
|
||||
"tags": [],
|
||||
"tagsQuery": "",
|
||||
"type": "query",
|
||||
"useTags": false
|
||||
},
|
||||
{
|
||||
"allValue": ".*",
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": ["All"],
|
||||
"value": ["$__all"]
|
||||
},
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_serve_deployment_request_counter{{deployment=~\"$Deployment\",{global_filters}}}, route)",
|
||||
"description": null,
|
||||
"error": null,
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": null,
|
||||
"multi": true,
|
||||
"name": "Route",
|
||||
"options": [],
|
||||
"query": {
|
||||
"query": "label_values(ray_serve_deployment_request_counter{{deployment=~\"$Deployment\",{global_filters}}}, route)",
|
||||
"refId": "Prometheus-Instance-Variable-Query"
|
||||
},
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"sort": 0,
|
||||
"tagValuesQuery": "",
|
||||
"tags": [],
|
||||
"tagsQuery": "",
|
||||
"type": "query",
|
||||
"useTags": false
|
||||
},
|
||||
{
|
||||
"allValue": ".*",
|
||||
"current": {
|
||||
"selected": false
|
||||
},
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_node_network_receive_speed{{{global_filters}}}, ray_io_cluster)",
|
||||
"description": "Filter queries to specific Ray clusters for KubeRay. When ingesting metrics across multiple ray clusters, the ray_io_cluster label should be set per cluster. For KubeRay users, this is done automatically with Prometheus PodMonitor.",
|
||||
"error": null,
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": null,
|
||||
"multi": false,
|
||||
"name": "Cluster",
|
||||
"options": [],
|
||||
"query": {
|
||||
"query": "label_values(ray_node_network_receive_speed{{{global_filters}}}, ray_io_cluster)",
|
||||
"refId": "StandardVariableQuery"
|
||||
},
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"sort": 2,
|
||||
"tagValuesQuery": "",
|
||||
"tags": [],
|
||||
"tagsQuery": "",
|
||||
"type": "query",
|
||||
"useTags": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"rayMeta": ["excludesSystemRoutes"],
|
||||
"time": {
|
||||
"from": "now-30m",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "Serve Deployment Dashboard",
|
||||
"uid": "rayServeDeploymentDashboard",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": "-- Grafana --",
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"gnetId": null,
|
||||
"graphTooltip": 1,
|
||||
"iteration": 1667344411089,
|
||||
"links": [],
|
||||
"panels": [],
|
||||
"refresh": false,
|
||||
"schemaVersion": 27,
|
||||
"style": "dark",
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {
|
||||
"selected": false
|
||||
},
|
||||
"description": "Filter queries of a specific Prometheus type.",
|
||||
"hide": 2,
|
||||
"includeAll": false,
|
||||
"multi": false,
|
||||
"name": "datasource",
|
||||
"options": [],
|
||||
"query": "prometheus",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "datasource"
|
||||
},
|
||||
{
|
||||
"allValue": ".*",
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": ["All"],
|
||||
"value": ["$__all"]
|
||||
},
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_serve_deployment_replica_healthy{{{global_filters}}}, application)",
|
||||
"description": null,
|
||||
"error": null,
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": null,
|
||||
"multi": true,
|
||||
"name": "Application",
|
||||
"options": [],
|
||||
"query": {
|
||||
"query": "label_values(ray_serve_deployment_replica_healthy{{{global_filters}}}, application)",
|
||||
"refId": "Prometheus-Instance-Variable-Query"
|
||||
},
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"sort": 0,
|
||||
"tagValuesQuery": "",
|
||||
"tags": [],
|
||||
"tagsQuery": "",
|
||||
"type": "query",
|
||||
"useTags": false
|
||||
},
|
||||
{
|
||||
"allValue": ".*",
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": ["All"],
|
||||
"value": ["$__all"]
|
||||
},
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_serve_num_http_requests_total{{{global_filters}}}, route)",
|
||||
"description": null,
|
||||
"error": null,
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": "HTTP Route",
|
||||
"multi": true,
|
||||
"name": "HTTP_Route",
|
||||
"options": [],
|
||||
"query": {
|
||||
"query": "label_values(ray_serve_num_http_requests_total{{{global_filters}}}, route)",
|
||||
"refId": "Prometheus-Instance-Variable-Query"
|
||||
},
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"sort": 0,
|
||||
"tagValuesQuery": "",
|
||||
"tags": [],
|
||||
"tagsQuery": "",
|
||||
"type": "query",
|
||||
"useTags": false
|
||||
},
|
||||
{
|
||||
"allValue": ".*",
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": ["All"],
|
||||
"value": ["$__all"]
|
||||
},
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_serve_num_grpc_requests_total{{{global_filters}}}, method)",
|
||||
"description": null,
|
||||
"error": null,
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": "gRPC Service Method",
|
||||
"multi": true,
|
||||
"name": "gRPC_Method",
|
||||
"options": [],
|
||||
"query": {
|
||||
"query": "label_values(ray_serve_num_grpc_requests_total{{{global_filters}}}, method)",
|
||||
"refId": "Prometheus-Instance-Variable-Query"
|
||||
},
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"sort": 0,
|
||||
"tagValuesQuery": "",
|
||||
"tags": [],
|
||||
"tagsQuery": "",
|
||||
"type": "query",
|
||||
"useTags": false
|
||||
},
|
||||
{
|
||||
"allValue": ".*",
|
||||
"current": {
|
||||
"selected": false
|
||||
},
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_node_network_receive_speed{{{global_filters}}}, ray_io_cluster)",
|
||||
"description": "Filter queries to specific Ray clusters for KubeRay. When ingesting metrics across multiple ray clusters, the ray_io_cluster label should be set per cluster. For KubeRay users, this is done automatically with Prometheus PodMonitor.",
|
||||
"error": null,
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": null,
|
||||
"multi": false,
|
||||
"name": "Cluster",
|
||||
"options": [],
|
||||
"query": {
|
||||
"query": "label_values(ray_node_network_receive_speed{{{global_filters}}}, ray_io_cluster)",
|
||||
"refId": "StandardVariableQuery"
|
||||
},
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"sort": 2,
|
||||
"tagValuesQuery": "",
|
||||
"tags": [],
|
||||
"tagsQuery": "",
|
||||
"type": "query",
|
||||
"useTags": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"rayMeta": ["excludesSystemRoutes"],
|
||||
"time": {
|
||||
"from": "now-30m",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "Serve Dashboard",
|
||||
"uid": "rayServeDashboard",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
# ruff: noqa: E501
|
||||
|
||||
from ray.dashboard.modules.metrics.dashboards.common import (
|
||||
DashboardConfig,
|
||||
GridPos,
|
||||
Panel,
|
||||
PanelTemplate,
|
||||
Row,
|
||||
Target,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reusable PromQL fragments
|
||||
# ---------------------------------------------------------------------------
|
||||
# WorkerId join: attaches deployment + replica labels to vLLM-only metrics.
|
||||
# The `* 0 + 1` trick turns the counter into a constant-1 lookup table keyed
|
||||
# by WorkerId so the join resolves deployment + replica labels without
|
||||
# affecting the numeric value of the left-hand side.
|
||||
# Keep `{global_filters}` trailing so an empty substitution leaves a tolerated
|
||||
# trailing comma instead of a leading/double comma that Prometheus rejects.
|
||||
_WORKER_JOIN = (
|
||||
"\n* on(WorkerId) group_left(deployment, replica)"
|
||||
"\nmax by(WorkerId, deployment, replica) ("
|
||||
'ray_serve_deployment_request_counter_total{{deployment=~"$deployment", {global_filters}}} * 0 + 1)'
|
||||
)
|
||||
|
||||
# Standard vLLM metric filter
|
||||
_VLLM_FILTER = 'model_name=~"$vllm_model_name", WorkerId=~"$workerid", {global_filters}'
|
||||
|
||||
# vLLM filter scoped to a specific deployment (used for ray_serve_* metrics
|
||||
# that also carry model_name / WorkerId labels).
|
||||
_VLLM_DEPLOYMENT_FILTER = 'model_name=~"$vllm_model_name", WorkerId=~"$workerid", deployment=~"$deployment", {global_filters}'
|
||||
|
||||
# Legend used by most per-worker panels
|
||||
_DEP_REPLICA = "{{deployment}}: {{replica}}"
|
||||
|
||||
|
||||
def _mean_with_join(metric_base: str) -> str:
|
||||
"""Mean = sum(_sum) / sum(_count) with NaN guard + WorkerId join."""
|
||||
return (
|
||||
"(\n"
|
||||
" (\n"
|
||||
f" sum by(WorkerId) (rate({metric_base}_sum{{{{{_VLLM_FILTER}}}}}[$interval]))\n"
|
||||
" /\n"
|
||||
f" sum by(WorkerId) (rate({metric_base}_count{{{{{_VLLM_FILTER}}}}}[$interval]))\n"
|
||||
" )\n"
|
||||
" and on(WorkerId)\n"
|
||||
" (\n"
|
||||
f" sum by(WorkerId) (rate({metric_base}_count{{{{{_VLLM_FILTER}}}}}[$interval])) > 0\n"
|
||||
" )\n"
|
||||
")" + _WORKER_JOIN
|
||||
)
|
||||
|
||||
|
||||
def _percentile_with_join(metric_base: str, quantile: float) -> str:
|
||||
"""histogram_quantile with NaN guard + WorkerId join."""
|
||||
return (
|
||||
"(\n"
|
||||
" histogram_quantile(\n"
|
||||
f" {quantile},\n"
|
||||
f" sum by (le, WorkerId) (rate({metric_base}_bucket{{{{{_VLLM_FILTER}}}}}[$interval]))\n"
|
||||
" )\n"
|
||||
" and on(WorkerId)\n"
|
||||
" (\n"
|
||||
f" sum by(WorkerId) (rate({metric_base}_count{{{{{_VLLM_FILTER}}}}}[$interval])) > 0\n"
|
||||
" )\n"
|
||||
")" + _WORKER_JOIN
|
||||
)
|
||||
|
||||
|
||||
def _gauge_with_join(metric: str) -> str:
|
||||
"""Simple gauge metric with WorkerId join (no rate, no guard)."""
|
||||
return f"sum by(WorkerId) ({metric}{{{{{_VLLM_FILTER}}}}})" + _WORKER_JOIN
|
||||
|
||||
|
||||
def _rate_with_join(metric: str, agg_fn: str = "rate") -> str:
|
||||
"""rate() or increase() of a metric summed by WorkerId, with join."""
|
||||
return (
|
||||
f"sum by(WorkerId) ({agg_fn}({metric}{{{{{_VLLM_FILTER}}}}}[$interval]))"
|
||||
+ _WORKER_JOIN
|
||||
)
|
||||
|
||||
|
||||
def _ratio_with_join_and_guard(
|
||||
numerator_metric: str,
|
||||
denominator_metric: str,
|
||||
*,
|
||||
scale: str = "",
|
||||
guard_metric: str | None = None,
|
||||
) -> str:
|
||||
"""Ratio of two rate metrics with NaN guard + WorkerId join.
|
||||
|
||||
Optionally applies a scale factor (e.g. '* 1000' or '/ 1024 / 1024 / 1024').
|
||||
"""
|
||||
guard = guard_metric or denominator_metric
|
||||
return (
|
||||
"(\n"
|
||||
" (\n"
|
||||
f" sum by(WorkerId) (rate({numerator_metric}{{{{{_VLLM_FILTER}}}}}[$interval]))\n"
|
||||
" /\n"
|
||||
f" sum by(WorkerId) (rate({denominator_metric}{{{{{_VLLM_FILTER}}}}}[$interval]))\n"
|
||||
+ (f" {scale}\n" if scale else "")
|
||||
+ " )\n"
|
||||
" and on(WorkerId)\n"
|
||||
" (\n"
|
||||
f" sum by(WorkerId) (rate({guard}{{{{{_VLLM_FILTER}}}}}[$interval])) > 0\n"
|
||||
" )\n"
|
||||
")" + _WORKER_JOIN
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Histogram helper: generates Mean / P50 / P90 panels for a given metric
|
||||
# ---------------------------------------------------------------------------
|
||||
def _histogram_panels(
|
||||
metric_base: str,
|
||||
label: str,
|
||||
ids: tuple,
|
||||
y: int,
|
||||
unit: str = "s",
|
||||
linewidth: int = 2,
|
||||
description: str = "",
|
||||
) -> list:
|
||||
"""Return [Mean, P50, P90] panels for a histogram metric."""
|
||||
return [
|
||||
Panel(
|
||||
id=ids[0],
|
||||
title=f"{label} -- Mean",
|
||||
description=description,
|
||||
unit=unit,
|
||||
targets=[Target(expr=_mean_with_join(metric_base), legend=_DEP_REPLICA)],
|
||||
fill=1,
|
||||
linewidth=linewidth,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, y, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=ids[1],
|
||||
title=f"{label} -- P50",
|
||||
description=description,
|
||||
unit=unit,
|
||||
targets=[
|
||||
Target(
|
||||
expr=_percentile_with_join(metric_base, 0.5), legend=_DEP_REPLICA
|
||||
)
|
||||
],
|
||||
fill=1,
|
||||
linewidth=linewidth,
|
||||
stack=False,
|
||||
grid_pos=GridPos(8, y, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=ids[2],
|
||||
title=f"{label} -- P90",
|
||||
description=description,
|
||||
unit=unit,
|
||||
targets=[
|
||||
Target(
|
||||
expr=_percentile_with_join(metric_base, 0.9), legend=_DEP_REPLICA
|
||||
)
|
||||
],
|
||||
fill=1,
|
||||
linewidth=linewidth,
|
||||
stack=False,
|
||||
grid_pos=GridPos(16, y, 8, 8),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Row 1: Throughput
|
||||
# ===================================================================
|
||||
_throughput_panels = [
|
||||
Panel(
|
||||
id=2,
|
||||
title="Requests / s",
|
||||
description="",
|
||||
unit="short",
|
||||
targets=[
|
||||
Target(
|
||||
expr=f"sum by (deployment, replica) (rate(ray_serve_deployment_request_counter_total{{{{{_VLLM_DEPLOYMENT_FILTER}}}}}[$interval]))",
|
||||
legend=_DEP_REPLICA,
|
||||
),
|
||||
Target(
|
||||
expr=f"sum(rate(ray_serve_deployment_request_counter_total{{{{{_VLLM_DEPLOYMENT_FILTER}}}}}[$interval]))",
|
||||
legend="Total QPS",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 1, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=3,
|
||||
title="Prompt Tokens/s",
|
||||
description="Number of tokens processed per second",
|
||||
unit="tokens/s",
|
||||
targets=[
|
||||
Target(
|
||||
expr=_rate_with_join("ray_vllm_request_prompt_tokens_sum"),
|
||||
legend=_DEP_REPLICA,
|
||||
),
|
||||
Target(
|
||||
expr=f"sum(rate(ray_vllm_request_prompt_tokens_sum{{{{{_VLLM_FILTER}}}}}[$interval]))",
|
||||
legend="Total",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(8, 1, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=4,
|
||||
title="Generation Tokens/s",
|
||||
description="Number of tokens processed per second",
|
||||
unit="tokens/s",
|
||||
targets=[
|
||||
Target(
|
||||
expr=_rate_with_join("ray_vllm_generation_tokens_total"),
|
||||
legend=_DEP_REPLICA,
|
||||
),
|
||||
Target(
|
||||
expr=f"sum(rate(ray_vllm_generation_tokens_total{{{{{_VLLM_FILTER}}}}}[$interval]))",
|
||||
legend="Total",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(16, 1, 8, 8),
|
||||
),
|
||||
]
|
||||
|
||||
# ===================================================================
|
||||
# Row 2: Latency (3x3 grid)
|
||||
# ===================================================================
|
||||
_latency_panels_list = [
|
||||
*_histogram_panels(
|
||||
"ray_vllm_request_time_per_output_token_seconds", "TPOT", (6, 7, 8), 10
|
||||
),
|
||||
*_histogram_panels("ray_vllm_time_to_first_token_seconds", "TTFT", (9, 10, 11), 18),
|
||||
*_histogram_panels(
|
||||
"ray_vllm_e2e_request_latency_seconds",
|
||||
"Request Latency",
|
||||
(12, 13, 14),
|
||||
26,
|
||||
description="Latency from request start to first token returned (in seconds).",
|
||||
),
|
||||
]
|
||||
|
||||
# ===================================================================
|
||||
# Row 3: Cache
|
||||
# ===================================================================
|
||||
_cache_panels = [
|
||||
Panel(
|
||||
id=16,
|
||||
title="Cache Utilization",
|
||||
description="Percentage of used cache blocks by vLLM.",
|
||||
unit="percentunit",
|
||||
targets=[
|
||||
Target(
|
||||
expr=_gauge_with_join("ray_vllm_kv_cache_usage_perc"),
|
||||
legend=_DEP_REPLICA,
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 35, 12, 8),
|
||||
),
|
||||
Panel(
|
||||
id=17,
|
||||
title="GPU KV Cache Hit Rate",
|
||||
description="",
|
||||
unit="percent",
|
||||
targets=[
|
||||
Target(
|
||||
expr=(
|
||||
f"100 * ("
|
||||
f"(sum by(WorkerId) (rate(ray_vllm_prefix_cache_hits_total{{{{{_VLLM_FILTER}}}}}[$interval])) "
|
||||
f"/ sum by(WorkerId) (rate(ray_vllm_prefix_cache_queries_total{{{{{_VLLM_FILTER}}}}}[$interval])))"
|
||||
f" and on(WorkerId) "
|
||||
f"(sum by(WorkerId) (rate(ray_vllm_prefix_cache_queries_total{{{{{_VLLM_FILTER}}}}}[$interval])) > 0))"
|
||||
+ _WORKER_JOIN
|
||||
),
|
||||
legend=_DEP_REPLICA,
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=1,
|
||||
stack=False,
|
||||
grid_pos=GridPos(12, 35, 12, 8),
|
||||
),
|
||||
]
|
||||
|
||||
# ===================================================================
|
||||
# Row 4: Request Length
|
||||
# ===================================================================
|
||||
_request_length_panels = [
|
||||
*_histogram_panels(
|
||||
"ray_vllm_request_prompt_tokens",
|
||||
"Prompt Length",
|
||||
(19, 20, 21),
|
||||
44,
|
||||
unit="short",
|
||||
linewidth=1,
|
||||
),
|
||||
*_histogram_panels(
|
||||
"ray_vllm_request_generation_tokens",
|
||||
"Generation Length",
|
||||
(22, 23, 24),
|
||||
52,
|
||||
unit="short",
|
||||
linewidth=1,
|
||||
),
|
||||
]
|
||||
|
||||
# ===================================================================
|
||||
# Row 5: Scheduler
|
||||
# ===================================================================
|
||||
_scheduler_panels = [
|
||||
Panel(
|
||||
id=26,
|
||||
title="Scheduler: Running",
|
||||
description="",
|
||||
unit="short",
|
||||
targets=[
|
||||
Target(
|
||||
expr=_gauge_with_join("ray_vllm_num_requests_running"),
|
||||
legend=_DEP_REPLICA,
|
||||
)
|
||||
],
|
||||
fill=1,
|
||||
linewidth=1,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 61, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=27,
|
||||
title="Scheduler: Swapped",
|
||||
description="",
|
||||
unit="short",
|
||||
targets=[
|
||||
Target(
|
||||
expr=_gauge_with_join("ray_vllm_num_requests_swapped"),
|
||||
legend=_DEP_REPLICA,
|
||||
)
|
||||
],
|
||||
fill=1,
|
||||
linewidth=1,
|
||||
stack=False,
|
||||
grid_pos=GridPos(8, 61, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=28,
|
||||
title="Scheduler: Waiting",
|
||||
description="",
|
||||
unit="short",
|
||||
targets=[
|
||||
Target(
|
||||
expr=_gauge_with_join("ray_vllm_num_requests_waiting"),
|
||||
legend=_DEP_REPLICA,
|
||||
)
|
||||
],
|
||||
fill=1,
|
||||
linewidth=1,
|
||||
stack=False,
|
||||
grid_pos=GridPos(16, 61, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=29,
|
||||
title="Finish Reason",
|
||||
description="Number of finished requests by their finish reason.",
|
||||
unit="short",
|
||||
targets=[
|
||||
Target(
|
||||
expr=(
|
||||
f"sum by(finished_reason, WorkerId) (increase(ray_vllm_request_success_total{{{{{_VLLM_FILTER}}}}}[$interval]))"
|
||||
+ _WORKER_JOIN
|
||||
),
|
||||
legend="{{finished_reason}} \u2014 {{deployment}}: {{replica}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=1,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 69, 12, 8),
|
||||
),
|
||||
Panel(
|
||||
id=30,
|
||||
title="Queue Time",
|
||||
description="",
|
||||
unit="s",
|
||||
targets=[
|
||||
Target(
|
||||
expr=_rate_with_join("ray_vllm_request_queue_time_seconds_sum"),
|
||||
legend=_DEP_REPLICA,
|
||||
)
|
||||
],
|
||||
fill=1,
|
||||
linewidth=1,
|
||||
stack=False,
|
||||
grid_pos=GridPos(12, 69, 12, 8),
|
||||
),
|
||||
Panel(
|
||||
id=31,
|
||||
title="Prefill Time",
|
||||
description="",
|
||||
unit="s",
|
||||
targets=[
|
||||
Target(
|
||||
expr=_rate_with_join("ray_vllm_request_prefill_time_seconds_sum"),
|
||||
legend=_DEP_REPLICA,
|
||||
)
|
||||
],
|
||||
fill=1,
|
||||
linewidth=1,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 77, 12, 8),
|
||||
),
|
||||
Panel(
|
||||
id=32,
|
||||
title="Decode Time",
|
||||
description="",
|
||||
unit="s",
|
||||
targets=[
|
||||
Target(
|
||||
expr=_rate_with_join("ray_vllm_request_decode_time_seconds_sum"),
|
||||
legend=_DEP_REPLICA,
|
||||
)
|
||||
],
|
||||
fill=1,
|
||||
linewidth=1,
|
||||
stack=False,
|
||||
grid_pos=GridPos(12, 77, 12, 8),
|
||||
),
|
||||
]
|
||||
|
||||
# ===================================================================
|
||||
# Row 6: NIXL
|
||||
# ===================================================================
|
||||
_nixl_panels = [
|
||||
Panel(
|
||||
id=34,
|
||||
title="NIXL: Transfer Latency",
|
||||
description="Average NIXL KV cache transfer latency in milliseconds.",
|
||||
unit="ms",
|
||||
targets=[
|
||||
Target(
|
||||
expr=_ratio_with_join_and_guard(
|
||||
"ray_vllm_nixl_xfer_time_seconds_sum",
|
||||
"ray_vllm_nixl_xfer_time_seconds_count",
|
||||
scale="* 1000",
|
||||
),
|
||||
legend=_DEP_REPLICA,
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=1,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 86, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=35,
|
||||
title="NIXL: Transfer Throughput",
|
||||
description="NIXL KV cache transfer throughput in GB/s.",
|
||||
unit="GBs",
|
||||
targets=[
|
||||
Target(
|
||||
expr=_ratio_with_join_and_guard(
|
||||
"ray_vllm_nixl_bytes_transferred_sum",
|
||||
"ray_vllm_nixl_xfer_time_seconds_sum",
|
||||
scale="/ 1024 / 1024 / 1024",
|
||||
),
|
||||
legend=_DEP_REPLICA,
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=1,
|
||||
stack=False,
|
||||
grid_pos=GridPos(8, 86, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=36,
|
||||
title="NIXL: Transfer Rate",
|
||||
description="Number of NIXL KV cache transfers per second.",
|
||||
unit="ops",
|
||||
targets=[
|
||||
Target(
|
||||
expr=_rate_with_join("ray_vllm_nixl_xfer_time_seconds_count"),
|
||||
legend=_DEP_REPLICA,
|
||||
)
|
||||
],
|
||||
fill=1,
|
||||
linewidth=1,
|
||||
stack=False,
|
||||
grid_pos=GridPos(16, 86, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=37,
|
||||
title="NIXL: Avg Post Time",
|
||||
description="Average time to post/initiate a NIXL transfer in milliseconds.",
|
||||
unit="ms",
|
||||
targets=[
|
||||
Target(
|
||||
expr=_ratio_with_join_and_guard(
|
||||
"ray_vllm_nixl_post_time_seconds_sum",
|
||||
"ray_vllm_nixl_post_time_seconds_count",
|
||||
scale="* 1000",
|
||||
),
|
||||
legend=_DEP_REPLICA,
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=1,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 94, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=38,
|
||||
title="NIXL: KV Transfer Failures",
|
||||
description="Number of failed NIXL KV cache transfers. Any non-zero value is concerning and indicates RDMA transfer errors.",
|
||||
unit="short",
|
||||
targets=[
|
||||
Target(
|
||||
expr=_rate_with_join(
|
||||
"ray_vllm_nixl_num_failed_transfers", agg_fn="increase"
|
||||
),
|
||||
legend=_DEP_REPLICA,
|
||||
)
|
||||
],
|
||||
fill=1,
|
||||
linewidth=1,
|
||||
stack=False,
|
||||
grid_pos=GridPos(8, 94, 8, 8),
|
||||
),
|
||||
Panel(
|
||||
id=39,
|
||||
title="NIXL: KV Expired Requests",
|
||||
description="Number of requests whose KV blocks expired before decode consumed them. Spikes indicate prefill is outrunning decode or the timeout is too short.",
|
||||
unit="short",
|
||||
targets=[
|
||||
Target(
|
||||
expr=_rate_with_join(
|
||||
"ray_vllm_nixl_num_kv_expired_reqs_total", agg_fn="increase"
|
||||
),
|
||||
legend=_DEP_REPLICA,
|
||||
)
|
||||
],
|
||||
fill=1,
|
||||
linewidth=1,
|
||||
stack=False,
|
||||
grid_pos=GridPos(16, 94, 8, 8),
|
||||
),
|
||||
]
|
||||
|
||||
# ===================================================================
|
||||
# Row 7: Token Distribution (collapsed)
|
||||
# ===================================================================
|
||||
_WORKERID_FILTER = 'WorkerId=~"$workerid", {global_filters}'
|
||||
|
||||
_token_distribution_panels = [
|
||||
Panel(
|
||||
id=41,
|
||||
title="Tokens Last 24 Hours",
|
||||
description="",
|
||||
unit="short",
|
||||
targets=[
|
||||
Target(
|
||||
expr=f"(sum by (model_name) (delta(ray_vllm_prompt_tokens_total{{{{{_WORKERID_FILTER}}}}}[1d])))",
|
||||
legend="Input: {{model_name}}",
|
||||
),
|
||||
Target(
|
||||
expr=f"(sum by (model_name) (delta(ray_vllm_generation_tokens_total{{{{{_WORKERID_FILTER}}}}}[1d])))",
|
||||
legend="Generated: {{model_name}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 103, 12, 8),
|
||||
template=PanelTemplate.STAT,
|
||||
),
|
||||
Panel(
|
||||
id=42,
|
||||
title="Tokens Last Hour",
|
||||
description="",
|
||||
unit="short",
|
||||
targets=[
|
||||
Target(
|
||||
expr=f"sum by (model_name) (delta(ray_vllm_prompt_tokens_total{{{{{_WORKERID_FILTER}}}}}[1h]))",
|
||||
legend="Input: {{model_name}}",
|
||||
),
|
||||
Target(
|
||||
expr=f"sum by (model_name) (delta(ray_vllm_generation_tokens_total{{{{{_WORKERID_FILTER}}}}}[1h]))",
|
||||
legend="Generated: {{model_name}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(12, 103, 12, 8),
|
||||
template=PanelTemplate.STAT,
|
||||
),
|
||||
Panel(
|
||||
id=43,
|
||||
title="Ratio Input:Generated Tokens Last 24 Hours",
|
||||
description="",
|
||||
unit="short",
|
||||
targets=[
|
||||
Target(
|
||||
expr=f"sum by (model_name) (delta(ray_vllm_prompt_tokens_total{{{{{_WORKERID_FILTER}}}}}[1d])) / sum by (model_name) (delta(ray_vllm_generation_tokens_total{{{{{_WORKERID_FILTER}}}}}[1d]))",
|
||||
legend="{{model_name}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 111, 12, 8),
|
||||
template=PanelTemplate.STAT,
|
||||
),
|
||||
Panel(
|
||||
id=44,
|
||||
title="Distribution of Requests Per Model Last 24 Hours",
|
||||
description="",
|
||||
unit="Requests",
|
||||
targets=[
|
||||
Target(
|
||||
expr=f"sum by (model_name) (delta(ray_vllm_request_success_total{{{{{_WORKERID_FILTER}}}}}[1d]))",
|
||||
legend="{{model_name}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(12, 111, 12, 8),
|
||||
template=PanelTemplate.PIE_CHART,
|
||||
),
|
||||
Panel(
|
||||
id=45,
|
||||
title="Peak Tokens Per Second Per Model Last 24 Hours",
|
||||
description="",
|
||||
unit="short",
|
||||
targets=[
|
||||
Target(
|
||||
expr=f"max_over_time(sum by (model_name) (rate(ray_vllm_generation_tokens_total{{{{{_WORKERID_FILTER}}}}}[2m]))[24h:1m])",
|
||||
legend="{{model_name}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 119, 12, 8),
|
||||
template=PanelTemplate.STAT,
|
||||
),
|
||||
Panel(
|
||||
id=46,
|
||||
title="Tokens Per Model Last 24 Hours",
|
||||
description="",
|
||||
unit="short",
|
||||
targets=[
|
||||
Target(
|
||||
expr=f"sum by (model_name) (delta(ray_vllm_prompt_tokens_total{{{{{_WORKERID_FILTER}}}}}[1d])) + sum by (model_name) (delta(ray_vllm_generation_tokens_total{{{{{_WORKERID_FILTER}}}}}[1d]))",
|
||||
legend="{{model_name}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(12, 119, 12, 8),
|
||||
template=PanelTemplate.STAT,
|
||||
),
|
||||
Panel(
|
||||
id=47,
|
||||
title="Avg Total Tokens Per Request Last 7 Days",
|
||||
description="",
|
||||
unit="short",
|
||||
targets=[
|
||||
Target(
|
||||
expr=(
|
||||
f"(sum by (model_name) (delta(ray_vllm_prompt_tokens_total{{{{{_WORKERID_FILTER}}}}}[1w])) +\n"
|
||||
f"sum by (model_name) (delta(ray_vllm_generation_tokens_total{{{{{_WORKERID_FILTER}}}}}[1w])))"
|
||||
f" / sum by (model_name) (delta(ray_vllm_request_success_total{{{{{_WORKERID_FILTER}}}}}[1w]))"
|
||||
),
|
||||
legend="{{ model_name}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 127, 12, 8),
|
||||
template=PanelTemplate.GAUGE,
|
||||
),
|
||||
Panel(
|
||||
id=48,
|
||||
title="Requests Per Model Last Week",
|
||||
description="",
|
||||
unit="short",
|
||||
targets=[
|
||||
Target(
|
||||
expr=f"sum by (model_name) (delta(ray_vllm_request_success_total{{{{{_WORKERID_FILTER}}}}}[1w]))",
|
||||
legend="{{ model_name}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(12, 127, 12, 8),
|
||||
template=PanelTemplate.GAUGE,
|
||||
),
|
||||
Panel(
|
||||
id=49,
|
||||
title="Tokens Per Model Last 7 Days",
|
||||
description="",
|
||||
unit="short",
|
||||
targets=[
|
||||
Target(
|
||||
expr=f"sum by (model_name) (delta(ray_vllm_prompt_tokens_total{{{{{_WORKERID_FILTER}}}}}[1w]))",
|
||||
legend="In: {{ model_name}}",
|
||||
),
|
||||
Target(
|
||||
expr=f"sum by (model_name) (delta(ray_vllm_generation_tokens_total{{{{{_WORKERID_FILTER}}}}}[1w]))",
|
||||
legend="Out: {{ model_name }}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 135, 12, 8),
|
||||
template=PanelTemplate.GAUGE,
|
||||
),
|
||||
Panel(
|
||||
id=50,
|
||||
title="Avg Total Tokens Per Request Per Model Last 7 Days",
|
||||
description="",
|
||||
unit="short",
|
||||
targets=[
|
||||
Target(
|
||||
expr=(
|
||||
f"(sum by (model_name) (delta(ray_vllm_prompt_tokens_total{{{{{_WORKERID_FILTER}}}}}[1w])) "
|
||||
f"+ sum by (model_name) (delta(ray_vllm_generation_tokens_total{{{{{_WORKERID_FILTER}}}}}[1w])))"
|
||||
f"/ sum by (model_name) (delta(ray_vllm_request_success_total{{{{{_WORKERID_FILTER}}}}}[1w]))"
|
||||
),
|
||||
legend="{{ model_name}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(12, 135, 12, 8),
|
||||
template=PanelTemplate.GAUGE,
|
||||
),
|
||||
Panel(
|
||||
id=51,
|
||||
title="Tokens Per Request Per Model Last 7 Days",
|
||||
description="",
|
||||
unit="short",
|
||||
targets=[
|
||||
Target(
|
||||
expr=f"sum by (model_name) (delta(ray_vllm_prompt_tokens_total{{{{{_WORKERID_FILTER}}}}}[1w])) / sum by (model_name) (delta(ray_vllm_request_success_total{{{{{_WORKERID_FILTER}}}}}[1w]))",
|
||||
legend="In: {{ model_name}}",
|
||||
),
|
||||
Target(
|
||||
expr=f"sum by (model_name) (delta(ray_vllm_generation_tokens_total{{{{{_WORKERID_FILTER}}}}}[1w])) / sum by (model_name) (delta(ray_vllm_request_success_total{{{{{_WORKERID_FILTER}}}}}[1w]))",
|
||||
legend="Out: {{ model_name}}",
|
||||
),
|
||||
],
|
||||
fill=1,
|
||||
linewidth=2,
|
||||
stack=False,
|
||||
grid_pos=GridPos(0, 143, 12, 8),
|
||||
template=PanelTemplate.GAUGE,
|
||||
),
|
||||
]
|
||||
|
||||
# ===================================================================
|
||||
# Assemble rows and config
|
||||
# ===================================================================
|
||||
_ALL_ROWS = [
|
||||
Row(title="Throughput", id=501, panels=_throughput_panels),
|
||||
Row(title="Latency", id=502, panels=_latency_panels_list),
|
||||
Row(title="Cache", id=503, panels=_cache_panels),
|
||||
Row(title="Request Length", id=504, panels=_request_length_panels),
|
||||
Row(title="Scheduler", id=505, panels=_scheduler_panels),
|
||||
Row(title="NIXL", id=506, panels=_nixl_panels),
|
||||
Row(
|
||||
title="Token Distribution",
|
||||
id=507,
|
||||
collapsed=True,
|
||||
panels=_token_distribution_panels,
|
||||
),
|
||||
]
|
||||
|
||||
# Validate uniqueness of panel IDs across all rows
|
||||
_all_ids = sorted(panel.id for row in _ALL_ROWS for panel in row.panels)
|
||||
assert len(_all_ids) == len(
|
||||
set(_all_ids)
|
||||
), f"Duplicated id found. Use unique id for each panel. {_all_ids}"
|
||||
|
||||
serve_llm_dashboard_config = DashboardConfig(
|
||||
name="SERVE_LLM",
|
||||
default_uid="rayServeLlmDashboard",
|
||||
standard_global_filters=[
|
||||
'ray_io_cluster=~"$Cluster"',
|
||||
],
|
||||
base_json_file_name="serve_llm_grafana_dashboard_base.json",
|
||||
rows=_ALL_ROWS,
|
||||
)
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": "-- Grafana --",
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"gnetId": null,
|
||||
"graphTooltip": 1,
|
||||
"iteration": 1667344411089,
|
||||
"links": [],
|
||||
"panels": [],
|
||||
"refresh": false,
|
||||
"schemaVersion": 27,
|
||||
"style": "dark",
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {
|
||||
"selected": false
|
||||
},
|
||||
"description": "Filter queries of a specific Prometheus type.",
|
||||
"hide": 2,
|
||||
"includeAll": false,
|
||||
"multi": false,
|
||||
"name": "datasource",
|
||||
"options": [],
|
||||
"query": "prometheus",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "datasource"
|
||||
},
|
||||
{
|
||||
"name": "vllm_model_name",
|
||||
"label": "vLLM Model Name",
|
||||
"type": "query",
|
||||
"hide": 0,
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_vllm_request_prompt_tokens_sum{{{global_filters}}}, model_name)",
|
||||
"query": {
|
||||
"query": "label_values(ray_vllm_request_prompt_tokens_sum{{{global_filters}}}, model_name)",
|
||||
"refId": "StandardVariableQuery"
|
||||
},
|
||||
"refresh": 1,
|
||||
"includeAll": true,
|
||||
"multi": false,
|
||||
"allValue": ".*",
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": [
|
||||
"All"
|
||||
],
|
||||
"value": [
|
||||
"$__all"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "workerid",
|
||||
"label": "Worker ID",
|
||||
"type": "query",
|
||||
"hide": 0,
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_vllm_request_prompt_tokens_sum{{{global_filters}}}, WorkerId)",
|
||||
"query": {
|
||||
"query": "label_values(ray_vllm_request_prompt_tokens_sum{{{global_filters}}}, WorkerId)",
|
||||
"refId": "StandardVariableQuery"
|
||||
},
|
||||
"refresh": 1,
|
||||
"includeAll": true,
|
||||
"multi": false,
|
||||
"allValue": ".*",
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": [
|
||||
"All"
|
||||
],
|
||||
"value": [
|
||||
"$__all"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "deployment",
|
||||
"label": "Deployment",
|
||||
"type": "query",
|
||||
"hide": 0,
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_serve_deployment_request_counter_total{{{global_filters}}}, deployment)",
|
||||
"query": {
|
||||
"query": "label_values(ray_serve_deployment_request_counter_total{{{global_filters}}}, deployment)",
|
||||
"refId": "StandardVariableQuery"
|
||||
},
|
||||
"refresh": 1,
|
||||
"includeAll": true,
|
||||
"multi": true,
|
||||
"allValue": ".*",
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": [
|
||||
"All"
|
||||
],
|
||||
"value": [
|
||||
"$__all"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"allValue": ".*",
|
||||
"current": {
|
||||
"selected": false
|
||||
},
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_node_network_receive_speed{{{global_filters}}}, ray_io_cluster)",
|
||||
"description": "Filter queries to specific Ray clusters for KubeRay. When ingesting metrics across multiple ray clusters, the ray_io_cluster label should be set per cluster. For KubeRay users, this is done automatically with Prometheus PodMonitor.",
|
||||
"error": null,
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": null,
|
||||
"multi": false,
|
||||
"name": "Cluster",
|
||||
"options": [],
|
||||
"query": {
|
||||
"query": "label_values(ray_node_network_receive_speed{{{global_filters}}}, ray_io_cluster)",
|
||||
"refId": "StandardVariableQuery"
|
||||
},
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"sort": 2,
|
||||
"tagValuesQuery": "",
|
||||
"tags": [],
|
||||
"tagsQuery": "",
|
||||
"type": "query",
|
||||
"useTags": false
|
||||
},
|
||||
{
|
||||
"name": "interval",
|
||||
"label": "Interval",
|
||||
"type": "custom",
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"multi": false,
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "30s",
|
||||
"value": "30s"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "1m",
|
||||
"value": "1m"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "5m",
|
||||
"value": "5m"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "10m",
|
||||
"value": "10m"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "15m",
|
||||
"value": "15m"
|
||||
}
|
||||
],
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": "5m",
|
||||
"value": "5m"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"rayMeta": ["excludesSystemRoutes"],
|
||||
"time": {
|
||||
"from": "now-30m",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "Serve LLM Dashboard",
|
||||
"uid": "rayServeLlmDashboard",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
# flake8: noqa E501
|
||||
from ray.dashboard.modules.metrics.dashboards.common import (
|
||||
DashboardConfig,
|
||||
Panel,
|
||||
Row,
|
||||
Target,
|
||||
)
|
||||
|
||||
# Ray Train Metrics (Controller)
|
||||
CONTROLLER_STATE_PANEL = Panel(
|
||||
id=1,
|
||||
title="Controller State",
|
||||
description="Current state of the Ray Train controller.",
|
||||
unit="",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_train_controller_state{{ray_train_run_name=~"$TrainRunName", ray_train_run_id=~"$TrainRunId", {global_filters}}}) by (ray_train_run_name, ray_train_controller_state)',
|
||||
legend="Run Name: {{ray_train_run_name}}, Controller State: {{ray_train_controller_state}}",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
CONTROLLER_OPERATION_TIME_PANEL = Panel(
|
||||
id=2,
|
||||
title="Cumulative Worker Group Start/Shutdown Time",
|
||||
description="Cumulative time the controller spends starting and shutting down worker groups (re-created on worker failures and resizes).",
|
||||
unit="seconds",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_train_worker_group_start_total_time_s{{ray_train_run_name=~"$TrainRunName", ray_train_run_id=~"$TrainRunId", {global_filters}}}) by (ray_train_run_name)',
|
||||
legend="Run Name: {{ray_train_run_name}}, Worker Group Start Time",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_train_worker_group_shutdown_total_time_s{{ray_train_run_name=~"$TrainRunName", ray_train_run_id=~"$TrainRunId", {global_filters}}}) by (ray_train_run_name)',
|
||||
legend="Run Name: {{ray_train_run_name}}, Worker Group Shutdown Time",
|
||||
),
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
)
|
||||
|
||||
# Ray Train Metrics (Worker)
|
||||
WORKER_TRAIN_REPORT_TIME_PANEL = Panel(
|
||||
id=3,
|
||||
title="Cumulative Time in ray.train.report",
|
||||
description="Cumulative time workers spend blocked inside `ray.train.report()`. This includes the cross-rank checkpoint directory sync barrier, the checkpoint file transfer to storage, and the time waiting for the report queue ordering. See the Checkpoint Sync and Checkpoint Transfer panels for a breakdown.",
|
||||
unit="seconds",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_train_report_total_blocked_time_s{{ray_train_run_name=~"$TrainRunName", ray_train_run_id=~"$TrainRunId", ray_train_worker_world_rank=~"$TrainWorkerWorldRank", ray_train_worker_actor_id=~"$TrainWorkerActorId", {global_filters}}}) by (ray_train_run_name, ray_train_worker_world_rank, ray_train_worker_actor_id)',
|
||||
legend="Run Name: {{ray_train_run_name}}, World Rank: {{ray_train_worker_world_rank}}",
|
||||
)
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
)
|
||||
WORKER_CHECKPOINT_SYNC_TIME_PANEL = Panel(
|
||||
id=16,
|
||||
title="Cumulative Checkpoint Sync Time",
|
||||
description="Cumulative time spent in the cross-rank barrier that synchronizes the checkpoint directory name across all workers. High values indicate workers are spending significant time waiting for each other to reach the synchronization point.",
|
||||
unit="seconds",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_train_checkpoint_sync_total_time_s{{ray_train_run_name=~"$TrainRunName", ray_train_run_id=~"$TrainRunId", ray_train_worker_world_rank=~"$TrainWorkerWorldRank", ray_train_worker_actor_id=~"$TrainWorkerActorId", {global_filters}}}) by (ray_train_run_name, ray_train_worker_world_rank, ray_train_worker_actor_id)',
|
||||
legend="Run Name: {{ray_train_run_name}}, World Rank: {{ray_train_worker_world_rank}}",
|
||||
)
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
)
|
||||
|
||||
WORKER_CHECKPOINT_TRANSFER_TIME_PANEL = Panel(
|
||||
id=17,
|
||||
title="Cumulative Checkpoint Transfer Time",
|
||||
description="Cumulative time spent transferring checkpoint files to storage. High values indicate slow storage throughput or large checkpoint sizes.",
|
||||
unit="seconds",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_train_checkpoint_transfer_total_time_s{{ray_train_run_name=~"$TrainRunName", ray_train_run_id=~"$TrainRunId", ray_train_worker_world_rank=~"$TrainWorkerWorldRank", ray_train_worker_actor_id=~"$TrainWorkerActorId", {global_filters}}}) by (ray_train_run_name, ray_train_worker_world_rank, ray_train_worker_actor_id)',
|
||||
legend="Run Name: {{ray_train_run_name}}, World Rank: {{ray_train_worker_world_rank}}",
|
||||
)
|
||||
],
|
||||
fill=0,
|
||||
stack=False,
|
||||
)
|
||||
|
||||
# Core System Resources
|
||||
CPU_UTILIZATION_PANEL = Panel(
|
||||
id=4,
|
||||
title="CPU Usage",
|
||||
description="CPU core utilization across all workers.",
|
||||
unit="cores",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_cpu_utilization{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}} * ray_node_cpu_count{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}} / 100) by (instance, RayNodeType)',
|
||||
legend="CPU Usage: {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_node_cpu_count{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}})',
|
||||
legend="MAX",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
MEMORY_UTILIZATION_PANEL = Panel(
|
||||
id=5,
|
||||
title="Total Memory Usage",
|
||||
description="Total physical memory used vs total available memory.",
|
||||
unit="bytes",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_mem_used{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="Memory Used: {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_node_mem_total{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}})',
|
||||
legend="MAX",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
MEMORY_DETAILED_PANEL = Panel(
|
||||
id=6,
|
||||
title="Memory Allocation Details",
|
||||
description="Memory allocation details including available and shared memory.",
|
||||
unit="bytes",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_mem_available{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="Available Memory: {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_node_mem_shared_bytes{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="Shared Memory: {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# GPU Resources
|
||||
# TODO: Add GPU Device/Index as a filter.
|
||||
GPU_UTILIZATION_PANEL = Panel(
|
||||
id=7,
|
||||
title="GPU Usage",
|
||||
description="GPU utilization across all workers.",
|
||||
unit="GPUs",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_gpus_utilization{{instance=~"$Instance", RayNodeType=~"$RayNodeType", GpuIndex=~"$GpuIndex", GpuDeviceName=~"$GpuDeviceName", {global_filters}}} / 100) by (instance, RayNodeType, GpuIndex, GpuDeviceName)',
|
||||
legend="GPU Usage: {{instance}} ({{RayNodeType}}), gpu.{{GpuIndex}}, {{GpuDeviceName}}",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_node_gpus_available{{instance=~"$Instance", RayNodeType=~"$RayNodeType", GpuIndex=~"$GpuIndex", GpuDeviceName=~"$GpuDeviceName", {global_filters}}})',
|
||||
legend="MAX",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
GPU_MEMORY_UTILIZATION_PANEL = Panel(
|
||||
id=8,
|
||||
title="GPU Memory Usage",
|
||||
description="GPU memory usage across all workers.",
|
||||
unit="bytes",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_gram_used{{instance=~"$Instance", RayNodeType=~"$RayNodeType", GpuIndex=~"$GpuIndex", GpuDeviceName=~"$GpuDeviceName", {global_filters}}} * 1024 * 1024) by (instance, RayNodeType, GpuIndex, GpuDeviceName)',
|
||||
legend="Used GRAM: {{instance}} ({{RayNodeType}}), gpu.{{GpuIndex}}, {{GpuDeviceName}}",
|
||||
),
|
||||
Target(
|
||||
expr='(sum(ray_node_gram_available{{instance=~"$Instance", RayNodeType=~"$RayNodeType", GpuIndex=~"$GpuIndex", GpuDeviceName=~"$GpuDeviceName", {global_filters}}}) + sum(ray_node_gram_used{{instance=~"$Instance", RayNodeType=~"$RayNodeType", GpuIndex=~"$GpuIndex", GpuDeviceName=~"$GpuDeviceName", {global_filters}}})) * 1024 * 1024',
|
||||
legend="MAX",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# Storage Resources
|
||||
DISK_UTILIZATION_PANEL = Panel(
|
||||
id=9,
|
||||
title="Disk Space Usage",
|
||||
description="Disk space usage across all workers.",
|
||||
unit="bytes",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_disk_usage{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="Disk Used: {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_node_disk_free{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) + sum(ray_node_disk_usage{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}})',
|
||||
legend="MAX",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
DISK_THROUGHPUT_PANEL = Panel(
|
||||
id=10,
|
||||
title="Disk Throughput",
|
||||
description="Current disk read/write throughput.",
|
||||
unit="Bps",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_disk_io_read_speed{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="Read Speed: {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_node_disk_io_write_speed{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="Write Speed: {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
DISK_OPERATIONS_PANEL = Panel(
|
||||
id=11,
|
||||
title="Disk Operations",
|
||||
description="Current disk read/write operations per second.",
|
||||
unit="ops/s",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_disk_read_iops{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="Read IOPS: {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_node_disk_write_iops{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="Write IOPS: {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# Network Resources
|
||||
NETWORK_THROUGHPUT_PANEL = Panel(
|
||||
id=12,
|
||||
title="Network Throughput",
|
||||
description="Current network send/receive throughput.",
|
||||
unit="Bps",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_network_receive_speed{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="Receive Speed: {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_node_network_send_speed{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="Send Speed: {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
NETWORK_TOTAL_PANEL = Panel(
|
||||
id=13,
|
||||
title="Network Total Traffic",
|
||||
description="Total network traffic sent/received.",
|
||||
unit="bytes",
|
||||
targets=[
|
||||
Target(
|
||||
expr='sum(ray_node_network_sent{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="Total Sent: {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
Target(
|
||||
expr='sum(ray_node_network_received{{instance=~"$Instance", RayNodeType=~"$RayNodeType", {global_filters}}}) by (instance, RayNodeType)',
|
||||
legend="Total Received: {{instance}} ({{RayNodeType}})",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
TRAIN_GRAFANA_PANELS = []
|
||||
|
||||
TRAIN_GRAFANA_ROWS = [
|
||||
# Train Metrics Row
|
||||
Row(
|
||||
title="Train Metrics",
|
||||
id=14,
|
||||
panels=[
|
||||
# Ray Train Metrics (Controller)
|
||||
CONTROLLER_STATE_PANEL,
|
||||
CONTROLLER_OPERATION_TIME_PANEL,
|
||||
# Ray Train Metrics (Worker)
|
||||
WORKER_TRAIN_REPORT_TIME_PANEL,
|
||||
WORKER_CHECKPOINT_SYNC_TIME_PANEL,
|
||||
WORKER_CHECKPOINT_TRANSFER_TIME_PANEL,
|
||||
],
|
||||
collapsed=False,
|
||||
),
|
||||
# System Resources Row
|
||||
Row(
|
||||
title="Resource Utilization",
|
||||
id=15,
|
||||
panels=[
|
||||
CPU_UTILIZATION_PANEL,
|
||||
MEMORY_UTILIZATION_PANEL,
|
||||
MEMORY_DETAILED_PANEL,
|
||||
# GPU Resources
|
||||
GPU_UTILIZATION_PANEL,
|
||||
GPU_MEMORY_UTILIZATION_PANEL,
|
||||
# Storage Resources
|
||||
DISK_UTILIZATION_PANEL,
|
||||
DISK_THROUGHPUT_PANEL,
|
||||
DISK_OPERATIONS_PANEL,
|
||||
# Network Resources
|
||||
NETWORK_THROUGHPUT_PANEL,
|
||||
NETWORK_TOTAL_PANEL,
|
||||
],
|
||||
collapsed=True,
|
||||
),
|
||||
]
|
||||
|
||||
TRAIN_RUN_PANELS = [
|
||||
# Ray Train Metrics (Controller)
|
||||
CONTROLLER_STATE_PANEL,
|
||||
CONTROLLER_OPERATION_TIME_PANEL,
|
||||
# Ray Train Metrics (Worker)
|
||||
WORKER_TRAIN_REPORT_TIME_PANEL,
|
||||
]
|
||||
|
||||
TRAIN_WORKER_PANELS = [
|
||||
# Ray Train Metrics (Worker)
|
||||
WORKER_TRAIN_REPORT_TIME_PANEL,
|
||||
WORKER_CHECKPOINT_SYNC_TIME_PANEL,
|
||||
WORKER_CHECKPOINT_TRANSFER_TIME_PANEL,
|
||||
# Core System Resources
|
||||
CPU_UTILIZATION_PANEL,
|
||||
MEMORY_UTILIZATION_PANEL,
|
||||
# GPU Resources
|
||||
GPU_UTILIZATION_PANEL,
|
||||
GPU_MEMORY_UTILIZATION_PANEL,
|
||||
# Storage Resources
|
||||
DISK_UTILIZATION_PANEL,
|
||||
# Network Resources
|
||||
NETWORK_THROUGHPUT_PANEL,
|
||||
]
|
||||
|
||||
# Get all panel IDs from both top-level panels and panels within rows
|
||||
all_panel_ids = [panel.id for panel in TRAIN_GRAFANA_PANELS]
|
||||
for row in TRAIN_GRAFANA_ROWS:
|
||||
all_panel_ids.append(row.id)
|
||||
all_panel_ids.extend(panel.id for panel in row.panels)
|
||||
|
||||
all_panel_ids.sort()
|
||||
|
||||
assert len(all_panel_ids) == len(
|
||||
set(all_panel_ids)
|
||||
), f"Duplicated id found. Use unique id for each panel. {all_panel_ids}"
|
||||
|
||||
train_dashboard_config = DashboardConfig(
|
||||
name="TRAIN",
|
||||
default_uid="rayTrainDashboard",
|
||||
rows=TRAIN_GRAFANA_ROWS,
|
||||
standard_global_filters=['SessionName=~"$SessionName"'],
|
||||
base_json_file_name="train_grafana_dashboard_base.json",
|
||||
)
|
||||
@@ -0,0 +1,267 @@
|
||||
{
|
||||
"title": "Train Dashboard",
|
||||
"uid": "rayTrainDashboard",
|
||||
"version": 1,
|
||||
"schemaVersion": 27,
|
||||
"style": "dark",
|
||||
"editable": true,
|
||||
"graphTooltip": 1,
|
||||
"refresh": false,
|
||||
"panels": [],
|
||||
|
||||
"time": {
|
||||
"from": "now-30m",
|
||||
"to": "now"
|
||||
},
|
||||
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": "-- Grafana --",
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"name": "datasource",
|
||||
"type": "datasource",
|
||||
"description": "Filter queries of a specific Prometheus type.",
|
||||
"datasource": null,
|
||||
"query": "prometheus",
|
||||
"refresh": 1,
|
||||
"hide": 2,
|
||||
"includeAll": false,
|
||||
"multi": false,
|
||||
"current": {
|
||||
"selected": false
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"name": "SessionName",
|
||||
"type": "query",
|
||||
"description": "Filter queries to specific ray sessions.",
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_train_worker_group_start_total_time_s{{{global_filters}}}, SessionName)",
|
||||
"query": {
|
||||
"query": "label_values(ray_train_worker_group_start_total_time_s{{{global_filters}}}, SessionName)",
|
||||
"refId": "StandardVariableQuery"
|
||||
},
|
||||
"refresh": 1,
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"multi": false,
|
||||
"allValue": ".*",
|
||||
"sort": 2,
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": ["All"],
|
||||
"value": ["$__all"]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"name": "TrainRunName",
|
||||
"type": "query",
|
||||
"description": "Filter queries to specific Ray Train run names.",
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_train_worker_group_start_total_time_s{{{global_filters}}}, ray_train_run_name)",
|
||||
"query": {
|
||||
"query": "label_values(ray_train_worker_group_start_total_time_s{{{global_filters}}}, ray_train_run_name)",
|
||||
"refId": "StandardVariableQuery"
|
||||
},
|
||||
"refresh": 1,
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"multi": false,
|
||||
"allValue": ".*",
|
||||
"sort": 2,
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": ["All"],
|
||||
"value": ["$__all"]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"name": "TrainRunId",
|
||||
"type": "query",
|
||||
"description": "Filter queries to specific Ray Train run ids.",
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_train_worker_group_start_total_time_s{{{global_filters}}}, ray_train_run_id)",
|
||||
"query": {
|
||||
"query": "label_values(ray_train_worker_group_start_total_time_s{{{global_filters}}}, ray_train_run_id)",
|
||||
"refId": "StandardVariableQuery"
|
||||
},
|
||||
"refresh": 1,
|
||||
"hide": 2,
|
||||
"includeAll": true,
|
||||
"multi": false,
|
||||
"allValue": ".*",
|
||||
"sort": 2,
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": ["All"],
|
||||
"value": ["$__all"]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"name": "TrainWorkerWorldRank",
|
||||
"type": "query",
|
||||
"description": "Filter queries to specific Ray Train worker world ranks.",
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_train_report_total_blocked_time_s{{{global_filters}}}, ray_train_worker_world_rank)",
|
||||
"query": {
|
||||
"query": "label_values(ray_train_report_total_blocked_time_s{{{global_filters}}}, ray_train_worker_world_rank)",
|
||||
"refId": "StandardVariableQuery"
|
||||
},
|
||||
"refresh": 1,
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"multi": false,
|
||||
"allValue": ".*",
|
||||
"sort": 2,
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": ["All"],
|
||||
"value": ["$__all"]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"name": "TrainWorkerActorId",
|
||||
"type": "query",
|
||||
"description": "Filter queries to specific Ray Train worker actor ids.",
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_train_report_total_blocked_time_s{{{global_filters}}}, ray_train_worker_actor_id)",
|
||||
"query": {
|
||||
"query": "label_values(ray_train_report_total_blocked_time_s{{{global_filters}}}, ray_train_worker_actor_id)",
|
||||
"refId": "StandardVariableQuery"
|
||||
},
|
||||
"refresh": 1,
|
||||
"hide": 2,
|
||||
"includeAll": true,
|
||||
"multi": false,
|
||||
"allValue": ".*",
|
||||
"sort": 2,
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": ["All"],
|
||||
"value": ["$__all"]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"name": "Instance",
|
||||
"type": "query",
|
||||
"description": "Filter queries to specific node instances.",
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_node_network_receive_speed{{{global_filters}}}, instance)",
|
||||
"query": {
|
||||
"query": "label_values(ray_node_network_receive_speed{{{global_filters}}}, instance)",
|
||||
"refId": "StandardVariableQuery"
|
||||
},
|
||||
"refresh": 1,
|
||||
"hide": 2,
|
||||
"includeAll": true,
|
||||
"multi": false,
|
||||
"allValue": ".*",
|
||||
"sort": 2,
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": ["All"],
|
||||
"value": ["$__all"]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"name": "GpuIndex",
|
||||
"type": "query",
|
||||
"description": "Filter queries to specific GPU indices.",
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_node_gpus_utilization{{{global_filters}}}, GpuIndex)",
|
||||
"query": {
|
||||
"query": "label_values(ray_node_gpus_utilization{{{global_filters}}}, GpuIndex)",
|
||||
"refId": "StandardVariableQuery"
|
||||
},
|
||||
"refresh": 1,
|
||||
"hide": 2,
|
||||
"includeAll": true,
|
||||
"multi": true,
|
||||
"allValue": ".*",
|
||||
"sort": 2,
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": ["All"],
|
||||
"value": ["$__all"]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"name": "GpuDeviceName",
|
||||
"type": "query",
|
||||
"description": "Filter queries to specific GPU device names.",
|
||||
"datasource": "${datasource}",
|
||||
"definition": "label_values(ray_node_gpus_utilization{{{global_filters}}}, GpuDeviceName)",
|
||||
"query": {
|
||||
"query": "label_values(ray_node_gpus_utilization{{{global_filters}}}, GpuDeviceName)",
|
||||
"refId": "StandardVariableQuery"
|
||||
},
|
||||
"refresh": 1,
|
||||
"hide": 2,
|
||||
"includeAll": true,
|
||||
"multi": true,
|
||||
"allValue": ".*",
|
||||
"sort": 2,
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": ["All"],
|
||||
"value": ["$__all"]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"current": {
|
||||
"text": [
|
||||
"All"
|
||||
],
|
||||
"value": [
|
||||
"$__all"
|
||||
]
|
||||
},
|
||||
"description": "Filter queries to specific Ray node types (head or worker).",
|
||||
"includeAll": true,
|
||||
"multi": true,
|
||||
"name": "RayNodeType",
|
||||
"options": [
|
||||
{
|
||||
"selected": false,
|
||||
"text": "All",
|
||||
"value": "$__all"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "Head Node",
|
||||
"value": "head"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "Worker Node",
|
||||
"value": "worker"
|
||||
}
|
||||
],
|
||||
"query": "head, worker",
|
||||
"type": "custom"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
from ray.dashboard.modules.metrics.dashboards.common import DashboardConfig
|
||||
|
||||
|
||||
def get_serve_dashboard_config() -> DashboardConfig:
|
||||
from ray.dashboard.modules.metrics.dashboards.serve_dashboard_panels import (
|
||||
serve_dashboard_config,
|
||||
)
|
||||
|
||||
return serve_dashboard_config
|
||||
|
||||
|
||||
# Anyscale overrides
|
||||
@@ -0,0 +1,12 @@
|
||||
# my global config
|
||||
global:
|
||||
scrape_interval: 10s # Set the scrape interval to every 10 seconds. Default is every 1 minute.
|
||||
evaluation_interval: 10s # Evaluate rules every 10 seconds. The default is every 1 minute.
|
||||
# scrape_timeout is set to the global default (10s).
|
||||
|
||||
scrape_configs:
|
||||
# Scrape from each Ray node as defined in the service_discovery.json provided by Ray.
|
||||
- job_name: 'ray'
|
||||
file_sd_configs:
|
||||
- files:
|
||||
- '/tmp/ray/prom_metrics_service_discovery.json'
|
||||
@@ -0,0 +1,541 @@
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from dataclasses import asdict
|
||||
from typing import List, Tuple
|
||||
|
||||
import ray
|
||||
from ray.dashboard.modules.metrics.dashboards.common import (
|
||||
DashboardConfig,
|
||||
Panel,
|
||||
PanelTemplate,
|
||||
)
|
||||
from ray.dashboard.modules.metrics.dashboards.data_dashboard_panels import (
|
||||
data_dashboard_config,
|
||||
)
|
||||
from ray.dashboard.modules.metrics.dashboards.data_llm_dashboard_panels import (
|
||||
data_llm_dashboard_config,
|
||||
)
|
||||
from ray.dashboard.modules.metrics.dashboards.default_dashboard_panels import (
|
||||
default_dashboard_config,
|
||||
)
|
||||
from ray.dashboard.modules.metrics.dashboards.serve_deployment_dashboard_panels import (
|
||||
serve_deployment_dashboard_config,
|
||||
)
|
||||
from ray.dashboard.modules.metrics.dashboards.serve_llm_dashboard_panels import (
|
||||
serve_llm_dashboard_config,
|
||||
)
|
||||
from ray.dashboard.modules.metrics.dashboards.train_dashboard_panels import (
|
||||
train_dashboard_config,
|
||||
)
|
||||
from ray.dashboard.modules.metrics.default_impl import get_serve_dashboard_config
|
||||
|
||||
GRAFANA_DASHBOARD_UID_OVERRIDE_ENV_VAR_TEMPLATE = "RAY_GRAFANA_{name}_DASHBOARD_UID"
|
||||
GRAFANA_DASHBOARD_GLOBAL_FILTERS_OVERRIDE_ENV_VAR_TEMPLATE = (
|
||||
"RAY_GRAFANA_{name}_DASHBOARD_GLOBAL_FILTERS"
|
||||
)
|
||||
GRAFANA_DASHBOARD_LOG_LINK_URL_ENV_VAR_TEMPLATE = "RAY_GRAFANA_{name}_LOG_LINK_URL"
|
||||
|
||||
# Grafana dashboard layout constants
|
||||
# Dashboard uses a 24-column grid with 2-column panels
|
||||
ROW_WIDTH = 24 # Full dashboard width
|
||||
PANELS_PER_ROW = 2
|
||||
PANEL_WIDTH = ROW_WIDTH // PANELS_PER_ROW # Width of each panel
|
||||
PANEL_HEIGHT = 8 # Height of each panel
|
||||
ROW_HEIGHT = 1 # Height of row container
|
||||
|
||||
|
||||
def _read_configs_for_dashboard(
|
||||
dashboard_config: DashboardConfig,
|
||||
) -> Tuple[str, List[str], str]:
|
||||
"""Reads environment variable configs for overriding uid, global_filters, and the log link URL for a given dashboard.
|
||||
|
||||
Args:
|
||||
dashboard_config: The dashboard whose env-var overrides are read.
|
||||
``dashboard_config.name`` selects the env-var suffix and
|
||||
``default_uid`` is used as a fallback.
|
||||
|
||||
Returns:
|
||||
Tuple with format uid, global_filters, log_link_url
|
||||
"""
|
||||
uid = (
|
||||
os.environ.get(
|
||||
GRAFANA_DASHBOARD_UID_OVERRIDE_ENV_VAR_TEMPLATE.format(
|
||||
name=dashboard_config.name
|
||||
)
|
||||
)
|
||||
or dashboard_config.default_uid
|
||||
)
|
||||
global_filters_str = (
|
||||
os.environ.get(
|
||||
GRAFANA_DASHBOARD_GLOBAL_FILTERS_OVERRIDE_ENV_VAR_TEMPLATE.format(
|
||||
name=dashboard_config.name
|
||||
)
|
||||
)
|
||||
or ""
|
||||
)
|
||||
if global_filters_str == "":
|
||||
global_filters = []
|
||||
else:
|
||||
global_filters = global_filters_str.split(",")
|
||||
|
||||
log_link_url = (
|
||||
os.environ.get(
|
||||
GRAFANA_DASHBOARD_LOG_LINK_URL_ENV_VAR_TEMPLATE.format(
|
||||
name=dashboard_config.name
|
||||
)
|
||||
)
|
||||
or ""
|
||||
)
|
||||
|
||||
return uid, global_filters, log_link_url
|
||||
|
||||
|
||||
def generate_default_grafana_dashboard() -> Tuple[str, str]:
|
||||
"""
|
||||
Generates the dashboard output for the default dashboard and returns
|
||||
both the content and the uid.
|
||||
|
||||
Returns:
|
||||
Tuple with format content, uid
|
||||
"""
|
||||
return _generate_grafana_dashboard(default_dashboard_config)
|
||||
|
||||
|
||||
def generate_serve_grafana_dashboard() -> Tuple[str, str]:
|
||||
"""
|
||||
Generates the dashboard output for the serve dashboard and returns
|
||||
both the content and the uid.
|
||||
|
||||
Returns:
|
||||
Tuple with format content, uid
|
||||
"""
|
||||
return _generate_grafana_dashboard(get_serve_dashboard_config())
|
||||
|
||||
|
||||
def generate_serve_deployment_grafana_dashboard() -> Tuple[str, str]:
|
||||
"""
|
||||
Generates the dashboard output for the serve dashboard and returns
|
||||
both the content and the uid.
|
||||
|
||||
Returns:
|
||||
Tuple with format content, uid
|
||||
"""
|
||||
return _generate_grafana_dashboard(serve_deployment_dashboard_config)
|
||||
|
||||
|
||||
def generate_serve_llm_grafana_dashboard() -> Tuple[str, str]:
|
||||
"""
|
||||
Generates the dashboard output for the serve dashboard and returns
|
||||
both the content and the uid.
|
||||
|
||||
Returns:
|
||||
Tuple with format content, uid
|
||||
"""
|
||||
return _generate_grafana_dashboard(serve_llm_dashboard_config)
|
||||
|
||||
|
||||
def generate_data_grafana_dashboard() -> Tuple[str, str]:
|
||||
"""
|
||||
Generates the dashboard output for the data dashboard and returns
|
||||
both the content and the uid.
|
||||
|
||||
Returns:
|
||||
Tuple with format content, uid
|
||||
"""
|
||||
return _generate_grafana_dashboard(data_dashboard_config)
|
||||
|
||||
|
||||
def generate_data_llm_grafana_dashboard() -> Tuple[str, str]:
|
||||
"""
|
||||
Generates the dashboard output for the Data LLM dashboard and returns
|
||||
both the content and the uid.
|
||||
|
||||
This dashboard provides vLLM metrics visibility for Ray Data LLM workloads,
|
||||
including latency (TTFT, TPOT), throughput, cache utilization, and
|
||||
prefix cache hit rate.
|
||||
|
||||
Returns:
|
||||
Tuple with format content, uid
|
||||
"""
|
||||
return _generate_grafana_dashboard(data_llm_dashboard_config)
|
||||
|
||||
|
||||
def generate_train_grafana_dashboard() -> Tuple[str, str]:
|
||||
"""
|
||||
Generates the dashboard output for the train dashboard and returns
|
||||
both the content and the uid.
|
||||
|
||||
Returns:
|
||||
Tuple with format content, uid
|
||||
"""
|
||||
return _generate_grafana_dashboard(train_dashboard_config)
|
||||
|
||||
|
||||
def _generate_grafana_dashboard(dashboard_config: DashboardConfig) -> str:
|
||||
"""Render the Grafana dashboard JSON for the given config.
|
||||
|
||||
Args:
|
||||
dashboard_config: Configuration describing the panels and base
|
||||
template JSON file to use for rendering.
|
||||
|
||||
Returns:
|
||||
Tuple with format dashboard_content, uid
|
||||
"""
|
||||
uid, global_filters, log_link_url = _read_configs_for_dashboard(dashboard_config)
|
||||
panels = _generate_grafana_panels(dashboard_config, global_filters, log_link_url)
|
||||
base_file_name = dashboard_config.base_json_file_name
|
||||
|
||||
base_json = json.load(
|
||||
open(os.path.join(os.path.dirname(__file__), "dashboards", base_file_name))
|
||||
)
|
||||
base_json["panels"] = panels
|
||||
# Update variables to use global_filters
|
||||
global_filters_str = ",".join(global_filters)
|
||||
variables = base_json.get("templating", {}).get("list", [])
|
||||
for variable in variables:
|
||||
if "definition" not in variable:
|
||||
continue
|
||||
definition = variable["definition"].format(global_filters=global_filters_str)
|
||||
query = variable["query"]["query"].format(global_filters=global_filters_str)
|
||||
if not global_filters_str:
|
||||
definition = _clean_empty_filters(definition)
|
||||
query = _clean_empty_filters(query)
|
||||
variable["definition"] = definition
|
||||
variable["query"]["query"] = query
|
||||
|
||||
tags = base_json.get("tags", []) or []
|
||||
tags.append(f"rayVersion:{ray.__version__}")
|
||||
base_json["tags"] = tags
|
||||
base_json["uid"] = uid
|
||||
# Ray metadata can be used to put arbitrary metadata
|
||||
ray_meta = base_json.get("rayMeta", []) or []
|
||||
ray_meta.append("supportsGlobalFilterOverride")
|
||||
base_json["rayMeta"] = ray_meta
|
||||
return json.dumps(base_json, indent=4), uid
|
||||
|
||||
|
||||
def _generate_panel_template(
|
||||
panel: Panel,
|
||||
panel_global_filters: List[str],
|
||||
panel_index: int,
|
||||
base_y_position: int,
|
||||
log_link_url: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Helper method to generate a panel template with common configuration.
|
||||
|
||||
Args:
|
||||
panel: The panel configuration
|
||||
panel_global_filters: List of global filters to apply
|
||||
panel_index: The index of the panel within its row (0-based)
|
||||
base_y_position: The base y-coordinate for the row in the dashboard grid
|
||||
log_link_url: The URL to the log link for the panel
|
||||
|
||||
Returns:
|
||||
dict: The configured panel template
|
||||
"""
|
||||
# Create base template from panel configuration
|
||||
template = copy.deepcopy(panel.template.value)
|
||||
template.update(
|
||||
{
|
||||
"title": panel.title,
|
||||
"description": panel.description,
|
||||
"id": panel.id,
|
||||
"targets": _generate_targets(panel, panel_global_filters),
|
||||
}
|
||||
)
|
||||
|
||||
# Set panel position and dimensions
|
||||
if panel.grid_pos:
|
||||
template["gridPos"] = asdict(panel.grid_pos)
|
||||
else:
|
||||
# Calculate panel position in 2-column grid layout
|
||||
# x: 0 or 12 (left or right column)
|
||||
# y: base position + (row number * panel height)
|
||||
row_number = panel_index // PANELS_PER_ROW
|
||||
template["gridPos"] = {
|
||||
"h": PANEL_HEIGHT,
|
||||
"w": PANEL_WIDTH,
|
||||
"x": PANEL_WIDTH * (panel_index % PANELS_PER_ROW),
|
||||
"y": base_y_position + (row_number * PANEL_HEIGHT),
|
||||
}
|
||||
|
||||
# Set unit format for legacy graph-style panels (GRAPH, HEATMAP, STAT, GAUGE, PIE_CHART, BAR_CHART)
|
||||
if panel.template in (
|
||||
PanelTemplate.GRAPH,
|
||||
PanelTemplate.HEATMAP,
|
||||
PanelTemplate.STAT,
|
||||
PanelTemplate.GAUGE,
|
||||
PanelTemplate.PIE_CHART,
|
||||
PanelTemplate.BAR_CHART,
|
||||
):
|
||||
template["yaxes"][0]["format"] = panel.unit
|
||||
|
||||
# Set fieldConfig unit (for newer panel types with fieldConfig.defaults)
|
||||
if panel.template in (
|
||||
PanelTemplate.STAT,
|
||||
PanelTemplate.GAUGE,
|
||||
PanelTemplate.HEATMAP,
|
||||
PanelTemplate.PIE_CHART,
|
||||
PanelTemplate.BAR_CHART,
|
||||
PanelTemplate.TABLE,
|
||||
PanelTemplate.GRAPH,
|
||||
):
|
||||
template["fieldConfig"]["defaults"]["unit"] = panel.unit
|
||||
|
||||
# Set fill, stack, linewidth, nullPointMode (only for GRAPH panels)
|
||||
if panel.template == PanelTemplate.GRAPH:
|
||||
template["fill"] = panel.fill
|
||||
template["stack"] = panel.stack
|
||||
template["linewidth"] = panel.linewidth
|
||||
if panel.stack is True:
|
||||
template["nullPointMode"] = "connected"
|
||||
|
||||
if panel.hideXAxis:
|
||||
template.setdefault("xaxis", {})["show"] = False
|
||||
|
||||
# Handle optional panel customization fields
|
||||
|
||||
# Thresholds (for panels with fieldConfig.defaults.thresholds)
|
||||
if panel.thresholds is not None:
|
||||
if panel.template in (PanelTemplate.STAT, PanelTemplate.GAUGE):
|
||||
template["fieldConfig"]["defaults"]["thresholds"][
|
||||
"steps"
|
||||
] = panel.thresholds
|
||||
|
||||
# Value mappings (for panels with fieldConfig.defaults.mappings)
|
||||
if panel.value_mappings is not None:
|
||||
if panel.template in (
|
||||
PanelTemplate.STAT,
|
||||
PanelTemplate.GAUGE,
|
||||
PanelTemplate.TABLE,
|
||||
):
|
||||
template["fieldConfig"]["defaults"]["mappings"] = panel.value_mappings
|
||||
|
||||
# Color mode (for STAT panels with options.colorMode)
|
||||
if panel.color_mode is not None:
|
||||
if panel.template == PanelTemplate.STAT:
|
||||
template["options"]["colorMode"] = panel.color_mode
|
||||
|
||||
# Legend mode
|
||||
if panel.legend_mode is not None:
|
||||
if panel.template in (PanelTemplate.GRAPH, PanelTemplate.BAR_CHART):
|
||||
# For graph panels (legacy format with top-level legend object)
|
||||
template["legend"]["show"] = panel.legend_mode != "hidden"
|
||||
template["legend"]["alignAsTable"] = panel.legend_mode == "table"
|
||||
elif panel.template == PanelTemplate.PIE_CHART:
|
||||
# For PIE_CHART (options.legend.displayMode)
|
||||
template["options"]["legend"]["displayMode"] = panel.legend_mode
|
||||
|
||||
# Min/max values (for panels with fieldConfig.defaults)
|
||||
if panel.min_val is not None or panel.max_val is not None:
|
||||
if panel.template in (
|
||||
PanelTemplate.STAT,
|
||||
PanelTemplate.GAUGE,
|
||||
PanelTemplate.HEATMAP,
|
||||
PanelTemplate.PIE_CHART,
|
||||
PanelTemplate.BAR_CHART,
|
||||
PanelTemplate.TABLE,
|
||||
PanelTemplate.GRAPH,
|
||||
):
|
||||
if panel.min_val is not None:
|
||||
template["fieldConfig"]["defaults"]["min"] = panel.min_val
|
||||
if panel.max_val is not None:
|
||||
template["fieldConfig"]["defaults"]["max"] = panel.max_val
|
||||
|
||||
# Reduce calculation (for panels with options.reduceOptions)
|
||||
if panel.reduce_calc is not None:
|
||||
if panel.template in (
|
||||
PanelTemplate.STAT,
|
||||
PanelTemplate.GAUGE,
|
||||
PanelTemplate.PIE_CHART,
|
||||
):
|
||||
template["options"]["reduceOptions"]["calcs"] = [panel.reduce_calc]
|
||||
|
||||
# Handle heatmap-specific options
|
||||
if panel.heatmap_color_scheme is not None:
|
||||
if panel.template == PanelTemplate.HEATMAP:
|
||||
template["options"]["color"]["scheme"] = panel.heatmap_color_scheme
|
||||
|
||||
if panel.heatmap_color_reverse is not None:
|
||||
if panel.template == PanelTemplate.HEATMAP:
|
||||
template["options"]["color"]["reverse"] = panel.heatmap_color_reverse
|
||||
|
||||
if panel.heatmap_yaxis_label is not None:
|
||||
if panel.template in (
|
||||
PanelTemplate.GRAPH,
|
||||
PanelTemplate.HEATMAP,
|
||||
PanelTemplate.STAT,
|
||||
PanelTemplate.GAUGE,
|
||||
PanelTemplate.PIE_CHART,
|
||||
PanelTemplate.BAR_CHART,
|
||||
):
|
||||
template["yaxes"][0]["label"] = panel.heatmap_yaxis_label
|
||||
|
||||
# Add log link if URL is provided via environment variable.
|
||||
if log_link_url:
|
||||
template["links"] = [
|
||||
{
|
||||
"targetBlank": True,
|
||||
"title": "View Logs",
|
||||
"url": log_link_url,
|
||||
}
|
||||
]
|
||||
|
||||
return template
|
||||
|
||||
|
||||
def _create_row_panel(row: Panel, y_position: int) -> dict:
|
||||
"""
|
||||
Creates a Grafana row panel that spans the full dashboard width.
|
||||
Row panels can be collapsed to hide their contained panels.
|
||||
|
||||
Args:
|
||||
row: Row config with title, id, and collapse state
|
||||
y_position: Vertical position in dashboard grid
|
||||
|
||||
Returns:
|
||||
Grafana row panel configuration
|
||||
"""
|
||||
return {
|
||||
"collapsed": row.collapsed,
|
||||
"gridPos": {"h": ROW_HEIGHT, "w": ROW_WIDTH, "x": 0, "y": y_position},
|
||||
"id": row.id,
|
||||
"title": row.title,
|
||||
"type": "row",
|
||||
"panels": [],
|
||||
}
|
||||
|
||||
|
||||
def _calculate_panel_heights(num_panels: int) -> int:
|
||||
"""
|
||||
Calculate the total height needed for a set of panels.
|
||||
|
||||
Args:
|
||||
num_panels: Number of panels to position
|
||||
|
||||
Returns:
|
||||
Total height needed for the panels
|
||||
"""
|
||||
rows_needed = math.ceil(num_panels / PANELS_PER_ROW)
|
||||
return rows_needed * PANEL_HEIGHT
|
||||
|
||||
|
||||
def _generate_grafana_panels(
|
||||
config: DashboardConfig, global_filters: List[str], log_link_url: str
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Generates Grafana panel configurations for a dashboard.
|
||||
|
||||
The dashboard layout follows these rules:
|
||||
- Panels are arranged in 2 columns (12 units wide each)
|
||||
- Each panel is 8 units high
|
||||
- Rows are 1 unit high and can be collapsed
|
||||
- Panels within rows follow the same 2-column layout
|
||||
- Panel positions can be overridden via panel.grid_pos or auto-calculated
|
||||
|
||||
Args:
|
||||
config: Dashboard configuration containing panels and rows
|
||||
global_filters: List of filters to apply to all panels
|
||||
log_link_url: Optional URL for panel log links. When set, each panel
|
||||
gets a "View Logs" link pointing to this URL.
|
||||
|
||||
Returns:
|
||||
List of Grafana panel configurations for the dashboard
|
||||
"""
|
||||
panels = []
|
||||
panel_global_filters = [*config.standard_global_filters, *global_filters]
|
||||
current_y_position = 0
|
||||
|
||||
# Add top-level panels in 2-column grid
|
||||
for panel_index, panel in enumerate(config.panels):
|
||||
panel_template = _generate_panel_template(
|
||||
panel, panel_global_filters, panel_index, current_y_position, log_link_url
|
||||
)
|
||||
panels.append(panel_template)
|
||||
|
||||
# Calculate space needed for top-level panels
|
||||
current_y_position += _calculate_panel_heights(len(config.panels))
|
||||
|
||||
# Add rows and their panels
|
||||
if not config.rows:
|
||||
return panels
|
||||
|
||||
for row in config.rows:
|
||||
# Create and add row panel
|
||||
row_panel = _create_row_panel(row, current_y_position)
|
||||
panels.append(row_panel)
|
||||
current_y_position += ROW_HEIGHT
|
||||
|
||||
# Add panels within row using 2-column grid
|
||||
for panel_index, panel in enumerate(row.panels):
|
||||
panel_template = _generate_panel_template(
|
||||
panel,
|
||||
panel_global_filters,
|
||||
panel_index,
|
||||
current_y_position,
|
||||
log_link_url,
|
||||
)
|
||||
|
||||
# Add panel to row if collapsed, otherwise to main dashboard
|
||||
if row.collapsed:
|
||||
row_panel["panels"].append(panel_template)
|
||||
else:
|
||||
panels.append(panel_template)
|
||||
|
||||
# Update y position for next row based on actual panel positions
|
||||
# when explicit grid_pos is used, or fallback to calculated height.
|
||||
if any(p.grid_pos for p in row.panels):
|
||||
max_y_bottom = max(
|
||||
(p.grid_pos.y + p.grid_pos.h for p in row.panels if p.grid_pos),
|
||||
default=current_y_position,
|
||||
)
|
||||
current_y_position = max_y_bottom
|
||||
else:
|
||||
current_y_position += _calculate_panel_heights(len(row.panels))
|
||||
|
||||
return panels
|
||||
|
||||
|
||||
def _clean_empty_filters(expr: str) -> str:
|
||||
"""Clean up malformed PromQL when global_filters is empty.
|
||||
|
||||
Removes artifacts like trailing/leading commas in label matchers:
|
||||
", ," → ","
|
||||
", }" → "}"
|
||||
"{ ," → "{"
|
||||
"""
|
||||
expr = re.sub(r",\s*,", ",", expr)
|
||||
expr = re.sub(r",\s*}", "}", expr)
|
||||
expr = re.sub(r"{\s*,", "{", expr)
|
||||
return expr
|
||||
|
||||
|
||||
def gen_incrementing_alphabets(length):
|
||||
assert 65 + length < 96, "we only support up to 26 targets at a time."
|
||||
# 65: ascii code of 'A'.
|
||||
return list(map(chr, range(65, 65 + length)))
|
||||
|
||||
|
||||
def _generate_targets(panel: Panel, panel_global_filters: List[str]) -> List[dict]:
|
||||
targets = []
|
||||
for target, ref_id in zip(
|
||||
panel.targets, gen_incrementing_alphabets(len(panel.targets))
|
||||
):
|
||||
template = copy.deepcopy(target.template.value)
|
||||
global_filters_str = ",".join(panel_global_filters)
|
||||
expr = target.expr.format(global_filters=global_filters_str)
|
||||
if not global_filters_str:
|
||||
expr = _clean_empty_filters(expr)
|
||||
template.update(
|
||||
{
|
||||
"expr": expr,
|
||||
"legendFormat": target.legend,
|
||||
"refId": ref_id,
|
||||
}
|
||||
)
|
||||
targets.append(template)
|
||||
return targets
|
||||
@@ -0,0 +1,203 @@
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from ray.dashboard.consts import PROMETHEUS_CONFIG_INPUT_PATH
|
||||
|
||||
FALLBACK_PROMETHEUS_VERSION = "2.48.1"
|
||||
DOWNLOAD_BLOCK_SIZE = 8192 # 8 KB
|
||||
TEST_MODE_ENV_VAR = "RAY_PROMETHEUS_DOWNLOAD_TEST_MODE"
|
||||
|
||||
|
||||
def get_system_info():
|
||||
os_type = platform.system().lower()
|
||||
architecture = platform.machine()
|
||||
if architecture == "x86_64":
|
||||
# In the Prometheus filename, it's called amd64
|
||||
architecture = "amd64"
|
||||
elif architecture == "aarch64":
|
||||
# In the Prometheus filename, it's called arm64
|
||||
architecture = "arm64"
|
||||
return os_type, architecture
|
||||
|
||||
|
||||
def download_file(url, filename):
|
||||
logging.info(f"Downloading {url} to {Path(filename).absolute()}...")
|
||||
try:
|
||||
test_mode = os.environ.get(TEST_MODE_ENV_VAR, False)
|
||||
request_method = requests.head if test_mode else requests.get
|
||||
response = request_method(url, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
total_size_in_bytes = int(response.headers.get("content-length", 0))
|
||||
total_size_in_mb = total_size_in_bytes / (1024 * 1024)
|
||||
|
||||
downloaded_size_in_mb = 0
|
||||
block_size = DOWNLOAD_BLOCK_SIZE
|
||||
|
||||
with open(filename, "wb") as file:
|
||||
for chunk in response.iter_content(chunk_size=block_size):
|
||||
file.write(chunk)
|
||||
downloaded_size_in_mb += len(chunk) / (1024 * 1024)
|
||||
print(
|
||||
f"Downloaded: {downloaded_size_in_mb:.2f} MB / "
|
||||
f"{total_size_in_mb:.2f} MB",
|
||||
end="\r",
|
||||
)
|
||||
|
||||
print("\nDownload completed.")
|
||||
return True
|
||||
|
||||
except requests.RequestException as e:
|
||||
logging.error(f"Error downloading file: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def install_prometheus(file_path):
|
||||
try:
|
||||
with tarfile.open(file_path) as tar:
|
||||
tar.extractall()
|
||||
logging.info("Prometheus installed successfully.")
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"Error installing Prometheus: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def start_prometheus(prometheus_dir):
|
||||
|
||||
# The function assumes the Ray cluster to be monitored by Prometheus uses the
|
||||
# default configuration with "/tmp/ray" as the default root temporary directory.
|
||||
#
|
||||
# This is to support the `ray metrics launch-prometheus` command, when a Ray cluster
|
||||
# hasn't started yet and the user doesn't have a way to get a `--temp-dir`
|
||||
# anywhere. So we choose to use a hardcoded default value.
|
||||
|
||||
config_file = Path(PROMETHEUS_CONFIG_INPUT_PATH)
|
||||
|
||||
if not config_file.exists():
|
||||
raise FileNotFoundError(f"Prometheus config file not found: {config_file}")
|
||||
|
||||
prometheus_cmd = [
|
||||
f"{prometheus_dir}/prometheus",
|
||||
"--config.file",
|
||||
str(config_file),
|
||||
"--web.enable-lifecycle",
|
||||
]
|
||||
try:
|
||||
process = subprocess.Popen(prometheus_cmd)
|
||||
logging.info("Prometheus has started.")
|
||||
return process
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to start Prometheus: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def print_shutdown_message(process_id):
|
||||
message = (
|
||||
f"Prometheus is running with PID {process_id}.\n"
|
||||
"To stop Prometheus, use the command: "
|
||||
"`ray metrics shutdown-prometheus`, "
|
||||
f"'kill {process_id}', or if you need to force stop, "
|
||||
f"use 'kill -9 {process_id}'."
|
||||
)
|
||||
print(message)
|
||||
|
||||
debug_message = (
|
||||
"To list all processes running Prometheus, use the command: "
|
||||
"'ps aux | grep prometheus'."
|
||||
)
|
||||
print(debug_message)
|
||||
|
||||
|
||||
def get_latest_prometheus_version():
|
||||
url = "https://api.github.com/repos/prometheus/prometheus/releases/latest"
|
||||
try:
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
# Remove the leading 'v' from the version number
|
||||
return data["tag_name"].lstrip("v")
|
||||
except requests.RequestException as e:
|
||||
logging.error(f"Error fetching latest Prometheus version: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_prometheus_filename(os_type=None, architecture=None, prometheus_version=None):
|
||||
if os_type is None or architecture is None:
|
||||
os_type, architecture = get_system_info()
|
||||
|
||||
if prometheus_version is None:
|
||||
prometheus_version = get_latest_prometheus_version()
|
||||
if prometheus_version is None:
|
||||
logging.warning(
|
||||
"Failed to retrieve the latest Prometheus version. Falling "
|
||||
f"back to {FALLBACK_PROMETHEUS_VERSION}."
|
||||
)
|
||||
# Fall back to a hardcoded version
|
||||
prometheus_version = FALLBACK_PROMETHEUS_VERSION
|
||||
|
||||
return (
|
||||
f"prometheus-{prometheus_version}.{os_type}-{architecture}.tar.gz",
|
||||
prometheus_version,
|
||||
)
|
||||
|
||||
|
||||
def get_prometheus_download_url(
|
||||
os_type=None, architecture=None, prometheus_version=None
|
||||
):
|
||||
file_name, prometheus_version = get_prometheus_filename(
|
||||
os_type, architecture, prometheus_version
|
||||
)
|
||||
return (
|
||||
"https://github.com/prometheus/prometheus/releases/"
|
||||
f"download/v{prometheus_version}/{file_name}"
|
||||
)
|
||||
|
||||
|
||||
def download_prometheus(os_type=None, architecture=None, prometheus_version=None):
|
||||
file_name, _ = get_prometheus_filename(os_type, architecture, prometheus_version)
|
||||
download_url = get_prometheus_download_url(
|
||||
os_type, architecture, prometheus_version
|
||||
)
|
||||
|
||||
return download_file(download_url, file_name), file_name
|
||||
|
||||
|
||||
def main():
|
||||
# Configure logging only when this script is run directly
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logging.warning("This script is not intended for production use.")
|
||||
|
||||
downloaded, file_name = download_prometheus()
|
||||
if not downloaded:
|
||||
logging.error("Failed to download Prometheus.")
|
||||
sys.exit(1)
|
||||
|
||||
# TODO: Verify the checksum of the downloaded file
|
||||
|
||||
if not install_prometheus(file_name):
|
||||
logging.error("Installation failed.")
|
||||
sys.exit(1)
|
||||
|
||||
# TODO: Add a check to see if Prometheus is already running
|
||||
|
||||
assert file_name.endswith(".tar.gz")
|
||||
process = start_prometheus(
|
||||
# remove the .tar.gz extension
|
||||
prometheus_dir=file_name.rstrip(".tar.gz")
|
||||
)
|
||||
if process:
|
||||
print_shutdown_message(process.pid)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user