1560 lines
47 KiB
Python
1560 lines
47 KiB
Python
import asyncio
|
|
import atexit
|
|
import collections
|
|
import os
|
|
import signal
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from typing import Callable, Generator, List
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
import ray
|
|
import ray.cluster_utils
|
|
from ray._common.test_utils import SignalActor, wait_for_condition
|
|
from ray._private.test_utils import (
|
|
generate_system_config_map,
|
|
wait_for_pid_to_exit,
|
|
)
|
|
from ray.actor import exit_actor
|
|
from ray.exceptions import AsyncioActorExit
|
|
|
|
SIGKILL = signal.SIGKILL if sys.platform != "win32" else signal.SIGTERM
|
|
|
|
|
|
@pytest.fixture
|
|
def ray_init_with_task_retry_delay():
|
|
address = ray.init(_system_config={"task_retry_delay_ms": 100})
|
|
yield address
|
|
ray.shutdown()
|
|
|
|
|
|
@pytest.fixture
|
|
def ray_init_with_actor_graceful_shutdown_timeout():
|
|
ray.shutdown()
|
|
address = ray.init(_system_config={"actor_graceful_shutdown_timeout_ms": 1000})
|
|
yield address
|
|
ray.shutdown()
|
|
|
|
|
|
@pytest.fixture
|
|
def tempfile_factory() -> Generator[Callable[[], str], None, None]:
|
|
"""Yields a factory function to generate tempfiles that will be deleted after the test run."""
|
|
files = []
|
|
|
|
def create_temp_file():
|
|
temp_file = tempfile.NamedTemporaryFile(delete=False)
|
|
temp_file.close()
|
|
files.append(temp_file.name)
|
|
return temp_file.name
|
|
|
|
yield create_temp_file
|
|
|
|
# Cleanup all created files
|
|
for file_path in files:
|
|
try:
|
|
os.unlink(file_path)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def check_file_exists_and_not_empty(file_path):
|
|
"""Helper to check if file exists and has content."""
|
|
return os.path.exists(file_path) and os.path.getsize(file_path) > 0
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ray_start_regular",
|
|
[
|
|
{
|
|
"object_store_memory": 150 * 1024 * 1024,
|
|
}
|
|
],
|
|
indirect=True,
|
|
)
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Segfaults on CI")
|
|
def test_actor_spilled(ray_start_regular):
|
|
object_store_memory = 150 * 1024 * 1024
|
|
|
|
@ray.remote
|
|
class Actor:
|
|
def __init__(self):
|
|
pass
|
|
|
|
def create_object(self, size):
|
|
return np.random.rand(size)
|
|
|
|
a = Actor.remote()
|
|
# Submit enough methods on the actor so that they exceed the size of the
|
|
# object store.
|
|
objects = []
|
|
num_objects = 40
|
|
for _ in range(num_objects):
|
|
obj = a.create_object.remote(object_store_memory // num_objects)
|
|
objects.append(obj)
|
|
# Get each object once to make sure each object gets created.
|
|
ray.get(obj)
|
|
|
|
# Get each object again. At this point, the earlier objects should have
|
|
# been spilled.
|
|
num_success = 0
|
|
for obj in objects:
|
|
val = ray.get(obj)
|
|
assert isinstance(val, np.ndarray), val
|
|
num_success += 1
|
|
# All of objects should've been spilled, so all of them should succeed.
|
|
assert num_success == len(objects)
|
|
|
|
|
|
def test_async_generator_crash_restart(ray_start_cluster):
|
|
"""
|
|
Timeline:
|
|
|
|
1. On a worker node, run a generator task that generates 2 objects in total and run
|
|
it to completion.
|
|
2. Kill the worker node so the objects are lost but the object refs exist.
|
|
3. Submit a consumer task that depends on the generated object refs.
|
|
4. Add a new worker node that the generator and the consumer can be run on
|
|
5. Verify that the generator outputs are reconstructed and the consumer succeeds.
|
|
"""
|
|
cluster = ray_start_cluster
|
|
head_node_id = cluster.add_node(
|
|
_system_config={
|
|
"health_check_timeout_ms": 1000,
|
|
"health_check_failure_threshold": 1,
|
|
}
|
|
).node_id
|
|
cluster.wait_for_nodes()
|
|
ray.init(address=cluster.address)
|
|
|
|
# Used to pause the generator task and kill it after it generates the first object.
|
|
signal = SignalActor.remote()
|
|
|
|
@ray.remote(
|
|
label_selector={"ray.io/node-id": f"!{head_node_id}"},
|
|
max_restarts=-1,
|
|
max_task_retries=-1,
|
|
)
|
|
class Generator:
|
|
async def generate(self):
|
|
print("Generate first object.")
|
|
yield np.ones(1024**2, dtype=np.uint8)
|
|
print("Wait for SignalActor.")
|
|
ray.get(signal.wait.remote())
|
|
print("Generate second object.")
|
|
yield np.ones(1024**2, dtype=np.uint8)
|
|
|
|
@ray.remote(label_selector={"ray.io/node-id": f"!{head_node_id}"})
|
|
def consumer(object_refs: List[ray.ObjectRef]):
|
|
assert len(object_refs) == 2
|
|
print("Calling `ray.get`.")
|
|
ray.get(object_refs)
|
|
print("`ray.get` succeeded.")
|
|
|
|
worker_node = cluster.add_node(num_cpus=2, resources={"worker": 2})
|
|
cluster.wait_for_nodes()
|
|
|
|
generator = Generator.remote()
|
|
|
|
# First run, let the generator run to completion.
|
|
obj_ref_gen_ref = generator.generate.remote()
|
|
wait_for_condition(lambda: ray.get(signal.cur_num_waiters.remote()) == 1)
|
|
ray.get(signal.send.remote(clear=True))
|
|
object_refs = list(ray.get(obj_ref_gen_ref))
|
|
assert len(object_refs) == 2
|
|
|
|
# Kill the worker node that holds the objects.
|
|
cluster.remove_node(worker_node, allow_graceful=False)
|
|
|
|
# Submit a consumer task that requires the objects from the generator.
|
|
consumer = consumer.remote(object_refs)
|
|
|
|
# Start a new worker node that the generator can be rerun on and the consumer can
|
|
# run on.
|
|
worker_node = cluster.add_node(num_cpus=2, resources={"worker": 2})
|
|
cluster.wait_for_nodes()
|
|
|
|
# Kill the generator after it generates a single object.
|
|
wait_for_condition(lambda: ray.get(signal.cur_num_waiters.remote()) == 1)
|
|
ray.kill(generator, no_restart=False)
|
|
|
|
# Now let the generator complete and check that the consumer succeeds.
|
|
ray.get(signal.send.remote())
|
|
ray.get(consumer)
|
|
|
|
|
|
def test_actor_restart(ray_init_with_task_retry_delay):
|
|
"""Test actor restart when actor process is killed."""
|
|
|
|
@ray.remote(max_restarts=1)
|
|
class RestartableActor:
|
|
"""An actor that will be restarted at most once."""
|
|
|
|
def __init__(self):
|
|
self.value = 0
|
|
|
|
def increase(self, exit=False):
|
|
if exit:
|
|
os._exit(-1)
|
|
self.value += 1
|
|
return self.value
|
|
|
|
def get_pid(self):
|
|
return os.getpid()
|
|
|
|
actor = RestartableActor.remote()
|
|
# Submit some tasks and kill on a task midway through.
|
|
results = [actor.increase.remote(exit=(i == 100)) for i in range(200)]
|
|
# Make sure that all tasks were executed in order before the actor's death.
|
|
i = 1
|
|
while results:
|
|
res = results[0]
|
|
try:
|
|
r = ray.get(res)
|
|
if r != i:
|
|
# Actor restarted at this task without any failed tasks in
|
|
# between.
|
|
break
|
|
results.pop(0)
|
|
i += 1
|
|
except ray.exceptions.RayActorError:
|
|
break
|
|
# Skip any tasks that errored.
|
|
while results:
|
|
try:
|
|
ray.get(results[0])
|
|
except ray.exceptions.RayActorError:
|
|
results.pop(0)
|
|
else:
|
|
break
|
|
# Check all tasks that executed after the restart.
|
|
if results:
|
|
# The actor executed some tasks after the restart.
|
|
i = 1
|
|
while results:
|
|
r = ray.get(results.pop(0))
|
|
assert r == i
|
|
i += 1
|
|
|
|
# Check that we can still call the actor.
|
|
result = actor.increase.remote()
|
|
assert ray.get(result) == r + 1
|
|
else:
|
|
# Wait for the actor to restart.
|
|
def ping():
|
|
try:
|
|
ray.get(actor.increase.remote())
|
|
return True
|
|
except ray.exceptions.RayActorError:
|
|
return False
|
|
|
|
wait_for_condition(ping)
|
|
|
|
# The actor has restarted. Kill actor process one more time.
|
|
actor.increase.remote(exit=True)
|
|
# The actor has exceeded max restarts. All tasks should fail.
|
|
for _ in range(100):
|
|
with pytest.raises(ray.exceptions.RayActorError):
|
|
ray.get(actor.increase.remote())
|
|
|
|
# Create another actor.
|
|
actor = RestartableActor.remote()
|
|
# Intentionlly exit the actor
|
|
actor.__ray_terminate__.remote()
|
|
# Check that the actor won't be restarted.
|
|
with pytest.raises(ray.exceptions.RayActorError):
|
|
ray.get(actor.increase.remote())
|
|
|
|
|
|
def test_actor_restart_with_retry(ray_init_with_task_retry_delay):
|
|
"""Test actor restart when actor process is killed."""
|
|
|
|
@ray.remote(max_restarts=1, max_task_retries=-1)
|
|
class RestartableActor:
|
|
"""An actor that will be restarted at most once."""
|
|
|
|
def sleep_and_echo(self, value, delay=0):
|
|
time.sleep(delay)
|
|
return value
|
|
|
|
def get_pid(self):
|
|
return os.getpid()
|
|
|
|
actor = RestartableActor.remote()
|
|
pid = ray.get(actor.get_pid.remote())
|
|
results = [actor.sleep_and_echo.remote(i) for i in range(100)]
|
|
# Kill actor process, while the above task is still being executed.
|
|
os.kill(pid, SIGKILL)
|
|
wait_for_pid_to_exit(pid)
|
|
# All tasks should be executed successfully.
|
|
results = ray.get(results)
|
|
assert results == list(range(100))
|
|
|
|
# kill actor process one more time.
|
|
results = [actor.sleep_and_echo.remote(i) for i in range(100)]
|
|
pid = ray.get(actor.get_pid.remote())
|
|
os.kill(pid, SIGKILL)
|
|
wait_for_pid_to_exit(pid)
|
|
# The actor has exceeded max restarts, and this task should fail.
|
|
with pytest.raises(ray.exceptions.RayActorError):
|
|
ray.get(actor.sleep_and_echo.remote(0))
|
|
|
|
# Create another actor.
|
|
actor = RestartableActor.remote()
|
|
# Intentionlly exit the actor
|
|
actor.__ray_terminate__.remote()
|
|
# Check that the actor won't be restarted.
|
|
with pytest.raises(ray.exceptions.RayActorError):
|
|
ray.get(actor.sleep_and_echo.remote(0))
|
|
|
|
|
|
def test_named_actor_max_task_retries(ray_init_with_task_retry_delay):
|
|
@ray.remote(num_cpus=0)
|
|
class Counter:
|
|
def __init__(self):
|
|
self.count = 0
|
|
self.event = asyncio.Event()
|
|
|
|
def increment(self):
|
|
self.count += 1
|
|
self.event.set()
|
|
|
|
async def wait_for_count(self, count):
|
|
while True:
|
|
if self.count >= count:
|
|
return
|
|
await self.event.wait()
|
|
self.event.clear()
|
|
|
|
@ray.remote
|
|
class ActorToKill:
|
|
def __init__(self, counter):
|
|
counter.increment.remote()
|
|
|
|
def run(self, counter, signal):
|
|
counter.increment.remote()
|
|
ray.get(signal.wait.remote())
|
|
|
|
@ray.remote
|
|
class CallingActor:
|
|
def __init__(self):
|
|
self.actor = ray.get_actor("a")
|
|
|
|
def call_other(self, counter, signal):
|
|
return ray.get(self.actor.run.remote(counter, signal))
|
|
|
|
init_counter = Counter.remote()
|
|
run_counter = Counter.remote()
|
|
signal = SignalActor.remote()
|
|
|
|
# Start the two actors, wait for ActorToKill's constructor to run.
|
|
a = ActorToKill.options(name="a", max_restarts=-1, max_task_retries=-1).remote(
|
|
init_counter
|
|
)
|
|
c = CallingActor.remote()
|
|
ray.get(init_counter.wait_for_count.remote(1), timeout=30)
|
|
|
|
# Signal the CallingActor to call ActorToKill, wait for it to be running,
|
|
# then kill ActorToKill.
|
|
# Verify that this causes ActorToKill's constructor to run a second time
|
|
# and the run method to begin a second time.
|
|
ref = c.call_other.remote(run_counter, signal)
|
|
ray.get(run_counter.wait_for_count.remote(1), timeout=30)
|
|
ray.kill(a, no_restart=False)
|
|
ray.get(init_counter.wait_for_count.remote(2), timeout=30)
|
|
ray.get(run_counter.wait_for_count.remote(2), timeout=30)
|
|
|
|
# Signal the run method to finish, verify that the CallingActor returns.
|
|
signal.send.remote()
|
|
ray.get(ref, timeout=30)
|
|
|
|
|
|
def test_actor_restart_on_node_failure(ray_start_cluster):
|
|
config = {
|
|
"health_check_failure_threshold": 10,
|
|
"health_check_period_ms": 100,
|
|
"health_check_initial_delay_ms": 0,
|
|
"object_timeout_milliseconds": 1000,
|
|
"task_retry_delay_ms": 100,
|
|
}
|
|
cluster = ray_start_cluster
|
|
# Head node with no resources.
|
|
cluster.add_node(num_cpus=0, _system_config=config)
|
|
cluster.wait_for_nodes()
|
|
ray.init(address=cluster.address)
|
|
|
|
# Node to place the signal actor.
|
|
cluster.add_node(num_cpus=1, resources={"signal": 1})
|
|
# Node to place the actor.
|
|
actor_node = cluster.add_node(num_cpus=1, resources={"actor": 1})
|
|
cluster.wait_for_nodes()
|
|
|
|
@ray.remote(num_cpus=1, max_restarts=1, max_task_retries=-1)
|
|
class RestartableActor:
|
|
"""An actor that will be reconstructed at most once."""
|
|
|
|
def __init__(self, signal):
|
|
self._signal = signal
|
|
|
|
def echo(self, value):
|
|
if value >= 50:
|
|
ray.get(self._signal.wait.remote())
|
|
return value
|
|
|
|
signal = SignalActor.options(resources={"signal": 1}).remote()
|
|
actor = RestartableActor.options(
|
|
lifetime="detached", resources={"actor": 1}
|
|
).remote(signal)
|
|
ray.get(actor.__ray_ready__.remote())
|
|
results = [actor.echo.remote(i) for i in range(100)]
|
|
# Kill actor node, while the above task is still being executed.
|
|
cluster.remove_node(actor_node)
|
|
ray.get(signal.send.remote())
|
|
cluster.add_node(num_cpus=1, resources={"actor": 1})
|
|
cluster.wait_for_nodes()
|
|
# All tasks should be executed successfully.
|
|
results = ray.get(results)
|
|
assert results == list(range(100))
|
|
|
|
|
|
def test_caller_actor_restart(ray_start_regular):
|
|
"""Test tasks from a restarted actor can be correctly processed
|
|
by the receiving actor."""
|
|
|
|
@ray.remote(max_restarts=1, max_task_retries=-1)
|
|
class RestartableActor:
|
|
"""An actor that will be restarted at most once."""
|
|
|
|
def __init__(self, actor):
|
|
self.actor = actor
|
|
|
|
def increase(self):
|
|
return ray.get(self.actor.increase.remote())
|
|
|
|
def get_pid(self):
|
|
return os.getpid()
|
|
|
|
@ray.remote(max_restarts=1)
|
|
class Actor:
|
|
"""An actor that will be restarted at most once."""
|
|
|
|
def __init__(self):
|
|
self.value = 0
|
|
|
|
def increase(self):
|
|
self.value += 1
|
|
return self.value
|
|
|
|
remote_actor = Actor.remote()
|
|
actor = RestartableActor.remote(remote_actor)
|
|
# Call increase 3 times
|
|
for _ in range(3):
|
|
ray.get(actor.increase.remote())
|
|
|
|
# kill the actor.
|
|
# TODO(zhijunfu): use ray.kill instead.
|
|
kill_actor(actor)
|
|
|
|
# Check that we can still call the actor.
|
|
assert ray.get(actor.increase.remote()) == 4
|
|
|
|
|
|
def test_caller_task_reconstruction(ray_start_regular):
|
|
"""Test a retried task from a dead worker can be correctly processed
|
|
by the receiving actor."""
|
|
|
|
@ray.remote(max_retries=5)
|
|
def RetryableTask(actor):
|
|
value = ray.get(actor.increase.remote())
|
|
if value > 2:
|
|
return value
|
|
else:
|
|
os._exit(0)
|
|
|
|
@ray.remote(max_restarts=1)
|
|
class Actor:
|
|
"""An actor that will be restarted at most once."""
|
|
|
|
def __init__(self):
|
|
self.value = 0
|
|
|
|
def increase(self):
|
|
self.value += 1
|
|
return self.value
|
|
|
|
remote_actor = Actor.remote()
|
|
|
|
assert ray.get(RetryableTask.remote(remote_actor)) == 3
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Very flaky on Windows.")
|
|
# NOTE(hchen): we set object_timeout_milliseconds to 1s for
|
|
# this test. Because if this value is too small, suprious task reconstruction
|
|
# may happen and cause the test fauilure. If the value is too large, this test
|
|
# could be very slow. We can remove this once we support dynamic timeout.
|
|
@pytest.mark.parametrize(
|
|
"ray_start_cluster_head",
|
|
[
|
|
generate_system_config_map(
|
|
object_timeout_milliseconds=1000,
|
|
health_check_initial_delay_ms=0,
|
|
health_check_period_ms=1000,
|
|
health_check_failure_threshold=10,
|
|
)
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_multiple_actor_restart(ray_start_cluster_head):
|
|
cluster = ray_start_cluster_head
|
|
num_nodes = 5
|
|
num_actors = 15
|
|
num_function_calls_at_a_time = 10
|
|
|
|
worker_nodes = [cluster.add_node(num_cpus=3) for _ in range(num_nodes)]
|
|
|
|
@ray.remote(max_restarts=-1, max_task_retries=-1)
|
|
class SlowCounter:
|
|
def __init__(self):
|
|
pass
|
|
|
|
def echo(self, duration, value):
|
|
time.sleep(duration)
|
|
return value
|
|
|
|
# Create some initial actors.
|
|
actors = [SlowCounter.remote() for _ in range(num_actors)]
|
|
ray.get([actor.__ray_ready__.remote() for actor in actors])
|
|
|
|
# This is a mapping from actor handles to object refs returned by
|
|
# methods on that actor.
|
|
result_ids = collections.defaultdict(lambda: [])
|
|
|
|
for i in range(len(actors)):
|
|
actor = actors[i]
|
|
for value in range(num_function_calls_at_a_time):
|
|
result_ids[actor].append(actor.echo.remote(i**2 * 0.000001, value))
|
|
|
|
# Kill nodes
|
|
for node in worker_nodes:
|
|
cluster.remove_node(node)
|
|
|
|
for i in range(len(actors)):
|
|
actor = actors[i]
|
|
for value in range(
|
|
num_function_calls_at_a_time, 2 * num_function_calls_at_a_time
|
|
):
|
|
result_ids[actor].append(actor.echo.remote(i**2 * 0.000001, value))
|
|
|
|
# Get the results and check that they have the correct values.
|
|
for actor, result_id_list in result_ids.items():
|
|
results = ray.get(result_id_list)
|
|
expected = list(range(num_function_calls_at_a_time * 2))
|
|
assert results == expected
|
|
|
|
|
|
def kill_actor(actor):
|
|
"""A helper function that kills an actor process."""
|
|
pid = ray.get(actor.get_pid.remote())
|
|
os.kill(pid, SIGKILL)
|
|
wait_for_pid_to_exit(pid)
|
|
|
|
|
|
def test_decorated_method(ray_start_regular):
|
|
def method_invocation_decorator(f):
|
|
def new_f_invocation(args, kwargs):
|
|
# Split one argument into two. Return th kwargs without passing
|
|
# them into the actor.
|
|
return f([args[0], args[0]], {}), kwargs
|
|
|
|
return new_f_invocation
|
|
|
|
def method_execution_decorator(f):
|
|
def new_f_execution(self, b, c):
|
|
# Turn two arguments into one.
|
|
return f(self, b + c)
|
|
|
|
new_f_execution.__ray_invocation_decorator__ = method_invocation_decorator
|
|
return new_f_execution
|
|
|
|
@ray.remote
|
|
class Actor:
|
|
@method_execution_decorator
|
|
def decorated_method(self, x):
|
|
return x + 1
|
|
|
|
a = Actor.remote()
|
|
|
|
object_ref, extra = a.decorated_method.remote(3, kwarg=3)
|
|
assert isinstance(object_ref, ray.ObjectRef)
|
|
assert extra == {"kwarg": 3}
|
|
assert ray.get(object_ref) == 7 # 2 * 3 + 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ray_start_cluster",
|
|
[
|
|
{
|
|
"num_cpus": 1,
|
|
"num_nodes": 1,
|
|
}
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_actor_owner_worker_dies_before_dependency_ready(ray_start_cluster):
|
|
"""Test actor owner worker dies before local dependencies are resolved.
|
|
This test verifies the scenario where owner worker
|
|
has failed before actor dependencies are resolved.
|
|
Reference: https://github.com/ray-project/ray/pull/8045
|
|
"""
|
|
|
|
@ray.remote
|
|
class Actor:
|
|
def __init__(self, dependency):
|
|
print("actor: {}".format(os.getpid()))
|
|
self.dependency = dependency
|
|
|
|
def f(self):
|
|
return self.dependency
|
|
|
|
@ray.remote
|
|
class Owner:
|
|
def get_pid(self):
|
|
return os.getpid()
|
|
|
|
def create_actor(self, caller_handle):
|
|
s = SignalActor.remote()
|
|
# Create an actor which depends on an object that can never be
|
|
# resolved.
|
|
actor_handle = Actor.remote(s.wait.remote())
|
|
|
|
pid = os.getpid()
|
|
signal_handle = SignalActor.remote()
|
|
caller_handle.call.remote(pid, signal_handle, actor_handle)
|
|
# Wait until the `Caller` start executing the remote `call` method.
|
|
ray.get(signal_handle.wait.remote())
|
|
# exit
|
|
os._exit(0)
|
|
|
|
@ray.remote
|
|
class Caller:
|
|
def call(self, owner_pid, signal_handle, actor_handle):
|
|
# Notify the `Owner` that the `Caller` is executing the remote
|
|
# `call` method.
|
|
ray.get(signal_handle.send.remote())
|
|
# Wait for the `Owner` to exit.
|
|
wait_for_pid_to_exit(owner_pid)
|
|
oid = actor_handle.f.remote()
|
|
# It will hang without location resolution protocol.
|
|
ray.get(oid)
|
|
|
|
def hang(self):
|
|
return True
|
|
|
|
owner = Owner.remote()
|
|
owner_pid = ray.get(owner.get_pid.remote())
|
|
|
|
caller = Caller.remote()
|
|
owner.create_actor.remote(caller)
|
|
# Wait for the `Owner` to exit.
|
|
wait_for_pid_to_exit(owner_pid)
|
|
# It will hang here if location is not properly resolved.
|
|
wait_for_condition(lambda: ray.get(caller.hang.remote()))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ray_start_cluster",
|
|
[
|
|
{
|
|
"num_cpus": 3,
|
|
"num_nodes": 1,
|
|
}
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_actor_owner_node_dies_before_dependency_ready(ray_start_cluster):
|
|
"""Test actor owner node dies before local dependencies are resolved.
|
|
This test verifies the scenario where owner node
|
|
has failed before actor dependencies are resolved.
|
|
Reference: https://github.com/ray-project/ray/pull/8045
|
|
"""
|
|
|
|
@ray.remote
|
|
class Actor:
|
|
def __init__(self, dependency):
|
|
print("actor: {}".format(os.getpid()))
|
|
self.dependency = dependency
|
|
|
|
def f(self):
|
|
return self.dependency
|
|
|
|
# Make sure it is scheduled in the second node.
|
|
@ray.remote(resources={"node": 1})
|
|
class Owner:
|
|
def get_pid(self):
|
|
return os.getpid()
|
|
|
|
def create_actor(self, caller_handle):
|
|
s = SignalActor.remote()
|
|
# Create an actor which depends on an object that can never be
|
|
# resolved.
|
|
actor_handle = Actor.remote(s.wait.remote())
|
|
|
|
pid = os.getpid()
|
|
signal_handle = SignalActor.remote()
|
|
caller_handle.call.remote(pid, signal_handle, actor_handle)
|
|
# Wait until the `Caller` start executing the remote `call` method.
|
|
ray.get(signal_handle.wait.remote())
|
|
|
|
@ray.remote(resources={"caller": 1})
|
|
class Caller:
|
|
def call(self, owner_pid, signal_handle, actor_handle):
|
|
# Notify the `Owner` that the `Caller` is executing the remote
|
|
# `call` method.
|
|
ray.get(signal_handle.send.remote())
|
|
# Wait for the `Owner` to exit.
|
|
wait_for_pid_to_exit(owner_pid)
|
|
oid = actor_handle.f.remote()
|
|
# It will hang without location resolution protocol.
|
|
ray.get(oid)
|
|
|
|
def hang(self):
|
|
return True
|
|
|
|
cluster = ray_start_cluster
|
|
node_to_be_broken = cluster.add_node(resources={"node": 1})
|
|
cluster.add_node(resources={"caller": 1})
|
|
|
|
owner = Owner.remote()
|
|
owner_pid = ray.get(owner.get_pid.remote())
|
|
|
|
caller = Caller.remote()
|
|
ray.get(owner.create_actor.remote(caller))
|
|
cluster.remove_node(node_to_be_broken)
|
|
wait_for_pid_to_exit(owner_pid)
|
|
|
|
# It will hang here if location is not properly resolved.
|
|
wait_for_condition(lambda: ray.get(caller.hang.remote()))
|
|
|
|
|
|
def test_recreate_child_actor(ray_start_cluster):
|
|
@ray.remote
|
|
class Actor:
|
|
def __init__(self):
|
|
pass
|
|
|
|
def ready(self):
|
|
return
|
|
|
|
@ray.remote(max_restarts=-1, max_task_retries=-1)
|
|
class Parent:
|
|
def __init__(self):
|
|
self.child = Actor.remote()
|
|
|
|
def ready(self):
|
|
return ray.get(self.child.ready.remote())
|
|
|
|
def pid(self):
|
|
return os.getpid()
|
|
|
|
ray.init(address=ray_start_cluster.address)
|
|
p = Parent.remote()
|
|
pid = ray.get(p.pid.remote())
|
|
os.kill(pid, 9)
|
|
ray.get(p.ready.remote())
|
|
|
|
|
|
def test_actor_failure_per_type(ray_start_cluster):
|
|
cluster = ray_start_cluster
|
|
cluster.add_node()
|
|
ray.init(address="auto")
|
|
|
|
@ray.remote
|
|
class Actor:
|
|
def check_alive(self):
|
|
return os.getpid()
|
|
|
|
def create_actor(self):
|
|
self.a = Actor.remote()
|
|
return self.a
|
|
|
|
# Test actor killed by ray.kill
|
|
a = Actor.remote()
|
|
ray.kill(a)
|
|
with pytest.raises(
|
|
ray.exceptions.RayActorError, match="it was killed by `ray.kill"
|
|
) as exc_info:
|
|
ray.get(a.check_alive.remote())
|
|
assert exc_info.value.actor_id == a._actor_id.hex()
|
|
print(exc_info._excinfo[1])
|
|
|
|
# Test actor killed because of worker failure.
|
|
a = Actor.remote()
|
|
pid = ray.get(a.check_alive.remote())
|
|
os.kill(pid, 9)
|
|
with pytest.raises(
|
|
ray.exceptions.RayActorError,
|
|
match=("The actor is dead because its worker process has died"),
|
|
) as exc_info:
|
|
ray.get(a.check_alive.remote())
|
|
assert exc_info.value.actor_id == a._actor_id.hex()
|
|
print(exc_info._excinfo[1])
|
|
|
|
# Test acator killed because of owner failure.
|
|
owner = Actor.remote()
|
|
a = ray.get(owner.create_actor.remote())
|
|
ray.kill(owner)
|
|
with pytest.raises(
|
|
ray.exceptions.RayActorError,
|
|
match="The actor is dead because its owner has died",
|
|
) as exc_info:
|
|
ray.get(a.check_alive.remote())
|
|
assert exc_info.value.actor_id == a._actor_id.hex()
|
|
print(exc_info._excinfo[1])
|
|
|
|
# Test actor killed because the node is dead.
|
|
node_to_kill = cluster.add_node(resources={"worker": 1})
|
|
a = Actor.options(resources={"worker": 1}).remote()
|
|
ray.get(a.check_alive.remote())
|
|
cluster.remove_node(node_to_kill)
|
|
with pytest.raises(
|
|
ray.exceptions.RayActorError,
|
|
match="The actor died because its node has died.",
|
|
) as exc_info:
|
|
ray.get(a.check_alive.remote())
|
|
assert exc_info.value.actor_id == a._actor_id.hex()
|
|
print(exc_info._excinfo[1])
|
|
|
|
|
|
def test_utf8_actor_exception(ray_start_regular):
|
|
@ray.remote
|
|
class FlakyActor:
|
|
def __init__(self):
|
|
raise RuntimeError("你好呀,祝你有个好心情!")
|
|
|
|
def ping(self):
|
|
return True
|
|
|
|
actor = FlakyActor.remote()
|
|
with pytest.raises(ray.exceptions.RayActorError):
|
|
ray.get(actor.ping.remote())
|
|
|
|
|
|
# https://github.com/ray-project/ray/issues/18908.
|
|
def test_failure_during_dependency_resolution(ray_start_regular):
|
|
@ray.remote
|
|
class Actor:
|
|
def dep(self):
|
|
while True:
|
|
time.sleep(1)
|
|
|
|
def foo(self, x):
|
|
return x
|
|
|
|
@ray.remote
|
|
def foo():
|
|
time.sleep(3)
|
|
return 1
|
|
|
|
a = Actor.remote()
|
|
# Check that the actor is alive.
|
|
ray.get(a.foo.remote(1))
|
|
|
|
ray.kill(a, no_restart=False)
|
|
dep = a.dep.remote()
|
|
ref = a.foo.remote(dep)
|
|
with pytest.raises(ray.exceptions.RayActorError):
|
|
ray.get(ref)
|
|
|
|
|
|
def test_exit_actor_invalid_usage_error(shutdown_only):
|
|
"""
|
|
Verify TypeError is raised when exit_actor is not used
|
|
inside an actor.
|
|
"""
|
|
with pytest.raises(
|
|
TypeError, match="exit_actor API is called on a non-actor worker"
|
|
):
|
|
exit_actor()
|
|
|
|
@ray.remote
|
|
def f():
|
|
exit_actor()
|
|
|
|
with pytest.raises(
|
|
TypeError, match="exit_actor API is called on a non-actor worker"
|
|
):
|
|
ray.get(f.remote())
|
|
|
|
|
|
def test_exit_actor_normal_actor_raise_immediately(shutdown_only, tmp_path):
|
|
temp_file_atexit = tmp_path / "atexit.log"
|
|
temp_file_after_exit_actor = tmp_path / "after_exit_actor.log"
|
|
assert not temp_file_atexit.exists()
|
|
assert not temp_file_after_exit_actor.exists()
|
|
|
|
@ray.remote
|
|
class Actor:
|
|
def __init__(self):
|
|
def f():
|
|
temp_file_atexit.touch()
|
|
|
|
atexit.register(f)
|
|
|
|
def exit(self):
|
|
exit_actor()
|
|
# The following code should not be executed.
|
|
temp_file_after_exit_actor.touch()
|
|
|
|
a = Actor.remote()
|
|
ray.get(a.__ray_ready__.remote())
|
|
with pytest.raises(ray.exceptions.RayActorError) as exc_info:
|
|
ray.get(a.exit.remote())
|
|
assert "exit_actor()" in str(exc_info.value)
|
|
|
|
def verify():
|
|
return temp_file_atexit.exists()
|
|
|
|
wait_for_condition(verify)
|
|
time.sleep(3)
|
|
assert not temp_file_after_exit_actor.exists()
|
|
|
|
|
|
def test_exit_actor_normal_actor_in_constructor_should_exit(shutdown_only, tmp_path):
|
|
temp_file_atexit = tmp_path / "atexit.log"
|
|
temp_file_after_exit_actor = tmp_path / "after_exit_actor.log"
|
|
assert not temp_file_atexit.exists()
|
|
assert not temp_file_after_exit_actor.exists()
|
|
|
|
@ray.remote
|
|
class Actor:
|
|
def __init__(self):
|
|
def f():
|
|
temp_file_atexit.touch()
|
|
|
|
atexit.register(f)
|
|
exit_actor()
|
|
# The following code should not be executed.
|
|
temp_file_after_exit_actor.touch()
|
|
|
|
a = Actor.remote() # noqa: F841 # Need to preserve the reference.
|
|
|
|
def verify():
|
|
return temp_file_atexit.exists()
|
|
|
|
wait_for_condition(verify)
|
|
time.sleep(3)
|
|
assert not temp_file_after_exit_actor.exists()
|
|
|
|
|
|
def test_exit_actor_normal_actor_user_catch_err_should_still_exit(
|
|
shutdown_only, tmp_path
|
|
):
|
|
temp_file = tmp_path / "actor.log"
|
|
assert not temp_file.exists()
|
|
|
|
@ray.remote
|
|
class Actor:
|
|
def exit(self):
|
|
try:
|
|
exit_actor()
|
|
except SystemExit:
|
|
pass
|
|
|
|
def create(self):
|
|
temp_file.touch()
|
|
|
|
a = Actor.remote()
|
|
ray.get(a.__ray_ready__.remote())
|
|
with pytest.raises(ray.exceptions.RayActorError):
|
|
ray.get(a.exit.remote())
|
|
|
|
with pytest.raises(ray.exceptions.RayActorError):
|
|
ray.get(a.create.remote())
|
|
|
|
assert not temp_file.exists()
|
|
|
|
|
|
def test_exit_actor_async_actor_raise_immediately(shutdown_only, tmp_path):
|
|
temp_file_atexit = tmp_path / "atexit.log"
|
|
temp_file_after_exit_actor = tmp_path / "after_exit_actor.log"
|
|
assert not temp_file_atexit.exists()
|
|
assert not temp_file_after_exit_actor.exists()
|
|
|
|
@ray.remote
|
|
class AsyncActor:
|
|
def __init__(self):
|
|
def f():
|
|
temp_file_atexit.touch()
|
|
|
|
atexit.register(f)
|
|
|
|
async def exit(self):
|
|
exit_actor()
|
|
# The following code should not be executed.
|
|
temp_file_after_exit_actor.touch()
|
|
|
|
a = AsyncActor.remote()
|
|
ray.get(a.__ray_ready__.remote())
|
|
|
|
try:
|
|
ray.get(a.exit.remote())
|
|
except Exception:
|
|
pass
|
|
|
|
with pytest.raises(ray.exceptions.RayActorError) as exc_info:
|
|
ray.get(a.exit.remote())
|
|
assert (
|
|
# Exited when task execution returns
|
|
"exit_actor()" in str(exc_info.value)
|
|
# Exited during periodical check in worker
|
|
or "User requested to exit the actor" in str(exc_info.value)
|
|
)
|
|
|
|
def verify():
|
|
return temp_file_atexit.exists()
|
|
|
|
wait_for_condition(verify)
|
|
time.sleep(3)
|
|
assert not temp_file_after_exit_actor.exists()
|
|
|
|
|
|
def test_exit_actor_async_actor_in_constructor_should_exit(shutdown_only, tmp_path):
|
|
temp_file_atexit = tmp_path / "atexit.log"
|
|
temp_file_after_exit_actor = tmp_path / "after_exit_actor.log"
|
|
assert not temp_file_atexit.exists()
|
|
assert not temp_file_after_exit_actor.exists()
|
|
|
|
@ray.remote
|
|
class AsyncActor:
|
|
def __init__(self):
|
|
def f():
|
|
temp_file_atexit.touch()
|
|
|
|
atexit.register(f)
|
|
exit_actor()
|
|
# The following code should not be executed.
|
|
temp_file_after_exit_actor.touch()
|
|
|
|
a = AsyncActor.remote() # noqa: F841 # Need to preserve the reference.
|
|
|
|
def verify():
|
|
return temp_file_atexit.exists()
|
|
|
|
wait_for_condition(verify)
|
|
time.sleep(3)
|
|
assert not temp_file_after_exit_actor.exists()
|
|
|
|
|
|
def test_exit_actor_async_actor_user_catch_err_should_still_exit(
|
|
shutdown_only, tmp_path
|
|
):
|
|
temp_file = tmp_path / "actor.log"
|
|
assert not temp_file.exists()
|
|
|
|
@ray.remote
|
|
class AsyncActor:
|
|
async def exit(self):
|
|
try:
|
|
exit_actor()
|
|
except AsyncioActorExit:
|
|
pass
|
|
|
|
async def create(self):
|
|
temp_file.touch()
|
|
|
|
a = AsyncActor.remote()
|
|
ray.get(a.__ray_ready__.remote())
|
|
with pytest.raises(ray.exceptions.RayActorError):
|
|
ray.get(a.exit.remote())
|
|
|
|
with pytest.raises(ray.exceptions.RayActorError):
|
|
ray.get(a.create.remote())
|
|
assert not temp_file.exists()
|
|
|
|
|
|
def test_exit_actor_async_actor_nested_task(shutdown_only, tmp_path):
|
|
temp_file_atexit = tmp_path / "atexit.log"
|
|
temp_file_after_exit_actor = tmp_path / "after_exit_actor.log"
|
|
assert not temp_file_atexit.exists()
|
|
assert not temp_file_after_exit_actor.exists()
|
|
|
|
signal = SignalActor.remote()
|
|
|
|
@ray.remote
|
|
class AsyncActor:
|
|
def __init__(self):
|
|
def f():
|
|
temp_file_atexit.touch()
|
|
|
|
atexit.register(f)
|
|
|
|
async def start_exit_task(self, signal):
|
|
asyncio.create_task(self.exit(signal))
|
|
|
|
async def exit(self, signal):
|
|
await signal.wait.remote()
|
|
exit_actor()
|
|
# The following code should not be executed.
|
|
temp_file_after_exit_actor.touch()
|
|
|
|
a = AsyncActor.remote()
|
|
ray.get(a.__ray_ready__.remote())
|
|
ray.get(a.start_exit_task.remote(signal))
|
|
ray.get(signal.send.remote())
|
|
|
|
def verify():
|
|
return temp_file_atexit.exists()
|
|
|
|
wait_for_condition(verify)
|
|
time.sleep(3)
|
|
assert not temp_file_after_exit_actor.exists()
|
|
|
|
|
|
def test_exit_actor_async_actor_nested_task_in_constructor_should_exit(
|
|
shutdown_only, tmp_path
|
|
):
|
|
temp_file_atexit = tmp_path / "atexit.log"
|
|
temp_file_after_exit_actor = tmp_path / "after_exit_actor.log"
|
|
assert not temp_file_atexit.exists()
|
|
assert not temp_file_after_exit_actor.exists()
|
|
|
|
@ray.remote
|
|
class AsyncActor:
|
|
def __init__(self):
|
|
def f():
|
|
temp_file_atexit.touch()
|
|
|
|
atexit.register(f)
|
|
asyncio.create_task(self.exit())
|
|
|
|
async def exit(self):
|
|
exit_actor()
|
|
# The following code should not be executed.
|
|
temp_file_after_exit_actor.touch()
|
|
|
|
a = AsyncActor.remote() # noqa: F841 # Need to preserve the reference.
|
|
|
|
def verify():
|
|
return temp_file_atexit.exists()
|
|
|
|
wait_for_condition(verify)
|
|
time.sleep(3)
|
|
assert not temp_file_after_exit_actor.exists()
|
|
|
|
|
|
def test_exit_actor_queued(shutdown_only):
|
|
"""Verify after exit_actor is called the queued tasks won't execute."""
|
|
|
|
@ray.remote
|
|
class RegressionSync:
|
|
def f(self):
|
|
time.sleep(1)
|
|
exit_actor()
|
|
|
|
def ping(self):
|
|
pass
|
|
|
|
@ray.remote(max_concurrency=1)
|
|
class RegressionAsync:
|
|
async def f(self):
|
|
await asyncio.sleep(1)
|
|
exit_actor()
|
|
|
|
def ping(self):
|
|
pass
|
|
|
|
# Test async actor.
|
|
# https://github.com/ray-project/ray/issues/32376
|
|
# If we didn't fix this issue, this will segfault.
|
|
a = RegressionAsync.remote()
|
|
a.f.remote()
|
|
refs = [a.ping.remote() for _ in range(1000)]
|
|
with pytest.raises(ray.exceptions.RayActorError) as exc_info:
|
|
ray.get(refs)
|
|
assert " Worker unexpectedly exits" not in str(exc_info.value)
|
|
|
|
# Test sync actor.
|
|
a = RegressionSync.remote()
|
|
a.f.remote()
|
|
with pytest.raises(ray.exceptions.RayActorError) as exc_info:
|
|
ray.get([a.ping.remote() for _ in range(1000)])
|
|
assert " Worker unexpectedly exits" not in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="SIGKILL not supported on windows")
|
|
def test_actor_restart_and_actor_received_task(shutdown_only):
|
|
# Create an actor with max_restarts=1 and max_task_retries=1.
|
|
# Submit a task to the actor and kill the actor after it receives
|
|
# the task. Then, the actor should restart in another core worker
|
|
# process, and the driver should resubmit the task to the new process.
|
|
# The task should be executed successfully.
|
|
@ray.remote(max_restarts=1, max_task_retries=1)
|
|
class RestartableActor:
|
|
def __init__(self):
|
|
self.counter = 0
|
|
|
|
def increment(self, signal_actor_1, signal_actor_2):
|
|
ray.get(signal_actor_1.send.remote())
|
|
ray.get(signal_actor_2.wait.remote())
|
|
self.counter += 1
|
|
return self.counter
|
|
|
|
def fail(self):
|
|
os._exit(1)
|
|
|
|
def get_pid(self):
|
|
return os.getpid()
|
|
|
|
actor = RestartableActor.remote()
|
|
pid = ray.get(actor.get_pid.remote())
|
|
|
|
signal_actor_1 = SignalActor.remote()
|
|
signal_actor_2 = SignalActor.remote()
|
|
ref = actor.increment.remote(signal_actor_1, signal_actor_2)
|
|
# Wait for the actor to execute the task `increment`
|
|
ray.get(signal_actor_1.wait.remote())
|
|
os.kill(pid, signal.SIGKILL)
|
|
|
|
ray.get(signal_actor_2.send.remote())
|
|
assert ray.get(ref) == 1
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="SIGKILL not supported on windows")
|
|
def test_actor_restart_and_partial_task_not_completed(shutdown_only):
|
|
# Create an actor with max_restarts=1 and max_task_retries=1.
|
|
# Submit 3 tasks to the actor and wait for them to complete.
|
|
# Then, submit 3 more tasks to the actor and kill the actor.
|
|
# The driver will resubmit the last 3 tasks to the new core worker
|
|
# process, and the tasks will be executed successfully.
|
|
@ray.remote(max_restarts=1, max_task_retries=1)
|
|
class RestartableActor:
|
|
def __init__(self):
|
|
pass
|
|
|
|
def echo(self, value):
|
|
return value
|
|
|
|
def wait_and_echo(self, value, signal_actor_1, signal_actor_2):
|
|
ray.get(signal_actor_1.send.remote())
|
|
ray.get(signal_actor_2.wait.remote())
|
|
return value
|
|
|
|
def get_pid(self):
|
|
return os.getpid()
|
|
|
|
actor = RestartableActor.remote()
|
|
pid = ray.get(actor.get_pid.remote())
|
|
refs = []
|
|
for i in range(3):
|
|
refs.append(actor.echo.remote(i))
|
|
assert ray.get(refs) == [0, 1, 2]
|
|
|
|
refs = []
|
|
signal_actor_1 = SignalActor.remote()
|
|
signal_actor_2 = SignalActor.remote()
|
|
refs.append(actor.wait_and_echo.remote(3, signal_actor_1, signal_actor_2))
|
|
ray.get(signal_actor_1.wait.remote())
|
|
refs.append(actor.echo.remote(4))
|
|
refs.append(actor.echo.remote(5))
|
|
|
|
os.kill(pid, signal.SIGKILL)
|
|
ray.get(signal_actor_2.send.remote())
|
|
assert ray.get(refs) == [3, 4, 5]
|
|
|
|
|
|
def test_actor_user_shutdown_method(ray_start_regular_shared, tempfile_factory):
|
|
"""Test that __ray_shutdown__ method is called during actor termination."""
|
|
shutdown_file = tempfile_factory()
|
|
|
|
@ray.remote
|
|
class UserShutdownActor:
|
|
def __init__(self):
|
|
pass
|
|
|
|
def __ray_shutdown__(self):
|
|
with open(shutdown_file, "w") as f:
|
|
f.write("ray_shutdown_called")
|
|
f.flush()
|
|
|
|
def get_ready(self):
|
|
return "ready"
|
|
|
|
actor = UserShutdownActor.remote()
|
|
ray.get(actor.get_ready.remote())
|
|
actor.__ray_terminate__.remote()
|
|
|
|
wait_for_condition(lambda: check_file_exists_and_not_empty(shutdown_file))
|
|
|
|
with open(shutdown_file, "r") as f:
|
|
assert f.read() == "ray_shutdown_called"
|
|
|
|
|
|
def test_actor_ray_shutdown_handles_exceptions(
|
|
ray_start_regular_shared, tempfile_factory
|
|
):
|
|
"""Test that Ray handles unhandled exceptions in __ray_shutdown__ gracefully."""
|
|
shutdown_file = tempfile_factory()
|
|
|
|
@ray.remote
|
|
class ExceptionActor:
|
|
def __ray_shutdown__(self):
|
|
# Write to file before raising exception
|
|
with open(shutdown_file, "w") as f:
|
|
f.write("cleanup_started")
|
|
f.flush()
|
|
|
|
# Let exception propagate to Ray's machinery
|
|
raise ValueError("Unhandled exception in __ray_shutdown__")
|
|
|
|
def get_ready(self):
|
|
return "ready"
|
|
|
|
actor = ExceptionActor.remote()
|
|
ray.get(actor.get_ready.remote())
|
|
actor.__ray_terminate__.remote()
|
|
|
|
# Verify that despite the exception:
|
|
# 1. File was written (cleanup started)
|
|
# 2. Actor shuts down properly (no system crash)
|
|
wait_for_condition(lambda: check_file_exists_and_not_empty(shutdown_file))
|
|
|
|
with open(shutdown_file, "r") as f:
|
|
assert f.read() == "cleanup_started"
|
|
|
|
|
|
def test_actor_atexit_handler_dont_conflict_with_ray_shutdown(
|
|
ray_start_regular_shared, tempfile_factory
|
|
):
|
|
"""Test that atexit handler methods don't conflict with __ray_shutdown__ and both run."""
|
|
shutdown_file = tempfile_factory()
|
|
atexit_file = tempfile_factory()
|
|
|
|
@ray.remote
|
|
class CleanupActor:
|
|
def __init__(self):
|
|
atexit.register(self.cleanup)
|
|
|
|
def __ray_shutdown__(self):
|
|
with open(shutdown_file, "w") as f:
|
|
f.write("ray_shutdown_called")
|
|
f.flush()
|
|
|
|
def cleanup(self):
|
|
with open(atexit_file, "w") as f:
|
|
f.write("atexit_cleanup_called")
|
|
f.flush()
|
|
|
|
def get_ready(self):
|
|
return "ready"
|
|
|
|
actor = CleanupActor.remote()
|
|
ray.get(actor.get_ready.remote())
|
|
actor.__ray_terminate__.remote()
|
|
|
|
wait_for_condition(lambda: check_file_exists_and_not_empty(shutdown_file))
|
|
|
|
with open(shutdown_file, "r") as f:
|
|
assert f.read() == "ray_shutdown_called"
|
|
wait_for_condition(lambda: check_file_exists_and_not_empty(atexit_file))
|
|
with open(atexit_file, "r") as f:
|
|
assert f.read() == "atexit_cleanup_called"
|
|
|
|
|
|
def test_actor_ray_shutdown_dont_interfere_with_kill(
|
|
ray_start_regular_shared, tempfile_factory
|
|
):
|
|
"""Test __ray_shutdown__ is not called when actor is killed with ray.kill()."""
|
|
shutdown_file = tempfile_factory()
|
|
|
|
@ray.remote
|
|
class KillableActor:
|
|
def __ray_shutdown__(self):
|
|
with open(shutdown_file, "w") as f:
|
|
f.write("shutdown_called_kill")
|
|
f.flush()
|
|
|
|
def get_ready(self):
|
|
return "ready"
|
|
|
|
def sleep_forever(self):
|
|
time.sleep(3600)
|
|
|
|
actor = KillableActor.remote()
|
|
ray.get(actor.get_ready.remote())
|
|
_ = actor.sleep_forever.remote()
|
|
ray.kill(actor)
|
|
|
|
wait_for_condition(lambda: not check_file_exists_and_not_empty(shutdown_file))
|
|
|
|
|
|
def test_actor_ray_shutdown_called_on_del(ray_start_regular_shared, tempfile_factory):
|
|
"""Test that __ray_shutdown__ is called when actor goes out of scope via del."""
|
|
shutdown_file = tempfile_factory()
|
|
|
|
@ray.remote
|
|
class DelTestActor:
|
|
def __ray_shutdown__(self):
|
|
with open(shutdown_file, "w") as f:
|
|
f.write("shutdown_called_on_del")
|
|
f.flush()
|
|
|
|
def ready(self):
|
|
return "ready"
|
|
|
|
actor = DelTestActor.remote()
|
|
ray.get(actor.ready.remote())
|
|
del actor
|
|
|
|
wait_for_condition(
|
|
lambda: check_file_exists_and_not_empty(shutdown_file), timeout=10
|
|
)
|
|
|
|
with open(shutdown_file, "r") as f:
|
|
assert f.read() == "shutdown_called_on_del", (
|
|
"Expected __ray_shutdown__ to be called within actor_graceful_shutdown_timeout_ms "
|
|
"after actor handle was deleted with del"
|
|
)
|
|
|
|
|
|
def test_actor_del_with_atexit(ray_start_regular_shared, tempfile_factory):
|
|
"""Test that both __ray_shutdown__ and atexit handlers run on del actor."""
|
|
shutdown_file = tempfile_factory()
|
|
atexit_file = tempfile_factory()
|
|
order_file = tempfile_factory()
|
|
|
|
@ray.remote
|
|
class BothHandlersActor:
|
|
def __init__(self):
|
|
atexit.register(self.cleanup)
|
|
|
|
def __ray_shutdown__(self):
|
|
with open(shutdown_file, "w") as f:
|
|
f.write("ray_shutdown_del")
|
|
f.flush()
|
|
with open(order_file, "a") as f:
|
|
f.write(f"shutdown:{time.time()}\n")
|
|
f.flush()
|
|
|
|
def cleanup(self):
|
|
with open(atexit_file, "w") as f:
|
|
f.write("atexit_del")
|
|
f.flush()
|
|
|
|
with open(order_file, "a") as f:
|
|
f.write(f"atexit:{time.time()}\n")
|
|
f.flush()
|
|
|
|
def ready(self):
|
|
return "ready"
|
|
|
|
actor = BothHandlersActor.remote()
|
|
ray.get(actor.ready.remote())
|
|
del actor
|
|
|
|
wait_for_condition(
|
|
lambda: check_file_exists_and_not_empty(shutdown_file), timeout=10
|
|
)
|
|
with open(shutdown_file, "r") as f:
|
|
assert (
|
|
f.read() == "ray_shutdown_del"
|
|
), "Expected __ray_shutdown__ to be called when actor deleted"
|
|
|
|
wait_for_condition(lambda: check_file_exists_and_not_empty(atexit_file), timeout=10)
|
|
with open(atexit_file, "r") as f:
|
|
assert f.read() == "atexit_del", "Expected atexit handler to be called"
|
|
|
|
# Verify execution order: __ray_shutdown__ should run before atexit
|
|
wait_for_condition(lambda: check_file_exists_and_not_empty(order_file), timeout=10)
|
|
with open(order_file, "r") as f:
|
|
order = f.read()
|
|
lines = order.strip().split("\n")
|
|
assert len(lines) == 2, f"Expected 2 entries, got: {lines}"
|
|
assert lines[0].startswith(
|
|
"shutdown:"
|
|
), f"Expected __ray_shutdown__ first, got order: {lines}"
|
|
assert lines[1].startswith(
|
|
"atexit:"
|
|
), f"Expected atexit second, got order: {lines}"
|
|
|
|
|
|
def test_actor_ray_shutdown_called_on_scope_exit(
|
|
ray_start_regular_shared, tempfile_factory
|
|
):
|
|
"""Test that __ray_shutdown__ is called when actor goes out of scope."""
|
|
shutdown_file = tempfile_factory()
|
|
|
|
@ray.remote
|
|
class ScopeTestActor:
|
|
def __ray_shutdown__(self):
|
|
with open(shutdown_file, "w") as f:
|
|
f.write("shutdown_called_on_scope_exit")
|
|
f.flush()
|
|
|
|
def ready(self):
|
|
return "ready"
|
|
|
|
def create_and_use_actor():
|
|
actor = ScopeTestActor.remote()
|
|
ray.get(actor.ready.remote())
|
|
# Actor goes out of scope at end of function
|
|
|
|
create_and_use_actor()
|
|
|
|
wait_for_condition(
|
|
lambda: check_file_exists_and_not_empty(shutdown_file), timeout=10
|
|
)
|
|
|
|
with open(shutdown_file, "r") as f:
|
|
assert f.read() == "shutdown_called_on_scope_exit"
|
|
|
|
|
|
def test_actor_graceful_shutdown_timeout_fallback(
|
|
ray_init_with_actor_graceful_shutdown_timeout, tempfile_factory
|
|
):
|
|
"""Test that actor is force killed if __ray_shutdown__ exceeds timeout."""
|
|
shutdown_started_file = tempfile_factory()
|
|
shutdown_completed_file = tempfile_factory()
|
|
|
|
@ray.remote
|
|
class HangingShutdownActor:
|
|
def __ray_shutdown__(self):
|
|
with open(shutdown_started_file, "w") as f:
|
|
f.write("shutdown_started")
|
|
f.flush()
|
|
|
|
# Hang indefinitely - simulating buggy cleanup code
|
|
time.sleep(5)
|
|
|
|
# This should never be reached due to force kill fallback
|
|
with open(shutdown_completed_file, "w") as f:
|
|
f.write("should_not_reach")
|
|
f.flush()
|
|
|
|
def ready(self):
|
|
return "ready"
|
|
|
|
actor = HangingShutdownActor.remote()
|
|
ray.get(actor.ready.remote())
|
|
del actor
|
|
|
|
# Verify that shutdown started
|
|
wait_for_condition(
|
|
lambda: check_file_exists_and_not_empty(shutdown_started_file), timeout=5
|
|
)
|
|
with open(shutdown_started_file, "r") as f:
|
|
assert (
|
|
f.read() == "shutdown_started"
|
|
), "Expected __ray_shutdown__ to start execution"
|
|
|
|
# Verify that shutdown did NOT complete (force killed before completion)
|
|
assert not check_file_exists_and_not_empty(shutdown_completed_file), (
|
|
"Expected actor to be force-killed before __ray_shutdown__ completed, "
|
|
"but completion file exists. This means force kill fallback did not work."
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-sv", __file__]))
|