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

305 lines
11 KiB
Python

import os
import signal
import sys
from typing import Optional, Tuple
import pytest
import ray
from ray._common.test_utils import SignalActor, wait_for_condition
from ray.exceptions import ActorDiedError, ActorUnavailableError
import psutil # We must import psutil after ray because we bundle it with ray.
@ray.remote(num_cpus=0)
class Counter:
def __init__(
self,
*,
caller_pid: Optional[int] = None,
init_signal: Optional[ray.actor.ActorHandle] = None,
):
if init_signal is not None:
ray.get(init_signal.wait.remote())
self._count = 0
self._caller_pid = caller_pid
def getpid(self):
return os.getpid()
def get(self) -> int:
return self._count
def inc(self, *, disconnect: bool = False) -> int:
if disconnect:
assert self._caller_pid is not None, "Must provide caller PID."
_close_common_connections(self._caller_pid)
self._count += 1
return self._count
def call_from(f, source):
if source == "driver":
return f()
elif source == "actor":
@ray.remote
class Wrapper:
def invoke(self):
f()
a = Wrapper.remote()
return ray.get(a.invoke.remote())
elif source == "task":
@ray.remote
def wrapper():
return f()
return ray.get(wrapper.remote())
else:
raise ValueError(f"unknown {source}")
def sigkill_actor(actor, timeout=5):
"""Sends SIGKILL to an actor's process. The actor must be on the same node, and it
must has a `getpid` method."""
pid = ray.get(actor.getpid.remote())
print(f"killing actor {actor}'s process {pid}")
try:
proc = psutil.Process(pid)
os.kill(pid, signal.SIGKILL)
# Wait for the process to terminate (with timeout)
try:
proc.wait(timeout=timeout)
print(f"Process {pid} terminated.")
except psutil.TimeoutExpired:
print(f"Process {pid} did not terminate within {timeout} seconds.")
except psutil.NoSuchProcess:
print(f"Process {pid} does not exist — it may have already exited.")
def _close_common_connections(pid: int):
"""Closes ipv2 connections between the current process and the target process."""
current_process = psutil.Process()
current_connections = current_process.connections(kind="inet")
try:
other_process = psutil.Process(pid)
other_connections = other_process.connections(kind="inet")
except psutil.NoSuchProcess:
print(f"No process with PID {pid} found.")
return
# Finding common connections based on matching addresses and ports.
common_connections = []
for conn1 in current_connections:
for conn2 in other_connections:
if conn1.laddr == conn2.raddr and conn1.raddr == conn2.laddr:
common_connections.append((conn1.fd, conn1.laddr, conn1.raddr))
# Closing the FDs.
for fd, laddr, raddr in common_connections:
if fd != -1: # FD is -1 if it's not accessible or if it's a pseudo FD.
os.close(fd)
print(f"Closed FD: {fd}, laddr: {laddr}, raddr: {raddr}")
@pytest.mark.parametrize("caller", ["actor", "task", "driver"])
@pytest.mark.skipif(sys.platform == "win32", reason="does not work on windows")
def test_actor_unavailable_conn_broken(ray_start_regular, caller):
def _run_test():
a = Counter.remote(caller_pid=os.getpid())
counter_pid = ray.get(a.getpid.remote())
# Server (counter actor) unexpectedly disconnects the connection once the method
# has started executing. The task should raise `ActorUnavailableError` but its
# side effects should be observable (count was incremented).
obj_ref = a.inc.remote(disconnect=True)
with pytest.raises(ActorUnavailableError):
ray.get(obj_ref)
assert ray.get(a.get.remote()) == 1
# Client (driver) unexpectedly disconnects the connection prior to submitting the
# task. The task should raise `ActorUnavailableError` and should never have
# executed, therefore the count should not have been incremented.
_close_common_connections(counter_pid)
with pytest.raises(ActorUnavailableError):
ray.get(a.inc.remote(disconnect=False))
assert ray.get(a.get.remote()) == 1
call_from(_run_test, caller)
@pytest.mark.parametrize("caller", ["actor", "task", "driver"])
@pytest.mark.skipif(sys.platform == "win32", reason="does not work on windows")
def test_actor_unavailable_restarting(ray_start_regular, caller):
def _run_test():
init_signal = SignalActor.remote()
a = Counter.options(max_restarts=1).remote(init_signal=init_signal)
wait_for_condition(lambda: ray.get(init_signal.cur_num_waiters.remote()) == 1)
ray.get(init_signal.send.remote(clear=True))
assert ray.get(a.inc.remote()) == 1
# Kill the actor process and expect `ActorUnavailableError` as it restarts.
sigkill_actor(a)
with pytest.raises(ActorUnavailableError):
ray.get(a.inc.remote())
with pytest.raises(ActorUnavailableError, match="The actor is restarting"):
ray.get(a.inc.remote())
ray.get(init_signal.send.remote())
# After the actor starts, the next calls are OK. However the previous actor
# instance's state is lost.
wait_for_condition(lambda: ray.get(a.get.remote()) == 0)
# Kill the actor again. This time it's not going to restart so ActorDiedError.
sigkill_actor(a)
with pytest.raises(ActorDiedError):
print(ray.get(a.inc.remote()))
call_from(_run_test, caller)
@pytest.mark.parametrize("caller", ["actor", "task", "driver"])
@pytest.mark.skipif(sys.platform == "win32", reason="does not work on windows")
def test_actor_unavailable_norestart(ray_start_regular, caller):
def _run_test():
a = Counter.remote()
assert ray.get(a.get.remote()) == 0
# Kill the actor process. The actor died permanently so ActorDiedError.
sigkill_actor(a)
with pytest.raises(ActorDiedError):
print(ray.get(a.get.remote()))
call_from(_run_test, caller)
@ray.remote(max_restarts=-1, max_task_retries=0)
class ActorAwaitingOnCreation:
"""
An actor that is awaiting for a signal to be sent to it during its init. It is used
to test the behavior of the actor when it is killed and restarted.
It also increments a counter during its init to keep track of the number of
restarts.
"""
def __init__(
self,
restart_counter: Counter,
blocking_signal: SignalActor,
restart_death_range: Tuple[int, int],
):
restart_count = ray.get(restart_counter.inc.remote())
ray.get(blocking_signal.wait.remote()) # block on signal
restart_death_lower, restart_death_upper = restart_death_range
if restart_count > restart_death_lower and restart_count < restart_death_upper:
msg = (
f"Failed to restart the actor because the restart count is in the death range [{restart_death_lower}, "
f"{restart_death_upper}]: {restart_count}"
)
print(msg)
raise ValueError(msg)
def ping(self, name):
print(f"ping from {name}")
return f"hello {name}!"
def getpid(self):
return os.getpid()
@pytest.mark.skipif(sys.platform == "win32", reason="does not work on windows")
def test_actor_restart(ray_start_regular):
"""
Test the following actor restart scenarios:
- The actor restarts successfully on being killed.
- The actor emits the right error message during the restart when it is not fully
initialized.
- The actor emits the right error message when it is permanently dead.
"""
counter = Counter.remote()
signal_actor = SignalActor.remote()
actor = ActorAwaitingOnCreation.options(max_restarts=3).remote(
restart_counter=counter,
blocking_signal=signal_actor,
restart_death_range=(2, 10),
)
# unblock actor creation, actor should be created eventually
ray.get(signal_actor.send.remote())
wait_for_condition(
lambda: ray.get(actor.ping.remote("lemon")) == "hello lemon!",
)
# block actor creation and kill it
ray.get(signal_actor.send.remote(clear=True))
sigkill_actor(actor)
with pytest.raises(ActorUnavailableError):
print(ray.get(actor.ping.remote("unavailable")))
# unblock actor creation, actor should be created eventually
ray.get(signal_actor.send.remote())
wait_for_condition(
lambda: ray.get(actor.ping.remote("ok")) == "hello ok!",
)
# block actor creation and kill it
ray.get(signal_actor.send.remote(clear=True))
sigkill_actor(actor)
with pytest.raises(ActorUnavailableError):
print(ray.get(actor.ping.remote("unavailable")))
# unblock actor creation, the actor still dies because it reaches the restart limit
ray.get(signal_actor.send.remote())
wait_for_condition(
lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 0,
)
with pytest.raises(ActorDiedError, match="an error raised in its creation task"):
print(ray.get(actor.ping.remote("actor error")))
@pytest.mark.skipif(sys.platform == "win32", reason="does not work on windows")
def test_actor_inifite_restart(ray_start_regular):
"""
Test that the actor can be restarted inifinitely. We do that by intentionally
cause the actor to fail when its restarting counter is in the death range. We
then test that the restarting counter will eventually go out of the death range
and the actor will be able to restart.
"""
counter = Counter.remote()
signal_actor = SignalActor.remote()
actor = ActorAwaitingOnCreation.options().remote(
restart_counter=counter,
blocking_signal=signal_actor,
restart_death_range=(2, 5),
)
# unblock actor creation
ray.get(signal_actor.send.remote())
wait_for_condition(
lambda: ray.get(actor.ping.remote("lemon")) == "hello lemon!",
)
# block actor creation and kill it
ray.get(signal_actor.send.remote(clear=True))
sigkill_actor(actor)
# When the actor is restarting, any method call raises ActorUnavailableError.
with pytest.raises(ActorUnavailableError):
print(ray.get(actor.ping.remote("unavailable")))
# unblock actor creation, the actor keeps retrying until it gets out of the death
# range
ray.get(signal_actor.send.remote())
assert ray.get(actor.ping.options(max_task_retries=-1).remote("ok")) == "hello ok!"
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))