413 lines
13 KiB
Python
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__]))
|