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

216 lines
7.5 KiB
Python

import os
import signal
import sys
from typing import List
import pytest
import ray
from ray._common.test_utils import SignalActor, wait_for_condition
@pytest.mark.skipif(
sys.platform == "win32", reason="Windows doesn't handle SIGTERM gracefully."
)
@pytest.mark.parametrize("actor_type", ["asyncio", "threaded"])
def test_ray_get_during_graceful_shutdown(ray_start_regular_shared, actor_type: str):
"""Test that ray.get works as expected when draining tasks during shutdown.
This currently only applies to concurrent actors, because single-threaded actors do
not allow tasks to finish exiting after SIGTERM.
"""
signal_actor = SignalActor.remote()
assert actor_type in {"asyncio", "threaded"}
if actor_type == "asyncio":
@ray.remote
class A:
def exit(self):
os.kill(os.getpid(), signal.SIGTERM)
async def wait_then_get(self, nested_ref: List[ray.ObjectRef]) -> str:
print("Waiting for signal...")
await signal_actor.wait.remote()
print("Got signal, calling ray.get")
return await nested_ref[0]
elif actor_type == "threaded":
@ray.remote(max_concurrency=2)
class A:
def exit(self):
os.kill(os.getpid(), signal.SIGTERM)
def wait_then_get(self, nested_ref: List[ray.ObjectRef]):
print("Waiting for signal...")
ray.get(signal_actor.wait.remote())
print("Got signal, calling ray.get")
return ray.get(nested_ref[0])
# Start the actor and wait for the method to begin executing and then block.
actor = A.remote()
wait_ref = actor.wait_then_get.remote([ray.put("hi")])
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 1)
# SIGTERM the process and then signal the method to unblock.
ray.get(actor.exit.remote())
ray.get(signal_actor.send.remote())
# Check that the method succeeds as expected.
assert ray.get(wait_ref) == "hi"
@pytest.mark.skipif(
sys.platform == "win32",
reason="Graceful shutdown draining is unreliable on Windows.",
)
def test_exit_actor_delivers_inflight_task_results(ray_start_regular_shared):
"""In-flight tasks (already executing) on a threaded actor finish and
deliver their results when exit_actor() is called from another thread,
instead of failing with ActorDiedError.
Asyncio actors are not covered: exit_actor() on an asyncio actor goes
through a different (AsyncioActorExit) path that does not deliver in-flight
results.
"""
signal_actor = SignalActor.remote()
num_tasks = 3
@ray.remote(max_concurrency=num_tasks + 1)
class A:
def exit(self):
ray.actor.exit_actor()
def wait_then_return(self, value):
ray.get(signal_actor.wait.remote())
return value
a = A.remote()
refs = [a.wait_then_return.remote(i) for i in range(num_tasks)]
# Wait until all tasks have started executing and are blocked on the signal.
wait_for_condition(
lambda: ray.get(signal_actor.cur_num_waiters.remote()) == num_tasks
)
# Request the exit, then unblock the in-flight tasks.
exit_ref = a.exit.remote()
ray.get(signal_actor.send.remote())
assert ray.get(refs, timeout=30) == list(range(num_tasks))
# The task that called exit_actor() exits the actor, so its caller observes
# the actor death.
with pytest.raises(ray.exceptions.RayActorError):
ray.get(exit_ref, timeout=30)
@pytest.mark.skipif(
sys.platform == "win32",
reason="Graceful shutdown draining is unreliable on Windows.",
)
def test_exit_actor_delivers_inflight_task_errors(ray_start_regular_shared):
"""An in-flight task on a threaded actor that raises an application
exception while the actor is gracefully exiting delivers that exception,
not ActorDiedError."""
signal_actor = SignalActor.remote()
@ray.remote(max_concurrency=2)
class A:
def exit(self):
ray.actor.exit_actor()
def wait_then_raise(self):
ray.get(signal_actor.wait.remote())
raise ValueError("application error")
a = A.remote()
ref = a.wait_then_raise.remote()
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 1)
exit_ref = a.exit.remote()
ray.get(signal_actor.send.remote())
with pytest.raises(ValueError, match="application error"):
ray.get(ref, timeout=30)
with pytest.raises(ray.exceptions.RayActorError):
ray.get(exit_ref, timeout=30)
@pytest.mark.skipif(
sys.platform == "win32",
reason="Graceful shutdown draining is unreliable on Windows.",
)
def test_exit_actor_with_swallowed_exception(ray_start_regular_shared):
"""A task that calls exit_actor() but swallows the resulting SystemExit
still exits the actor and its return value is discarded (the caller
observes the actor death), while in-flight tasks on other threads still
deliver their results."""
signal_actor = SignalActor.remote()
@ray.remote(max_concurrency=2)
class A:
def exit_and_swallow(self):
try:
ray.actor.exit_actor()
except SystemExit:
pass
return "should never be delivered"
def wait_then_return(self, value):
ray.get(signal_actor.wait.remote())
return value
a = A.remote()
inflight_ref = a.wait_then_return.remote("ok")
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 1)
exit_ref = a.exit_and_swallow.remote()
ray.get(signal_actor.send.remote())
# The in-flight task still delivers its result during the graceful exit.
assert ray.get(inflight_ref, timeout=30) == "ok"
# The exit task's return value is discarded even though the exception was
# swallowed; its caller observes the actor death.
with pytest.raises(ray.exceptions.RayActorError):
ray.get(exit_ref, timeout=30)
@pytest.mark.skipif(
sys.platform == "win32",
reason="Graceful shutdown draining is unreliable on Windows.",
)
def test_exit_actor_fails_queued_tasks(ray_start_regular_shared):
"""Methods queued (submitted but not yet started executing) when
exit_actor() is called fail with ActorDiedError."""
signal_actor = SignalActor.remote()
max_concurrency = 2
@ray.remote(max_concurrency=max_concurrency)
class A:
def block_then_exit(self):
# Hold the concurrency slot until released, then exit the actor.
ray.get(signal_actor.wait.remote())
ray.actor.exit_actor()
def work(self, value):
return value
a = A.remote()
# Fill every concurrency slot with a blocking task so later calls can't
# start and remain queued.
blocking_refs = [a.block_then_exit.remote() for _ in range(max_concurrency)]
wait_for_condition(
lambda: ray.get(signal_actor.cur_num_waiters.remote()) == max_concurrency
)
# These can't start (all slots busy) and are queued when the actor exits.
queued_refs = [a.work.remote(i) for i in range(3)]
# Release the blocking tasks; they call exit_actor() and the actor exits.
ray.get(signal_actor.send.remote())
# Tasks that called exit_actor() and queued (never-started) tasks all fail.
for ref in blocking_refs + queued_refs:
with pytest.raises(ray.exceptions.RayActorError):
ray.get(ref, timeout=30)
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))