Files
2026-07-13 13:17:40 +08:00

592 lines
17 KiB
Python

import asyncio
import concurrent.futures
import sys
import time
from collections import defaultdict
from typing import Set
import pytest
import ray
from ray._common.test_utils import SignalActor, wait_for_condition
from ray.exceptions import TaskCancelledError
from ray.util.state import list_tasks
def test_input_validation(shutdown_only):
# Verify force=True is not working.
@ray.remote
class A:
async def f(self):
pass
a = A.remote()
with pytest.raises(ValueError, match="force=True is not supported"):
ray.cancel(a.f.remote(), force=True)
def test_async_actor_cancel(shutdown_only):
"""
Test async actor task is canceled and
asyncio.CancelledError is raised within a task.
"""
ray.init(num_cpus=1)
@ray.remote
class VerifyActor:
def __init__(self):
self.called = False
self.running = False
def called(self):
self.called = True
def set_running(self):
self.running = True
def is_called(self):
return self.called
def is_running(self):
return self.running
def reset(self):
self.called = False
self.running = False
@ray.remote
class Actor:
async def f(self, verify_actor):
try:
ray.get(verify_actor.set_running.remote())
await asyncio.sleep(10)
except asyncio.CancelledError:
# It is False until this except block is finished.
assert not asyncio.current_task().cancelled()
ray.get(verify_actor.called.remote())
raise
except Exception:
return True
return True
v = VerifyActor.remote()
a = Actor.remote()
for i in range(50):
ref = a.f.remote(v)
wait_for_condition(lambda: ray.get(v.is_running.remote()))
ray.cancel(ref)
with pytest.raises(ray.exceptions.TaskCancelledError, match="was cancelled"):
ray.get(ref)
# Verify asyncio.CancelledError is raised from the actor task.
assert ray.get(v.is_running.remote())
assert ray.get(v.is_called.remote())
ray.get(v.reset.remote())
def test_async_actor_client_side_cancel(ray_start_cluster):
"""
Test a task is cancelled while it is queued on a client side.
It should raise ray.exceptions.TaskCancelledError.
"""
cluster = ray_start_cluster
cluster.add_node(num_cpus=0)
ray.init(address=cluster.address)
@ray.remote(num_cpus=1)
class Actor:
def __init__(self):
self.f_called = False
async def g(self, ref):
await asyncio.sleep(30)
async def f(self):
self.f_called = True
await asyncio.sleep(5)
def is_f_called(self):
return self.f_called
@ray.remote
def f():
time.sleep(100)
# Test the case where a task is queued on a client side.
# Tasks are not sent until actor is created.
a = Actor.remote()
ref = a.f.remote()
ray.cancel(ref)
with pytest.raises(TaskCancelledError):
ray.get(ref)
cluster.add_node(num_cpus=1)
assert not ray.get(a.is_f_called.remote())
# Test the case where it is canceled before dependencies
# are resolved.
a = Actor.remote()
ref_dep_not_resolved = a.g.remote(f.remote())
ray.cancel(ref_dep_not_resolved)
with pytest.raises(TaskCancelledError):
ray.get(ref_dep_not_resolved)
def test_async_actor_server_side_cancel(shutdown_only):
"""
Test Cancelation when a task is queued on a server side.
"""
@ray.remote
class Actor:
async def f(self):
await asyncio.sleep(5)
async def g(self):
await asyncio.sleep(0)
a = Actor.options(max_concurrency=1).remote()
ray.get(a.__ray_ready__.remote())
ref = a.f.remote() # noqa
# Queued on a server side.
# Task should not be executed at all.
refs = [a.g.remote() for _ in range(100)]
wait_for_condition(
lambda: len(
list_tasks(
filters=[
("name", "=", "Actor.g"),
("STATE", "=", "PENDING_ACTOR_TASK_ORDERING_OR_CONCURRENCY"),
]
)
)
== 100
)
for ref in refs:
ray.cancel(ref)
tasks = list_tasks(filters=[("name", "=", "Actor.g")])
for ref in refs:
with pytest.raises(TaskCancelledError, match=ref.task_id().hex()):
ray.get(ref)
# Verify the task is submitted to the worker and never executed
for task in tasks:
assert task.state == "PENDING_ACTOR_TASK_ORDERING_OR_CONCURRENCY"
def test_async_actor_cancel_after_task_finishes(shutdown_only):
@ray.remote
class Actor:
async def f(self):
await asyncio.sleep(5)
async def empty(self):
pass
# Cancel after task finishes
a = Actor.options(max_concurrency=1).remote()
ref = a.empty.remote()
ref2 = a.empty.remote()
ray.get([ref, ref2])
ray.cancel(ref)
ray.cancel(ref2)
# Exceptions shouldn't be raised.
ray.get([ref, ref2])
def test_async_actor_cancel_restart(ray_start_cluster, monkeypatch):
"""
Verify a cancelation works if actor is restarted.
"""
with monkeypatch.context() as m:
# This will slow down the cancelation RPC so that
# cancel won't succeed until a node is killed.
m.setenv(
"RAY_testing_asio_delay_us",
"CoreWorkerService.grpc_server.CancelTask=3000000:3000000",
)
cluster = ray_start_cluster
cluster.add_node(num_cpus=0)
ray.init(address=cluster.address)
node = cluster.add_node(num_cpus=1)
@ray.remote(num_cpus=1, max_restarts=-1, max_task_retries=-1)
class Actor:
async def f(self):
await asyncio.sleep(10)
a = Actor.remote()
ref = a.f.remote()
# This guarantees that a.f.remote() is executed
ray.get(a.__ray_ready__.remote())
ray.cancel(ref)
cluster.remove_node(node)
r, ur = ray.wait([ref])
# When cancel is called, the task won't be retried anymore.
# It will raise TaskCancelledError.
with pytest.raises(ray.exceptions.TaskCancelledError):
ray.get(ref)
# This will restart actor, but task won't be retried.
cluster.add_node(num_cpus=1)
# Verify actor is restarted. f should be retried
ray.get(a.__ray_ready__.remote())
with pytest.raises(ray.exceptions.TaskCancelledError):
ray.get(ref)
def test_remote_cancel(ray_start_regular):
@ray.remote
class Actor:
async def sleep(self):
await asyncio.sleep(1000)
@ray.remote
def f(refs):
ref = refs[0]
ray.cancel(ref)
a = Actor.remote()
sleep_ref = a.sleep.remote()
wait_for_condition(lambda: list_tasks(filters=[("name", "=", "Actor.sleep")]))
ref = f.remote([sleep_ref]) # noqa
with pytest.raises(ray.exceptions.TaskCancelledError):
ray.get(sleep_ref)
def test_cancel_recursive_tree(shutdown_only):
"""Verify recursive cancel works for tree-nested tasks.
Task A -> Task B
-> Task C
"""
ray.init(num_cpus=16)
# Test the tree structure.
@ray.remote
def child():
for _ in range(5):
time.sleep(1)
return True
@ray.remote
class ChildActor:
async def child(self):
await asyncio.sleep(5)
return True
@ray.remote
class Actor:
def __init__(self):
self.children_refs = defaultdict(list)
def get_children_refs(self, task_id):
return self.children_refs[task_id]
async def run(self, child_actor, sig):
ref1 = child.remote()
ref2 = child_actor.child.remote()
task_id = ray.get_runtime_context().get_task_id()
self.children_refs[task_id].append(ref1)
self.children_refs[task_id].append(ref2)
await sig.wait.remote()
await ref1
await ref2
sig = SignalActor.remote()
child_actor = ChildActor.remote()
a = Actor.remote()
ray.get(a.__ray_ready__.remote())
"""
Test the basic case.
"""
run_ref = a.run.remote(child_actor, sig)
task_id = run_ref.task_id().hex()
wait_for_condition(
lambda: list_tasks(filters=[("task_id", "=", task_id)])[0].state == "RUNNING",
timeout=20,
)
ray.cancel(run_ref, recursive=True)
ray.get(sig.send.remote())
children_refs = ray.get(a.get_children_refs.remote(task_id))
for ref in children_refs + [run_ref]:
with pytest.raises(ray.exceptions.TaskCancelledError):
ray.get(ref)
"""
Test recursive = False
"""
run_ref = a.run.remote(child_actor, sig)
task_id = run_ref.task_id().hex()
wait_for_condition(
lambda: list_tasks(filters=[("task_id", "=", task_id)])[0].state == "RUNNING",
timeout=20,
)
ray.cancel(run_ref, recursive=False)
ray.get(sig.send.remote())
children_refs = ray.get(a.get_children_refs.remote(task_id))
for ref in children_refs:
assert ray.get(ref)
with pytest.raises(ray.exceptions.TaskCancelledError):
ray.get(run_ref)
"""
Test concurrent cases.
"""
run_refs = [a.run.remote(ChildActor.remote(), sig) for _ in range(10)]
task_ids = []
for i, run_ref in enumerate(run_refs):
task_id = run_ref.task_id().hex()
task_ids.append(task_id)
wait_for_condition(
lambda task_id=task_id: list_tasks(filters=[("task_id", "=", task_id)])[
0
].state
== "RUNNING",
timeout=20,
)
children_refs = ray.get(a.get_children_refs.remote(task_id))
for child_ref in children_refs:
task_id = child_ref.task_id().hex()
wait_for_condition(
lambda task_id=task_id: list_tasks(filters=[("task_id", "=", task_id)])[
0
].state
== "RUNNING",
timeout=20,
)
recursive = i % 2 == 0
ray.cancel(run_ref, recursive=recursive)
ray.get(sig.send.remote())
for i, task_id in enumerate(task_ids):
children_refs = ray.get(a.get_children_refs.remote(task_id))
if i % 2 == 0:
for ref in children_refs:
with pytest.raises(ray.exceptions.TaskCancelledError):
ray.get(ref)
else:
for ref in children_refs:
assert ray.get(ref)
with pytest.raises(ray.exceptions.TaskCancelledError):
ray.get(run_refs[i])
@pytest.mark.parametrize("recursive", [True, False])
def test_cancel_recursive_chain(shutdown_only, recursive):
@ray.remote
class RecursiveActor:
def __init__(self, child=None):
self.child = child
self.chlid_ref = None
async def run(self, sig):
if self.child is None:
await sig.wait.remote()
return True
ref = self.child.run.remote(sig)
self.child_ref = ref
return await ref
def get_child_ref(self):
return self.child_ref
sig = SignalActor.remote()
r1 = RecursiveActor.remote()
r2 = RecursiveActor.remote(r1)
r3 = RecursiveActor.remote(r2)
r4 = RecursiveActor.remote(r3)
ref = r4.run.remote(sig)
ray.get(r4.__ray_ready__.remote())
wait_for_condition(
lambda: len(list_tasks(filters=[("name", "=", "RecursiveActor.run")])) == 4
)
ray.cancel(ref, recursive=recursive)
ray.get(sig.send.remote())
if recursive:
with pytest.raises(ray.exceptions.TaskCancelledError):
ray.get(ref)
with pytest.raises(ray.exceptions.TaskCancelledError):
ray.get(ray.get(r4.get_child_ref.remote()))
with pytest.raises(ray.exceptions.TaskCancelledError):
ray.get(ray.get(r3.get_child_ref.remote()))
with pytest.raises(ray.exceptions.TaskCancelledError):
ray.get(ray.get(r2.get_child_ref.remote()))
else:
assert ray.get(ray.get(r2.get_child_ref.remote()))
assert ray.get(ray.get(r3.get_child_ref.remote()))
assert ray.get(ray.get(r4.get_child_ref.remote()))
with pytest.raises(ray.exceptions.TaskCancelledError):
ray.get(ref)
def test_concurrent_submission_and_cancellation(shutdown_only):
"""Test submitting and then cancelling many tasks concurrently.
This is a regression test for race conditions such as:
https://github.com/ray-project/ray/issues/52628.
"""
NUM_TASKS = 2500
@ray.remote(num_cpus=0)
class Worker:
async def sleep(self, i: int):
# NOTE: all tasks should be cancelled, so this won't actually sleep for the
# full duration if the test is passing.
await asyncio.sleep(30)
worker = Worker.remote()
# Submit many tasks in parallel to cause queueing on the caller and receiver.
with concurrent.futures.ThreadPoolExecutor(max_workers=NUM_TASKS) as executor:
futures = [executor.submit(worker.sleep.remote, i) for i in range(NUM_TASKS)]
refs = [f.result() for f in concurrent.futures.as_completed(futures)]
# Cancel the tasks in reverse order of submission.
for ref in reversed(refs):
ray.cancel(ref)
# Check that all tasks were successfully cancelled (none ran to completion).
for ref in refs:
with pytest.raises(ray.exceptions.TaskCancelledError):
ray.get(ref)
def test_is_canceled_sync_actor_task(shutdown_only):
"""Test that is_canceled() works correctly for sync actor tasks."""
signal_actor = SignalActor.remote()
@ray.remote
class Actor:
def __init__(self):
self._was_canceled = False
def wait_until_canceled(self):
ray.get(signal_actor.wait.remote())
wait_for_condition(lambda: ray.get_runtime_context().is_canceled())
self._was_canceled = True
def was_canceled(self) -> bool:
return self._was_canceled
a = Actor.remote()
ref = a.wait_until_canceled.remote()
# Wait for the task to be actively waiting on the signal.
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 1)
# Cancel the task while it's blocked on the signal.
ray.cancel(ref, recursive=False)
# Now signal the task to unblock. The task result should be `TaskCancelledError`.
ray.get(signal_actor.send.remote())
with pytest.raises(TaskCancelledError):
ray.get(ref)
# Check that `is_canceled` was set correctly.
assert ray.get(a.was_canceled.remote())
def test_is_canceled_concurrent_actor_task(shutdown_only):
"""Test that is_canceled() works correctly for concurrent actor tasks."""
signal_actor = SignalActor.remote()
@ray.remote
class ConcurrentActor:
def __init__(self):
self._canceled_task_indices = set()
def task_with_cancel_check(self, task_index: int, expect_canceled: bool):
ray.get(signal_actor.wait.remote())
if expect_canceled:
wait_for_condition(lambda: ray.get_runtime_context().is_canceled())
self._canceled_task_indices.add(task_index)
return task_index
def get_canceled_task_indices(self) -> Set[int]:
return self._canceled_task_indices
actor = ConcurrentActor.options(max_concurrency=3).remote()
# Submit multiple tasks concurrently. Only task_index=1 will be canceled.
refs = [actor.task_with_cancel_check.remote(i, i == 1) for i in range(3)]
# Wait for all tasks to be running (waiting on the signal).
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 3)
# Cancel task_index=1.
ray.cancel(refs[1], recursive=False)
# Send signal to unblock all tasks.
ray.get(signal_actor.send.remote())
# The canceled task should raise TaskCancelledError.
with pytest.raises(TaskCancelledError):
ray.get(refs[1])
# The other tasks should complete normally.
assert ray.get([refs[0], refs[2]]) == [0, 2]
# Verify that `is_canceled` was propagated for task_index=1.
assert ray.get(actor.get_canceled_task_indices.remote()) == {1}
def test_is_canceled_not_supported_in_async_actor(shutdown_only):
"""Test is_canceled() for async actors."""
@ray.remote
class AsyncActor:
def __init__(self):
self.is_canceled = False
async def async_task(self):
# is_canceled() doesn't work for async actors
if ray.get_runtime_context().is_canceled():
self.is_canceled = True
return "canceled"
return "completed"
def is_canceled(self):
return self.is_canceled
actor = AsyncActor.remote()
ref = actor.async_task.remote()
# is_canceled() is not supported for async actors
with pytest.raises(
RuntimeError, match="This method is not supported in an async actor."
):
ray.get(ref)
# Verify the state for async actor does NOT change as there's no graceful
# termination for async actor task
assert not ray.get(actor.is_canceled.remote())
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))