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

441 lines
16 KiB
Python

import functools
import os
import pickle
import sys
import time
import unittest
from pathlib import Path
import ray
from ray.rllib.utils.actor_manager import FaultAwareApply, FaultTolerantActorManager
from ray.util.state import list_actors
def load_random_numbers():
"""Loads deterministic random numbers from data file."""
rllib_dir = Path(__file__).parent.parent.parent
pkl_file = os.path.join(
rllib_dir,
"utils",
"tests",
"random_numbers.pkl",
)
with open(pkl_file, "rb") as f:
return pickle.load(f)
RANDOM_NUMS = load_random_numbers()
@ray.remote(max_restarts=-1)
class Actor(FaultAwareApply):
def __init__(self, i, maybe_crash=True):
self.random_numbers = RANDOM_NUMS[i]
self.count = 0
self.maybe_crash = maybe_crash
self.config = {
"restart_failed_env_runners": True,
}
def _maybe_crash(self):
if not self.maybe_crash:
return
r = self.random_numbers[self.count]
# 10% chance of crashing.
if r < 0.1:
sys.exit(1)
# Another 10% chance of throwing errors.
elif r < 0.2:
raise AttributeError("sorry")
def call(self):
self.count += 1
self._maybe_crash()
# Otherwise, return good result.
return self.count
def ping(self):
self._maybe_crash()
return "pong"
def wait_for_restore():
"""Wait for Ray actor fault tolerence to restore all failed actors."""
while True:
states = [
# Wait till all actors are either "ALIVE" (retored),
# or "DEAD" (cancelled. these actors are from other
# finished test cases).
a["state"] == "ALIVE" or a["state"] == "DEAD"
for a in list_actors(filters=[("class_name", "=", "Actor")])
]
print("waiting ... ", states)
if all(states):
break
# Otherwise, wait a bit.
time.sleep(0.5)
class TestActorManager(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
ray.init()
@classmethod
def tearDownClass(cls) -> None:
ray.shutdown()
def test_sync_call_healthy_only(self):
"""Test synchronous remote calls to only healthy actors."""
actors = [Actor.remote(i) for i in range(4)]
manager = FaultTolerantActorManager(actors=actors)
results = []
for _ in range(10):
results.extend(manager.foreach_actor(lambda w: w.call()).ignore_errors())
# Wait for actors to recover.
wait_for_restore()
# Notice that since we only fire calls against healthy actors,
# we wouldn't be aware that the actors have been recovered.
# So once an actor is taken out of the lineup (10% chance),
# it will not go back in, and we should have few results here.
# Basically takes us 7 calls to kill all the actors.
# Note that we can hardcode 10 here because we are using deterministic
# sequences of random numbers.
self.assertEqual(len(results), 7)
manager.clear()
def test_sync_call_all_actors(self):
"""Test synchronous remote calls to all actors, regardless of their states."""
actors = [Actor.remote(i) for i in range(4)]
manager = FaultTolerantActorManager(actors=actors)
results = []
for _ in range(10):
# Make sure we have latest states of all actors.
results.extend(
manager.foreach_actor(lambda w: w.call(), healthy_only=False)
)
# Wait for actors to recover.
wait_for_restore()
# We fired against all actors regardless of their status.
# So we should get 40 results back.
self.assertEqual(len(results), 40)
# Since the actors are always restored before next round of calls,
# we should get more results back.
# Some of these calls still failed, but 15 good results in total.
# Note that we can hardcode 15 here because we are using deterministic
# sequences of random numbers.
self.assertEqual(len([r for r in results if r.ok]), 15)
manager.clear()
def test_sync_call_return_obj_refs(self):
"""Test synchronous remote calls to all actors asking for raw ObjectRefs."""
actors = [Actor.remote(i, maybe_crash=False) for i in range(4)]
manager = FaultTolerantActorManager(actors=actors)
results = list(
manager.foreach_actor(
lambda w: w.call(),
healthy_only=False,
return_obj_refs=True,
)
)
# We fired against all actors regardless of their status.
# So we should get 40 results back.
self.assertEqual(len(results), 4)
for r in results:
# Each result is an ObjectRef.
self.assertTrue(r.ok)
self.assertTrue(isinstance(r.get(), ray.ObjectRef))
manager.clear()
def test_sync_call_fire_and_forget(self):
"""Test synchronous remote calls with 0 timeout_seconds."""
actors = [Actor.remote(i, maybe_crash=False) for i in range(4)]
manager = FaultTolerantActorManager(actors=actors)
results1 = []
for _ in range(10):
manager.probe_unhealthy_actors(mark_healthy=True)
results1.extend(
manager.foreach_actor(lambda w: w.call(), timeout_seconds=0)
)
# Wait for actors to recover.
wait_for_restore()
# Timeout is 0, so we returned immediately.
# We may get a couple of results back if the calls are fast,
# but that is not important.
results2 = [
r.get()
for r in manager.foreach_actor(
lambda w: w.call(), healthy_only=False
).ignore_errors()
]
# Results from blocking calls show the # of calls happened on
# each remote actor. 11 calls to each actor in total.
self.assertEqual(results2, [11, 11, 11, 11])
manager.clear()
def test_sync_call_same_actor_multiple_times(self):
"""Test multiple synchronous remote calls to the same actor."""
actors = [Actor.remote(i, maybe_crash=False) for i in range(4)]
manager = FaultTolerantActorManager(actors=actors)
# 2 synchronous call to actor 0.
results = manager.foreach_actor(
lambda w: w.call(),
remote_actor_ids=[0, 0],
)
# Returns 1 and 2, representing the first and second calls to actor 0.
self.assertEqual([r.get() for r in results.ignore_errors()], [1, 2])
manager.clear()
def test_async_call_same_actor_multiple_times(self):
"""Test multiple asynchronous remote calls to the same actor."""
actors = [Actor.remote(i, maybe_crash=False) for i in range(4)]
manager = FaultTolerantActorManager(actors=actors)
# 2 asynchronous call to actor 0.
num_of_calls = manager.foreach_actor_async(
lambda w: w.call(),
remote_actor_ids=[0, 0],
)
self.assertEqual(num_of_calls, 2)
# Now, let's actually fetch the results.
results = manager.fetch_ready_async_reqs(timeout_seconds=None)
# Returns 1 and 2, representing the first and second calls to actor 0.
self.assertEqual([r.get() for r in results.ignore_errors()], [1, 2])
manager.clear()
def test_sync_call_not_ignore_error(self):
"""Test synchronous remote calls that returns errors."""
actors = [Actor.remote(i) for i in range(4)]
manager = FaultTolerantActorManager(actors=actors)
results = []
for _ in range(10):
manager.probe_unhealthy_actors(mark_healthy=True)
results.extend(manager.foreach_actor(lambda w: w.call()))
# Wait for actors to recover.
wait_for_restore()
# Some calls did error out.
self.assertTrue(any([not r.ok for r in results]))
manager.clear()
def test_sync_call_not_bringing_back_actors(self):
"""Test successful remote calls will not bring back actors unless told to."""
actors = [Actor.remote(i) for i in range(4)]
manager = FaultTolerantActorManager(actors=actors)
results = manager.foreach_actor(lambda w: w.call())
# Some calls did error out.
self.assertTrue(any([not r.ok for r in results]))
# Wait for actors to recover.
wait_for_restore()
manager.probe_unhealthy_actors(mark_healthy=False)
# Restored actors were not marked healthy (`mark_healthy=False` above).
# Only 2 healthy actors.
self.assertEqual(manager.num_healthy_actors(), 2)
manager.clear()
def test_async_call(self):
"""Test asynchronous remote calls work."""
actors = [Actor.remote(i) for i in range(4)]
manager = FaultTolerantActorManager(actors=actors)
results = []
for _ in range(10):
manager.foreach_actor_async(lambda w: w.call())
results.extend(manager.fetch_ready_async_reqs(timeout_seconds=None))
# Wait for actors to recover.
wait_for_restore()
# Note that we can hardcode the numbers here because of the deterministic
# lists of random numbers we use.
# 7 calls succeeded, 4 failed.
# The number of results back is much lower than 40, because we do not probe
# the actors with this test. As soon as an actor errors out, it will get
# taken out of the lineup forever.
self.assertEqual(len([r for r in results if r.ok]), 7)
self.assertEqual(len([r for r in results if not r.ok]), 4)
manager.clear()
def test_async_calls_get_dropped_if_inflight_requests_over_limit(self):
"""Test asynchronous remote calls get dropped if too many in-flight calls."""
actors = [Actor.remote(i, maybe_crash=False) for i in range(4)]
manager = FaultTolerantActorManager(
actors=actors,
max_remote_requests_in_flight_per_actor=2,
)
# 2 asynchronous call to actor 1.
num_of_calls = manager.foreach_actor_async(
lambda w: w.call(),
remote_actor_ids=[0, 0],
)
self.assertEqual(num_of_calls, 2)
# Now, let's try to make another async call to actor 1.
num_of_calls = manager.foreach_actor_async(
lambda w: w.call(),
healthy_only=False,
remote_actor_ids=[0],
)
# We actually made 0 calls.
self.assertEqual(num_of_calls, 0)
manager.clear()
def test_healthy_only_works_for_list_of_functions(self):
"""Test healthy only mode works when a list of funcs are provided."""
actors = [Actor.remote(i) for i in range(4)]
manager = FaultTolerantActorManager(actors=actors)
# Mark first and second actor as unhealthy.
manager.set_actor_state(1, False)
manager.set_actor_state(2, False)
def f(id, _):
return id
func = [functools.partial(f, i) for i in range(4)]
manager.foreach_actor_async(func, healthy_only=True)
results = manager.fetch_ready_async_reqs(timeout_seconds=None)
# Should get results back from calling actor 0 and 3.
self.assertEqual([r.get() for r in results], [0, 3])
manager.clear()
def test_probe_unhealthy_actors(self):
"""Test probe brings back unhealthy actors."""
actors = [Actor.remote(i, maybe_crash=False) for i in range(4)]
manager = FaultTolerantActorManager(actors=actors)
# Mark first and second actor as unhealthy.
manager.set_actor_state(1, False)
manager.set_actor_state(2, False)
# These actors are actually healthy.
manager.probe_unhealthy_actors(mark_healthy=True)
# Both actors are now healthy.
self.assertEqual(len(manager.healthy_actor_ids()), 4)
def test_tags(self):
"""Test that tags work for async calls."""
actors = [Actor.remote(i, maybe_crash=False) for i in range(4)]
manager = FaultTolerantActorManager(actors=actors)
manager.foreach_actor_async(lambda w: w.ping(), tag="pingpong")
manager.foreach_actor_async(lambda w: w.call(), tag="call")
results_ping_pong = manager.fetch_ready_async_reqs(
tags="pingpong", timeout_seconds=10.0
)
results_call = manager.fetch_ready_async_reqs(tags="call", timeout_seconds=2.0)
self.assertEqual(len(results_ping_pong), 4)
self.assertEqual(len(results_call), 4)
for result in results_ping_pong:
data = result.get()
self.assertEqual(data, "pong")
self.assertEqual(result.tag, "pingpong")
for result in results_call:
data = result.get()
self.assertEqual(data, 1)
self.assertEqual(result.tag, "call")
# test with default tag
manager.foreach_actor_async(lambda w: w.ping())
manager.foreach_actor_async(lambda w: w.call())
time.sleep(1)
results = manager.fetch_ready_async_reqs(timeout_seconds=5)
self.assertEqual(len(results), 8)
for result in results:
data = result.get()
self.assertEqual(result.tag, None)
if isinstance(data, str):
self.assertEqual(data, "pong")
elif isinstance(data, int):
self.assertEqual(data, 2)
else:
raise ValueError("data is not str or int")
# test with custom tags
manager.foreach_actor_async(lambda w: w.ping(), tag="pingpong")
manager.foreach_actor_async(lambda w: w.call(), tag="call")
time.sleep(1)
results = manager.fetch_ready_async_reqs(
timeout_seconds=5, tags=["pingpong", "call"]
)
self.assertEqual(len(results), 8)
for result in results:
data = result.get()
if isinstance(data, str):
self.assertEqual(data, "pong")
self.assertEqual(result.tag, "pingpong")
elif isinstance(data, int):
self.assertEqual(data, 3)
self.assertEqual(result.tag, "call")
else:
raise ValueError("data is not str or int")
# test with incorrect tags
manager.foreach_actor_async(lambda w: w.ping(), tag="pingpong")
manager.foreach_actor_async(lambda w: w.call(), tag="call")
time.sleep(1)
results = manager.fetch_ready_async_reqs(timeout_seconds=5, tags=["incorrect"])
self.assertEqual(len(results), 0)
# now test that passing no tags still gives back all of the results
results = manager.fetch_ready_async_reqs(timeout_seconds=5)
self.assertEqual(len(results), 8)
for result in results:
data = result.get()
if isinstance(data, str):
self.assertEqual(data, "pong")
self.assertEqual(result.tag, "pingpong")
elif isinstance(data, int):
self.assertEqual(data, 4)
self.assertEqual(result.tag, "call")
else:
raise ValueError("result is not str or int")
def test_foreach_actor_async_fetch_ready(self):
"""Test foreach_actor_async_fetch_ready works."""
actors = [Actor.remote(i, maybe_crash=False) for i in range(2)]
manager = FaultTolerantActorManager(actors=actors)
manager.foreach_actor_async_fetch_ready(lambda w: w.ping(), tag="ping")
results = manager.foreach_actor_async_fetch_ready(
lambda w: w.ping(), tag="ping", timeout_seconds=30
)
self.assertEqual(len(results), 2)
if __name__ == "__main__":
import pytest
sys.exit(pytest.main(["-v", __file__]))