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

460 lines
13 KiB
Python

import asyncio
import os
import signal
import sys
import pytest
import ray
from ray._common.test_utils import (
run_string_as_driver,
wait_for_condition,
)
from ray._private.state_api_test_utils import verify_failed_task
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
from ray.util.state import list_nodes, list_tasks, list_workers
def get_worker_by_pid(pid, detail=True):
for w in list_workers(detail=detail):
if w["pid"] == pid:
return w
assert False
@pytest.mark.skipif(
sys.platform == "win32",
reason="Failed on Windows",
)
def test_worker_exit_system_error(ray_start_cluster):
"""
SYSTEM_ERROR
- (tested) Failure from the connection E.g., core worker dead.
- (tested) Unexpected exception or exit with exit_code !=0 on core worker.
- (tested for owner node death) Node died. Currently worker failure detection
upon node death is not detected by Ray. TODO(sang): Fix it.
- (Cannot test) Direct call failure.
"""
cluster = ray_start_cluster
cluster.add_node(num_cpus=4)
ray.init(address=cluster.address)
cluster.add_node(num_cpus=1, resources={"worker": 1})
@ray.remote
class Actor:
def pid(self):
import os
return os.getpid()
def exit(self, exit_code):
sys.exit(exit_code)
"""
Failure from the connection
"""
a = Actor.remote()
pid = ray.get(a.pid.remote())
print(pid)
os.kill(pid, signal.SIGKILL)
def verify_connection_failure():
worker = get_worker_by_pid(pid)
print(worker)
type = worker["exit_type"]
detail = worker["exit_detail"]
# If the worker is killed by SIGKILL, it is highly likely by OOM, so
# the error message should contain information.
return type == "SYSTEM_ERROR" and "OOM" in detail
wait_for_condition(verify_connection_failure)
"""
Unexpected exception or exit with exit_code !=0 on core worker.
"""
a = Actor.remote()
pid = ray.get(a.pid.remote())
with pytest.raises(ray.exceptions.RayActorError):
ray.get(a.exit.options(name="exit").remote(4))
def verify_exit_failure():
worker = get_worker_by_pid(pid)
type = worker["exit_type"]
detail = worker["exit_detail"]
# If the worker is killed by SIGKILL, it is highly likely by OOM, so
# the error message should contain information.
assert type == "SYSTEM_ERROR" and "exit code 4" in detail
# Verify the task failed with the info.
return verify_failed_task(
name="exit", error_type="ACTOR_DIED", error_message="exit code 4"
)
wait_for_condition(verify_exit_failure)
@pytest.mark.skipif(
sys.platform == "win32",
reason="Failed on Windows",
)
def test_worker_exit_intended_user_exit(ray_start_cluster):
"""
INTENDED_USER_EXIT
- (tested) Shutdown driver
- (tested) exit_actor
- (tested) exit(0)
- (tested) Actor kill request
- (tested) Task cancel request
"""
cluster = ray_start_cluster
cluster.add_node(num_cpus=4)
ray.init(address=cluster.address)
cluster.add_node(num_cpus=1, resources={"worker": 1})
driver = """
import ray
import os
ray.init(address="{address}")
print(os.getpid())
ray.shutdown()
""".format(
address=cluster.address
)
a = run_string_as_driver(driver)
driver_pid = int(a.strip().split("\n")[-1].strip())
def verify_worker_exit_by_shutdown():
worker = get_worker_by_pid(driver_pid)
type = worker["exit_type"]
detail = worker["exit_detail"]
assert type == "INTENDED_USER_EXIT" and "ray.shutdown()" in detail
return True
wait_for_condition(verify_worker_exit_by_shutdown)
@ray.remote
class A:
def pid(self):
return os.getpid()
def exit(self):
ray.actor.exit_actor()
def exit_with_exit_code(self):
sys.exit(0)
def sleep_forever(self):
import time
# RIP
time.sleep(999999)
a = A.remote()
pid = ray.get(a.pid.remote())
with pytest.raises(ray.exceptions.RayActorError, match="exit_actor"):
ray.get(a.exit.options(name="exit").remote())
def verify_worker_exit_actor():
worker = get_worker_by_pid(pid)
type = worker["exit_type"]
detail = worker["exit_detail"]
assert type == "INTENDED_USER_EXIT" and "exit_actor" in detail
t = list_tasks(filters=[("name", "=", "exit")])[0]
# exit_actor should be shown as non-task failure.
assert t["state"] == "FINISHED"
return True
wait_for_condition(verify_worker_exit_actor)
a = A.remote()
pid = ray.get(a.pid.remote())
with pytest.raises(ray.exceptions.RayActorError, match="exit code 0"):
ray.get(a.exit_with_exit_code.options(name="exit_with_exit_code").remote())
def verify_exit_code_0():
worker = get_worker_by_pid(pid)
type = worker["exit_type"]
detail = worker["exit_detail"]
assert type == "INTENDED_USER_EXIT" and "exit code 0" in detail
t = list_tasks(filters=[("name", "=", "exit_with_exit_code")])[0]
# exit_actor should be shown as non-task failure.
assert t["state"] == "FINISHED"
return True
wait_for_condition(verify_exit_code_0)
a = A.remote()
pid = ray.get(a.pid.remote())
ray.kill(a)
with pytest.raises(ray.exceptions.RayActorError, match="ray.kill"):
ray.get(a.sleep_forever.options(name="sleep_forever").remote())
def verify_exit_by_ray_kill():
worker = get_worker_by_pid(pid)
type = worker["exit_type"]
detail = worker["exit_detail"]
assert type == "INTENDED_SYSTEM_EXIT" and "ray.kill" in detail
return verify_failed_task(
name="sleep_forever",
error_type="ACTOR_DIED",
error_message="ray.kill",
)
wait_for_condition(verify_exit_by_ray_kill)
@ray.remote
class PidDB:
def __init__(self):
self.pid = None
def record_pid(self, pid):
self.pid = pid
def get_pid(self):
return self.pid
p = PidDB.remote()
@ray.remote
def f():
ray.get(p.record_pid.remote(os.getpid()))
import time
time.sleep(300)
t = f.options(name="cancel-f").remote()
wait_for_condition(lambda: ray.get(p.get_pid.remote()) is not None, timeout=300)
ray.cancel(t, force=True)
pid = ray.get(p.get_pid.remote())
def verify_exit_by_ray_cancel():
worker = get_worker_by_pid(pid)
type = worker["exit_type"]
detail = worker["exit_detail"]
assert type == "INTENDED_USER_EXIT" and "ray.cancel" in detail
return verify_failed_task(
name="cancel-f",
error_type="TASK_CANCELLED",
)
wait_for_condition(verify_exit_by_ray_cancel)
@pytest.mark.skipif(
sys.platform == "win32",
reason="Failed on Windows",
)
def test_worker_exit_intended_system_exit_and_user_error(ray_start_cluster):
"""
INTENDED_SYSTEM_EXIT
- (not tested, hard to test) Unused resource removed
- (tested) Pg removed
- (tested) Idle
USER_ERROR
- (tested) Actor init failed
"""
cluster = ray_start_cluster
cluster.add_node(num_cpus=1)
ray.init(address=cluster.address)
@ray.remote
def f():
return ray.get(g.remote())
@ray.remote
def g():
return os.getpid()
# Start a task that has a blocking call ray.get with g.remote.
# g.remote will borrow the CPU and start a new worker.
# The worker started for g.remote will exit by IDLE timeout.
pid = ray.get(f.remote())
def verify_exit_by_idle_timeout():
worker = get_worker_by_pid(pid)
type = worker["exit_type"]
detail = worker["exit_detail"]
return type == "INTENDED_SYSTEM_EXIT" and "it was idle" in detail
wait_for_condition(verify_exit_by_idle_timeout)
@ray.remote(num_cpus=1)
class A:
def __init__(self):
self.sleeping = False
async def getpid(self):
while not self.sleeping:
await asyncio.sleep(0.1)
return os.getpid()
async def sleep(self):
self.sleeping = True
await asyncio.sleep(9999)
pg = ray.util.placement_group(bundles=[{"CPU": 1}])
a = A.options(
scheduling_strategy=PlacementGroupSchedulingStrategy(placement_group=pg)
).remote()
a.sleep.options(name="sleep").remote()
pid = ray.get(a.getpid.remote())
ray.util.remove_placement_group(pg)
def verify_exit_by_pg_removed():
worker = get_worker_by_pid(pid)
type = worker["exit_type"]
detail = worker["exit_detail"]
assert verify_failed_task(
name="sleep",
error_type="ACTOR_DIED",
error_message=["INTENDED_SYSTEM_EXIT", "placement group was removed"],
)
return (
type == "INTENDED_SYSTEM_EXIT" and "placement group was removed" in detail
)
wait_for_condition(verify_exit_by_pg_removed)
@ray.remote
class PidDB:
def __init__(self):
self.pid = None
def record_pid(self, pid):
self.pid = pid
def get_pid(self):
return self.pid
p = PidDB.remote()
@ray.remote
class FaultyActor:
def __init__(self):
p.record_pid.remote(os.getpid())
raise Exception("exception in the initialization method")
def ready(self):
pass
a = FaultyActor.remote()
wait_for_condition(lambda: ray.get(p.get_pid.remote()) is not None)
pid = ray.get(p.get_pid.remote())
def verify_exit_by_actor_init_failure():
worker = get_worker_by_pid(pid)
type = worker["exit_type"]
detail = worker["exit_detail"]
assert (
type == "USER_ERROR" and "exception in the initialization method" in detail
)
return verify_failed_task(
name="FaultyActor.__init__",
error_type="TASK_EXECUTION_EXCEPTION",
error_message="exception in the initialization method",
)
wait_for_condition(verify_exit_by_actor_init_failure)
@pytest.mark.skipif(
sys.platform == "win32",
reason="Failed on Windows because sigkill doesn't work on Windows",
)
def test_worker_start_end_time(shutdown_only):
ray.init(num_cpus=1)
@ray.remote
class Worker:
def ready(self):
return os.getpid()
# Test normal exit.
worker = Worker.remote()
pid = ray.get(worker.ready.remote())
def verify():
workers = list_workers(
detail=True, filters=[("pid", "=", pid)], raise_on_missing_output=False
)[0]
print(workers)
assert workers["start_time_ms"] > 0
assert workers["end_time_ms"] == 0
return True
wait_for_condition(verify)
ray.kill(worker)
def verify():
workers = list_workers(
detail=True, filters=[("pid", "=", pid)], raise_on_missing_output=False
)[0]
assert workers["start_time_ms"] > 0
assert workers["end_time_ms"] > 0
return True
wait_for_condition(verify)
# Test unexpected exit.
worker = Worker.remote()
pid = ray.get(worker.ready.remote())
os.kill(pid, signal.SIGKILL)
def verify():
workers = list_workers(
detail=True, filters=[("pid", "=", pid)], raise_on_missing_output=False
)[0]
assert workers["start_time_ms"] > 0
assert workers["end_time_ms"] > 0
return True
wait_for_condition(verify)
def test_node_start_end_time(ray_start_cluster):
cluster = ray_start_cluster
# head
cluster.add_node(num_cpus=0)
nodes = list_nodes(detail=True)
head_node_id = nodes[0]["node_id"]
worker_node = cluster.add_node(num_cpus=0)
nodes = list_nodes(detail=True)
worker_node_data = list(
filter(lambda x: x["node_id"] != head_node_id and x["state"] == "ALIVE", nodes)
)[0]
assert worker_node_data["start_time_ms"] > 0
assert worker_node_data["end_time_ms"] == 0
# Test expected exit.
cluster.remove_node(worker_node, allow_graceful=True)
nodes = list_nodes(detail=True)
worker_node_data = list(
filter(lambda x: x["node_id"] != head_node_id and x["state"] == "DEAD", nodes)
)[0]
assert worker_node_data["start_time_ms"] > 0
assert worker_node_data["end_time_ms"] > 0
# Test unexpected exit.
worker_node = cluster.add_node(num_cpus=0)
nodes = list_nodes(detail=True)
worker_node_data = list(
filter(lambda x: x["node_id"] != head_node_id and x["state"] == "ALIVE", nodes)
)[0]
assert worker_node_data["start_time_ms"] > 0
assert worker_node_data["end_time_ms"] == 0
cluster.remove_node(worker_node, allow_graceful=False)
nodes = list_nodes(detail=True)
worker_node_data = list(
filter(lambda x: x["node_id"] != head_node_id and x["state"] == "DEAD", nodes)
)[0]
assert worker_node_data["start_time_ms"] > 0
assert worker_node_data["end_time_ms"] > 0
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))