580 lines
19 KiB
Python
580 lines
19 KiB
Python
# coding: utf-8
|
|
|
|
import gc
|
|
import logging
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from ray._common.test_utils import SignalActor, wait_for_condition
|
|
from ray._private.test_utils import client_test_enabled
|
|
from ray._private.worker import _wait_generators_bulk
|
|
from ray.exceptions import (
|
|
ActorDiedError,
|
|
ObjectRefStreamEndOfStreamError,
|
|
RayTaskError,
|
|
TaskCancelledError,
|
|
WorkerCrashedError,
|
|
)
|
|
|
|
if client_test_enabled():
|
|
from ray.util.client import ray
|
|
else:
|
|
import ray
|
|
import ray.util.state
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def test_wait(ray_start_regular):
|
|
@ray.remote
|
|
def f(delay):
|
|
time.sleep(delay)
|
|
return
|
|
|
|
object_refs = [f.remote(0), f.remote(0), f.remote(0), f.remote(0)]
|
|
ready_ids, remaining_ids = ray.wait(object_refs)
|
|
assert len(ready_ids) == 1
|
|
assert len(remaining_ids) == 3
|
|
ready_ids, remaining_ids = ray.wait(object_refs, num_returns=4)
|
|
assert set(ready_ids) == set(object_refs)
|
|
assert remaining_ids == []
|
|
|
|
object_refs = [f.remote(0), f.remote(5)]
|
|
ready_ids, remaining_ids = ray.wait(object_refs, timeout=0.5, num_returns=2)
|
|
assert len(ready_ids) == 1
|
|
assert len(remaining_ids) == 1
|
|
|
|
# Verify that calling wait with duplicate object refs throws an
|
|
# exception.
|
|
x = ray.put(1)
|
|
with pytest.raises(Exception):
|
|
ray.wait([x, x])
|
|
|
|
# Make sure it is possible to call wait with an empty list.
|
|
ready_ids, remaining_ids = ray.wait([])
|
|
assert ready_ids == []
|
|
assert remaining_ids == []
|
|
|
|
# Test semantics of num_returns with no timeout.
|
|
obj_refs = [ray.put(i) for i in range(10)]
|
|
(found, rest) = ray.wait(obj_refs, num_returns=2)
|
|
assert len(found) == 2
|
|
assert len(rest) == 8
|
|
|
|
# Verify that incorrect usage raises a TypeError.
|
|
x = ray.put(1)
|
|
with pytest.raises(TypeError):
|
|
ray.wait(x)
|
|
with pytest.raises(TypeError):
|
|
ray.wait(1)
|
|
with pytest.raises(TypeError):
|
|
ray.wait([1])
|
|
|
|
|
|
def test_wait_timing(ray_start_2_cpus):
|
|
@ray.remote
|
|
def f():
|
|
time.sleep(1)
|
|
|
|
future = f.remote()
|
|
|
|
start = time.time()
|
|
ready, not_ready = ray.wait([future], timeout=0.2)
|
|
assert 0.2 < time.time() - start < 0.3
|
|
assert len(ready) == 0
|
|
assert len(not_ready) == 1
|
|
|
|
|
|
@pytest.mark.skipif(client_test_enabled(), reason="util not available with ray client")
|
|
def test_wait_always_fetch_local(monkeypatch, ray_start_cluster):
|
|
monkeypatch.setenv("RAY_scheduler_report_pinned_bytes_only", "false")
|
|
cluster = ray_start_cluster
|
|
head_node = cluster.add_node(num_cpus=0, object_store_memory=300e6)
|
|
ray.init(address=cluster.address)
|
|
worker_node = cluster.add_node(num_cpus=1, object_store_memory=300e6)
|
|
|
|
@ray.remote(num_cpus=1)
|
|
def return_large_object():
|
|
# 100mb so will spill on worker, but not once on head
|
|
return np.zeros(100 * 1024 * 1024, dtype=np.uint8)
|
|
|
|
@ray.remote(num_cpus=0)
|
|
def small_local_task():
|
|
return 1
|
|
|
|
put_on_head = {ray._raylet.RAY_NODE_ID_KEY: head_node.node_id}
|
|
put_on_worker = {ray._raylet.RAY_NODE_ID_KEY: worker_node.node_id}
|
|
x = small_local_task.options(label_selector=put_on_head).remote()
|
|
y = return_large_object.options(label_selector=put_on_worker).remote()
|
|
z = return_large_object.options(label_selector=put_on_worker).remote()
|
|
|
|
# will return when tasks are done
|
|
ray.wait([x, y, z], num_returns=3, fetch_local=False)
|
|
assert (
|
|
ray._private.state.available_resources_per_node()[head_node.node_id][
|
|
"object_store_memory"
|
|
]
|
|
> 250e6
|
|
)
|
|
|
|
# x should be immediately available locally, start fetching y and z
|
|
ray.wait([x, y, z], num_returns=1, fetch_local=True)
|
|
assert (
|
|
ray._private.state.available_resources_per_node()[head_node.node_id][
|
|
"object_store_memory"
|
|
]
|
|
> 250e6
|
|
)
|
|
|
|
time.sleep(5)
|
|
# y, z should be pulled here
|
|
assert (
|
|
ray._private.state.available_resources_per_node()[head_node.node_id][
|
|
"object_store_memory"
|
|
]
|
|
< 150e6
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(client_test_enabled(), reason="util not available with ray client")
|
|
def test__wait_generators_bulk_fetch_local(monkeypatch, ray_start_cluster):
|
|
monkeypatch.setenv("RAY_scheduler_report_pinned_bytes_only", "false")
|
|
cluster = ray_start_cluster
|
|
cluster.add_node(num_cpus=0, object_store_memory=500e6)
|
|
ray.init(address=cluster.address)
|
|
worker_node = cluster.add_node(num_cpus=2, object_store_memory=500e6)
|
|
|
|
@ray.remote(num_cpus=1)
|
|
def gen_large_objects():
|
|
# 100mb so the object is stored in plasma.
|
|
yield np.zeros(100 * 1024 * 1024, dtype=np.uint8)
|
|
yield np.ones(100 * 1024 * 1024, dtype=np.uint8)
|
|
|
|
put_on_worker = {ray._raylet.RAY_NODE_ID_KEY: worker_node.node_id}
|
|
gen1 = gen_large_objects.options(label_selector=put_on_worker).remote()
|
|
gen2 = gen_large_objects.options(label_selector=put_on_worker).remote()
|
|
|
|
ready = _wait_generators_bulk(
|
|
[(gen1, [True, False]), (gen2, [False, True])],
|
|
num_return=2,
|
|
timeout=10,
|
|
)
|
|
assert len(ready) == 2
|
|
assert [gen for gen, _ in ready] == [gen1, gen2]
|
|
assert all(len(refs) == 2 for _, refs in ready)
|
|
|
|
assert np.all(ray.get(ready[0][1][0], timeout=0) == 0)
|
|
assert np.all(ray.get(ready[1][1][1], timeout=0) == 1)
|
|
|
|
|
|
@pytest.mark.skipif(client_test_enabled(), reason="util not available with ray client")
|
|
def test__wait_generators_bulk_wait_for_at_most_num_return(ray_start_regular):
|
|
@ray.remote
|
|
def gen(base, delays):
|
|
for i, delay in enumerate(delays):
|
|
time.sleep(delay)
|
|
yield base + i
|
|
|
|
gen1 = gen.remote(10, [0, 0, 0])
|
|
gen2 = gen.remote(20, [0, 5])
|
|
|
|
ready = _wait_generators_bulk(
|
|
[(gen1, [True, False]), (gen2, [False, True])],
|
|
num_return=1,
|
|
timeout=2,
|
|
)
|
|
|
|
assert len(ready) == 1
|
|
ready_gen, refs = ready[0]
|
|
assert ready_gen is gen1
|
|
assert ray.get(refs) == [10, 11]
|
|
|
|
# The returned refs are consumed from the stream.
|
|
assert ray.get(next(gen1)) == 12
|
|
|
|
|
|
@pytest.mark.skipif(client_test_enabled(), reason="util not available with ray client")
|
|
def test__wait_generators_bulk_timeout(ray_start_regular):
|
|
@ray.remote(num_cpus=0, max_concurrency=2)
|
|
class Signal:
|
|
def __init__(self):
|
|
self.ready = False
|
|
|
|
def wait(self):
|
|
while not self.ready:
|
|
time.sleep(0.01)
|
|
|
|
def send(self):
|
|
self.ready = True
|
|
|
|
@ray.remote
|
|
def slow_gen(signal):
|
|
ray.get(signal.wait.remote())
|
|
yield 1
|
|
|
|
signal = Signal.remote()
|
|
gen = slow_gen.remote(signal)
|
|
assert _wait_generators_bulk([(gen, [False])], timeout=0.01) == []
|
|
ray.get(signal.send.remote())
|
|
ready = _wait_generators_bulk([(gen, [False])], timeout=5)
|
|
assert len(ready) == 1
|
|
ready_gen, refs = ready[0]
|
|
assert ready_gen is gen
|
|
assert ray.get(refs) == [1]
|
|
|
|
|
|
@pytest.mark.skipif(client_test_enabled(), reason="util not available with ray client")
|
|
def test__wait_generators_bulk_validation(ray_start_regular):
|
|
@ray.remote
|
|
def gen():
|
|
yield 1
|
|
|
|
gen = gen.remote()
|
|
|
|
with pytest.raises(TypeError):
|
|
_wait_generators_bulk({})
|
|
with pytest.raises(TypeError):
|
|
_wait_generators_bulk([(ray.put(1), [False])])
|
|
with pytest.raises(TypeError):
|
|
_wait_generators_bulk([(gen, False)])
|
|
with pytest.raises(ValueError):
|
|
_wait_generators_bulk([(gen, [])])
|
|
with pytest.raises(ValueError):
|
|
_wait_generators_bulk([(gen, [False])], num_return=2)
|
|
|
|
|
|
@pytest.mark.skipif(client_test_enabled(), reason="util not available with ray client")
|
|
def test__consume_next_ref_n_rejects_unready(ray_start_regular):
|
|
"""Consuming before the last requested ref is ready must raise rather than
|
|
silently advancing past (and dropping) the not-yet-produced object."""
|
|
|
|
@ray.remote(num_cpus=0, max_concurrency=2)
|
|
class Signal:
|
|
def __init__(self):
|
|
self.ready = False
|
|
|
|
def wait(self):
|
|
while not self.ready:
|
|
time.sleep(0.01)
|
|
|
|
def send(self):
|
|
self.ready = True
|
|
|
|
@ray.remote
|
|
def slow_gen(signal):
|
|
ray.get(signal.wait.remote())
|
|
yield 1
|
|
|
|
signal = Signal.remote()
|
|
gen = slow_gen.remote(signal)
|
|
|
|
# Peek without waiting: the ref isn't produced yet, so consuming it is rejected.
|
|
gen._get_next_ref_n(1)
|
|
with pytest.raises(ValueError):
|
|
gen._consume_next_ref_n(1)
|
|
|
|
# After the value is produced, the same generator consumes normally.
|
|
ray.get(signal.send.remote())
|
|
ready = _wait_generators_bulk([(gen, [False])], timeout=10)
|
|
assert len(ready) == 1
|
|
assert ray.get(ready[0][1]) == [1]
|
|
|
|
|
|
@pytest.mark.skipif(client_test_enabled(), reason="util not available with ray client")
|
|
def test__wait_generators_bulk_after_eof_raise_EndOfStreamError(ray_start_regular):
|
|
@ray.remote
|
|
def empty_gen():
|
|
if False:
|
|
yield 1
|
|
|
|
empty = empty_gen.remote()
|
|
ready = _wait_generators_bulk([(empty, [True, True, True])], timeout=1)
|
|
assert len(ready) == 1
|
|
ready_gen, refs = ready[0]
|
|
assert ready_gen is empty
|
|
assert len(set(refs)) == 3
|
|
for ref in refs:
|
|
with pytest.raises(ObjectRefStreamEndOfStreamError):
|
|
ray.get(ref)
|
|
|
|
|
|
@pytest.mark.skipif(client_test_enabled(), reason="util not available with ray client")
|
|
def test__wait_generators_bulk_after_partial_eof(ray_start_regular):
|
|
@ray.remote
|
|
def one_item_gen():
|
|
yield 1
|
|
|
|
one_item = one_item_gen.remote()
|
|
ready = _wait_generators_bulk([(one_item, [False, False, False])], timeout=1)
|
|
assert len(ready) == 1
|
|
ready_gen, refs = ready[0]
|
|
assert ready_gen is one_item
|
|
assert len(set(refs)) == 3
|
|
assert ray.get(refs[0]) == 1
|
|
for ref in refs[1:]:
|
|
with pytest.raises(ObjectRefStreamEndOfStreamError):
|
|
ray.get(ref)
|
|
|
|
ready_again = _wait_generators_bulk([(one_item, [False])], timeout=1)
|
|
assert len(ready_again) == 1
|
|
_, refs_again = ready_again[0]
|
|
assert len(refs_again) == 1
|
|
assert refs_again[0] not in refs
|
|
with pytest.raises(ObjectRefStreamEndOfStreamError):
|
|
ray.get(refs_again[0])
|
|
|
|
|
|
@pytest.mark.skipif(client_test_enabled(), reason="util not available with ray client")
|
|
def test__wait_generators_bulk_after_partial_error(ray_start_regular):
|
|
@ray.remote
|
|
def one_item_then_error_gen():
|
|
yield 1
|
|
raise ValueError("expected test error")
|
|
|
|
one_item_then_error = one_item_then_error_gen.remote()
|
|
ready = _wait_generators_bulk(
|
|
[(one_item_then_error, [False, False, False])], timeout=1
|
|
)
|
|
assert len(ready) == 1
|
|
ready_gen, refs = ready[0]
|
|
assert ready_gen is one_item_then_error
|
|
assert len(set(refs)) == 3
|
|
assert ray.get(refs[0]) == 1
|
|
with pytest.raises(RayTaskError) as exc_info:
|
|
ray.get(refs[1])
|
|
assert isinstance(exc_info.value.as_instanceof_cause(), ValueError)
|
|
with pytest.raises(ObjectRefStreamEndOfStreamError):
|
|
ray.get(refs[2])
|
|
|
|
|
|
@pytest.mark.skipif(client_test_enabled(), reason="util not available with ray client")
|
|
def test__get_next_ref_n_cancelled_surfaces_task_cancelled(ray_start_regular):
|
|
"""A ref peeked in advance that the generator will never produce because the
|
|
task was cancelled must surface TaskCancelledError -- the same error as the
|
|
generator completion ref -- not a generic end-of-stream."""
|
|
|
|
@ray.remote(num_returns="streaming")
|
|
def gen():
|
|
# Block forever so the value at stream position 0 is never produced.
|
|
time.sleep(1000)
|
|
yield "value"
|
|
|
|
g = gen.remote()
|
|
|
|
# Eagerly peek the value ref before the task is cancelled: EOF is not marked
|
|
# yet, so the peeked ref is only materialized once the stream ends.
|
|
[value_ref] = g._get_next_ref_n(1)
|
|
|
|
ray.cancel(g)
|
|
|
|
with pytest.raises(TaskCancelledError):
|
|
ray.get(value_ref, timeout=30)
|
|
# Oracle: the peeked ref surfaces the same error as the completion ref.
|
|
with pytest.raises(TaskCancelledError):
|
|
ray.get(g.completed(), timeout=30)
|
|
|
|
|
|
@pytest.mark.skipif(client_test_enabled(), reason="util not available with ray client")
|
|
def test__get_next_ref_n_consumed_value_not_repeated_after_cancel(ray_start_regular):
|
|
"""If cancellation collides with an already-produced value ref, consuming the
|
|
peeked value must still advance the bulk cursor past that ref."""
|
|
|
|
@ray.remote
|
|
class Signal:
|
|
def __init__(self):
|
|
self.ready = False
|
|
|
|
def wait(self):
|
|
while not self.ready:
|
|
time.sleep(0.01)
|
|
|
|
def send(self):
|
|
self.ready = True
|
|
|
|
@ray.remote(num_returns="streaming")
|
|
def gen(signal):
|
|
yield 1
|
|
ray.get(signal.wait.remote())
|
|
yield 2
|
|
|
|
signal = Signal.remote()
|
|
g = gen.remote(signal)
|
|
|
|
[first_ref] = g._get_next_ref_n(1)
|
|
assert ray.get(first_ref) == 1
|
|
|
|
ray.cancel(g)
|
|
with pytest.raises(TaskCancelledError):
|
|
ray.get(g.completed(), timeout=30)
|
|
|
|
g._consume_next_ref_n(1)
|
|
[next_ref] = g._get_next_ref_n(1)
|
|
assert next_ref != first_ref
|
|
with pytest.raises(TaskCancelledError):
|
|
ray.get(next_ref, timeout=30)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform == "win32", reason="sys.exit() actor crash flaky on Windows"
|
|
)
|
|
@pytest.mark.skipif(client_test_enabled(), reason="util not available with ray client")
|
|
def test__get_next_ref_n_actor_crash_surfaces_actor_died(ray_start_regular):
|
|
"""A ref peeked in advance that a streaming generator method will never
|
|
produce because its actor died must surface ActorDiedError, matching the
|
|
generator completion ref."""
|
|
|
|
@ray.remote
|
|
class Crasher:
|
|
@ray.method(num_returns="streaming")
|
|
def gen(self):
|
|
# Die before producing the value at stream position 0.
|
|
sys.exit(0)
|
|
yield "value"
|
|
|
|
actor = Crasher.remote()
|
|
g = actor.gen.remote()
|
|
|
|
[value_ref] = g._get_next_ref_n(1)
|
|
|
|
with pytest.raises(ActorDiedError):
|
|
ray.get(value_ref, timeout=30)
|
|
# Oracle: the peeked ref surfaces the same error as the completion ref.
|
|
with pytest.raises(ActorDiedError):
|
|
ray.get(g.completed(), timeout=30)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform == "win32", reason="sys.exit() actor crash flaky on Windows"
|
|
)
|
|
@pytest.mark.skipif(client_test_enabled(), reason="util not available with ray client")
|
|
def test__get_next_ref_n_actor_crash_after_yield_surfaces_actor_died(ray_start_regular):
|
|
"""After one successful yield, an actor that dies before producing the next
|
|
value must leave the produced ref retrievable while the peeked next ref
|
|
surfaces ActorDiedError (the EOF marker lands on an already-produced index)."""
|
|
|
|
@ray.remote
|
|
class Crasher:
|
|
@ray.method(num_returns="streaming")
|
|
def gen(self, signal):
|
|
yield "value"
|
|
# Block until the consumer has taken the first value, then die
|
|
# before producing the value at stream position 1. SignalActor is
|
|
# async so send() runs concurrently with this wait().
|
|
ray.get(signal.wait.remote())
|
|
sys.exit(0)
|
|
yield "never"
|
|
|
|
signal = SignalActor.remote()
|
|
actor = Crasher.remote()
|
|
g = actor.gen.remote(signal)
|
|
|
|
# Peek the produced value ref and the not-yet-produced next ref together.
|
|
value_ref, next_ref = g._get_next_ref_n(2)
|
|
|
|
# The first yield completed and stays retrievable.
|
|
assert ray.get(value_ref, timeout=30) == "value"
|
|
|
|
# Let the actor die now that the first value has been consumed.
|
|
signal.send.remote()
|
|
|
|
# The second position is never produced because the actor died.
|
|
with pytest.raises(ActorDiedError):
|
|
ray.get(next_ref, timeout=30)
|
|
# Oracle: the peeked ref surfaces the same error as the completion ref.
|
|
with pytest.raises(ActorDiedError):
|
|
ray.get(g.completed(), timeout=30)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform == "win32", reason="os._exit() worker crash flaky on Windows"
|
|
)
|
|
@pytest.mark.skipif(client_test_enabled(), reason="util not available with ray client")
|
|
def test__get_next_ref_n_worker_crash_surfaces_worker_crashed(ray_start_regular):
|
|
"""A ref peeked in advance that a streaming generator task will never produce
|
|
because its worker crashed must surface WorkerCrashedError, matching the
|
|
generator completion ref. This exercises a non-actor terminal error type
|
|
(WORKER_DIED) on the propagation path, complementing the actor-death and
|
|
cancellation cases."""
|
|
|
|
# max_retries=0 so the worker crash fails the task immediately instead of
|
|
# being retried, giving a deterministic terminal error.
|
|
@ray.remote(num_returns="streaming", max_retries=0)
|
|
def gen():
|
|
# Crash the worker before producing the value at stream position 0.
|
|
os._exit(1)
|
|
yield "value"
|
|
|
|
g = gen.remote()
|
|
|
|
[value_ref] = g._get_next_ref_n(1)
|
|
|
|
with pytest.raises(WorkerCrashedError):
|
|
ray.get(value_ref, timeout=30)
|
|
# Oracle: the peeked ref surfaces the same error as the completion ref.
|
|
with pytest.raises(WorkerCrashedError):
|
|
ray.get(g.completed(), timeout=30)
|
|
|
|
|
|
def _assert_no_owned_refs_leak():
|
|
"""Wait until the owner holds no live references and the store is empty."""
|
|
|
|
def check():
|
|
gc.collect()
|
|
core_worker = ray._private.worker.global_worker.core_worker
|
|
ref_counts = core_worker.get_all_reference_counts()
|
|
for rc in ref_counts.values():
|
|
if rc["local"] != 0 or rc["submitted"] != 0:
|
|
return False
|
|
return core_worker.get_memory_store_size() == 0
|
|
|
|
wait_for_condition(check, timeout=30)
|
|
|
|
|
|
@pytest.mark.skipif(client_test_enabled(), reason="util not available with ray client")
|
|
def test__wait_generators_bulk_no_ref_leak(ray_start_regular):
|
|
"""Draining a generator entirely via _wait_generators_bulk must not leak
|
|
owner-side references for the consumed objects.
|
|
|
|
The bulk peek (peek_object_ref_stream_n) hands back ObjectRefs that add their
|
|
own local reference, while the owner-side stream reference taken at peek/report
|
|
time is only released for *unconsumed* refs at stream teardown. This test
|
|
confirms whether consumed refs leave that owner-side reference dangling.
|
|
"""
|
|
|
|
@ray.remote
|
|
def gen():
|
|
for i in range(3):
|
|
yield i
|
|
|
|
g = gen.remote()
|
|
|
|
collected = []
|
|
saw_eof = False
|
|
while not saw_eof:
|
|
ready = _wait_generators_bulk([(g, [True, True, True])], timeout=10)
|
|
assert len(ready) == 1
|
|
# Avoid binding the generator object to a local (e.g. via tuple unpacking),
|
|
# which would keep its stream alive and prevent teardown.
|
|
refs = ready[0][1]
|
|
for ref in refs:
|
|
try:
|
|
collected.append(ray.get(ref))
|
|
except ObjectRefStreamEndOfStreamError:
|
|
saw_eof = True
|
|
break
|
|
|
|
assert collected == [0, 1, 2]
|
|
|
|
# Drop every handle to the generator and its (consumed) objects.
|
|
del g, ready, refs, ref
|
|
|
|
_assert_no_owned_refs_leak()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-sv", __file__]))
|