Files
ray-project--ray/python/ray/_private/gcs_pubsub.py
T
2026-07-13 13:17:40 +08:00

294 lines
10 KiB
Python

import asyncio
import logging
import random
from collections import deque
from typing import List, Optional, Tuple
import grpc
from grpc import aio as aiogrpc
import ray._private.gcs_utils as gcs_utils
from ray._common.utils import get_or_create_event_loop
from ray.core.generated import (
gcs_pb2,
gcs_service_pb2,
gcs_service_pb2_grpc,
pubsub_pb2,
)
logger = logging.getLogger(__name__)
_OBSERVABILITY_PUBSUB_CHANNELS = (
pubsub_pb2.RAY_ERROR_INFO_CHANNEL,
pubsub_pb2.RAY_LOG_CHANNEL,
pubsub_pb2.RAY_NODE_RESOURCE_USAGE_CHANNEL,
)
class _SubscriberBase:
def __init__(self, worker_id: bytes = None):
self._worker_id = worker_id
# self._subscriber_id needs to match the binary format of a random
# SubscriberID / UniqueID, which is 28 (kUniqueIDSize) random bytes.
self._subscriber_id = bytes(bytearray(random.getrandbits(8) for _ in range(28)))
self._last_batch_size = 0
self._max_processed_sequence_id = 0
self._publisher_id = b""
# Batch size of the result from last poll. Used to indicate whether the
# subscriber can keep up.
@property
def last_batch_size(self):
return self._last_batch_size
def _subscribe_request(self, channel):
cmd = pubsub_pb2.Command(channel_type=channel, subscribe_message={})
req = gcs_service_pb2.GcsSubscriberCommandBatchRequest(
subscriber_id=self._subscriber_id, sender_id=self._worker_id, commands=[cmd]
)
return req
def _poll_request(self):
return gcs_service_pb2.GcsSubscriberPollRequest(
subscriber_id=self._subscriber_id,
max_processed_sequence_id=self._max_processed_sequence_id,
publisher_id=self._publisher_id,
)
def _unsubscribe_request(self, channels):
req = gcs_service_pb2.GcsSubscriberCommandBatchRequest(
subscriber_id=self._subscriber_id, sender_id=self._worker_id, commands=[]
)
for channel in channels:
req.commands.append(
pubsub_pb2.Command(channel_type=channel, unsubscribe_message={})
)
return req
@staticmethod
def _should_terminate_polling(e: grpc.RpcError) -> None:
# Caller only expects polling to be terminated after deadline exceeded.
if e.code() == grpc.StatusCode.DEADLINE_EXCEEDED:
return True
# Could be a temporary connection issue. Suppress error.
# TODO: reconnect GRPC channel?
if e.code() == grpc.StatusCode.UNAVAILABLE:
return True
return False
class _AioSubscriber(_SubscriberBase):
"""Async io subscriber to GCS.
Usage example common to Aio subscribers:
subscriber = GcsAioXxxSubscriber(address="...")
await subscriber.subscribe()
while running:
...... = await subscriber.poll()
......
await subscriber.close()
"""
def __init__(
self,
pubsub_channel_type,
worker_id: bytes = None,
address: str = None,
channel: aiogrpc.Channel = None,
):
super().__init__(worker_id)
if address:
assert channel is None, "address and channel cannot both be specified"
channel = gcs_utils.create_gcs_channel(address, aio=True)
else:
assert channel is not None, "One of address and channel must be specified"
if pubsub_channel_type in _OBSERVABILITY_PUBSUB_CHANNELS:
self._stub = gcs_service_pb2_grpc.ObservabilityPubSubServiceStub(channel)
else:
self._stub = gcs_service_pb2_grpc.ControlPlanePubSubGcsServiceStub(channel)
# Type of the channel.
self._channel = pubsub_channel_type
# A queue of received PubMessage.
self._queue = deque()
# Indicates whether the subscriber has closed.
self._close = asyncio.Event()
async def subscribe(self) -> None:
"""Registers a subscription for the subscriber's channel type.
Before the registration, published messages in the channel will not be
saved for the subscriber.
"""
if self._close.is_set():
return
req = self._subscribe_request(self._channel)
await self._stub.GcsSubscriberCommandBatch(req, timeout=30)
async def _poll_call(self, req, timeout=None):
# Wrap GRPC _AioCall as a coroutine.
return await self._stub.GcsSubscriberPoll(req, timeout=timeout)
async def _poll(self, timeout=None) -> None:
while len(self._queue) == 0:
req = self._poll_request()
poll = get_or_create_event_loop().create_task(
self._poll_call(req, timeout=timeout)
)
close = get_or_create_event_loop().create_task(self._close.wait())
done, others = await asyncio.wait(
[poll, close], timeout=timeout, return_when=asyncio.FIRST_COMPLETED
)
# Cancel the other task if needed to prevent memory leak.
other_task = others.pop()
if not other_task.done():
other_task.cancel()
if poll not in done or close in done:
# Request timed out or subscriber closed.
break
try:
self._last_batch_size = len(poll.result().pub_messages)
if poll.result().publisher_id != self._publisher_id:
if self._publisher_id != b"":
logger.debug(
f"replied publisher_id {poll.result().publisher_id} "
f"different from {self._publisher_id}, this should "
"only happen during gcs failover."
)
self._publisher_id = poll.result().publisher_id
self._max_processed_sequence_id = 0
for msg in poll.result().pub_messages:
if msg.sequence_id <= self._max_processed_sequence_id:
logger.warning(f"Ignoring out of order message {msg}")
continue
self._max_processed_sequence_id = msg.sequence_id
self._queue.append(msg)
except grpc.RpcError as e:
if self._should_terminate_polling(e):
return
raise
async def close(self) -> None:
"""Closes the subscriber and its active subscription."""
# Mark close to terminate inflight polling and prevent future requests.
if self._close.is_set():
return
self._close.set()
req = self._unsubscribe_request(channels=[self._channel])
try:
await self._stub.GcsSubscriberCommandBatch(req, timeout=5)
except Exception:
pass
self._stub = None
class GcsAioResourceUsageSubscriber(_AioSubscriber):
def __init__(
self,
worker_id: bytes = None,
address: str = None,
channel: grpc.Channel = None,
):
super().__init__(
pubsub_pb2.RAY_NODE_RESOURCE_USAGE_CHANNEL, worker_id, address, channel
)
async def poll(self, timeout: Optional[float] = None) -> Tuple[bytes, str]:
"""Polls for new resource usage message.
Args:
timeout: Optional timeout in seconds for the poll request.
Returns:
A tuple of string reporter ID and resource usage json string.
"""
await self._poll(timeout=timeout)
return self._pop_resource_usage(self._queue)
@staticmethod
def _pop_resource_usage(queue):
if len(queue) == 0:
return None, None
msg = queue.popleft()
return msg.key_id.decode(), msg.node_resource_usage_message.json
class GcsAioActorSubscriber(_AioSubscriber):
def __init__(
self,
worker_id: bytes = None,
address: str = None,
channel: grpc.Channel = None,
):
super().__init__(pubsub_pb2.GCS_ACTOR_CHANNEL, worker_id, address, channel)
@property
def queue_size(self):
return len(self._queue)
async def poll(
self, batch_size: int, timeout: Optional[float] = None
) -> List[Tuple[bytes, gcs_pb2.ActorTableData]]:
"""Polls for new actor message.
Args:
batch_size: Maximum number of messages to return.
timeout: Optional timeout in seconds for the poll request.
Returns:
A list of tuples of binary actor ID and actor table data.
"""
await self._poll(timeout=timeout)
return self._pop_actors(self._queue, batch_size=batch_size)
@staticmethod
def _pop_actors(queue, batch_size):
if len(queue) == 0:
return []
popped = 0
msgs = []
while len(queue) > 0 and popped < batch_size:
msg = queue.popleft()
msgs.append((msg.key_id, msg.actor_message))
popped += 1
return msgs
class GcsAioNodeInfoSubscriber(_AioSubscriber):
def __init__(
self,
worker_id: bytes = None,
address: str = None,
channel: grpc.Channel = None,
):
super().__init__(pubsub_pb2.GCS_NODE_INFO_CHANNEL, worker_id, address, channel)
async def poll(
self, batch_size: int, timeout: Optional[float] = None
) -> List[Tuple[bytes, gcs_pb2.GcsNodeInfo]]:
"""Polls for new node info message.
Args:
batch_size: Maximum number of messages to return.
timeout: Optional timeout in seconds for the poll request.
Returns:
A list of tuples of (node_id, GcsNodeInfo).
"""
await self._poll(timeout=timeout)
return self._pop_node_infos(self._queue, batch_size=batch_size)
@staticmethod
def _pop_node_infos(queue, batch_size):
if len(queue) == 0:
return []
popped = 0
msgs = []
while len(queue) > 0 and popped < batch_size:
msg = queue.popleft()
msgs.append((msg.key_id, msg.node_info_message))
popped += 1
return msgs