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

506 lines
16 KiB
Python

import os
import subprocess
import sys
import pytest
import ray
import ray._private.ray_constants as ray_constants
from ray._common.network_utils import parse_address
from ray._common.test_utils import (
Semaphore,
run_string_as_driver,
wait_for_condition,
)
from ray._private.test_utils import (
client_test_enabled,
external_redis_test_enabled,
get_gcs_memory_used,
run_string_as_driver_nonblocking,
)
from ray._raylet import GCS_PID_KEY, GcsClient
from ray.experimental.internal_kv import _internal_kv_list
from ray.tests.conftest import call_ray_start
import psutil
@pytest.fixture
def shutdown_only_with_initialization_check():
yield None
# The code after the yield will run as teardown code.
ray.shutdown()
assert not ray.is_initialized()
def test_back_pressure(shutdown_only_with_initialization_check):
ray.init()
signal_actor = Semaphore.options(max_pending_calls=10).remote(value=0)
try:
for i in range(10):
signal_actor.acquire.remote()
except ray.exceptions.PendingCallsLimitExceeded:
assert False
with pytest.raises(ray.exceptions.PendingCallsLimitExceeded):
signal_actor.acquire.remote()
@ray.remote
def release(signal_actor):
ray.get(signal_actor.release.remote())
return 1
# Release signal actor through common task,
# because actor tasks will be back pressured
for i in range(10):
ray.get(release.remote(signal_actor))
# Check whether we can call remote actor normally after
# back presssure released.
try:
signal_actor.acquire.remote()
except ray.exceptions.PendingCallsLimitExceeded:
assert False
ray.shutdown()
def function_entry_num(job_id):
from ray._private.ray_constants import KV_NAMESPACE_FUNCTION_TABLE
return (
len(
_internal_kv_list(
b"RemoteFunction:" + job_id, namespace=KV_NAMESPACE_FUNCTION_TABLE
)
)
+ len(
_internal_kv_list(
b"ActorClass:" + job_id, namespace=KV_NAMESPACE_FUNCTION_TABLE
)
)
+ len(
_internal_kv_list(
b"FunctionsToRun:" + job_id, namespace=KV_NAMESPACE_FUNCTION_TABLE
)
)
)
@pytest.mark.skipif(
client_test_enabled(), reason="client api doesn't support namespace right now."
)
def test_function_table_gc(call_ray_start):
"""This test tries to verify that function table is cleaned up
after job exits.
"""
def f():
data = "0" * 1024 * 1024 # 1MB
@ray.remote
def r():
nonlocal data
@ray.remote
class Actor:
pass
return r.remote()
ray.init(address="auto", namespace="b")
# It should use > 500MB data
ray.get([f() for _ in range(500)])
# It's not working on win32.
if sys.platform != "win32":
assert get_gcs_memory_used() > 500 * 1024 * 1024
job_id = ray._private.worker.global_worker.current_job_id.hex().encode()
assert function_entry_num(job_id) > 0
ray.shutdown()
# now check the function table is cleaned up after job finished
ray.init(address="auto", namespace="a")
wait_for_condition(lambda: function_entry_num(job_id) == 0, timeout=30)
@pytest.mark.skipif(
client_test_enabled(), reason="client api doesn't support namespace right now."
)
def test_function_table_gc_actor(call_ray_start):
"""If there is a detached actor, the table won't be cleaned up."""
ray.init(address="auto", namespace="a")
@ray.remote
class Actor:
def ready(self):
return
# If there is a detached actor, the function won't be deleted.
a = Actor.options(lifetime="detached", name="a").remote()
ray.get(a.ready.remote())
job_id = ray._private.worker.global_worker.current_job_id.hex().encode()
ray.shutdown()
ray.init(address="auto", namespace="b")
with pytest.raises(Exception):
wait_for_condition(lambda: function_entry_num(job_id) == 0)
a = ray.get_actor("a", namespace="a")
ray.kill(a)
wait_for_condition(lambda: function_entry_num(job_id) == 0)
# If there is not a detached actor, it'll be deleted when the job finishes.
a = Actor.remote()
ray.get(a.ready.remote())
job_id = ray._private.worker.global_worker.current_job_id.hex().encode()
ray.shutdown()
ray.init(address="auto", namespace="c")
wait_for_condition(lambda: function_entry_num(job_id) == 0)
def test_node_liveness_after_restart(ray_start_cluster):
cluster = ray_start_cluster
cluster.add_node()
ray.init(cluster.address)
worker = cluster.add_node(node_manager_port=9037)
wait_for_condition(lambda: len([n for n in ray.nodes() if n["Alive"]]) == 2)
cluster.remove_node(worker)
wait_for_condition(lambda: len([n for n in ray.nodes() if n["Alive"]]) == 1)
worker = cluster.add_node(node_manager_port=9037)
wait_for_condition(lambda: len([n for n in ray.nodes() if n["Alive"]]) == 2)
@pytest.mark.skipif(
sys.platform != "linux",
reason="This test is only run on linux machines.",
)
def test_worker_oom_score(shutdown_only):
@ray.remote
def get_oom_score():
pid = os.getpid()
with open(f"/proc/{pid}/oom_score", "r") as f:
oom_score = f.read()
return int(oom_score)
assert ray.get(get_oom_score.remote()) >= 1000
call_ray_start_2 = call_ray_start
@pytest.mark.skipif(not external_redis_test_enabled(), reason="Only valid in redis env")
@pytest.mark.parametrize(
"call_ray_start,call_ray_start_2",
[
(
{"env": {"RAY_external_storage_namespace": "A1"}},
{"env": {"RAY_external_storage_namespace": "A2"}},
)
],
indirect=True,
)
def test_storage_isolation(external_redis, call_ray_start, call_ray_start_2):
script = """
import ray
ray.init("{address}", namespace="a")
@ray.remote
class A:
def ready(self):
return {val}
pass
a = A.options(lifetime="detached", name="A").remote()
assert ray.get(a.ready.remote()) == {val}
assert ray.get_runtime_context().get_job_id() == '01000000'
"""
run_string_as_driver(script.format(address=call_ray_start, val=1))
run_string_as_driver(script.format(address=call_ray_start_2, val=2))
script = """
import ray
ray.init("{address}", namespace="a")
a = ray.get_actor(name="A")
assert ray.get(a.ready.remote()) == {val}
assert ray.get_runtime_context().get_job_id() == '02000000'
"""
run_string_as_driver(script.format(address=call_ray_start, val=1))
run_string_as_driver(script.format(address=call_ray_start_2, val=2))
@pytest.mark.skipif(sys.platform != "linux", reason="Only works on linux.")
def test_gcs_connection_no_leak(ray_start_cluster):
cluster = ray_start_cluster
head_node = cluster.add_node()
gcs_server_process = head_node.all_processes["gcs_server"][0].process
gcs_server_pid = gcs_server_process.pid
def get_gcs_num_of_connections():
p = psutil.Process(gcs_server_pid)
num_connections = len(p.connections())
print(">>", num_connections)
return num_connections
@ray.remote
class GcsKVActor:
def __init__(self, address):
self.gcs_client = GcsClient(address=address)
self.gcs_client.internal_kv_get(
GCS_PID_KEY.encode(),
)
def ready(self):
return "WORLD"
@ray.remote
class A:
def ready(self):
print("HELLO")
return "WORLD"
gcs_kv_actor = None
with ray.init(cluster.address):
# Wait for workers to be ready.
gcs_kv_actor = GcsKVActor.remote(cluster.address)
_ = ray.get(gcs_kv_actor.ready.remote())
# Note: `fds_with_some_workers` need to be recorded *after* `ray.init`, because
# a prestarted worker is started on the first driver init. This worker keeps 1
# connection to the GCS, and it stays alive even after the driver exits. If
# we move this line before `ray.init`, we will find 1 extra connection after
# the driver exits.
fds_with_some_workers = get_gcs_num_of_connections()
num_of_actors = 10
actors = [A.remote() for _ in range(num_of_actors)]
print(ray.get([t.ready.remote() for t in actors]))
# Kill the actors
del actors
# Make sure the # of fds opened by the GCS dropped.
# This assumes worker processes are not created after the actor worker
# processes die.
wait_for_condition(lambda: get_gcs_num_of_connections() < fds_with_some_workers)
num_fds_after_workers_die = get_gcs_num_of_connections()
n = cluster.add_node(wait=True)
# Make sure the # of fds opened by the GCS increased.
wait_for_condition(lambda: get_gcs_num_of_connections() > num_fds_after_workers_die)
cluster.remove_node(n)
# Make sure the # of fds opened by the GCS dropped.
wait_for_condition(lambda: get_gcs_num_of_connections() < fds_with_some_workers)
@pytest.mark.parametrize(
"call_ray_start",
["ray start --head --num-cpus=2"],
indirect=True,
)
def test_demands_when_driver_exits(call_ray_start):
script = f"""
import ray
ray.init(address='{call_ray_start}')
import os
import time
@ray.remote(num_cpus=3)
def use_gpu():
pass
@ray.remote(num_gpus=10)
class A:
pass
A.options(name="a", lifetime="detached").remote()
print(ray.get([use_gpu.remote(), use_gpu.remote()]))
"""
proc = run_string_as_driver_nonblocking(script)
gcs_cli = ray._raylet.GcsClient(address=f"{call_ray_start}")
def check_demands(n):
status = gcs_cli.internal_kv_get(
ray._private.ray_constants.DEBUG_AUTOSCALING_STATUS.encode(), namespace=None
)
import json
status = json.loads(status.decode())
return len(status["load_metrics_report"]["resource_demand"]) == n
wait_for_condition(lambda: check_demands(2))
proc.terminate()
wait_for_condition(lambda: check_demands(1))
@pytest.mark.skipif(external_redis_test_enabled(), reason="Only valid in non redis env")
def test_redis_not_available(monkeypatch, call_ray_stop_only):
monkeypatch.setenv("RAY_redis_db_connect_retries", "5")
monkeypatch.setenv("RAY_REDIS_ADDRESS", "localhost:12345")
p = subprocess.run(
"ray start --head",
shell=True,
capture_output=True,
)
assert "Could not establish connection to Redis" in p.stderr.decode()
assert "Please check " in p.stderr.decode()
assert "redis storage is alive or not." in p.stderr.decode()
@pytest.mark.skipif(not external_redis_test_enabled(), reason="Only valid in redis env")
def test_redis_wrong_password(monkeypatch, external_redis, call_ray_stop_only):
monkeypatch.setenv("RAY_redis_db_connect_retries", "5")
p = subprocess.run(
"ray start --head --redis-password=1234",
shell=True,
capture_output=True,
)
assert "RedisError: ERR AUTH <password> called" in p.stderr.decode()
@pytest.mark.skipif(not external_redis_test_enabled(), reason="Only valid in redis env")
def test_redis_full(ray_start_cluster_head):
import redis
gcs_address = ray_start_cluster_head.gcs_address
redis_addr = os.environ["RAY_REDIS_ADDRESS"]
host, port = parse_address(redis_addr)
if os.environ.get("TEST_EXTERNAL_REDIS_REPLICAS", "1") != "1":
cli = redis.RedisCluster(host, int(port))
else:
cli = redis.Redis(host, int(port))
# Set the max memory to 10MB
cli.config_set("maxmemory", 5 * 1024 * 1024)
gcs_cli = ray._raylet.GcsClient(address=gcs_address)
# GCS should fail
# GcsClient assumes GCS is HA so it keeps retrying, although GCS is down. We must
# set timeout for this.
with pytest.raises(ray.exceptions.RpcError):
gcs_cli.internal_kv_put(b"A", b"A" * 6 * 1024 * 1024, True, timeout=5)
logs_dir = ray_start_cluster_head.head_node._logs_dir
with open(os.path.join(logs_dir, "gcs_server.err")) as err:
assert "OOM command not allowed when used" in err.read()
def test_omp_threads_set_third_party(ray_start_cluster, monkeypatch):
###########################
# Test the OMP_NUM_THREADS are picked up by 3rd party libraries
# when running tasks if no OMP_NUM_THREADS is set by user.
# e.g. numpy, numexpr
###########################
with monkeypatch.context() as m:
m.delenv("OMP_NUM_THREADS", raising=False)
cluster = ray_start_cluster
cluster.add_node(num_cpus=4)
ray.init(address=cluster.address)
@ray.remote(num_cpus=2)
def f():
# Assert numpy using 2 threads for it's parallelism backend.
import numpy # noqa: F401
from threadpoolctl import threadpool_info
for pool_info in threadpool_info():
assert pool_info["num_threads"] == 2
import numexpr
assert numexpr.nthreads == 2
return True
assert ray.get(f.remote())
def test_gcs_fd_usage(shutdown_only):
ray.init(
_system_config={
"prestart_worker_first_driver": False,
"enable_worker_prestart": False,
},
)
gcs_process = ray._private.worker._global_node.all_processes["gcs_server"][0]
gcs_process = psutil.Process(gcs_process.process.pid)
print("GCS connections", len(gcs_process.connections()))
@ray.remote(runtime_env={"env_vars": {"Hello": "World"}})
class A:
def f(self):
return os.environ.get("Hello")
# In case there are still some pre-start workers, consume all of them
aa = [A.remote() for _ in range(32)]
for a in aa:
assert ray.get(a.f.remote()) == "World"
base_fd_num = len(gcs_process.connections())
print("GCS connections", base_fd_num)
bb = [A.remote() for _ in range(16)]
for b in bb:
assert ray.get(b.f.remote()) == "World"
new_fd_num = len(gcs_process.connections())
print("GCS connections", new_fd_num)
# each worker has two connections:
# GCS -> CoreWorker
# CoreWorker -> GCS
# Sometimes, there is one more sockets opened. The reason
# is still unknown.
assert (new_fd_num - base_fd_num) <= len(bb) * 2 + 1
@pytest.mark.skipif(
sys.platform != "linux", reason="jemalloc is only prebuilt on linux"
)
def test_jemalloc_ray_start(monkeypatch, ray_start_cluster):
def check_jemalloc_enabled(pid=None):
if pid is None:
pid = os.getpid()
pmap = subprocess.run(
["pmap", str(pid)], check=True, text=True, stdout=subprocess.PIPE
)
return "libjemalloc.so" in pmap.stdout
# Firstly, remove the LD_PRELOAD and make sure
# jemalloc is loaded.
monkeypatch.delenv("LD_PRELOAD", False)
cluster = ray_start_cluster
node = cluster.add_node(num_cpus=1)
# Make sure raylet/gcs/worker all have jemalloc
assert check_jemalloc_enabled(
node.all_processes[ray_constants.PROCESS_TYPE_GCS_SERVER][0].process.pid
)
assert check_jemalloc_enabled(
node.all_processes[ray_constants.PROCESS_TYPE_RAYLET][0].process.pid
)
assert not ray.get(ray.remote(check_jemalloc_enabled).remote())
ray.shutdown()
cluster.shutdown()
monkeypatch.setenv("LD_PRELOAD", "")
node = cluster.add_node(num_cpus=1)
# Make sure raylet/gcs/worker all have jemalloc
assert not check_jemalloc_enabled(
node.all_processes[ray_constants.PROCESS_TYPE_GCS_SERVER][0].process.pid
)
assert not check_jemalloc_enabled(
node.all_processes[ray_constants.PROCESS_TYPE_RAYLET][0].process.pid
)
assert not ray.get(ray.remote(check_jemalloc_enabled).remote())
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))