Files
ray-project--ray/rllib/algorithms/tests/test_node_failures.py
T
2026-07-13 13:17:40 +08:00

231 lines
7.1 KiB
Python

import pytest
import ray
import ray._common
from ray._private.test_utils import get_other_nodes
from ray.cluster_utils import Cluster
from ray.rllib.algorithms.appo import APPOConfig
from ray.rllib.algorithms.ppo import PPOConfig
EXPECTED_PER_NODE_OBJECT_STORE_MEMORY = 10**8
HEAD_REDIS_PORT = 6379
HEAD_CPUS = 2
WORKER_CPUS = 4
NUM_ENV_RUNNERS = 4
def _add_node(cluster, worker=True):
cluster.add_node(
redis_port=None if worker else HEAD_REDIS_PORT,
num_cpus=WORKER_CPUS if worker else HEAD_CPUS,
object_store_memory=EXPECTED_PER_NODE_OBJECT_STORE_MEMORY,
include_dashboard=not worker,
)
@pytest.fixture
def cluster():
"""Create a 2-node fake cluster: head (2 CPUs) + worker (4 CPUs).
Head holds the algo process + local env runner.
Worker holds all 4 remote env runners (deterministic placement).
"""
assert (
2 * EXPECTED_PER_NODE_OBJECT_STORE_MEMORY
< ray._common.utils.get_system_memory() / 2
), "Not enough memory on this machine to run this workload."
cluster = Cluster()
_add_node(cluster, worker=False)
_add_node(cluster)
cluster.wait_for_nodes()
ray.init(address=cluster.address)
yield cluster
# Detach head_node before cluster.shutdown() and kill its processes
# directly afterwards. Otherwise cluster.remove_node(head) refuses if a
# daemon thread (e.g. APPO/IMPALA's learner thread) called an
# auto-init-wrapped Ray API and re-established global_worker.node after
# our ray.shutdown(), leaving port 8265 bound for the next test.
ray.shutdown()
head = cluster.head_node
cluster.head_node = None
cluster.shutdown()
head.kill_all_processes(check_alive=False, allow_graceful=False, wait=True)
def _kill_worker_node(cluster):
others = get_other_nodes(cluster, exclude_head=True)
if others:
cluster.remove_node(others[0])
return True
return False
def _train(cluster, algo, config, iters, preempt_freq):
"""Train loop with periodic node kill/restore and health tracking."""
num_runners = config.num_env_runners
saw_healthy_drop = False
saw_recovery = False
for i in range(iters):
algo.train()
assert algo.env_runner_group.num_remote_env_runners() == num_runners
healthy = algo.env_runner_group.num_healthy_remote_workers()
assert 0 <= healthy <= num_runners
if healthy < num_runners:
saw_healthy_drop = True
if saw_healthy_drop and healthy == num_runners:
saw_recovery = True
print(
f"ITER={i}, healthy={healthy}/{num_runners}, "
f"saw_drop={saw_healthy_drop}, saw_recovery={saw_recovery}"
)
# Shut down one node every preempt_freq iterations.
if i % preempt_freq == 0:
_kill_worker_node(cluster)
# Bring back a previously failed node.
elif (i - 1) % preempt_freq == 0:
_add_node(cluster)
# Workers must have gone down at some point.
assert saw_healthy_drop, (
"Expected healthy worker count to drop after node kill, " "but it never did."
)
# If restart is enabled, workers must have come back.
if config.restart_failed_env_runners:
assert saw_recovery, (
"Expected workers to recover after node restore "
"(restart_failed_env_runners=True), but they never did."
)
# If restart is disabled, workers must NOT have come back.
if not config.restart_failed_env_runners:
assert (
not saw_recovery
), "Workers recovered despite restart_failed_env_runners=False."
def test_node_failure_ignore(cluster):
"""restart=False, ignore=True: workers die and stay dead, training
continues with fewer workers."""
config = (
PPOConfig()
.environment("CartPole-v1")
.env_runners(
num_env_runners=NUM_ENV_RUNNERS,
sample_timeout_s=5.0,
)
.training(
train_batch_size=500,
num_epochs=1,
minibatch_size=500,
)
.reporting(min_train_timesteps_per_iteration=1)
.fault_tolerance(
ignore_env_runner_failures=True,
restart_failed_env_runners=False,
env_runner_health_probe_timeout_s=20.0,
)
)
algo = config.build()
_train(cluster, algo, config, iters=10, preempt_freq=3)
def test_node_failure_recreate_appo(cluster):
"""restart=True with APPO (async): workers die, get auto-restarted by Ray,
and restore_env_runners() syncs their state."""
config = (
APPOConfig()
.environment("CartPole-v1")
.learners(num_learners=0)
.experimental(_validate_config=False)
.env_runners(
num_env_runners=NUM_ENV_RUNNERS,
)
.reporting(
# Must be >= 2s so APPO's async mechanism has time to detect
# worker death within a single iteration.
min_time_s_per_iteration=2,
min_train_timesteps_per_iteration=1,
)
.fault_tolerance(
restart_failed_env_runners=True,
env_runner_health_probe_timeout_s=20.0,
)
)
algo = config.build()
_train(cluster, algo, config, iters=10, preempt_freq=7)
def test_node_failure_recreate_ppo(cluster):
"""restart=True with PPO (sync): workers die, get auto-restarted by Ray,
and restore_env_runners() syncs their state."""
config = (
PPOConfig()
.environment("CartPole-v1")
.learners(num_learners=0)
.env_runners(
num_env_runners=NUM_ENV_RUNNERS,
sample_timeout_s=5.0,
)
.training(
train_batch_size=500,
num_epochs=1,
minibatch_size=500,
)
.reporting(
min_time_s_per_iteration=2,
min_train_timesteps_per_iteration=1,
)
.fault_tolerance(
restart_failed_env_runners=True,
env_runner_health_probe_timeout_s=20.0,
)
)
algo = config.build()
_train(cluster, algo, config, iters=10, preempt_freq=7)
def test_node_failure_no_recovery(cluster):
"""restart=False, ignore=False: dead worker RayErrors propagate and
crash training. Verify the crash happens."""
config = (
PPOConfig()
.environment("CartPole-v1")
.env_runners(
num_env_runners=NUM_ENV_RUNNERS,
sample_timeout_s=5.0,
)
.training(
train_batch_size=500,
num_epochs=1,
minibatch_size=500,
)
.reporting(min_train_timesteps_per_iteration=1)
.fault_tolerance(
restart_failed_env_runners=False,
env_runner_health_probe_timeout_s=20.0,
)
)
algo = config.build()
# _train will crash with an ActorDiedError when dead workers are detected
# (ignore=False, restart=False → errors propagate).
with pytest.raises(ray.exceptions.ActorDiedError, match="actor died unexpectedly"):
_train(cluster, algo, config, iters=10, preempt_freq=3)
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))