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

1626 lines
49 KiB
Python

import asyncio
import gc
import os
import signal
import sys
import threading
import time
from typing import List, Optional, Tuple, Type
import numpy as np
import pytest
import ray
from ray._common.test_utils import run_string_as_driver, wait_for_condition
from ray.util.state import list_tasks
from ray.util.state.exception import RayStateApiException
def _list_tasks(**kwargs):
"""Same as ``list_tasks`` but pinned to this test's cluster (when several
local Ray instances exist) and tolerant of transient state-API errors.
The dashboard/state API can briefly return a non-JSON body (surfacing as
``RayStateApiException``) when it is overloaded during a heavy test run.
That is unrelated to what these tests assert, so retry for a short window
instead of letting a momentary hiccup flake the test.
"""
address = ray.get_runtime_context().gcs_address
deadline = time.monotonic() + 30
while True:
try:
return list_tasks(address=address, **kwargs)
except RayStateApiException:
if time.monotonic() >= deadline:
raise
time.sleep(0.5)
@pytest.mark.parametrize("actor", [False, True])
def test_streaming_generator_backpressure_basic(shutdown_only, actor):
"""Verify backpressure works with
_generator_backpressure_num_objects = 0
"""
ray.init(num_cpus=1)
@ray.remote
class Reporter:
def __init__(self):
self.reported = set()
def report(self, i):
self.reported.add(i)
def reported(self):
return self.reported
TOTAL_RETURN = 10
@ray.remote(_generator_backpressure_num_objects=1)
def f(reporter):
for i in range(TOTAL_RETURN):
print("yield ", i)
ray.get(reporter.report.remote(i))
yield i
@ray.remote
class A:
@ray.method(_generator_backpressure_num_objects=1)
def f(self, reporter):
for i in range(TOTAL_RETURN):
print("yield ", i)
ray.get(reporter.report.remote(i))
yield i
reporter = Reporter.remote()
def check_reported(i):
return i in ray.get(reporter.reported.remote())
if actor:
a = A.remote()
gen = a.f.remote(reporter)
else:
gen = f.remote(reporter)
# Don't iterate everything to test is deleing the generator
# would stop backpressure (see the end of the test).
for i in range(TOTAL_RETURN - 4):
print("iteration ", i)
r, _ = ray.wait([gen])
assert len(r) == 1
wait_for_condition(lambda: check_reported(i))
wait_for_condition(lambda: not check_reported(i + 1))
# Wait a little bit to make sure it is backpressured.
time.sleep(2)
wait_for_condition(lambda: not check_reported(i + 1))
# Consume the ref -> task will progress.
ray.get(next(gen))
wait_for_condition(lambda: check_reported(i + 1))
"""
Verify deleting a generator will stop backpressure
and proceed a task.
"""
del gen
del r
wait_for_condition(lambda: check_reported(TOTAL_RETURN - 1))
@pytest.mark.parametrize("backpressure_size", [2, 3, 5, 7, 10, 15])
def test_streaming_generator_backpressure_multiple_objects(
shutdown_only, backpressure_size
):
"""Verify backpressure works when it needs more than 1 objects
to backpressure.
"""
ray.init()
@ray.remote
class Reporter:
def __init__(self):
self.reported = set()
def report(self, i):
self.reported.add(i)
def reported(self):
return self.reported
TOTAL_RETURN = 10
@ray.remote(_generator_backpressure_num_objects=backpressure_size)
def f(reporter):
for i in range(TOTAL_RETURN):
print("yield ", i)
ray.get(reporter.report.remote(i))
yield np.random.rand(1024) # 8KB
reporter = Reporter.remote()
def check_reported(i):
return i in ray.get(reporter.reported.remote())
gen = f.remote(reporter)
# It is backpressured for every 2 objects.
for i in range(0, TOTAL_RETURN - backpressure_size, backpressure_size):
print("iteration ", i)
r, _ = ray.wait([gen])
assert len(r) == 1
for j in range(backpressure_size):
wait_for_condition(lambda: check_reported(i + j))
wait_for_condition(lambda: not check_reported(i + backpressure_size))
# Wait a little bit to make sure it is backpressured.
time.sleep(2)
wait_for_condition(lambda: not check_reported(i + backpressure_size))
# Consume the ref -> task will progress.
for j in range(backpressure_size):
ray.get(next(gen))
# We cannot have more values than TOTAL_RETURN - 1, so
# we should use min here.
wait_for_condition(
lambda: check_reported(min(i + backpressure_size + j, TOTAL_RETURN - 1))
)
def test_caller_failure_doesnt_hang(shutdown_only):
"""
Verify if the caller fails (e.g., exception or crash),
the generator is not backpressured forever.
"""
ray.init(num_cpus=2)
@ray.remote(_generator_backpressure_num_objects=1)
def f():
for i in range(5):
print("yield", i)
yield i
@ray.remote
class Caller:
def f(self, failure_type, hang, task_name):
gen = f.options(name=task_name).remote()
assert ray.get(next(gen)) == 0
if hang:
time.sleep(300)
if failure_type == "exc":
raise ValueError
elif failure_type == "exit":
sys.exit(1)
else:
pass
# If caller finishes, f should finish.
print("Check caller finishes")
caller = Caller.remote()
caller.f.remote(None, False, "1")
def verify():
task = _list_tasks(filters=[("name", "=", "1")])[0]
assert task.state == "FINISHED"
return True
wait_for_condition(verify)
# If caller fails with an exception, f should finish.
print("Check caller raises an exception")
caller = Caller.remote()
r = caller.f.remote("exc", False, "2") # noqa
def verify():
task = _list_tasks(filters=[("name", "=", "2")])[0]
assert task.state == "FINISHED"
return True
wait_for_condition(verify)
# If caller fails with an exit, f (child) should finish.
print("Check caller exits")
caller = Caller.remote()
r = caller.f.remote("exit", False, "3") # noqa
def verify():
task = _list_tasks(filters=[("name", "=", "3")])[0]
assert task.state == "FINISHED"
return True
wait_for_condition(verify)
# If caller fails by a system exit, f (child) should fail.
print("Check caller killed")
caller = Caller.remote()
r = caller.f.remote(None, True, "4") # noqa
wait_for_condition(
lambda: _list_tasks(filters=[("name", "=", "4")])[0].state == "RUNNING"
)
ray.kill(caller)
def verify():
task = _list_tasks(filters=[("name", "=", "4")])[0]
assert task.state == "FAILED"
return True
wait_for_condition(verify)
def test_backpressure_async_generator(shutdown_only):
"""
Verify that both per-method and actor-wide backpressure work for async
streaming generators, and that the per-method threshold actually limits how
far ahead an async generator runs.
"""
ray.init(num_cpus=1)
TOTAL_RETURN = 10
@ray.remote
class Reporter:
def __init__(self):
self.reported = set()
def report(self, i):
self.reported.add(i)
def reported(self):
return self.reported
@ray.remote
class A:
async def f(self, reporter):
for i in range(TOTAL_RETURN):
await reporter.report.remote(i)
yield i
reporter = Reporter.remote()
def check_reported(i):
return i in ray.get(reporter.reported.remote())
a = A.remote()
gen = a.f.options(_generator_backpressure_num_objects=1).remote(reporter)
# With a threshold of 1, the async generator must not produce object i + 1
# until object i has been consumed.
for i in range(TOTAL_RETURN - 1):
wait_for_condition(lambda: check_reported(i))
# i + 1 must not be produced while i is unconsumed.
time.sleep(1)
assert not check_reported(i + 1), "async generator was not backpressured"
# Consume the ref -> the task is allowed to produce one more.
ray.get(next(gen))
wait_for_condition(lambda: check_reported(i + 1))
ray.get(next(gen))
# Actor-wide backpressure on an async actor.
@ray.remote(_actor_generator_backpressure_num_objects=1)
class AsyncActorWithActorBP:
async def f(self):
for i in range(TOTAL_RETURN):
yield i
async_actor_bp = AsyncActorWithActorBP.remote()
gen = async_actor_bp.f.remote()
assert [ray.get(ref) for ref in gen] == list(range(TOTAL_RETURN))
def test_backpressure_async_generator_concurrent_no_deadlock(shutdown_only):
"""
Regression test: concurrent backpressured async streaming generators on the
same async actor must not deadlock, even when the caller drains them out of
submission order.
Output reporting is serialized on a single shared executor thread. If a
generator parked under backpressure held that thread, a second concurrent
generator could never report its objects, so draining the second generator
before the first would hang forever.
"""
ray.init(num_cpus=1)
N = 20
@ray.remote
class A:
async def f(self, n):
for i in range(n):
yield i
a = A.remote()
# Start two generators concurrently; each backpressures after 1 object.
gen_a = a.f.options(_generator_backpressure_num_objects=1).remote(N)
gen_b = a.f.options(_generator_backpressure_num_objects=1).remote(N)
# Let both tasks start and become backpressured before we consume anything.
time.sleep(1)
result = {}
def drain():
# Drain the second generator fully first, then the first. This is the
# ordering that deadlocks if the parked generator holds the shared
# report thread.
result["b"] = [ray.get(ref) for ref in gen_b]
result["a"] = [ray.get(ref) for ref in gen_a]
t = threading.Thread(target=drain, daemon=True)
t.start()
t.join(timeout=60)
assert not t.is_alive(), "concurrent backpressured async generators deadlocked"
assert result["a"] == list(range(N))
assert result["b"] == list(range(N))
def test_backpressure_invalid(shutdown_only):
"""
Verify invalid cases.
1. Verify _generator_backpressure_num_objects == 0 is not allowed.
2. Verify _num_objects_per_yield == 0 is not allowed.
"""
ray.init(num_cpus=1)
with pytest.raises(ValueError, match="backpressure_num_objects=0 is not allowed"):
@ray.remote(_generator_backpressure_num_objects=0)
def f():
pass
with pytest.raises(ValueError, match="_num_objects_per_yield"):
@ray.remote(_num_objects_per_yield=0)
def g():
pass
with pytest.raises(ValueError, match="_num_objects_per_yield"):
class Actor:
@ray.method(_num_objects_per_yield=0)
def g(self):
pass
def test_threaded_actor_generator_backpressure(shutdown_only):
ray.init()
@ray.remote(max_concurrency=10)
class Actor:
def f(self):
for i in range(30):
time.sleep(0.1)
print("yield", i)
yield np.ones(1024 * 1024) * i
async def main():
a = Actor.remote()
async def run():
i = 0
gen = a.f.options(_generator_backpressure_num_objects=1).remote()
async for ref in gen:
val = ray.get(ref)
print(val)
print(ref)
assert np.array_equal(val, np.ones(1024 * 1024) * i)
i += 1
del ref
coroutines = [run() for _ in range(10)]
await asyncio.gather(*coroutines)
asyncio.run(main())
def test_backpressure_pause_signal(shutdown_only):
"""Verify the signal can be caught while the main thread is blocked
by a backpressure.
"""
ray.init(num_cpus=2)
@ray.remote(
_generator_backpressure_num_objects=1,
max_retries=0,
)
def backpressure_task():
for i in range(5):
print("yield ", i)
yield i
gen = backpressure_task.remote()
print(ray.get(next(gen)))
def wait_for_task():
t = _list_tasks(filters=[("name", "=", "backpressure_task")])[0]
assert t.state == "RUNNING"
return True
wait_for_condition(wait_for_task)
t = _list_tasks(filters=[("name", "=", "backpressure_task")])[0]
os.kill(t.worker_pid, signal.SIGTERM)
with pytest.raises(ray.exceptions.WorkerCrashedError):
ray.get(gen._generator_ref)
# ----------------------------------------------------------------------------
# Actor-wide streaming-generator backpressure
# ----------------------------------------------------------------------------
_ACTOR_GEN_BP_WAIT_S = 30
@ray.remote
class TagReporter:
"""Records ``(tag, i)`` for each value a generator is about to yield."""
def __init__(self):
self.records: List[Tuple[str, int]] = []
def report(self, tag: str, i: int) -> None:
self.records.append((tag, i))
def total_len(self) -> int:
return len(self.records)
def count_tag(self, tag: str) -> int:
return sum(1 for t, _ in self.records if t == tag)
def _dual_stream_actor_class(
*,
actor_cap: int,
method_cap: Optional[int],
) -> Type:
"""Threaded dual-stream actor: shared actor-wide cap, optional tighter per-method cap.
When ``method_cap`` is ``None``, only ``_actor_generator_backpressure_num_objects``
is used (owner threshold includes that cap via ``EffectiveStreamingGenerator...``).
When set, ``method_cap`` is the per-method ``_generator_backpressure_num_objects``
for the intersection test (tighter than ``actor_cap``).
"""
if method_cap is None:
@ray.remote(
max_concurrency=2,
_actor_generator_backpressure_num_objects=actor_cap,
)
class _A:
def gen(self, reporter, tag: str):
for i in range(5):
ray.get(reporter.report.remote(tag, i))
yield i
return _A
@ray.remote(
max_concurrency=2,
_actor_generator_backpressure_num_objects=actor_cap,
)
class _B:
@ray.method(_generator_backpressure_num_objects=method_cap)
def gen(self, reporter, tag: str):
for i in range(5):
ray.get(reporter.report.remote(tag, i))
yield i
return _B
def _drain_all(gens):
"""Consume every value from each generator in `gens` to completion."""
for gen in gens:
try:
while True:
ray.get(next(gen))
except StopIteration:
pass
def _drain_cancelled(gen):
"""Best-effort drain after ``ray.cancel``; the stream may end with an error."""
try:
while True:
ray.get(next(gen))
except StopIteration:
pass
except (
ray.exceptions.RayTaskError,
ray.exceptions.TaskCancelledError,
):
pass
def test_actor_generator_backpressure_single_task(shutdown_only):
"""Actor-wide cap only (no per-method ``_generator_backpressure_num_objects``)."""
ray.init(num_cpus=2)
reporter = TagReporter.remote()
@ray.remote(_actor_generator_backpressure_num_objects=3)
class A:
def gen(self, rep, tag):
for i in range(5):
ray.get(rep.report.remote(tag, i))
yield i
a = A.remote()
g = a.gen.remote(reporter, "a")
# First 3 items get reported, then the producer is parked at the cap.
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("a")) == 3,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
# Consume one → exactly one more is produced.
ray.get(next(g))
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("a")) == 4,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
# Drain the rest, generator ends.
_drain_all([g])
# Fire again: the cap is reclaimed, the new task fills up to 3 again.
g2 = a.gen.remote(reporter, "a")
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("a")) == 5 + 3,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
_drain_all([g2])
def test_actor_generator_backpressure_task_completes_before_consumed(shutdown_only):
ray.init(num_cpus=2)
reporter = TagReporter.remote()
@ray.remote(max_concurrency=1, _actor_generator_backpressure_num_objects=4)
class A:
def gen(self, rep, tag):
for i in range(2):
ray.get(rep.report.remote(tag, i))
yield i
def ping(self):
return "ok"
a = A.remote()
g1 = a.gen.remote(reporter, "1")
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("1")) == 2,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
assert ray.get(a.ping.remote(), timeout=2) == "ok"
g2 = a.gen.remote(reporter, "2")
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("2")) == 2,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
g3 = a.gen.remote(reporter, "3")
time.sleep(1)
assert ray.get(reporter.count_tag.remote("3")) == 0
assert ray.get(next(g1)) == 0
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("3")) == 1,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
_drain_all([g1, g2, g3])
def test_actor_generator_backpressure_num_objects_per_yield(shutdown_only):
"""Actor-wide cap counts objects, not yields, when ``_num_objects_per_yield`` > 1."""
ray.init(num_cpus=2)
reporter = TagReporter.remote()
# Cap of 4 objects with 2 objects per yield admits at most 2 yields (4
# objects) before parking. With the (buggy) per-yield accounting the cap
# would instead admit 4 yields (8 objects) before parking.
@ray.remote(_actor_generator_backpressure_num_objects=4)
class A:
@ray.method(_num_objects_per_yield=2)
def gen(self, rep, tag):
for i in range(5):
ray.get(rep.report.remote(tag, i))
yield i, i
a = A.remote()
g = a.gen.remote(reporter, "a")
# Exactly two yields (= 4 objects) fill the cap, then the producer parks.
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("a")) == 2,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
time.sleep(1)
assert ray.get(reporter.count_tag.remote("a")) == 2
# Consuming a ref frees budget below the cap and admits one more yield.
ray.get(next(g))
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("a")) == 3,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
# Draining the rest lets the generator run to completion (5 yields total).
_drain_all([g])
assert ray.get(reporter.count_tag.remote("a")) == 5
def test_actor_generator_backpressure_mt_actor(shutdown_only):
"""Two concurrent sync generator tasks; actor-wide cap 6; reclaim on drain."""
ray.init(num_cpus=4)
reporter = TagReporter.remote()
A = _dual_stream_actor_class(actor_cap=6, method_cap=None)
a = A.remote()
g1 = a.gen.remote(reporter, "1")
g2 = a.gen.remote(reporter, "2")
wait_for_condition(
lambda: ray.get(reporter.total_len.remote()) == 6,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
# Consume one ref from g1 — exactly one more report.
ray.get(next(g1))
wait_for_condition(
lambda: ray.get(reporter.total_len.remote()) == 7,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
_drain_all([g1])
# Pull the rest from g2 (consumption unblocks the producer path for the tail).
_drain_all([g2])
assert ray.get(reporter.count_tag.remote("1")) == 5
assert ray.get(reporter.count_tag.remote("2")) == 5
assert ray.get(reporter.total_len.remote()) == 10
def test_actor_generator_backpressure_mt_with_method_cap(shutdown_only):
"""Intersections: per-method cap 2 (owner defers sooner) under actor cap 6."""
ray.init(num_cpus=4)
reporter = TagReporter.remote()
A = _dual_stream_actor_class(actor_cap=6, method_cap=2)
a = A.remote()
g1 = a.gen.remote(reporter, "1")
g2 = a.gen.remote(reporter, "2")
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("1")) == 2,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("2")) == 2,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
ray.get(next(g1))
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("1")) == 3,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
_drain_all([g1, g2])
def test_actor_generator_backpressure_reclaim_on_cancel(shutdown_only):
"""``ray.cancel`` on one stream reclaims actor-wide slots for the other."""
ray.init(num_cpus=4)
reporter = TagReporter.remote()
A = _dual_stream_actor_class(actor_cap=6, method_cap=None)
a = A.remote()
g1 = a.gen.remote(reporter, "1")
g2 = a.gen.remote(reporter, "2")
wait_for_condition(
lambda: ray.get(reporter.total_len.remote()) == 6,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
ray.cancel(g1)
_drain_cancelled(g1)
_drain_all([g2])
assert ray.get(reporter.count_tag.remote("2")) == 5
def test_actor_generator_backpressure_reclaim_on_del_gen(shutdown_only):
"""Deleting ``g2`` after partial reads frees BP budget so ``g1`` can emit further yields."""
ray.init(num_cpus=4)
reporter = TagReporter.remote()
@ray.remote(max_concurrency=2, _actor_generator_backpressure_num_objects=6)
class A:
def __init__(self):
self._extend = threading.Event()
def enable_extend(self) -> None:
self._extend.set()
def gen(self, rep, tag: str):
if tag == "2":
for i in range(5):
ray.get(rep.report.remote(tag, i))
yield i
return
for i in range(16):
ray.get(rep.report.remote(tag, i))
yield i
if i == 5:
self._extend.wait()
a = A.remote()
g1 = a.gen.remote(reporter, "1")
g2 = a.gen.remote(reporter, "2")
wait_for_condition(
lambda: ray.get(reporter.total_len.remote()) == 6,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
n_take = 2
for _ in range(n_take):
ray.get(next(g2))
n1_before = ray.get(reporter.count_tag.remote("1"))
del g2
gc.collect()
ray.get(a.enable_extend.remote())
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("1")) >= n1_before + n_take,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
_drain_all([g1])
assert ray.get(reporter.count_tag.remote("1")) == 16
def test_actor_generator_backpressure_large_actor_small_method(shutdown_only):
"""Large actor cap does not bypass per-method cap on the executor."""
ray.init(num_cpus=4)
reporter = TagReporter.remote()
A = _dual_stream_actor_class(actor_cap=50, method_cap=2)
a = A.remote()
g1 = a.gen.remote(reporter, "1")
g2 = a.gen.remote(reporter, "2")
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("1")) == 2,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("2")) == 2,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
ray.get(next(g1))
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("1")) == 3,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
_drain_all([g1, g2])
def test_actor_generator_backpressure_reclaim_on_exception(shutdown_only):
"""Yield-then-raise reclaims cap so a follow-up task can use the budget."""
ray.init()
@ray.remote(max_concurrency=2, _actor_generator_backpressure_num_objects=1)
class A:
def yields_then_raises(self):
yield 0
raise RuntimeError("boom")
def ok(self):
for i in range(3):
yield i
a = A.remote()
bad = a.yields_then_raises.remote()
ray.get(next(bad))
with pytest.raises(ray.exceptions.RayTaskError):
ray.get(next(bad))
good = a.ok.remote()
seen = [ray.get(next(good)) for _ in range(3)]
assert seen == [0, 1, 2]
@pytest.mark.skipif(
sys.platform == "win32",
reason="Uses SIGTERM on worker_pid from task metadata.",
)
def test_actor_generator_backpressure_reconstruction_worker_crash(shutdown_only):
"""Actor-wide BP sets owner effective threshold 1; worker SIGTERM mid-stream retries."""
ray.init(num_cpus=2)
reporter = TagReporter.remote()
@ray.remote(
max_concurrency=1,
max_restarts=5,
max_task_retries=10,
_actor_generator_backpressure_num_objects=3,
)
class Producer:
def gen(self, rep):
for i in range(5):
ray.get(rep.report.remote("recon", i))
time.sleep(0.25)
yield i
p = Producer.remote()
gen = p.gen.options(name="actor_wide_bp_recon").remote(reporter)
assert ray.get(next(gen)) == 0
def running():
tasks = _list_tasks(filters=[("name", "=", "actor_wide_bp_recon")])
return bool(tasks) and tasks[0].state == "RUNNING"
wait_for_condition(running, timeout=30)
t = _list_tasks(filters=[("name", "=", "actor_wide_bp_recon")])[0]
os.kill(t.worker_pid, signal.SIGTERM)
values = [0]
def drained_five_values():
if len(values) >= 5:
return True
ref = next(gen)
try:
values.append(ray.get(ref))
except (
ray.exceptions.WorkerCrashedError,
ray.exceptions.RayActorError,
):
pass
return len(values) >= 5
wait_for_condition(drained_five_values, timeout=120, raise_exceptions=True)
assert values == list(range(5))
# Retries replay producer-side reporting before owner-visible consumption catches up.
assert ray.get(reporter.count_tag.remote("recon")) >= 5
try:
while True:
ray.get(next(gen))
except StopIteration:
pass
def test_actor_generator_backpressure_zero_value_rejected(shutdown_only):
"""``_actor_generator_backpressure_num_objects=0`` is invalid."""
with pytest.raises(
ValueError,
match=r"_actor_generator_backpressure_num_objects must be > 0",
):
@ray.remote(_actor_generator_backpressure_num_objects=0)
class A:
def f(self):
yield 0
@pytest.mark.skipif(
sys.platform == "win32",
reason="This test uses os._exit(0), which can report an access violation on Windows.",
)
def test_actor_generator_backpressure_reclaim_on_owner_death(shutdown_only):
namespace = "actor_generator_backpressure_owner_death"
actor_name = f"actor_generator_backpressure_owner_death_actor_{os.getpid()}"
reporter_name = f"actor_generator_backpressure_owner_death_reporter_{os.getpid()}"
address = ray.init(num_cpus=2, namespace=namespace).address_info["address"]
driver = f"""
import os
import ray
from ray._common.test_utils import wait_for_condition
ray.init(address="{address}", namespace="{namespace}")
@ray.remote(name="{reporter_name}", lifetime="detached")
class Reporter:
def __init__(self):
self.counts = {{}}
def report(self, tag):
self.counts[tag] = self.counts.get(tag, 0) + 1
def count(self, tag):
return self.counts.get(tag, 0)
@ray.remote(
name="{actor_name}",
lifetime="detached",
max_concurrency=1,
_actor_generator_backpressure_num_objects=2,
)
class A:
def gen(self, reporter, tag, count):
for i in range(count):
ray.get(reporter.report.remote(tag))
yield i
def ping(self):
return "ok"
reporter = Reporter.remote()
a = A.remote()
g = a.gen.remote(reporter, "first", 1)
wait_for_condition(lambda: ray.get(reporter.count.remote("first")) == 1, timeout=10)
assert ray.get(a.ping.remote(), timeout=10) == "ok"
# Exit
os._exit(0)
"""
run_string_as_driver(driver)
a = ray.get_actor(actor_name, namespace=namespace)
reporter = ray.get_actor(reporter_name, namespace=namespace)
try:
g = a.gen.remote(reporter, "second", 2)
wait_for_condition(
lambda: ray.get(reporter.count.remote("second")) == 2,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
_drain_all([g])
finally:
ray.kill(a)
ray.kill(reporter)
@pytest.mark.skipif(
sys.platform == "win32",
reason="This test uses os._exit(0), which can report an access violation on Windows.",
)
def test_actor_generator_backpressure_owner_death_unblocks_task_waiter(shutdown_only):
namespace = "actor_generator_backpressure_owner_death_waiter"
actor_name = f"actor_generator_backpressure_owner_death_waiter_actor_{os.getpid()}"
reporter_name = (
f"actor_generator_backpressure_owner_death_waiter_reporter_{os.getpid()}"
)
address = ray.init(num_cpus=2, namespace=namespace).address_info["address"]
driver = f"""
import os
import ray
from ray._common.test_utils import wait_for_condition
ray.init(address="{address}", namespace="{namespace}")
@ray.remote(name="{reporter_name}", lifetime="detached")
class Reporter:
def __init__(self):
self.counts = {{}}
def report(self, tag):
self.counts[tag] = self.counts.get(tag, 0) + 1
def count(self, tag):
return self.counts.get(tag, 0)
@ray.remote(
name="{actor_name}",
lifetime="detached",
max_concurrency=1,
_actor_generator_backpressure_num_objects=10,
)
class A:
@ray.method(_generator_backpressure_num_objects=1)
def gen(self, reporter, tag):
for i in range(2):
ray.get(reporter.report.remote(tag))
yield i
def ping(self):
return "ok"
reporter = Reporter.remote()
a = A.remote()
g = a.gen.remote(reporter, "first")
wait_for_condition(lambda: ray.get(reporter.count.remote("first")) == 1, timeout=10)
os._exit(0)
"""
run_string_as_driver(driver)
a = ray.get_actor(actor_name, namespace=namespace)
reporter = ray.get_actor(reporter_name, namespace=namespace)
try:
assert ray.get(a.ping.remote(), timeout=_ACTOR_GEN_BP_WAIT_S) == "ok"
finally:
ray.kill(a)
ray.kill(reporter)
@pytest.mark.skipif(
sys.platform == "win32",
reason="This test uses os._exit(0), which can report an access violation on Windows.",
)
def test_actor_generator_backpressure_owner_death_skips_between_yield_work(
shutdown_only,
):
"""After owner death we should not run another iteration of between-yield
user code before exiting. HandleOwnerDied marks the task canceled; the
executor loop bails before the next gen.send."""
namespace = "actor_generator_backpressure_owner_death_skip"
actor_name = f"actor_generator_backpressure_owner_death_skip_actor_{os.getpid()}"
reporter_name = (
f"actor_generator_backpressure_owner_death_skip_reporter_{os.getpid()}"
)
address = ray.init(num_cpus=2, namespace=namespace).address_info["address"]
driver = f"""
import os
import ray
from ray._common.test_utils import wait_for_condition
ray.init(address="{address}", namespace="{namespace}")
@ray.remote(name="{reporter_name}", lifetime="detached")
class Reporter:
def __init__(self):
self.between_yield_count = 0
def between_yield(self):
self.between_yield_count += 1
def count(self):
return self.between_yield_count
@ray.remote(
name="{actor_name}",
lifetime="detached",
max_concurrency=1,
_actor_generator_backpressure_num_objects=10,
)
class A:
@ray.method(_generator_backpressure_num_objects=1)
def gen(self, reporter):
for i in range(10):
ray.get(reporter.between_yield.remote())
yield i
def ping(self):
return "ok"
reporter = Reporter.remote()
a = A.remote()
g = a.gen.remote(reporter)
# After this returns, the executor has run the between-yield call exactly
# once and is parked in WaitUntilObjectConsumed for yield 0.
wait_for_condition(lambda: ray.get(reporter.count.remote()) == 1, timeout=10)
os._exit(0)
"""
run_string_as_driver(driver)
a = ray.get_actor(actor_name, namespace=namespace)
reporter = ray.get_actor(reporter_name, namespace=namespace)
try:
# The actor must accept new tasks (gen task drained).
assert ray.get(a.ping.remote(), timeout=_ACTOR_GEN_BP_WAIT_S) == "ok"
# And the between-yield code must not have run another iteration after
# owner death — the count stays at the one call made before the driver
# exited.
assert ray.get(reporter.count.remote()) == 1
finally:
ray.kill(a)
ray.kill(reporter)
def _async_dual_stream_actor_class(
*,
actor_cap: int,
method_cap: Optional[int],
) -> Type:
"""Async counterpart of ``_dual_stream_actor_class``."""
if method_cap is None:
@ray.remote(_actor_generator_backpressure_num_objects=actor_cap)
class _A:
async def gen(self, reporter, tag: str):
for i in range(5):
await reporter.report.remote(tag, i)
yield i
return _A
@ray.remote(_actor_generator_backpressure_num_objects=actor_cap)
class _B:
@ray.method(_generator_backpressure_num_objects=method_cap)
async def gen(self, reporter, tag: str):
for i in range(5):
await reporter.report.remote(tag, i)
yield i
return _B
def test_actor_generator_backpressure_async_single_task(shutdown_only):
"""Async generator, actor-wide cap only (no per-method cap)."""
ray.init(num_cpus=2)
reporter = TagReporter.remote()
@ray.remote(_actor_generator_backpressure_num_objects=3)
class A:
async def gen(self, rep, tag):
for i in range(5):
await rep.report.remote(tag, i)
yield i
a = A.remote()
g = a.gen.remote(reporter, "a")
# First 3 items get reported, then the producer parks at the cap.
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("a")) == 3,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
# Confirm it is actually parked (no further production without consumption).
time.sleep(1)
assert ray.get(reporter.count_tag.remote("a")) == 3
# Consume one → exactly one more is produced.
ray.get(next(g))
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("a")) == 4,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
# Drain the rest, generator ends.
_drain_all([g])
# Fire again: the cap is reclaimed, the new task fills up to 3 again.
g2 = a.gen.remote(reporter, "a")
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("a")) == 5 + 3,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
_drain_all([g2])
def test_actor_generator_backpressure_async_mt_actor(shutdown_only):
"""Two concurrent async generator tasks share an actor-wide cap of 6.
Exercises the cross-task wakeup: when one stream's consumption frees shared
budget, the other parked async generator must be woken to re-check.
"""
ray.init(num_cpus=4)
reporter = TagReporter.remote()
A = _async_dual_stream_actor_class(actor_cap=6, method_cap=None)
a = A.remote()
g1 = a.gen.remote(reporter, "1")
g2 = a.gen.remote(reporter, "2")
# Together the two producers fill the shared cap of 6, then both park.
wait_for_condition(
lambda: ray.get(reporter.total_len.remote()) == 6,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
time.sleep(1)
assert ray.get(reporter.total_len.remote()) == 6
# Consume one ref from g1 — exactly one more report across the two streams.
ray.get(next(g1))
wait_for_condition(
lambda: ray.get(reporter.total_len.remote()) == 7,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
_drain_all([g1])
_drain_all([g2])
assert ray.get(reporter.count_tag.remote("1")) == 5
assert ray.get(reporter.count_tag.remote("2")) == 5
assert ray.get(reporter.total_len.remote()) == 10
def test_actor_generator_backpressure_async_num_objects_per_yield(shutdown_only):
"""Actor-wide cap counts objects, not yields, for async generators too."""
ray.init(num_cpus=2)
reporter = TagReporter.remote()
@ray.remote(_actor_generator_backpressure_num_objects=4)
class A:
@ray.method(_num_objects_per_yield=2)
async def gen(self, rep, tag):
for i in range(5):
await rep.report.remote(tag, i)
yield i, i
a = A.remote()
g = a.gen.remote(reporter, "a")
# Two yields (= 4 objects) fill the cap, then the producer parks.
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("a")) == 2,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
time.sleep(1)
assert ray.get(reporter.count_tag.remote("a")) == 2
# Consuming a ref frees budget below the cap and admits one more yield.
ray.get(next(g))
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("a")) == 3,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
_drain_all([g])
assert ray.get(reporter.count_tag.remote("a")) == 5
def test_actor_generator_backpressure_async_mt_with_method_cap(shutdown_only):
"""Per-method cap 2 (tighter) under async actor-wide cap 6.
Exercises both backpressure paths together: per-task (IsBackpressured) and
actor-wide (TryReserveSlot), each awaiting the asyncio.Event.
"""
ray.init(num_cpus=4)
reporter = TagReporter.remote()
A = _async_dual_stream_actor_class(actor_cap=6, method_cap=2)
a = A.remote()
g1 = a.gen.remote(reporter, "1")
g2 = a.gen.remote(reporter, "2")
# Each stream parks at its per-method cap of 2 (tighter than the actor cap).
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("1")) == 2,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("2")) == 2,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
time.sleep(1)
assert ray.get(reporter.count_tag.remote("1")) == 2
assert ray.get(reporter.count_tag.remote("2")) == 2
# Consuming a ref from g1 admits exactly one more for that stream.
ray.get(next(g1))
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("1")) == 3,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
_drain_all([g1, g2])
def test_actor_generator_backpressure_async_reclaim_on_exception(shutdown_only):
"""Async yield-then-raise reclaims the cap for a follow-up task."""
ray.init()
@ray.remote(_actor_generator_backpressure_num_objects=1)
class A:
async def yields_then_raises(self):
yield 0
raise RuntimeError("boom")
async def ok(self):
for i in range(3):
yield i
a = A.remote()
bad = a.yields_then_raises.remote()
ray.get(next(bad))
with pytest.raises(ray.exceptions.RayTaskError):
ray.get(next(bad))
# If the exception did not reclaim the cap, this task could never start.
good = a.ok.remote()
seen = [ray.get(next(good)) for _ in range(3)]
assert seen == [0, 1, 2]
def test_actor_generator_backpressure_async_reclaim_on_del_gen(shutdown_only):
"""Deleting a partially-read async stream frees BP budget for the other."""
ray.init(num_cpus=4)
reporter = TagReporter.remote()
@ray.remote(_actor_generator_backpressure_num_objects=6)
class A:
def __init__(self):
self._extend = asyncio.Event()
async def enable_extend(self) -> None:
self._extend.set()
async def gen(self, rep, tag: str):
if tag == "2":
for i in range(5):
await rep.report.remote(tag, i)
yield i
return
for i in range(16):
await rep.report.remote(tag, i)
yield i
if i == 5:
await self._extend.wait()
a = A.remote()
g1 = a.gen.remote(reporter, "1")
g2 = a.gen.remote(reporter, "2")
wait_for_condition(
lambda: ray.get(reporter.total_len.remote()) == 6,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
n_take = 2
for _ in range(n_take):
ray.get(next(g2))
n1_before = ray.get(reporter.count_tag.remote("1"))
del g2
gc.collect()
ray.get(a.enable_extend.remote())
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("1")) >= n1_before + n_take,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
_drain_all([g1])
assert ray.get(reporter.count_tag.remote("1")) == 16
def test_actor_generator_backpressure_async_exception_wakes_parked_sibling(
shutdown_only,
):
"""An async generator that raises while holding actor-wide budget must wake a
sibling async generator already parked in its actor-wide reserve.
Regression test for the executor teardown path. When a streaming generator
ends abnormally (a user exception, not normal completion), the executor
reclaims the actor-wide budget via ``TeardownGeneratorBackpressureTask``,
which signals the waiter's condition variable (waking sync reservers). Async
reservers wait on an ``asyncio.Event`` instead and must be notified
explicitly -- without that notify, and with no polling fallback, the parked
sibling would hang forever.
The exception path is used deliberately because it is the *only* reclaim
here: there is no consumption update (nothing drains the raiser) and no owner
death, so neither ``HandleUpdateGeneratorBackpressureConsumed`` nor
``HandleOwnerDied`` -- both of which also notify -- can mask the teardown.
"""
ray.init(num_cpus=4)
reporter = TagReporter.remote()
@ray.remote(_actor_generator_backpressure_num_objects=1)
class A:
def __init__(self):
self._go = asyncio.Event()
async def release(self) -> None:
self._go.set()
async def raiser(self, rep):
# Reserves the only actor-wide slot (filling the cap), reports so the
# test can observe it, then parks holding the slot until released --
# at which point it raises WITHOUT yielding. The exception reclaims
# the slot through the executor teardown path.
await rep.report.remote("raiser", 0)
await self._go.wait()
raise ValueError("boom")
yield # unreachable; makes this an async generator
async def waiter(self, rep):
for i in range(3):
await rep.report.remote("waiter", i)
yield i
a = A.remote()
# Keep the ref alive: letting it GC would trigger the consumption/teardown
# path (which notifies) and mask the bug this test targets.
r = a.raiser.remote(reporter) # noqa: F841
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("raiser")) == 1,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
# The sibling parks in its actor-wide reserve: the cap (1) is fully held by
# the raiser's reserved slot, so waiter cannot produce even its first value.
w = a.waiter.remote(reporter)
time.sleep(2)
assert ray.get(reporter.count_tag.remote("waiter")) == 0
# Let the raiser raise. Its exception reclaims the cap via the executor
# teardown -- the only relief path here -- which must wake the parked sibling.
ray.get(a.release.remote())
wait_for_condition(
lambda: ray.get(reporter.count_tag.remote("waiter")) >= 1,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
_drain_all([w])
assert ray.get(reporter.count_tag.remote("waiter")) == 3
def test_actor_generator_backpressure_mixed_sync_async(shutdown_only):
"""A sync and an async streaming generator on the same actor share the cap.
Both methods reserve against the one actor-wide waiter; consumption from
either stream frees shared budget for both (sync producers are woken via the
condition variable, async producers via the asyncio.Event).
"""
ray.init(num_cpus=4)
reporter = TagReporter.remote()
@ray.remote(_actor_generator_backpressure_num_objects=6)
class A:
def sync_gen(self, rep, tag):
for i in range(5):
ray.get(rep.report.remote(tag, i))
yield i
async def async_gen(self, rep, tag):
for i in range(5):
await rep.report.remote(tag, i)
yield i
a = A.remote()
g_sync = a.sync_gen.remote(reporter, "sync")
g_async = a.async_gen.remote(reporter, "async")
# The two streams share one cap of 6 (not 6 each): collectively they park at
# 6 produced. The split between the streams is nondeterministic -- one stream
# may grab the whole budget -- so we only assert the shared total here.
wait_for_condition(
lambda: ray.get(reporter.total_len.remote()) == 6,
timeout=_ACTOR_GEN_BP_WAIT_S,
)
time.sleep(1)
assert ray.get(reporter.total_len.remote()) == 6
# Drain both streams concurrently and assert each completes: consuming from
# either stream frees shared budget for whichever needs it (sync producers
# are woken via the condition variable, async via the asyncio.Event). They
# must be drained concurrently because, under an uneven split, one stream can
# hold the whole budget until the other is consumed.
results = {}
def drain(gen, tag):
results[tag] = [ray.get(ref) for ref in gen]
threads = [
threading.Thread(target=drain, args=(g_sync, "sync"), daemon=True),
threading.Thread(target=drain, args=(g_async, "async"), daemon=True),
]
for t in threads:
t.start()
for t in threads:
t.join(timeout=_ACTOR_GEN_BP_WAIT_S)
assert all(not t.is_alive() for t in threads), "mixed generators deadlocked"
assert results["sync"] == list(range(5))
assert results["async"] == list(range(5))
assert ray.get(reporter.total_len.remote()) == 10
@pytest.mark.skipif(
sys.platform == "win32",
reason="This test uses os._exit(0), which can report an access violation on Windows.",
)
def test_actor_generator_backpressure_async_owner_death_skips_between_yield_work(
shutdown_only,
):
"""Async analog of the sync owner-death test: after the owner dies, the async
executor must not run another iteration of between-yield user code before
exiting. HandleOwnerDied marks the task canceled; the async loop bails on the
IsTaskCanceled check before the next gen.asend (otherwise the reserve/wait
completes for the now-dead task and one more gen body runs)."""
namespace = "actor_generator_backpressure_async_owner_death_skip"
actor_name = (
f"actor_generator_backpressure_async_owner_death_skip_actor_{os.getpid()}"
)
reporter_name = (
f"actor_generator_backpressure_async_owner_death_skip_reporter_{os.getpid()}"
)
address = ray.init(num_cpus=2, namespace=namespace).address_info["address"]
driver = f"""
import os
import ray
from ray._common.test_utils import wait_for_condition
ray.init(address="{address}", namespace="{namespace}")
@ray.remote(name="{reporter_name}", lifetime="detached")
class Reporter:
def __init__(self):
self.between_yield_count = 0
def between_yield(self):
self.between_yield_count += 1
def count(self):
return self.between_yield_count
# max_concurrency=1 so a leaked/hung gen coroutine would hold the actor's only
# slot and block the ping below (an async coroutine parked in an await still
# holds its concurrency slot until it completes).
@ray.remote(
name="{actor_name}",
lifetime="detached",
max_concurrency=1,
_actor_generator_backpressure_num_objects=10,
)
class A:
@ray.method(_generator_backpressure_num_objects=1)
async def gen(self, reporter):
for i in range(10):
await reporter.between_yield.remote()
yield i
async def ping(self):
return "ok"
reporter = Reporter.remote()
a = A.remote()
g = a.gen.remote(reporter)
# After this returns, the executor has run the between-yield call exactly once
# and is parked awaiting consumption for yield 0.
wait_for_condition(lambda: ray.get(reporter.count.remote()) == 1, timeout=10)
os._exit(0)
"""
run_string_as_driver(driver)
a = ray.get_actor(actor_name, namespace=namespace)
reporter = ray.get_actor(reporter_name, namespace=namespace)
try:
# ping is the load-bearing assertion for the owner-death notification: on
# a max_concurrency=1 actor the gen coroutine holds the only slot until it
# completes, so ping can only run once the gen task has drained. If the
# owner-death path failed to wake the parked async wait, the coroutine
# would hang forever holding the slot and this ping would time out (count
# alone can't detect that — a hung coroutine also stops at 1).
assert ray.get(a.ping.remote(), timeout=_ACTOR_GEN_BP_WAIT_S) == "ok"
# The between-yield code must not have run another iteration after owner
# death — the count stays at the one call made before the driver exited.
assert ray.get(reporter.count.remote()) == 1
finally:
ray.kill(a)
ray.kill(reporter)
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))