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

413 lines
13 KiB
Python

import asyncio
import gc
import json
import os
import random
import signal
import sys
import time
from typing import Optional
import numpy as np
import pytest
from pydantic import BaseModel
import ray
from ray._common.test_utils import SignalActor
RECONSTRUCTION_CONFIG = {
"health_check_failure_threshold": 10,
"health_check_period_ms": 100,
"health_check_timeout_ms": 100,
"health_check_initial_delay_ms": 0,
"max_direct_call_object_size": 100,
"task_retry_delay_ms": 100,
"object_timeout_milliseconds": 200,
"fetch_warn_timeout_milliseconds": 1000,
}
def assert_no_leak():
gc.collect()
core_worker = ray._private.worker.global_worker.core_worker
ref_counts = core_worker.get_all_reference_counts()
print(ref_counts)
for rc in ref_counts.values():
assert rc["local"] == 0
assert rc["submitted"] == 0
assert core_worker.get_memory_store_size() == 0
@pytest.mark.skipif(
sys.platform == "win32", reason="SIGKILL is not available on Windows"
)
def test_caller_death(monkeypatch, shutdown_only):
"""
Test the case where caller of a streaming generator actor task dies
while the streaming generator task is executing. The streaming
generator task should still finish and won't block other actor tasks.
This means that `ReportGeneratorItemReturns` RPC should fail and it shouldn't
be retried indefinitely.
"""
ray.init()
@ray.remote
class Callee:
def gen(self, caller_pid):
os.kill(caller_pid, signal.SIGKILL)
yield [1] * 1024 * 1024
def ping(self):
pass
@ray.remote
def caller(callee):
ray.get(callee.gen.remote(os.getpid()))
callee = Callee.remote()
o = caller.remote(callee)
ray.wait([o])
# Make sure gen will finish and ping can run.
ray.get(callee.ping.remote())
def test_intermediate_generator_object_recovery_while_generator_running(
ray_start_cluster,
):
"""
1. Streaming producer starts on worker1.
2. consumer consumes value 1 from producer on worker2 and finishes.
3. Run an extra consumer on worker2 to track when reconstruction is triggered.
4. Add worker3.
5. worker2 dies.
6. Try to get consumer output.
7. Therefore Ray tries to reconstruct value 1 from producer.
8. Get the reconstructed extra_consumer_ref (assures 7 happened).
9. Streaming producer should be cancelled and resubmitted.
10. Retry for consumer should complete.
"""
cluster = ray_start_cluster
cluster.add_node(num_cpus=0) # head
ray.init(address=cluster.address)
cluster.add_node(num_cpus=1, resources={"producer": 1}) # worker1
worker2 = cluster.add_node(num_cpus=1, resources={"consumer": 1})
@ray.remote(num_cpus=1, resources={"producer": 1})
def producer():
for _ in range(3):
yield np.zeros(10 * 1024 * 1024, dtype=np.uint8)
@ray.remote(num_cpus=1, resources={"consumer": 1})
def consumer(np_arr):
return np_arr
streaming_ref = producer.options(_generator_backpressure_num_objects=1).remote()
consumer_ref = consumer.remote(next(streaming_ref))
extra_consumer_ref = consumer.remote(np.zeros(10 * 1024 * 1024, dtype=np.uint8))
ray.wait([consumer_ref, extra_consumer_ref], num_returns=2, fetch_local=False)
cluster.add_node(num_cpus=1, resources={"consumer": 1}) # worker3
cluster.remove_node(worker2, allow_graceful=True)
# Make sure reconstruction was triggered.
assert ray.get(extra_consumer_ref).size == (10 * 1024 * 1024)
# Allow first streaming generator attempt to finish
ray.get([next(streaming_ref), next(streaming_ref)])
assert ray.get(consumer_ref).size == (10 * 1024 * 1024)
def test_actor_intermediate_generator_object_recovery_while_generator_running(
ray_start_cluster,
):
"""
1. Producer actor and its generator producer task start on worker1.
2. consumer consumes value 1 from producer on worker2 and finishes.
3. Run an extra consumer on worker2 to track when reconstruction is triggered.
4. Add worker3.
5. worker2 dies.
6. Ray tries to reconstruct value 1 from producer.
7. Get the reconstructed extra_consumer_ref (assures 6 happened).
8. Ray tries and fails to cancel the producer task.
9. Get the next two values to relieve backpressure and allow producer to finish.
10. Ray resubmits the producer generator task.
11. Retry for consumer should complete.
"""
cluster = ray_start_cluster
cluster.add_node(num_cpus=0) # head
ray.init(address=cluster.address)
cluster.add_node(num_cpus=1, resources={"producer": 1}) # worker 1
worker2 = cluster.add_node(num_cpus=1, resources={"consumer": 1})
@ray.remote(num_cpus=1, resources={"producer": 1}, max_task_retries=-1)
class Producer:
def producer(self):
for _ in range(3):
yield np.zeros(10 * 1024 * 1024, dtype=np.uint8)
@ray.remote(num_cpus=1, resources={"consumer": 1})
def consumer(np_arr):
return np_arr
producer_actor = Producer.remote()
streaming_ref = producer_actor.producer.options(
_generator_backpressure_num_objects=1
).remote()
consumer_ref = consumer.remote(next(streaming_ref))
extra_consumer_ref = consumer.remote(np.zeros(10 * 1024 * 1024, dtype=np.uint8))
ray.wait([consumer_ref, extra_consumer_ref], num_returns=2, fetch_local=False)
cluster.add_node(num_cpus=1, resources={"consumer": 1}) # worker 3
cluster.remove_node(worker2, allow_graceful=True)
# Make sure reconstruction was triggered.
ray.get(extra_consumer_ref)
# Allow first streaming generator attempt to finish
ray.get([next(streaming_ref), next(streaming_ref)])
assert ray.get(consumer_ref).size == (10 * 1024 * 1024)
@pytest.mark.parametrize("backpressure", [False, True])
@pytest.mark.parametrize("delay_latency", [0.1, 1])
@pytest.mark.parametrize("threshold", [1, 3])
def test_many_tasks_lineage_reconstruction_mini_stress_test(
monkeypatch, ray_start_cluster, backpressure, delay_latency, threshold
):
"""Test a workload that spawns many tasks and relies on lineage reconstruction."""
if not backpressure:
if delay_latency == 0.1 and threshold == 1:
return
elif delay_latency == 1:
return
with monkeypatch.context() as m:
m.setenv(
"RAY_testing_asio_delay_us",
"CoreWorkerService.grpc_server.ReportGeneratorItemReturns=10000:1000000",
)
m.setenv(
"RAY_testing_rpc_failure",
json.dumps(
{
"CoreWorkerService.grpc_client.ReportGeneratorItemReturns": {
"num_failures": 5,
"req_failure_prob": 25,
"resp_failure_prob": 25,
"in_flight_failure_prob": 25,
}
}
),
)
cluster = ray_start_cluster
cluster.add_node(
num_cpus=1,
resources={"head": 1},
_system_config=RECONSTRUCTION_CONFIG,
enable_object_reconstruction=True,
)
ray.init(address=cluster.address)
if backpressure:
threshold = 1
else:
threshold = -1
@ray.remote(
max_retries=-1,
_generator_backpressure_num_objects=threshold,
)
def dynamic_generator(num_returns):
for i in range(num_returns):
time.sleep(0.1)
yield np.ones(1_000_000, dtype=np.int8) * i
@ray.remote(num_cpus=0, resources={"head": 1})
def driver():
unready = [dynamic_generator.remote(10) for _ in range(5)]
ready = []
while unready:
for a in unready:
print(a._generator_ref)
ready, unready = ray.wait(
unready, num_returns=len(unready), timeout=0.1
)
for r in ready:
try:
ref = next(r)
print(ref)
ray.get(ref)
except StopIteration:
pass
else:
unready.append(r)
return None
ref = driver.remote()
nodes = []
for _ in range(4):
nodes.append(cluster.add_node(num_cpus=1, object_store_memory=10**8))
cluster.wait_for_nodes()
for _ in range(10):
time.sleep(0.1)
node_to_kill = random.choices(nodes)[0]
nodes.remove(node_to_kill)
cluster.remove_node(node_to_kill, allow_graceful=False)
nodes.append(cluster.add_node(num_cpus=1, object_store_memory=10**8))
ray.get(ref)
del ref
assert_no_leak()
def test_local_gc_not_hang(shutdown_only, monkeypatch):
"""Verify the generator doesn't deadlock when a local GC is triggered."""
with monkeypatch.context() as m:
m.setenv("RAY_local_gc_interval_s", 1)
ray.init()
@ray.remote(_generator_backpressure_num_objects=1)
def f():
for _ in range(5):
yield 1
gen = f.remote()
time.sleep(5)
# It should not hang.
for ref in gen:
ray.get(gen)
def test_sync_async_mix_regression_test(shutdown_only):
"""Verify when sync and async tasks are mixed up
it doesn't raise a segfault
https://github.com/ray-project/ray/issues/41346
"""
class PayloadPydantic(BaseModel):
class Error(BaseModel):
msg: str
code: int
type: str
text: Optional[str] = None
ts: Optional[float] = None
reason: Optional[str] = None
error: Optional[Error] = None
ray.init()
@ray.remote
class B:
def __init__(self, a):
self.a = a
async def stream(self):
async for ref in self.a.stream.remote(1):
print("stream")
await ref
async def start(self):
await asyncio.gather(*[self.stream() for _ in range(2)])
@ray.remote
class A:
def stream(self, i):
payload = PayloadPydantic(
text="Test output",
ts=time.time(),
reason="Success!",
)
for _ in range(10):
yield payload
async def aio_stream(self):
for _ in range(10):
yield 1
a = A.remote()
b = B.remote(a)
ray.get(b.start.remote())
@pytest.mark.parametrize("use_asyncio", [False, True])
def test_cancel(shutdown_only, use_asyncio):
"""Test concurrent task cancellation with generator task.
Once the caller receives an ack that the executor has cancelled the task
execution, the caller should receive a TaskCancelledError for the next
ObjectRef that it tries to read from the generator. This should happen even
if the caller has already received values for the next object indices in
the stream. Also, we should not apply the usual logic that reorders
out-of-order reports if the task was cancelled; waiting for the
intermediate indices to appear would hang the caller."""
@ray.remote
class Actor:
def ready(self):
return
def stream(self, signal):
cancelled_ref = signal.wait.remote()
i = 0
done_at = time.time() + 1
while time.time() < done_at:
yield i
i += 1
ready, _ = ray.wait([cancelled_ref], timeout=0)
if not ready:
# Continue executing for one second after the driver
# cancels. This is to make sure that we receive the cancel
# signal while the task is still running.
done_at = time.time() + 1
async def async_stream(self, signal):
cancelled_ref = signal.wait.remote()
i = 0
done_at = time.time() + 1
while time.time() < done_at:
yield i
i += 1
ready, _ = ray.wait([cancelled_ref], timeout=0)
if not ready:
# Continue executing for one second after the driver
# cancels. This is to make sure that we receive the cancel
# signal while the task is still running.
done_at = time.time() + 1
signal = SignalActor.remote()
a = Actor.remote()
ray.get(a.ready.remote())
if use_asyncio:
gen = a.async_stream.remote(signal)
else:
gen = a.stream.remote(signal)
try:
for i, ref in enumerate(gen):
assert i == ray.get(ref)
print(i)
if i == 0:
ray.cancel(gen)
signal.send.remote()
except ray.exceptions.TaskCancelledError:
pass
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))