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

772 lines
23 KiB
Python

import asyncio
import gc
import sys
import threading
import time
from unittest.mock import Mock, patch
import numpy as np
import pytest
import ray
from ray._common.test_utils import wait_for_condition
from ray._raylet import ObjectRefGenerator, ObjectRefStreamEndOfStreamError
from ray.cloudpickle import dumps
from ray.exceptions import WorkerCrashedError
from ray.experimental.state.api import list_objects
class MockedWorker:
def __init__(self, mocked_core_worker):
self.core_worker = mocked_core_worker
def reset_core_worker(self):
"""Emulate the case ray.shutdown is called
and the core_worker instance is GC'ed.
"""
self.core_worker = None
def check_connected(self):
return True
@pytest.fixture
def mocked_worker():
mocked_core_worker = Mock()
mocked_core_worker.try_read_next_object_ref_stream.return_value = None
mocked_core_worker.try_read_next_object_ref_stream_n.return_value = None
mocked_core_worker.async_delete_object_ref_stream.return_value = None
mocked_core_worker.create_object_ref_stream.return_value = None
mocked_core_worker.peek_object_ref_stream.return_value = [], []
worker = MockedWorker(mocked_core_worker)
yield worker
def test_streaming_object_ref_generator_basic_unit(mocked_worker):
"""
Verify the basic case:
create a generator -> read values -> nothing more to read -> delete.
"""
with patch("ray.wait") as mocked_ray_wait:
with patch("ray.get") as mocked_ray_get:
c = mocked_worker.core_worker
generator_ref = ray.ObjectRef.from_random()
generator = ObjectRefGenerator(generator_ref, mocked_worker)
# Make sure we cannot serialize the generator.
with pytest.raises(TypeError):
dumps(generator)
# Test when there's no new ref, it returns a nil.
new_ref = ray.ObjectRef.from_random()
c.peek_object_ref_stream.return_value = (new_ref, False)
mocked_ray_wait.return_value = [], [new_ref]
ref = generator._next_sync(timeout_s=0)
assert ref.is_nil()
# When the new ref is available, next should return it.
# When peek_object_ref_stream returns is_ready = True,
# it shouldn't call ray.wait.
new_ref = ray.ObjectRef.from_random()
c.peek_object_ref_stream.return_value = (new_ref, True)
c.try_read_next_object_ref_stream.return_value = new_ref
ref = generator._next_sync(timeout_s=0)
assert new_ref == ref
# When the new ref is available, next should return it.
# When peek_object_ref_stream returns is_ready = False,
# it should wait until ray.wait returns.
for _ in range(3):
new_ref = ray.ObjectRef.from_random()
c.peek_object_ref_stream.return_value = (new_ref, False)
mocked_ray_wait.return_value = [new_ref], []
c.try_read_next_object_ref_stream.return_value = new_ref
ref = generator._next_sync(timeout_s=0)
assert new_ref == ref
# When try_read_next_object_ref_stream raises a
# ObjectRefStreamEndOfStreamError, it should raise a stop iteration.
new_ref = ray.ObjectRef.from_random()
c.peek_object_ref_stream.return_value = (new_ref, True)
c.try_read_next_object_ref_stream.side_effect = (
ObjectRefStreamEndOfStreamError("")
) # noqa
mocked_ray_get.return_value = None
with pytest.raises(StopIteration):
generator._next_sync(timeout_s=0)
def test_streaming_object_ref_generator_consume_bulk_unit(mocked_worker):
c = mocked_worker.core_worker
generator_ref = ray.ObjectRef.from_random()
generator = ObjectRefGenerator(generator_ref, mocked_worker)
generator._consume_next_ref_n(2)
c.try_read_next_object_ref_stream_n.assert_called_once_with(generator_ref, 2)
def test_streaming_object_ref_generator_task_failed_unit(mocked_worker):
"""
Verify when a task is failed by a system error,
the generator ref is returned.
"""
with patch("ray.get") as mocked_ray_get:
with patch("ray.wait") as mocked_ray_wait:
c = mocked_worker.core_worker
generator_ref = ray.ObjectRef.from_random()
generator = ObjectRefGenerator(generator_ref, mocked_worker)
# Simulate the worker failure happens.
next_ref = ray.ObjectRef.from_random()
c.peek_object_ref_stream.return_value = (next_ref, False)
mocked_ray_wait.return_value = [next_ref], []
mocked_ray_get.side_effect = WorkerCrashedError()
c.try_read_next_object_ref_stream.side_effect = (
ObjectRefStreamEndOfStreamError("")
) # noqa
ref = generator._next_sync(timeout_s=0)
# If the generator task fails by a systsem error,
# meaning the ref will raise an exception
# it should be returned.
assert ref == generator_ref
# Once exception is raised, it should always
# raise stopIteration regardless of what
# the ref contains now.
with pytest.raises(StopIteration):
ref = generator._next_sync(timeout_s=0)
def test_generator_basic(shutdown_only):
ray.init(num_cpus=1)
"""Basic cases"""
print("Test basic case")
@ray.remote
def f():
for i in range(5):
yield i
gen = f.remote()
i = 0
for ref in gen:
print(ray.get(ref))
assert i == ray.get(ref)
del ref
i += 1
"""Exceptions"""
print("Test exceptions")
@ray.remote
def f():
for i in range(5):
if i == 2:
raise ValueError
yield i
gen = f.remote()
print(ray.get(next(gen)))
print(ray.get(next(gen)))
with pytest.raises(ray.exceptions.RayTaskError) as e:
print(ray.get(next(gen)))
with pytest.raises(StopIteration):
ray.get(next(gen))
with pytest.raises(StopIteration):
ray.get(next(gen))
"""Generator Task failure"""
print("Test task failures")
@ray.remote
class A:
def getpid(self):
import os
return os.getpid()
def f(self):
for i in range(5):
time.sleep(1)
yield i
a = A.remote()
gen = a.f.remote()
i = 0
for ref in gen:
if i == 2:
ray.kill(a)
if i == 3:
with pytest.raises(ray.exceptions.RayActorError) as e:
ray.get(ref)
assert "The actor is dead because it was killed by `ray.kill`" in str(
e.value
)
break
assert i == ray.get(ref)
del ref
i += 1
for _ in range(10):
with pytest.raises(StopIteration):
next(gen)
"""Retry exceptions"""
print("Test retry exceptions")
@ray.remote
class Actor:
def __init__(self):
self.should_kill = True
def should_kill(self):
return self.should_kill
async def set(self, wait_s):
await asyncio.sleep(wait_s)
self.should_kill = False
@ray.remote(retry_exceptions=[ValueError], max_retries=10)
def f(a):
for i in range(5):
should_kill = ray.get(a.should_kill.remote())
if i == 3 and should_kill:
raise ValueError
yield i
a = Actor.remote()
gen = f.remote(a)
assert ray.get(next(gen)) == 0
assert ray.get(next(gen)) == 1
assert ray.get(next(gen)) == 2
a.set.remote(3)
assert ray.get(next(gen)) == 3
assert ray.get(next(gen)) == 4
with pytest.raises(StopIteration):
ray.get(next(gen))
"""Cancel"""
print("Test cancel")
@ray.remote
def f():
for i in range(5):
time.sleep(5)
yield i
gen = f.remote()
assert ray.get(next(gen)) == 0
ray.cancel(gen)
with pytest.raises(ray.exceptions.RayTaskError) as e:
assert ray.get(next(gen)) == 1
assert "was cancelled" in str(e.value)
with pytest.raises(StopIteration):
next(gen)
def test_streaming_generator_bad_exception_not_failing(shutdown_only, capsys):
"""This test verifies when a return value cannot be stored
e.g., because it holds a lock) if it handles failures gracefully.
Previously, when it happens, there was a check failure. This verifies
the check failure doesn't happen anymore.
"""
ray.init()
class UnserializableException(Exception):
def __init__(self):
self.lock = threading.Lock()
@ray.remote
def f():
raise UnserializableException
yield 1 # noqa
for ref in f.remote():
with pytest.raises(ray.exceptions.RayTaskError):
ray.get(ref)
captured = capsys.readouterr()
lines = captured.err.strip().split("\n")
# Verify check failure doesn't happen because we handle the error
# properly.
for line in lines:
assert "Check failed:" not in line
@pytest.mark.parametrize("crash_type", ["exception", "worker_crash"])
def test_generator_streaming_no_leak_upon_failures(
monkeypatch, shutdown_only, crash_type
):
with monkeypatch.context() as m:
m.setenv(
"RAY_testing_asio_delay_us",
"CoreWorkerService.grpc_server.ReportGeneratorItemReturns=100000:1000000",
)
ray.init(num_cpus=1)
@ray.remote
def g():
try:
gen = f.remote()
for ref in gen:
print(ref)
ray.get(ref)
except Exception:
print("exception!")
del ref
del gen
gc.collect()
# Only the ref g is alive.
def verify():
print(list_objects())
return len(list_objects()) == 1
wait_for_condition(verify)
return True
@ray.remote
def f():
for i in range(10):
time.sleep(0.2)
if i == 4:
if crash_type == "exception":
raise ValueError
else:
sys.exit(9)
yield 2
for _ in range(5):
ray.get(g.remote())
@pytest.mark.parametrize("use_actors", [False, True])
@pytest.mark.parametrize("store_in_plasma", [False, True])
def test_generator_streaming(shutdown_only, use_actors, store_in_plasma):
"""Verify the generator is working in a streaming fashion."""
ray.init()
remote_generator_fn = None
if use_actors:
@ray.remote
class Generator:
def __init__(self):
pass
def generator(self, num_returns, store_in_plasma):
for i in range(num_returns):
if store_in_plasma:
yield np.ones(1_000_000, dtype=np.int8) * i
else:
yield [i]
g = Generator.remote()
remote_generator_fn = g.generator
else:
@ray.remote(max_retries=0)
def generator(num_returns, store_in_plasma):
for i in range(num_returns):
if store_in_plasma:
yield np.ones(1_000_000, dtype=np.int8) * i
else:
yield [i]
remote_generator_fn = generator
"""Verify num_returns="streaming" is streaming"""
gen = remote_generator_fn.remote(3, store_in_plasma)
i = 0
for ref in gen:
id = ref.hex()
if store_in_plasma:
expected = np.ones(1_000_000, dtype=np.int8) * i
assert np.array_equal(ray.get(ref), expected)
else:
expected = [i]
assert ray.get(ref) == expected
del ref
wait_for_condition(
lambda id=id: len(list_objects(filters=[("object_id", "=", id)])) == 0
)
i += 1
def test_generator_dist_chain(ray_start_cluster):
"""E2E test to verify chain of generator works properly."""
cluster = ray_start_cluster
cluster.add_node(num_cpus=0, object_store_memory=1 * 1024 * 1024 * 1024)
ray.init()
cluster.add_node(num_cpus=1)
cluster.add_node(num_cpus=1)
cluster.add_node(num_cpus=1)
cluster.add_node(num_cpus=1)
@ray.remote
class ChainActor:
def __init__(self, child=None):
self.child = child
def get_data(self):
if not self.child:
for _ in range(10):
time.sleep(0.1)
yield np.ones(5 * 1024 * 1024)
else:
for data in self.child.get_data.remote():
yield ray.get(data)
chain_actor = ChainActor.remote()
chain_actor_2 = ChainActor.remote(chain_actor)
chain_actor_3 = ChainActor.remote(chain_actor_2)
chain_actor_4 = ChainActor.remote(chain_actor_3)
for ref in chain_actor_4.get_data.remote():
assert np.array_equal(np.ones(5 * 1024 * 1024), ray.get(ref))
print("getting the next data")
del ref
def test_generator_slow_pinning_requests(monkeypatch, shutdown_only):
"""
Verify when the Object pinning request from the raylet
is reported slowly, there's no refernece leak.
"""
with monkeypatch.context() as m:
m.setenv(
"RAY_testing_asio_delay_us",
"CoreWorkerService.grpc_server.PubsubLongPolling=1000000:1000000",
)
@ray.remote
def f():
yield np.ones(5 * 1024 * 1024)
for ref in f.remote():
del ref
print(list_objects())
@pytest.mark.parametrize("store_in_plasma", [False, True])
def test_actor_streaming_generator(shutdown_only, store_in_plasma):
"""Test actor/async actor with sync/async generator interfaces."""
ray.init()
@ray.remote
class Actor:
def f(self, ref):
for i in range(3):
yield i
async def async_f(self, ref):
for i in range(3):
await asyncio.sleep(0.1)
yield i
def g(self):
return 3
a = Actor.remote()
if store_in_plasma:
arr = np.random.rand(5 * 1024 * 1024)
else:
arr = 3
def verify_sync_task_executor():
generator = a.f.remote(ray.put(arr))
# Verify it works with next.
assert isinstance(generator, ObjectRefGenerator)
assert ray.get(next(generator)) == 0
assert ray.get(next(generator)) == 1
assert ray.get(next(generator)) == 2
with pytest.raises(StopIteration):
ray.get(next(generator))
# Verify it works with for.
generator = a.f.remote(ray.put(3))
for index, ref in enumerate(generator):
assert index == ray.get(ref)
def verify_async_task_executor():
# Verify it works with next.
generator = a.async_f.remote(ray.put(arr))
assert isinstance(generator, ObjectRefGenerator)
assert ray.get(next(generator)) == 0
assert ray.get(next(generator)) == 1
assert ray.get(next(generator)) == 2
# Verify it works with for.
generator = a.f.remote(ray.put(3))
for index, ref in enumerate(generator):
assert index == ray.get(ref)
async def verify_sync_task_async_generator():
# Verify anext
async_generator = a.f.remote(ray.put(arr))
assert isinstance(async_generator, ObjectRefGenerator)
for expected in range(3):
ref = await async_generator.__anext__()
assert await ref == expected
with pytest.raises(StopAsyncIteration):
await async_generator.__anext__()
# Verify async for.
async_generator = a.f.remote(ray.put(arr))
expected = 0
async for ref in async_generator:
value = await ref
assert expected == value
expected += 1
async def verify_async_task_async_generator():
async_generator = a.async_f.remote(ray.put(arr))
assert isinstance(async_generator, ObjectRefGenerator)
for expected in range(3):
ref = await async_generator.__anext__()
assert await ref == expected
with pytest.raises(StopAsyncIteration):
await async_generator.__anext__()
# Verify async for.
async_generator = a.async_f.remote(ray.put(arr))
expected = 0
async for ref in async_generator:
value = await ref
assert expected == value
expected += 1
verify_sync_task_executor()
verify_async_task_executor()
asyncio.run(verify_sync_task_async_generator())
asyncio.run(verify_async_task_async_generator())
def test_streaming_generator_num_objects_per_yield(shutdown_only):
ray.init()
@ray.remote(_num_objects_per_yield=2)
def generator():
for i in range(3):
stats = yield i, f"metadata-{i}"
assert stats is None or stats.object_creation_dur_s >= 0
gen = generator.remote()
for i in range(3):
assert ray.get(next(gen)) == i
assert ray.get(next(gen)) == f"metadata-{i}"
with pytest.raises(StopIteration):
next(gen)
@ray.remote
def per_call():
yield 1, 2
with pytest.raises(ValueError, match="_num_objects_per_yield"):
per_call.options(_num_objects_per_yield=2).remote()
def test_actor_streaming_generator_num_objects_per_yield(shutdown_only):
ray.init()
@ray.remote
class Actor:
@ray.method(_num_objects_per_yield=2)
def decorated(self):
yield "block", "metadata"
def per_call(self):
yield 1, 2
actor = Actor.remote()
gen = actor.decorated.remote()
assert ray.get(next(gen)) == "block"
assert ray.get(next(gen)) == "metadata"
with pytest.raises(StopIteration):
next(gen)
with pytest.raises(ValueError, match="_num_objects_per_yield"):
actor.per_call.options(_num_objects_per_yield=2).remote()
def test_streaming_generator_num_objects_per_yield_invalid_yield(shutdown_only):
ray.init()
@ray.remote(_num_objects_per_yield=2)
def generator():
yield (1,)
gen = generator.remote()
with pytest.raises(ValueError, match="_num_objects_per_yield=2"):
ray.get(next(gen))
with pytest.raises(StopIteration):
next(gen)
def test_streaming_generator_num_objects_per_yield_serialization_failure(shutdown_only):
ray.init()
@ray.remote(_num_objects_per_yield=2)
def generator():
yield threading.Lock(), 1
gen = generator.remote()
with pytest.raises(ray.exceptions.RayTaskError):
ray.get(next(gen))
with pytest.raises(ray.exceptions.RayTaskError):
ray.get(next(gen))
with pytest.raises(StopIteration):
next(gen)
def test_streaming_generator_num_objects_per_yield_partial_store_failure(
shutdown_only,
):
ray.init()
@ray.remote(_num_objects_per_yield=2)
def generator():
# If a later object fails after an earlier object has been stored, the
# caller should still receive a ref for every object in the grouped yield.
yield 1, threading.Lock()
gen = generator.remote()
assert ray.get(next(gen)) == 1
with pytest.raises(ray.exceptions.RayTaskError):
ray.get(next(gen))
with pytest.raises(StopIteration):
next(gen)
def test_streaming_generator_num_objects_per_yield_failure_not_retried(
shutdown_only,
):
ray.init()
@ray.remote(_num_objects_per_yield=2, retry_exceptions=True, max_retries=1)
def generator():
# Once grouped-yield IDs are allocated, the whole group must be
# reported so those temporary refs are cleared instead of retrying.
yield 1, threading.Lock()
gen = generator.remote()
assert ray.get(next(gen)) == 1
with pytest.raises(ray.exceptions.RayTaskError):
ray.get(next(gen))
with pytest.raises(StopIteration):
next(gen)
def test_streaming_generator_exception(shutdown_only):
# Verify the exceptions are correctly raised.
# Also verify the followup next will raise StopIteration.
ray.init()
@ray.remote
class Actor:
def f(self):
raise ValueError
yield 1 # noqa
async def async_f(self):
raise ValueError
yield 1 # noqa
a = Actor.remote()
g = a.f.remote()
with pytest.raises(ValueError):
ray.get(next(g))
with pytest.raises(StopIteration):
ray.get(next(g))
with pytest.raises(StopIteration):
ray.get(next(g))
g = a.async_f.remote()
with pytest.raises(ValueError):
ray.get(next(g))
with pytest.raises(StopIteration):
ray.get(next(g))
with pytest.raises(StopIteration):
ray.get(next(g))
def test_next_sync_timeout_when_generator_ref_unavailable(
monkeypatch, ray_start_cluster
):
"""_next_sync(timeout_s) must not block in its end-of-stream handling.
After all yielded refs are consumed, ``_next_sync`` calls
``ray.get(generator_ref)`` to distinguish a normal end of the stream
from a task failure. If that object is unavailable — e.g. it lived in
the plasma store of a node that died — the get must be bounded by the
caller's timeout (reporting "not ready yet" with a nil ref) instead of
blocking the caller until the object is reconstructed. A blocked caller
can deadlock: reconstruction needs a CPU, and the blocked caller may be
what releases one (see ray-project/ray#63701).
"""
# Force the generator task's return object into plasma (instead of
# being inlined with the owner) so that it is lost when its node dies.
monkeypatch.setenv("RAY_max_direct_call_object_size", "0")
cluster = ray_start_cluster
cluster.add_node(num_cpus=0)
ray.init(address=cluster.address)
worker_node = cluster.add_node(num_cpus=1)
cluster.wait_for_nodes()
@ray.remote(num_returns="streaming")
def gen():
yield 1
g = gen.remote()
# Consume the single yielded ref so that the stream is exhausted.
first = g._next_sync(timeout_s=30)
assert not first.is_nil()
# Lose the generator's return object together with its node.
cluster.remove_node(worker_node)
# End-of-stream handling must honor the timeout: a nil ref, not a block.
start = time.monotonic()
assert g._next_sync(timeout_s=0).is_nil()
assert time.monotonic() - start < 10
# The async counterpart must honor the timeout the same way.
start = time.monotonic()
assert asyncio.run(g._next_async(timeout_s=0)).is_nil()
assert time.monotonic() - start < 10
# Once a node is back, lineage reconstruction restores the return object
# and the stream terminates normally.
cluster.add_node(num_cpus=1)
cluster.wait_for_nodes()
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
try:
ref = g._next_sync(timeout_s=1)
except StopIteration:
break
assert ref.is_nil()
else:
pytest.fail("Generator did not finish after the node was restored.")
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))