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

386 lines
14 KiB
Python

from __future__ import annotations
import asyncio
import collections
from typing import TYPE_CHECKING, Deque, Iterator, Optional
import ray
from ray.exceptions import GetTimeoutError, ObjectRefStreamEndOfStreamError
from ray.util.annotations import DeveloperAPI, PublicAPI
if TYPE_CHECKING:
from ray._private.worker import Worker
@DeveloperAPI
class DynamicObjectRefGenerator:
def __init__(self, refs: Deque["ray.ObjectRef"]):
# TODO(swang): As an optimization, can also store the generator
# ObjectID so that we don't need to keep individual ref counts for the
# inner ObjectRefs.
self._refs: Deque["ray.ObjectRef"] = collections.deque(refs)
def __iter__(self) -> Iterator("ray.ObjectRef"):
while self._refs:
yield self._refs.popleft()
def __len__(self) -> int:
return len(self._refs)
@PublicAPI
class ObjectRefGenerator:
"""A generator to obtain object references from a task in a streaming manner.
The class is compatible with the Python generator and async generator interfaces.
The class is not thread-safe.
Do not initialize the class and create an instance directly.
The instance should be created by `.remote`.
.. testcode::
import ray
from typing import Generator
@ray.remote(num_returns="streaming")
def gen() -> Generator[int, None, None]:
for i in range(5):
yield i
obj_ref_gen: ray.ObjectRefGenerator = gen.remote()
for obj_ref in obj_ref_gen:
print("Got:", ray.get(obj_ref))
"""
def __init__(self, generator_ref: "ray.ObjectRef", worker: "Worker"):
# The reference to a generator task.
self._generator_ref = generator_ref
# True if an exception has been raised from the generator task.
self._generator_task_raised = False
# Ray's worker class. ray._private.worker.global_worker
self.worker = worker
self.worker.check_connected()
assert hasattr(worker, "core_worker")
# Public APIs
def __iter__(self) -> "ObjectRefGenerator":
return self
def __next__(self) -> "ray.ObjectRef":
"""Waits until a next ref is available and returns the object ref.
Raises StopIteration if there's no more objects
to generate.
The object ref will contain an exception if the task fails.
When the generator task returns N objects, it can return
up to N + 1 objects (if there's a system failure, the
last object will contain a system level exception).
"""
return self._next_sync()
def send(self, value):
raise NotImplementedError("`gen.send` is not supported.")
def throw(self, value):
raise NotImplementedError("`gen.throw` is not supported.")
def close(self):
raise NotImplementedError("`gen.close` is not supported.")
def __aiter__(self) -> "ObjectRefGenerator":
return self
async def __anext__(self):
return await self._next_async()
async def asend(self, value):
raise NotImplementedError("`gen.asend` is not supported.")
async def athrow(self, value):
raise NotImplementedError("`gen.athrow` is not supported.")
async def aclose(self):
raise NotImplementedError("`gen.aclose` is not supported.")
def completed(self) -> "ray.ObjectRef":
"""Returns an object ref that is ready when
a generator task completes.
If the task is failed unexpectedly (e.g., worker failure),
the `ray.get(gen.completed())` raises an exception.
The function returns immediately.
"""
return self._generator_ref
def next_ready(self) -> bool:
"""If True, it means the output of next(gen) is ready and
ray.get(next(gen)) returns immediately. False otherwise.
It returns False when next(gen) raises a StopIteration
(this condition should be checked using is_finished).
The function returns immediately.
"""
self.worker.check_connected()
core_worker = self.worker.core_worker
if self.is_finished():
return False
expected_ref, is_ready = core_worker.peek_object_ref_stream(self._generator_ref)
if is_ready:
return True
ready, _ = ray.wait([expected_ref], timeout=0, fetch_local=False)
return len(ready) > 0
def is_finished(self) -> bool:
"""If True, it means the generator is finished
and all output is taken. False otherwise.
When True, if next(gen) is called, it will raise StopIteration
or StopAsyncIteration
The function returns immediately.
"""
self.worker.check_connected()
core_worker = self.worker.core_worker
finished = core_worker.is_object_ref_stream_finished(self._generator_ref)
if finished:
if self._generator_task_raised:
return True
else:
# We should try ray.get on a generator ref.
# If it raises an exception and
# _generator_task_raised is not set,
# this means the last ref is not taken yet.
try:
ray.get(self._generator_ref)
except Exception:
# The exception from _generator_ref
# hasn't been taken yet.
return False
else:
return True
else:
return False
# Private APIs
def _get_next_object_id_binary(self) -> bytes:
"""Return the binary id of the next object in the stream."""
self.worker.check_connected()
return self.worker.core_worker.peek_next_object_id_binary(self._generator_ref)
def _stream_exhausted(self) -> bool:
"""Whether the stream's end-of-stream marker has been reached and all
yielded refs consumed.
Non-blocking, in-memory check (unlike ``is_finished``, this does not
``ray.get`` the generator return object). When True, the only thing
left is the end-of-stream ``ray.get`` of the return object that
``_next_sync`` performs to surface ``StopIteration`` / task errors.
"""
self.worker.check_connected()
return self.worker.core_worker.is_object_ref_stream_finished(
self._generator_ref
)
def _get_next_ref_n(self, num_refs: int) -> list["ray.ObjectRef"]:
"""Return the next num_refs references from a generator without consuming them.
The returned refs are not consumed; wait for the last one to become ready
before calling ``_consume_next_ref_n`` to advance the stream.
Args:
num_refs: The number of references to return, starting from the
current head of the stream. Must be positive.
Returns:
A list of exactly num_refs ObjectRefs corresponding to the next
results in the stream, starting from the current head.
"""
if num_refs <= 0:
raise ValueError("num_refs must be positive")
self.worker.check_connected()
core_worker = self.worker.core_worker
return [
ref
for ref, _ in core_worker.peek_object_ref_stream_n(
self._generator_ref, num_refs
)
]
def _consume_next_ref_n(self, num_refs: int) -> None:
"""Consume (advance) the next num_refs references from a generator.
The caller must have waited for the last requested ref to become ready
(see ``_get_next_ref_n``); otherwise this raises ``ValueError`` instead
of silently advancing past unwritten objects.
If fewer than num_refs references remain before the end of the stream,
only the remaining references are consumed and the call returns
without raising.
Args:
num_refs: The number of references to consume, starting from the
current head of the stream. Must be positive.
"""
if num_refs <= 0:
raise ValueError("num_refs must be positive")
self.worker.check_connected()
core_worker = self.worker.core_worker
try:
core_worker.try_read_next_object_ref_stream_n(self._generator_ref, num_refs)
except ObjectRefStreamEndOfStreamError:
return
def _next_sync(self, timeout_s: Optional[int | float] = None) -> "ray.ObjectRef":
"""Waits for timeout_s and returns the object ref if available.
If an object is not available within the given timeout, it
returns a nil object reference.
If -1 timeout is provided, it means it waits infinitely.
Waiting is implemented as busy waiting.
Raises StopIteration if there's no more objects
to generate.
The object ref will contain an exception if the task fails.
When the generator task returns N objects, it can return
up to N + 1 objects (if there's a system failure, the
last object will contain a system level exception).
Args:
timeout_s: If the next object is not ready within
this timeout, it returns the nil object ref.
Returns:
ObjectRef corresponding to the next result in the stream.
"""
core_worker = self.worker.core_worker
# Wait for the next ObjectRef to become ready.
expected_ref, is_ready = core_worker.peek_object_ref_stream(self._generator_ref)
if not is_ready:
_, unready = ray.wait([expected_ref], timeout=timeout_s, fetch_local=False)
if len(unready) > 0:
return ray.ObjectRef.nil()
try:
ref = core_worker.try_read_next_object_ref_stream(self._generator_ref)
assert not ref.is_nil()
except ObjectRefStreamEndOfStreamError:
if self._generator_task_raised:
# Exception has been returned.
raise StopIteration from None
try:
# The generator ref contains an exception
# if there's any failure. It contains nothing otherwise.
# In that case, it should raise StopIteration.
#
# Bound this get by the caller's timeout: the return object
# can be remote — or lost to a failed node and pending
# reconstruction — and an unbounded get would block the
# caller until it is restored (e.g. the Ray Data scheduling
# thread; a saturated cluster can then deadlock, since the
# blocked consumer is what releases backpressured CPUs).
# Per this method's contract, a timeout is reported as "no
# object ready yet" (nil ref) so the caller retries.
ray.get(
self._generator_ref,
timeout=(None if timeout_s is None or timeout_s < 0 else timeout_s),
)
except GetTimeoutError:
return ray.ObjectRef.nil()
except Exception:
self._generator_task_raised = True
return self._generator_ref
else:
# The task finished without an exception.
raise StopIteration from None
return ref
async def _suppress_exceptions(self, ref: "ray.ObjectRef") -> None:
# Wrap a streamed ref to avoid asyncio warnings about not retrieving
# the exception when we are just waiting for the ref to become ready.
# The exception will get returned (or warned) to the user once they
# actually await the ref.
try:
await ref
except Exception:
pass
async def _next_async(self, timeout_s: Optional[int | float] = None):
"""Same API as _next_sync, but it is for async context."""
core_worker = self.worker.core_worker
ref, is_ready = core_worker.peek_object_ref_stream(self._generator_ref)
if not is_ready:
# TODO(swang): Avoid fetching the value.
_, unready = await asyncio.wait(
[asyncio.create_task(self._suppress_exceptions(ref))], timeout=timeout_s
)
if len(unready) > 0:
return ray.ObjectRef.nil()
try:
ref = core_worker.try_read_next_object_ref_stream(self._generator_ref)
assert not ref.is_nil()
except ObjectRefStreamEndOfStreamError:
if self._generator_task_raised:
# Exception has been returned.
raise StopAsyncIteration from None
try:
# The generator ref contains an exception
# if there's any failure. It contains nothing otherwise.
# In that case, it should raise StopAsyncIteration.
#
# Bound this await by the caller's timeout, mirroring
# _next_sync: the return object can be remote — or lost to a
# failed node and pending reconstruction — and an unbounded
# await would block the caller until it is restored. Per this
# method's contract, a timeout is reported as "no object
# ready yet" (nil ref) so the caller retries.
if timeout_s is None or timeout_s < 0:
await self._generator_ref
else:
await asyncio.wait_for(self._generator_ref, timeout=timeout_s)
except asyncio.TimeoutError:
return ray.ObjectRef.nil()
except Exception:
self._generator_task_raised = True
return self._generator_ref
else:
# Meaning the task succeed without failure raise StopAsyncIteration.
raise StopAsyncIteration from None
return ref
def __del__(self):
if hasattr(self.worker, "core_worker"):
# The stream is created when a task is first submitted.
# NOTE: This can be called multiple times
# because python doesn't guarantee __del__ is called
# only once.
self.worker.core_worker.async_delete_object_ref_stream(self._generator_ref)
def __getstate__(self):
raise TypeError(
"You cannot return or pass a generator to other task. "
"Serializing a ObjectRefGenerator is not allowed."
)