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://:" 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