1326 lines
40 KiB
Python
1326 lines
40 KiB
Python
import asyncio
|
|
import json
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from typing import Any, Tuple
|
|
|
|
import pytest
|
|
from filelock import FileLock
|
|
|
|
import ray
|
|
from ray._common.network_utils import parse_address
|
|
from ray._common.test_utils import (
|
|
run_string_as_driver,
|
|
wait_for_condition,
|
|
)
|
|
from ray._private import ray_constants
|
|
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
|
|
from ray._private.test_utils import (
|
|
external_redis_test_enabled,
|
|
generate_system_config_map,
|
|
persistent_gcs_test_enabled,
|
|
redis_sentinel_replicas,
|
|
wait_for_pid_to_exit,
|
|
)
|
|
from ray._raylet import GcsClient
|
|
from ray.autoscaler.v2.sdk import get_cluster_status
|
|
from ray.job_submission import JobStatus, JobSubmissionClient
|
|
from ray.util.placement_group import placement_group
|
|
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
|
|
from ray.util.state import list_placement_groups
|
|
|
|
import psutil
|
|
|
|
|
|
@ray.remote
|
|
class Increase:
|
|
def method(self, x):
|
|
return x + 2
|
|
|
|
|
|
@ray.remote
|
|
def increase(x):
|
|
return x + 1
|
|
|
|
|
|
def cluster_kill_gcs_wait(cluster):
|
|
head_node = cluster.head_node
|
|
gcs_server_process = head_node.all_processes["gcs_server"][0].process
|
|
gcs_server_pid = gcs_server_process.pid
|
|
# Kill gcs server.
|
|
cluster.head_node.kill_gcs_server()
|
|
# Wait to prevent the gcs server process becoming zombie.
|
|
gcs_server_process.wait()
|
|
wait_for_pid_to_exit(gcs_server_pid, 300)
|
|
|
|
|
|
def test_gcs_server_restart(ray_start_regular_with_external_redis):
|
|
actor1 = Increase.remote()
|
|
result = ray.get(actor1.method.remote(1))
|
|
assert result == 3
|
|
|
|
ray._private.worker._global_node.kill_gcs_server()
|
|
ray._private.worker._global_node.start_gcs_server()
|
|
|
|
actor2 = Increase.remote()
|
|
result = ray.get(actor2.method.remote(2))
|
|
assert result == 4
|
|
|
|
result = ray.get(increase.remote(1))
|
|
assert result == 2
|
|
|
|
# Check whether actor1 is alive or not.
|
|
# NOTE: We can't execute it immediately after gcs restarts
|
|
# because it takes time for the worker to exit.
|
|
result = ray.get(actor1.method.remote(7))
|
|
assert result == 9
|
|
|
|
|
|
@pytest.mark.skip(
|
|
reason="GCS pubsub may lose messages after GCS restarts. Need to "
|
|
"implement re-fetching state in GCS client.",
|
|
)
|
|
# TODO(mwtian): re-enable after fixing https://github.com/ray-project/ray/issues/22340
|
|
def test_gcs_server_restart_during_actor_creation(
|
|
ray_start_regular_with_external_redis,
|
|
):
|
|
ids = []
|
|
# We reduce the number of actors because there are too many actors created
|
|
# and `Too many open files` error will be thrown.
|
|
for i in range(0, 20):
|
|
actor = Increase.remote()
|
|
ids.append(actor.method.remote(1))
|
|
|
|
ray._private.worker._global_node.kill_gcs_server()
|
|
ray._private.worker._global_node.start_gcs_server()
|
|
|
|
# The timeout seems too long.
|
|
# TODO(mwtian): after fixing reconnection in GCS pubsub, try using a lower
|
|
# timeout.
|
|
ready, unready = ray.wait(ids, num_returns=20, timeout=240)
|
|
print("Ready objects is {}.".format(ready))
|
|
print("Unready objects is {}.".format(unready))
|
|
assert len(unready) == 0
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ray_start_cluster_head_with_external_redis",
|
|
[
|
|
generate_system_config_map(
|
|
health_check_initial_delay_ms=0,
|
|
health_check_period_ms=1000,
|
|
health_check_failure_threshold=3,
|
|
enable_autoscaler_v2=True,
|
|
),
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_autoscaler_init(
|
|
ray_start_cluster_head_with_external_redis,
|
|
):
|
|
"""
|
|
Checks that autoscaler initializes properly after GCS restarts.
|
|
"""
|
|
cluster = ray_start_cluster_head_with_external_redis
|
|
cluster.add_node()
|
|
cluster.wait_for_nodes()
|
|
|
|
# Make sure both head and worker node are alive.
|
|
nodes = ray.nodes()
|
|
assert len(nodes) == 2
|
|
assert nodes[0]["alive"] and nodes[1]["alive"]
|
|
|
|
# Restart gcs server process.
|
|
cluster_kill_gcs_wait(cluster)
|
|
cluster.head_node.start_gcs_server()
|
|
|
|
# Fetch the cluster status from the autoscaler and check that it works.
|
|
wait_for_condition(lambda: len(get_cluster_status(cluster.address).idle_nodes) == 2)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ray_start_cluster_head_with_external_redis",
|
|
[
|
|
generate_system_config_map(
|
|
health_check_initial_delay_ms=0,
|
|
health_check_period_ms=1000,
|
|
health_check_failure_threshold=3,
|
|
),
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_node_failure_detector_when_gcs_server_restart(
|
|
ray_start_cluster_head_with_external_redis,
|
|
):
|
|
"""Checks that the node failure detector is correct when gcs server restart.
|
|
|
|
We set the cluster to timeout nodes after 2 seconds of heartbeats. We then
|
|
kill gcs server and remove the worker node and restart gcs server again to
|
|
check that the removed node will die finally.
|
|
"""
|
|
cluster = ray_start_cluster_head_with_external_redis
|
|
worker = cluster.add_node()
|
|
cluster.wait_for_nodes()
|
|
|
|
# Make sure both head and worker node are alive.
|
|
nodes = ray.nodes()
|
|
assert len(nodes) == 2
|
|
assert nodes[0]["alive"] and nodes[1]["alive"]
|
|
|
|
to_be_removed_node = None
|
|
for node in nodes:
|
|
if node["RayletSocketName"] == worker.raylet_socket_name:
|
|
to_be_removed_node = node
|
|
assert to_be_removed_node is not None
|
|
|
|
cluster_kill_gcs_wait(cluster)
|
|
|
|
raylet_process = worker.all_processes["raylet"][0].process
|
|
raylet_pid = raylet_process.pid
|
|
# Remove worker node.
|
|
cluster.remove_node(worker, allow_graceful=False)
|
|
# Wait to prevent the raylet process becoming zombie.
|
|
raylet_process.wait()
|
|
wait_for_pid_to_exit(raylet_pid)
|
|
|
|
# Restart gcs server process.
|
|
cluster.head_node.start_gcs_server()
|
|
|
|
def condition():
|
|
nodes = ray.nodes()
|
|
assert len(nodes) == 2
|
|
for node in nodes:
|
|
if node["NodeID"] == to_be_removed_node["NodeID"]:
|
|
return not node["alive"]
|
|
return False
|
|
|
|
# Wait for the removed node dead.
|
|
wait_for_condition(condition, timeout=10)
|
|
|
|
|
|
def test_actor_raylet_resubscription(ray_start_regular_with_external_redis):
|
|
# stat an actor
|
|
@ray.remote
|
|
class A:
|
|
def ready(self):
|
|
return os.getpid()
|
|
|
|
actor = A.options(name="abc", max_restarts=0).remote()
|
|
pid = ray.get(actor.ready.remote())
|
|
print("actor is ready and kill gcs")
|
|
|
|
ray._private.worker._global_node.kill_gcs_server()
|
|
|
|
print("make actor exit")
|
|
|
|
p = psutil.Process(pid)
|
|
p.kill()
|
|
p.wait(timeout=10)
|
|
|
|
print("start gcs")
|
|
ray._private.worker._global_node.start_gcs_server()
|
|
|
|
print("try actor method again")
|
|
with pytest.raises(ray.exceptions.RayActorError):
|
|
ray.get(actor.ready.remote())
|
|
|
|
|
|
def test_del_actor_after_gcs_server_restart(ray_start_regular_with_external_redis):
|
|
actor = Increase.options(name="abc").remote()
|
|
result = ray.get(actor.method.remote(1))
|
|
assert result == 3
|
|
|
|
ray._private.worker._global_node.kill_gcs_server()
|
|
ray._private.worker._global_node.start_gcs_server()
|
|
|
|
actor_id = actor._actor_id.hex()
|
|
del actor
|
|
|
|
def condition():
|
|
actor_status = ray.util.state.get_actor(id=actor_id)
|
|
if actor_status.state == "DEAD":
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
# Wait for the actor dead.
|
|
wait_for_condition(condition, timeout=10)
|
|
|
|
# If `ReportActorOutOfScope` was successfully called,
|
|
# name should be properly deleted.
|
|
with pytest.raises(ValueError):
|
|
ray.get_actor("abc")
|
|
|
|
|
|
def test_raylet_resubscribe_to_worker_death(
|
|
tmp_path, ray_start_regular_with_external_redis
|
|
):
|
|
"""Verify that the Raylet resubscribes to worker death notifications on GCS restart."""
|
|
|
|
child_task_pid_path = tmp_path / "blocking_child.pid"
|
|
|
|
@ray.remote(num_cpus=0)
|
|
def child():
|
|
print("Child worker ID:", ray.get_runtime_context().get_worker_id())
|
|
child_task_pid_path.write_text(str(os.getpid()))
|
|
while True:
|
|
time.sleep(0.1)
|
|
print("Child still running...")
|
|
|
|
@ray.remote(num_cpus=0)
|
|
def parent() -> Tuple[int, int, ray.ObjectRef]:
|
|
print("Parent worker ID:", ray.get_runtime_context().get_worker_id())
|
|
child_obj_ref = child.remote()
|
|
|
|
# Wait for the child to be running and report back its PID.
|
|
wait_for_condition(lambda: child_task_pid_path.exists(), timeout=10)
|
|
child_pid = int(child_task_pid_path.read_text())
|
|
return os.getpid(), child_pid, child_obj_ref
|
|
|
|
parent_pid, child_pid, child_obj_ref = ray.get(parent.remote())
|
|
print(f"Parent PID: {parent_pid}, child PID: {child_pid}")
|
|
assert parent_pid != child_pid
|
|
|
|
# Kill and restart the GCS.
|
|
ray._private.worker._global_node.kill_gcs_server()
|
|
ray._private.worker._global_node.start_gcs_server()
|
|
|
|
# Schedule an actor to ensure that the GCS is back alive and the Raylet is
|
|
# reconnected to it.
|
|
# TODO(iycheng): this shouldn't be necessary, but the current resubscription
|
|
# implementation can lose the worker failure message because we don't ask for
|
|
# the snapshot of worker statuses.
|
|
@ray.remote
|
|
class A:
|
|
pass
|
|
|
|
ray.get(A.remote().__ray_ready__.remote())
|
|
|
|
# Kill the parent task and verify that the child task is killed due to fate sharing
|
|
# with its parent.
|
|
print("Killing parent process.")
|
|
p = psutil.Process(parent_pid)
|
|
p.kill()
|
|
p.wait()
|
|
print("Parent process exited.")
|
|
|
|
# The child task should exit.
|
|
wait_for_pid_to_exit(child_pid, 20)
|
|
with pytest.raises(ray.exceptions.OwnerDiedError):
|
|
ray.get(child_obj_ref)
|
|
|
|
|
|
def test_core_worker_resubscription(tmp_path, ray_start_regular_with_external_redis):
|
|
# This test is to ensure core worker will resubscribe to GCS after GCS
|
|
# restarts.
|
|
lock_file = str(tmp_path / "lock")
|
|
lock = FileLock(lock_file)
|
|
lock.acquire()
|
|
|
|
@ray.remote
|
|
class Actor:
|
|
def __init__(self):
|
|
lock = FileLock(lock_file)
|
|
lock.acquire()
|
|
|
|
def ready(self):
|
|
return
|
|
|
|
a = Actor.remote()
|
|
r = a.ready.remote()
|
|
# Actor is not ready before GCS is down.
|
|
ray._private.worker._global_node.kill_gcs_server()
|
|
|
|
lock.release()
|
|
# Actor is ready after GCS starts
|
|
ray._private.worker._global_node.start_gcs_server()
|
|
# Test the resubscribe works: if not, it'll timeout because worker
|
|
# will think the actor is not ready.
|
|
ray.get(r, timeout=5)
|
|
|
|
|
|
def test_detached_actor_restarts(ray_start_regular_with_external_redis):
|
|
# Detached actors are owned by GCS. This test is to ensure detached actors
|
|
# can restart even GCS restarts.
|
|
|
|
@ray.remote
|
|
class A:
|
|
def ready(self):
|
|
return os.getpid()
|
|
|
|
a = A.options(name="a", lifetime="detached", max_restarts=-1).remote()
|
|
|
|
pid = ray.get(a.ready.remote())
|
|
ray._private.worker._global_node.kill_gcs_server()
|
|
p = psutil.Process(pid)
|
|
p.kill()
|
|
ray._private.worker._global_node.start_gcs_server()
|
|
|
|
while True:
|
|
try:
|
|
assert ray.get(a.ready.remote()) != pid
|
|
break
|
|
except ray.exceptions.RayActorError:
|
|
continue
|
|
|
|
|
|
def test_gcs_client_reconnect(ray_start_regular_with_external_redis):
|
|
"""Tests reconnect behavior on GCS restart for sync and asyncio clients."""
|
|
gcs_client = ray._private.worker.global_worker.gcs_client
|
|
|
|
gcs_client.internal_kv_put(b"a", b"b", True, None)
|
|
assert gcs_client.internal_kv_get(b"a", None) == b"b"
|
|
|
|
def _get(use_asyncio: bool) -> bytes:
|
|
if use_asyncio:
|
|
|
|
async def _get_async() -> bytes:
|
|
return await gcs_client.async_internal_kv_get(b"a", None)
|
|
|
|
result = asyncio.run(_get_async())
|
|
else:
|
|
result = gcs_client.internal_kv_get(b"a", None)
|
|
|
|
return result
|
|
|
|
# Kill the GCS, start an internal KV GET request, and check that it succeeds once
|
|
# the GCS is restarted.
|
|
ray._private.worker._global_node.kill_gcs_server()
|
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
sync_future = executor.submit(_get, False)
|
|
asyncio_future = executor.submit(_get, True)
|
|
|
|
ray._private.worker._global_node.start_gcs_server()
|
|
|
|
assert sync_future.result() == b"b"
|
|
assert asyncio_future.result() == b"b"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ray_start_regular_with_external_redis",
|
|
[
|
|
{
|
|
**generate_system_config_map(
|
|
gcs_rpc_server_reconnect_timeout_s=3600,
|
|
),
|
|
"namespace": "actor",
|
|
}
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_actor_workloads(ray_start_regular_with_external_redis):
|
|
"""Tests actor creation and task execution while the GCS is down."""
|
|
|
|
@ray.remote(num_cpus=0)
|
|
class Counter:
|
|
def noop(self, v: Any) -> Any:
|
|
return v
|
|
|
|
# Start two actors, one normal and one detached, and wait for them to be running.
|
|
counter_1 = Counter.remote()
|
|
r = ray.get(counter_1.noop.remote(1))
|
|
assert r == 1
|
|
|
|
detached_counter = Counter.options(
|
|
lifetime="detached", name="detached_counter"
|
|
).remote()
|
|
assert ray.get(detached_counter.noop.remote("detached")) == "detached"
|
|
|
|
# Kill the GCS.
|
|
ray._private.worker._global_node.kill_gcs_server()
|
|
|
|
# Tasks to the existing actors should continue to work.
|
|
assert ray.get(counter_1.noop.remote(1)) == 1
|
|
|
|
# Create a new actor. Making actor calls shouldn't error and they should
|
|
# succeed after the GCS comes back up and starts the actor.
|
|
counter_2 = Counter.remote()
|
|
counter_2_alive_ref = counter_2.noop.remote(2)
|
|
|
|
ready, _ = ray.wait([counter_2_alive_ref], timeout=0.1)
|
|
assert len(ready) == 0
|
|
|
|
# Restart the GCS and check that the actor is started and task succeeds.
|
|
ray._private.worker._global_node.start_gcs_server()
|
|
|
|
assert ray.get(counter_2_alive_ref) == 2
|
|
|
|
# Check that the existing actors continue to function, including the detached
|
|
# actor being called from another driver.
|
|
assert ray.get(counter_1.noop.remote(1)) == 1
|
|
return
|
|
run_string_as_driver(
|
|
"""
|
|
import ray
|
|
ray.init("auto", namespace="actor")
|
|
detached_counter = ray.get_actor("detached_counter")
|
|
assert ray.get(detached_counter.noop.remote("detached")) == "detached"
|
|
"""
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ray_start_regular_with_external_redis",
|
|
[
|
|
{
|
|
**generate_system_config_map(
|
|
gcs_rpc_server_reconnect_timeout_s=3600,
|
|
),
|
|
"namespace": "actor",
|
|
}
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_pg_actor_workloads(ray_start_regular_with_external_redis):
|
|
bundle1 = {"CPU": 1}
|
|
pg = placement_group([bundle1], strategy="STRICT_PACK")
|
|
|
|
ray.get(pg.ready())
|
|
|
|
@ray.remote
|
|
class Counter:
|
|
def r(self, v):
|
|
return v
|
|
|
|
def pid(self):
|
|
return os.getpid()
|
|
|
|
c = Counter.options(
|
|
scheduling_strategy=PlacementGroupSchedulingStrategy(placement_group=pg)
|
|
).remote()
|
|
r = ray.get(c.r.remote(10))
|
|
assert r == 10
|
|
|
|
print("GCS is killed")
|
|
pid = ray.get(c.pid.remote())
|
|
ray.worker._global_node.kill_gcs_server()
|
|
|
|
assert ray.get(c.r.remote(10)) == 10
|
|
|
|
ray.worker._global_node.start_gcs_server()
|
|
|
|
for _ in range(100):
|
|
assert pid == ray.get(c.pid.remote())
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ray_start_regular_with_external_redis",
|
|
[
|
|
generate_system_config_map(
|
|
gcs_server_request_timeout_seconds=10,
|
|
)
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_get_actor_when_gcs_is_down(ray_start_regular_with_external_redis):
|
|
@ray.remote
|
|
def create_actor():
|
|
@ray.remote
|
|
class A:
|
|
def pid(self):
|
|
return os.getpid()
|
|
|
|
a = A.options(lifetime="detached", name="A").remote()
|
|
ray.get(a.pid.remote())
|
|
|
|
ray.get(create_actor.remote())
|
|
|
|
ray._private.worker._global_node.kill_gcs_server()
|
|
|
|
with pytest.raises(ray.exceptions.GetTimeoutError):
|
|
ray.get_actor("A")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ray_start_regular_with_external_redis",
|
|
[
|
|
generate_system_config_map(
|
|
gcs_server_request_timeout_seconds=10,
|
|
)
|
|
],
|
|
indirect=True,
|
|
)
|
|
@pytest.mark.skip(
|
|
reason="python publisher and subscriber doesn't handle gcs server failover"
|
|
)
|
|
def test_publish_and_subscribe_error_info(ray_start_regular_with_external_redis):
|
|
address_info = ray_start_regular_with_external_redis
|
|
gcs_server_addr = address_info["gcs_address"]
|
|
|
|
subscriber = ray._raylet.GcsErrorSubscriber(address=gcs_server_addr)
|
|
subscriber.subscribe()
|
|
|
|
gcs_client = ray._raylet.GcsClient(address=gcs_server_addr)
|
|
print("sending error message 1")
|
|
gcs_client.publish_error(b"aaa_id", "", "test error message 1")
|
|
|
|
ray._private.worker._global_node.kill_gcs_server()
|
|
ray._private.worker._global_node.start_gcs_server()
|
|
|
|
print("sending error message 2")
|
|
gcs_client.publish_error(b"bbb_id", "", "test error message 2")
|
|
print("done")
|
|
|
|
(key_id, err) = subscriber.poll()
|
|
assert key_id == b"bbb_id"
|
|
assert err["error_message"] == "test error message 2"
|
|
|
|
subscriber.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def redis_replicas(monkeypatch):
|
|
monkeypatch.setenv("TEST_EXTERNAL_REDIS_REPLICAS", "3")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ray_start_cluster_head_with_external_redis",
|
|
[
|
|
generate_system_config_map(
|
|
gcs_server_request_timeout_seconds=10,
|
|
redis_db_connect_retries=50,
|
|
)
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_redis_failureover(redis_replicas, ray_start_cluster_head_with_external_redis):
|
|
"""This test is to cover ray cluster's behavior when Redis master failed.
|
|
The management of the Redis cluster is not covered by Ray, but Ray should handle
|
|
the failure correctly.
|
|
For this test we ensure:
|
|
- When Redis master failed, Ray should crash (TODO: make ray automatically switch to
|
|
new master).
|
|
- After Redis recovered, Ray should be able to use the new Master.
|
|
- When the master becomes slaves, Ray should crash.
|
|
"""
|
|
cluster = ray_start_cluster_head_with_external_redis
|
|
import redis
|
|
|
|
redis_addr = os.environ.get("RAY_REDIS_ADDRESS")
|
|
ip, port = parse_address(redis_addr)
|
|
redis_cli = redis.Redis(ip, port)
|
|
|
|
def get_connected_nodes():
|
|
return [
|
|
(k, v) for (k, v) in redis_cli.cluster("nodes").items() if v["connected"]
|
|
]
|
|
|
|
wait_for_condition(
|
|
lambda: len(get_connected_nodes())
|
|
== int(os.environ.get("TEST_EXTERNAL_REDIS_REPLICAS"))
|
|
)
|
|
nodes = redis_cli.cluster("nodes")
|
|
leader_cli = None
|
|
follower_cli = []
|
|
for addr in nodes:
|
|
ip, port = parse_address(addr)
|
|
cli = redis.Redis(ip, port)
|
|
meta = nodes[addr]
|
|
flags = meta["flags"].split(",")
|
|
if "master" in flags:
|
|
leader_cli = cli
|
|
print("LEADER", addr, redis_addr)
|
|
else:
|
|
follower_cli.append(cli)
|
|
|
|
leader_pid = leader_cli.info()["process_id"]
|
|
|
|
@ray.remote(max_restarts=-1)
|
|
class Counter:
|
|
def r(self, v):
|
|
return v
|
|
|
|
def pid(self):
|
|
return os.getpid()
|
|
|
|
c = Counter.options(name="c", namespace="test", lifetime="detached").remote()
|
|
c_pid = ray.get(c.pid.remote())
|
|
c_process = psutil.Process(pid=c_pid)
|
|
r = ray.get(c.r.remote(10))
|
|
assert r == 10
|
|
|
|
head_node = cluster.head_node
|
|
gcs_server_process = head_node.all_processes["gcs_server"][0].process
|
|
gcs_server_pid = gcs_server_process.pid
|
|
|
|
# Wait until all data is updated in the replica
|
|
leader_cli.set("_hole", "0")
|
|
wait_for_condition(lambda: all([b"_hole" in f.keys("*") for f in follower_cli]))
|
|
|
|
# Now kill pid
|
|
leader_process = psutil.Process(pid=leader_pid)
|
|
leader_process.kill()
|
|
|
|
print(">>> Waiting gcs server to exit", gcs_server_pid)
|
|
wait_for_pid_to_exit(gcs_server_pid, 1000)
|
|
print("GCS killed")
|
|
|
|
follower_cli[0].cluster("failover", "takeover")
|
|
wait_for_condition(
|
|
lambda: len(get_connected_nodes())
|
|
== int(os.environ.get("TEST_EXTERNAL_REDIS_REPLICAS")) - 1
|
|
)
|
|
|
|
# Kill Counter actor. It should restart after GCS is back
|
|
c_process.kill()
|
|
# Cleanup the in memory data and then start gcs
|
|
cluster.head_node.kill_gcs_server(False)
|
|
|
|
print("Start gcs")
|
|
cluster.head_node.start_gcs_server()
|
|
|
|
assert len(ray.nodes()) == 1
|
|
assert ray.nodes()[0]["alive"]
|
|
|
|
driver_script = f"""
|
|
import ray
|
|
ray.init('{cluster.address}')
|
|
@ray.remote
|
|
def f():
|
|
return 10
|
|
assert ray.get(f.remote()) == 10
|
|
|
|
c = ray.get_actor("c", namespace="test")
|
|
v = ray.get(c.r.remote(10))
|
|
assert v == 10
|
|
print("DONE")
|
|
"""
|
|
|
|
# Make sure the cluster is usable
|
|
wait_for_condition(lambda: "DONE" in run_string_as_driver(driver_script))
|
|
|
|
# Now make follower_cli[0] become replica
|
|
# and promote follower_cli[1] as leader
|
|
follower_cli[1].cluster("failover", "takeover")
|
|
head_node = cluster.head_node
|
|
gcs_server_process = head_node.all_processes["gcs_server"][0].process
|
|
gcs_server_pid = gcs_server_process.pid
|
|
# GCS should exit in this case
|
|
print(">>> Waiting gcs server to exit", gcs_server_pid)
|
|
wait_for_pid_to_exit(gcs_server_pid, 10000)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ray_start_cluster_head_with_external_redis_sentinel",
|
|
[
|
|
generate_system_config_map(
|
|
gcs_server_request_timeout_seconds=10,
|
|
redis_db_connect_retries=50,
|
|
)
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_redis_with_sentinel_failureover(
|
|
ray_start_cluster_head_with_external_redis_sentinel,
|
|
):
|
|
"""This test is to cover ray cluster's behavior with Redis sentinel.
|
|
The expectation is Redis sentinel should manage failover
|
|
automatically, and GCS can continue talking to the same address
|
|
without any human intervention on Redis.
|
|
For this test we ensure:
|
|
- When Redis master failed, Ray should crash (TODO: GCS should
|
|
autommatically try re-connect to sentinel).
|
|
- When restart Ray, it should continue talking to sentinel, which
|
|
should return information about new master.
|
|
"""
|
|
cluster = ray_start_cluster_head_with_external_redis_sentinel
|
|
import redis
|
|
|
|
redis_addr = os.environ.get("RAY_REDIS_ADDRESS")
|
|
ip, port = parse_address(redis_addr)
|
|
redis_cli = redis.Redis(ip, port)
|
|
print(redis_cli.info("sentinel"))
|
|
redis_name = redis_cli.info("sentinel")["master0"]["name"]
|
|
|
|
def get_sentinel_nodes():
|
|
leader_address = (
|
|
redis_cli.sentinel_master(redis_name)["ip"],
|
|
redis_cli.sentinel_master(redis_name)["port"],
|
|
)
|
|
follower_addresses = [
|
|
(x["ip"], x["port"]) for x in redis_cli.sentinel_slaves(redis_name)
|
|
]
|
|
return [leader_address] + follower_addresses
|
|
|
|
wait_for_condition(lambda: len(get_sentinel_nodes()) == redis_sentinel_replicas())
|
|
|
|
@ray.remote(max_restarts=-1)
|
|
class Counter:
|
|
def r(self, v):
|
|
return v
|
|
|
|
def pid(self):
|
|
return os.getpid()
|
|
|
|
c = Counter.options(name="c", namespace="test", lifetime="detached").remote()
|
|
c_pid = ray.get(c.pid.remote())
|
|
c_process = psutil.Process(pid=c_pid)
|
|
r = ray.get(c.r.remote(10))
|
|
assert r == 10
|
|
|
|
head_node = cluster.head_node
|
|
gcs_server_process = head_node.all_processes["gcs_server"][0].process
|
|
gcs_server_pid = gcs_server_process.pid
|
|
|
|
leader_cli = redis.Redis(*get_sentinel_nodes()[0])
|
|
leader_pid = leader_cli.info()["process_id"]
|
|
follower_cli = [redis.Redis(*x) for x in get_sentinel_nodes()[1:]]
|
|
|
|
# Wait until all data is updated in the replica
|
|
leader_cli.set("_hole", "0")
|
|
wait_for_condition(lambda: all([b"_hole" in f.keys("*") for f in follower_cli]))
|
|
current_leader = get_sentinel_nodes()[0]
|
|
|
|
# Now kill pid
|
|
leader_process = psutil.Process(pid=leader_pid)
|
|
leader_process.kill()
|
|
|
|
print(">>> Waiting gcs server to exit", gcs_server_pid)
|
|
wait_for_pid_to_exit(gcs_server_pid, 1000)
|
|
print("GCS killed")
|
|
|
|
wait_for_condition(lambda: current_leader != get_sentinel_nodes()[0])
|
|
|
|
# Kill Counter actor. It should restart after GCS is back
|
|
c_process.kill()
|
|
# Cleanup the in memory data and then start gcs
|
|
cluster.head_node.kill_gcs_server(False)
|
|
|
|
print("Start gcs")
|
|
cluster.head_node.start_gcs_server()
|
|
|
|
assert len(ray.nodes()) == 1
|
|
assert ray.nodes()[0]["alive"]
|
|
|
|
driver_script = f"""
|
|
import ray
|
|
ray.init('{cluster.address}')
|
|
@ray.remote
|
|
def f():
|
|
return 10
|
|
assert ray.get(f.remote()) == 10
|
|
|
|
c = ray.get_actor("c", namespace="test")
|
|
v = ray.get(c.r.remote(10))
|
|
assert v == 10
|
|
print("DONE")
|
|
"""
|
|
|
|
# Make sure the cluster is usable
|
|
wait_for_condition(lambda: "DONE" in run_string_as_driver(driver_script))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ray_start_regular",
|
|
[
|
|
generate_system_config_map(
|
|
enable_cluster_auth=True,
|
|
raylet_liveness_self_check_interval_ms=5000,
|
|
)
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_raylet_fate_sharing(ray_start_regular):
|
|
# Kill GCS and check that raylets kill themselves when not backed by Redis,
|
|
# and stay alive when backed by Redis.
|
|
# Raylets should kill themselves due to cluster ID mismatch in the
|
|
# non-persisted case.
|
|
raylet_proc = ray._private.worker._global_node.all_processes[
|
|
ray_constants.PROCESS_TYPE_RAYLET
|
|
][0].process
|
|
|
|
def check_raylet_healthy():
|
|
return raylet_proc.poll() is None
|
|
|
|
wait_for_condition(lambda: check_raylet_healthy())
|
|
for i in range(10):
|
|
assert check_raylet_healthy()
|
|
|
|
ray._private.worker._global_node.kill_gcs_server()
|
|
ray._private.worker._global_node.start_gcs_server()
|
|
|
|
if not persistent_gcs_test_enabled():
|
|
# Waiting for raylet to become unhealthy
|
|
wait_for_condition(lambda: not check_raylet_healthy())
|
|
else:
|
|
# Waiting for raylet to stay healthy
|
|
for i in range(10):
|
|
assert check_raylet_healthy()
|
|
|
|
|
|
def test_session_name(ray_start_cluster):
|
|
# Kill GCS and check that raylets kill themselves when not backed by Redis,
|
|
# and stay alive when backed by Redis.
|
|
# Raylets should kill themselves due to cluster ID mismatch in the
|
|
# non-persisted case.
|
|
cluster = ray_start_cluster
|
|
cluster.add_node()
|
|
cluster.wait_for_nodes()
|
|
|
|
head_node = cluster.head_node
|
|
session_dir = head_node.get_session_dir_path()
|
|
|
|
gcs_server_process = head_node.all_processes["gcs_server"][0].process
|
|
gcs_server_pid = gcs_server_process.pid
|
|
cluster.remove_node(head_node, allow_graceful=False)
|
|
# Wait to prevent the gcs server process becoming zombie.
|
|
gcs_server_process.wait()
|
|
wait_for_pid_to_exit(gcs_server_pid, 1000)
|
|
|
|
# Add head node back
|
|
cluster.add_node()
|
|
head_node = cluster.head_node
|
|
new_session_dir = head_node.get_session_dir_path()
|
|
|
|
if not persistent_gcs_test_enabled():
|
|
assert session_dir != new_session_dir
|
|
else:
|
|
assert session_dir == new_session_dir
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ray_start_regular_with_external_redis",
|
|
[
|
|
generate_system_config_map(
|
|
gcs_server_request_timeout_seconds=10,
|
|
raylet_liveness_self_check_interval_ms=3000,
|
|
)
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_redis_data_loss_no_leak(ray_start_regular_with_external_redis):
|
|
@ray.remote
|
|
def create_actor():
|
|
@ray.remote
|
|
class A:
|
|
def pid(self):
|
|
return os.getpid()
|
|
|
|
a = A.options(lifetime="detached", name="A").remote()
|
|
ray.get(a.pid.remote())
|
|
|
|
ray.get(create_actor.remote())
|
|
|
|
ray._private.worker._global_node.kill_gcs_server()
|
|
# Delete redis
|
|
redis_addr = os.environ.get("RAY_REDIS_ADDRESS")
|
|
import redis
|
|
|
|
ip, port = parse_address(redis_addr)
|
|
cli = redis.Redis(ip, port)
|
|
cli.flushall()
|
|
raylet_proc = ray._private.worker._global_node.all_processes[
|
|
ray_constants.PROCESS_TYPE_RAYLET
|
|
][0].process
|
|
|
|
def check_raylet_healthy():
|
|
return raylet_proc.poll() is None
|
|
|
|
wait_for_condition(lambda: check_raylet_healthy())
|
|
|
|
# Start GCS
|
|
ray._private.worker._global_node.start_gcs_server()
|
|
|
|
# Waiting for raylet to become unhealthy
|
|
wait_for_condition(lambda: not check_raylet_healthy())
|
|
|
|
|
|
def test_redis_logs(external_redis):
|
|
try:
|
|
process = subprocess.Popen(
|
|
["ray", "start", "--head"], stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
|
)
|
|
stdout, stderr = process.communicate(timeout=30)
|
|
print(stdout.decode())
|
|
print(stderr.decode())
|
|
assert "redis_context.cc" not in stderr.decode()
|
|
assert "redis_context.cc" not in stdout.decode()
|
|
assert "Resolve Redis address" not in stderr.decode()
|
|
assert "Resolve Redis address" not in stdout.decode()
|
|
# assert "redis_context.cc" not in result.output
|
|
finally:
|
|
from click.testing import CliRunner
|
|
|
|
import ray.scripts.scripts as scripts
|
|
|
|
runner = CliRunner(env={"RAY_USAGE_STATS_PROMPT_ENABLED": "0"})
|
|
runner.invoke(
|
|
scripts.stop,
|
|
[
|
|
"--force",
|
|
],
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ray_start_cluster_head_with_external_redis",
|
|
[
|
|
generate_system_config_map(
|
|
gcs_rpc_server_reconnect_timeout_s=2,
|
|
)
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_job_finished_after_head_node_restart(
|
|
ray_start_cluster_head_with_external_redis,
|
|
):
|
|
cluster = ray_start_cluster_head_with_external_redis
|
|
head_node = cluster.head_node
|
|
|
|
# submit job
|
|
client = JobSubmissionClient(head_node.address)
|
|
submission_id = client.submit_job(
|
|
entrypoint="python -c 'import ray; ray.init(); print(ray.cluster_resources()); \
|
|
import time; time.time.sleep(1000)'"
|
|
)
|
|
|
|
def get_job_info(submission_id):
|
|
gcs_client = GcsClient(cluster.address)
|
|
all_job_info = gcs_client.get_all_job_info(job_or_submission_id=submission_id)
|
|
|
|
return list(
|
|
filter(
|
|
lambda job_info: "job_submission_id" in job_info.config.metadata
|
|
and job_info.config.metadata["job_submission_id"] == submission_id,
|
|
list(all_job_info.values()),
|
|
)
|
|
)
|
|
|
|
def _check_job_running(submission_id: str) -> bool:
|
|
job_infos = get_job_info(submission_id)
|
|
if len(job_infos) == 0:
|
|
return False
|
|
job_info = job_infos[0].job_info
|
|
return job_info.status == JobStatus.RUNNING
|
|
|
|
# wait until job info is written in redis
|
|
wait_for_condition(_check_job_running, submission_id=submission_id, timeout=10)
|
|
|
|
# kill head node
|
|
ray.shutdown()
|
|
gcs_server_process = head_node.all_processes["gcs_server"][0].process
|
|
gcs_server_pid = gcs_server_process.pid
|
|
|
|
cluster.remove_node(head_node)
|
|
|
|
# Wait to prevent the gcs server process becoming zombie.
|
|
gcs_server_process.wait()
|
|
wait_for_pid_to_exit(gcs_server_pid, 1000)
|
|
|
|
# restart head node
|
|
cluster.add_node()
|
|
ray.init(cluster.address)
|
|
|
|
# verify if job is finished, which marked is_dead
|
|
def _check_job_is_dead(submission_id: str) -> bool:
|
|
job_infos = get_job_info(submission_id)
|
|
if len(job_infos) == 0:
|
|
return False
|
|
job_info = job_infos[0]
|
|
return job_info.is_dead
|
|
|
|
wait_for_condition(_check_job_is_dead, submission_id=submission_id, timeout=10)
|
|
|
|
|
|
def raises_exception(exc_type, f):
|
|
try:
|
|
f()
|
|
except exc_type:
|
|
return True
|
|
return False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"case",
|
|
[
|
|
{"kill_job": False, "kill_actor": False, "expect_alive": "all"},
|
|
{"kill_job": True, "kill_actor": False, "expect_alive": "AB"},
|
|
{"kill_job": True, "kill_actor": True, "expect_alive": "none"},
|
|
{"kill_job": False, "kill_actor": True, "expect_alive": "regular"},
|
|
],
|
|
)
|
|
@pytest.mark.skipif(not external_redis_test_enabled(), reason="Only valid in redis env")
|
|
def test_gcs_server_restart_destroys_out_of_scope_actors(
|
|
external_redis, ray_start_cluster, case
|
|
):
|
|
"""
|
|
If an actor goes out of scope *when GCS is down*, when GCS restarts, the actor
|
|
should be destroyed by GCS in its restarting.
|
|
|
|
Set up: in a job,
|
|
- create a regular actor
|
|
- create a detached actor A, which creates a child actor B
|
|
|
|
Situations:
|
|
|
|
Case 0: nobody died
|
|
all should be alive
|
|
|
|
Case 1: before GCS is down, job died
|
|
regular actor should be dead, A and B should still be alive
|
|
|
|
Case 2: before GCS is down, job died; during GCS is down, A died
|
|
all should be dead
|
|
|
|
Case 3: during GCS is down, A died
|
|
regular actor should be alive, A and B should be dead
|
|
"""
|
|
|
|
cluster = ray_start_cluster
|
|
cluster.add_node(num_cpus=4)
|
|
cluster.wait_for_nodes()
|
|
ray.init(address=cluster.address)
|
|
|
|
@ray.remote
|
|
class A:
|
|
def __init__(self):
|
|
self.children = []
|
|
|
|
def getpid(self):
|
|
return os.getpid()
|
|
|
|
def spawn(self, name, namespace):
|
|
child = A.options(name=name, namespace=namespace).remote()
|
|
self.children.append(child)
|
|
return child
|
|
|
|
regular = A.options(name="regular", namespace="ns").remote()
|
|
detached = A.options(lifetime="detached", name="parent", namespace="ns").remote()
|
|
child = ray.get(detached.spawn.remote("child", "ns"))
|
|
|
|
regular_pid = ray.get(regular.getpid.remote())
|
|
detached_pid = ray.get(detached.getpid.remote())
|
|
child_pid = ray.get(child.getpid.remote())
|
|
|
|
print(f"regular actor ID: {regular._actor_id}, pid: {regular_pid}")
|
|
print(f"detached actor ID: {detached._actor_id}, pid: {detached_pid}")
|
|
print(f"child actor ID: {child._actor_id}, pid: {child_pid}")
|
|
|
|
if case["kill_job"]:
|
|
# kill the job and restart.
|
|
ray.shutdown()
|
|
ray.init(address=cluster.address)
|
|
|
|
cluster_kill_gcs_wait(cluster)
|
|
|
|
# When GCS is down...
|
|
if case["kill_actor"]:
|
|
os.kill(detached_pid, signal.SIGKILL)
|
|
|
|
cluster.head_node.start_gcs_server()
|
|
print("GCS restarted")
|
|
|
|
if case["expect_alive"] == "all":
|
|
regular2 = ray.get_actor("regular", namespace="ns")
|
|
detached2 = ray.get_actor("parent", namespace="ns")
|
|
child2 = ray.get_actor("child", namespace="ns")
|
|
|
|
assert ray.get(regular2.getpid.remote()) == regular_pid
|
|
assert ray.get(detached2.getpid.remote()) == detached_pid
|
|
assert ray.get(child2.getpid.remote()) == child_pid
|
|
elif case["expect_alive"] == "AB":
|
|
with pytest.raises(ValueError):
|
|
ray.get_actor("regular", namespace="ns")
|
|
detached2 = ray.get_actor("parent", namespace="ns")
|
|
child2 = ray.get_actor("child", namespace="ns")
|
|
assert ray.get(detached2.getpid.remote()) == detached_pid
|
|
assert ray.get(child2.getpid.remote()) == child_pid
|
|
elif case["expect_alive"] == "none":
|
|
|
|
with pytest.raises(ValueError):
|
|
ray.get_actor("regular", namespace="ns")
|
|
|
|
# It took some time for raylet to report worker failure.
|
|
wait_for_condition(
|
|
lambda: raises_exception(
|
|
ValueError, lambda: ray.get_actor("parent", namespace="ns")
|
|
)
|
|
)
|
|
wait_for_condition(
|
|
lambda: raises_exception(
|
|
ValueError, lambda: ray.get_actor("child", namespace="ns")
|
|
)
|
|
)
|
|
elif case["expect_alive"] == "regular":
|
|
regular2 = ray.get_actor("regular", namespace="ns")
|
|
wait_for_condition(
|
|
lambda: raises_exception(
|
|
ValueError, lambda: ray.get_actor("parent", namespace="ns")
|
|
)
|
|
)
|
|
wait_for_condition(
|
|
lambda: raises_exception(
|
|
ValueError, lambda: ray.get_actor("child", namespace="ns")
|
|
)
|
|
)
|
|
assert ray.get(regular2.getpid.remote()) == regular_pid
|
|
else:
|
|
raise ValueError(f"Unknown case: {case}")
|
|
|
|
|
|
MyPlugin = "MyPlugin"
|
|
MY_PLUGIN_CLASS_PATH = "ray.tests.test_gcs_fault_tolerance.HangPlugin"
|
|
|
|
|
|
class HangPlugin(RuntimeEnvPlugin):
|
|
name = MyPlugin
|
|
|
|
async def create(
|
|
self,
|
|
uri,
|
|
runtime_env,
|
|
ctx,
|
|
logger, # noqa: F821
|
|
) -> float:
|
|
signal_path = runtime_env[self.name].get("signal_path")
|
|
if signal_path is not None:
|
|
with open(signal_path, "w") as f:
|
|
f.write("hello world!")
|
|
f.flush()
|
|
|
|
await asyncio.time.sleep(1000)
|
|
|
|
@staticmethod
|
|
def validate(runtime_env_dict: dict) -> str:
|
|
return 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ray_start_regular_with_external_redis",
|
|
[
|
|
generate_system_config_map(
|
|
testing_asio_delay_us="NodeManagerService.grpc_server.RemovePlacementGroupBundles=500000000:500000000", # noqa: E501
|
|
),
|
|
],
|
|
indirect=True,
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"set_runtime_env_plugins",
|
|
[
|
|
'[{"class":"' + MY_PLUGIN_CLASS_PATH + '"}]',
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_pg_removal_after_gcs_restarts(
|
|
set_runtime_env_plugins, ray_start_regular_with_external_redis
|
|
):
|
|
@ray.remote
|
|
def task():
|
|
pass
|
|
|
|
# Use a temporary file to deterministically wait for the runtime_env setup to start.
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
signal_path = os.path.join(tmpdir, "signal")
|
|
|
|
pg = ray.util.placement_group(bundles=[{"CPU": 1}])
|
|
_ = task.options(
|
|
max_retries=0,
|
|
num_cpus=1,
|
|
scheduling_strategy=PlacementGroupSchedulingStrategy(
|
|
placement_group=pg,
|
|
),
|
|
runtime_env={
|
|
MyPlugin: {"signal_path": signal_path},
|
|
"config": {"setup_timeout_seconds": -1},
|
|
},
|
|
).remote()
|
|
|
|
# Wait until the runtime_env is setting up, which means we are in the process of
|
|
# popping a worker in the raylet.
|
|
wait_for_condition(lambda: os.path.exists(signal_path))
|
|
|
|
ray.util.remove_placement_group(pg)
|
|
# The PG is marked as REMOVED in redis but not removed yet from raylet
|
|
# due to the injected delay of RemovePlacementGroupBundles rpc
|
|
wait_for_condition(lambda: list_placement_groups()[0].state == "REMOVED")
|
|
|
|
ray._private.worker._global_node.kill_gcs_server()
|
|
# After GCS restarts, it will try to remove the PG resources
|
|
# again via ReleaseUnusedBundles rpc
|
|
ray._private.worker._global_node.start_gcs_server()
|
|
|
|
def verify_pg_resources_cleaned():
|
|
r_keys = ray.available_resources().keys()
|
|
return all("group" not in k for k in r_keys)
|
|
|
|
wait_for_condition(verify_pg_resources_cleaned, timeout=30)
|
|
|
|
|
|
def test_mark_job_finished_rpc_retry_and_idempotency(shutdown_only, monkeypatch):
|
|
"""
|
|
Test that MarkJobFinished RPC retries work correctly and are idempotent
|
|
when network failures occur.
|
|
|
|
This test verifies the fix for issue #53645 where duplicate MarkJobFinished
|
|
calls would crash the GCS due to non-idempotent RemoveJobReference().
|
|
Uses RPC failure injection to simulate network retry scenarios.
|
|
"""
|
|
# Inject RPC failures for MarkJobFinished - simulate network failures
|
|
# We inject request failures to force retries and test idempotency
|
|
monkeypatch.setenv(
|
|
"RAY_testing_rpc_failure",
|
|
json.dumps(
|
|
{
|
|
"ray::rpc::JobInfoGcsService.grpc_client.MarkJobFinished": {
|
|
"num_failures": 3,
|
|
"req_failure_prob": 50,
|
|
"resp_failure_prob": 0,
|
|
"in_flight_failure_prob": 0,
|
|
}
|
|
}
|
|
),
|
|
)
|
|
|
|
ray.init(num_cpus=1)
|
|
|
|
@ray.remote
|
|
def test_task(i):
|
|
return i * 2
|
|
|
|
# Submit several tasks to ensure job has some work
|
|
futures = [test_task.remote(i) for i in range(5)]
|
|
results = ray.get(futures)
|
|
assert results == [0, 2, 4, 6, 8]
|
|
|
|
# Get job ID for verification
|
|
job_id = ray.get_runtime_context().get_job_id()
|
|
assert job_id is not None
|
|
|
|
# Shutdown Ray - this will trigger MarkJobFinished with potential retries
|
|
# The RPC failure injection will cause some calls to fail, forcing retries
|
|
# The fix ensures that multiple calls to RemoveJobReference are handled gracefully
|
|
ray.shutdown()
|
|
|
|
# If we reach here without crashing, the test passes
|
|
assert True
|
|
|
|
|
|
def test_concurrent_mark_job_finished(shutdown_only):
|
|
"""
|
|
Test that concurrent or rapid successive calls to job finish operations
|
|
don't cause issues.
|
|
"""
|
|
ray.init(num_cpus=2)
|
|
|
|
@ray.remote
|
|
def concurrent_task(task_id):
|
|
_ = sum(i * i for i in range(100))
|
|
return f"task_{task_id}_completed"
|
|
|
|
# Submit multiple tasks
|
|
futures = [concurrent_task.remote(i) for i in range(10)]
|
|
results = ray.get(futures)
|
|
|
|
# Verify all tasks completed
|
|
expected = [f"task_{i}_completed" for i in range(10)]
|
|
assert results == expected
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-sv", __file__]))
|