1685 lines
51 KiB
Python
1685 lines
51 KiB
Python
"""
|
|
This file defines the common pytest fixtures used in current directory.
|
|
"""
|
|
|
|
import copy
|
|
import json
|
|
import logging
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from tempfile import gettempdir
|
|
from typing import List, Optional
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
import ray
|
|
import ray._private.ray_constants as ray_constants
|
|
from ray._common.network_utils import build_address, find_free_port
|
|
from ray._common.test_utils import wait_for_condition
|
|
from ray._private.authentication_test_utils import (
|
|
authentication_env_guard,
|
|
clear_auth_token_sources,
|
|
reset_auth_token_state,
|
|
set_auth_mode,
|
|
set_env_auth_token,
|
|
)
|
|
from ray._private.conftest_utils import set_override_dashboard_url # noqa: F401
|
|
from ray._private.runtime_env import virtualenv_utils
|
|
from ray._private.test_utils import (
|
|
RayletKiller,
|
|
external_redis_test_enabled,
|
|
get_and_run_resource_killer,
|
|
get_redis_cli,
|
|
init_error_pubsub,
|
|
init_log_pubsub,
|
|
kill_processes,
|
|
redis_replicas,
|
|
redis_sentinel_replicas,
|
|
reset_autoscaler_v2_enabled_cache,
|
|
rocksdb_gcs_test_enabled,
|
|
setup_tls,
|
|
start_redis_instance,
|
|
start_redis_sentinel_instance,
|
|
teardown_tls,
|
|
)
|
|
from ray.cluster_utils import AutoscalingCluster, Cluster, cluster_not_supported
|
|
|
|
import psutil
|
|
|
|
# TODO (mengjin) Improve the logging in the conftest files so that the logger can log
|
|
# information in stdout as well as stderr and replace the print statements in the test
|
|
# files
|
|
logger = logging.getLogger(__name__)
|
|
|
|
START_REDIS_WAIT_RETRIES = int(os.environ.get("RAY_START_REDIS_WAIT_RETRIES", "60"))
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def pre_envs(monkeypatch):
|
|
# To make test run faster
|
|
monkeypatch.setenv("RAY_NUM_REDIS_GET_RETRIES", "2")
|
|
ray_constants.NUM_REDIS_GET_RETRIES = 2
|
|
yield
|
|
|
|
|
|
def wait_for_redis_to_start(
|
|
redis_ip_address: str,
|
|
redis_port: int,
|
|
password: Optional[str] = None,
|
|
username: Optional[str] = None,
|
|
):
|
|
"""Wait for a Redis server to be available.
|
|
|
|
This is accomplished by creating a Redis client and sending a random
|
|
command to the server until the command gets through.
|
|
|
|
Args:
|
|
redis_ip_address: The IP address of the redis server.
|
|
redis_port: The port of the redis server.
|
|
password: The password of the Redis server.
|
|
username: The username of the Redis server.
|
|
|
|
Raises:
|
|
Exception: An exception is raised if we could not connect with Redis.
|
|
"""
|
|
import redis
|
|
|
|
redis_client = redis.StrictRedis(
|
|
host=redis_ip_address, port=redis_port, username=username, password=password
|
|
)
|
|
# Wait for the Redis server to start.
|
|
num_retries = START_REDIS_WAIT_RETRIES
|
|
|
|
delay = 0.001
|
|
for i in range(num_retries):
|
|
try:
|
|
# Run some random command and see if it worked.
|
|
logger.debug(
|
|
"Waiting for redis server at {} to respond...".format(
|
|
build_address(redis_ip_address, redis_port)
|
|
)
|
|
)
|
|
redis_client.client_list()
|
|
# If the Redis service is delayed getting set up for any reason, we may
|
|
# get a redis.ConnectionError: Error 111 connecting to host:port.
|
|
# Connection refused.
|
|
# Unfortunately, redis.ConnectionError is also the base class of
|
|
# redis.AuthenticationError. We *don't* want to obscure a
|
|
# redis.AuthenticationError, because that indicates the user provided a
|
|
# bad password. Thus a double except clause to ensure a
|
|
# redis.AuthenticationError isn't trapped here.
|
|
except redis.AuthenticationError as authEx:
|
|
raise RuntimeError(
|
|
f"Unable to connect to Redis at {build_address(redis_ip_address, redis_port)}."
|
|
) from authEx
|
|
except redis.ConnectionError as connEx:
|
|
if i >= num_retries - 1:
|
|
raise RuntimeError(
|
|
f"Unable to connect to Redis at {build_address(redis_ip_address, redis_port)} "
|
|
f"after {num_retries} retries. Check that "
|
|
f"{build_address(redis_ip_address, redis_port)} is reachable from this "
|
|
"machine. If it is not, your firewall may be blocking "
|
|
"this port. If the problem is a flaky connection, try "
|
|
"setting the environment variable "
|
|
"`RAY_START_REDIS_WAIT_RETRIES` to increase the number of"
|
|
" attempts to ping the Redis server."
|
|
) from connEx
|
|
# Wait a little bit.
|
|
time.sleep(delay)
|
|
# Make sure the retry interval doesn't increase too large, which will
|
|
# affect the delivery time of the Ray cluster.
|
|
delay = min(1, delay * 2)
|
|
else:
|
|
break
|
|
else:
|
|
raise RuntimeError(
|
|
f"Unable to connect to Redis (after {num_retries} retries). "
|
|
"If the Redis instance is on a different machine, check that "
|
|
"your firewall and relevant Ray ports are configured properly. "
|
|
"You can also set the environment variable "
|
|
"`RAY_START_REDIS_WAIT_RETRIES` to increase the number of "
|
|
"attempts to ping the Redis server."
|
|
)
|
|
|
|
|
|
def get_default_fixure_system_config():
|
|
system_config = {
|
|
"object_timeout_milliseconds": 200,
|
|
"health_check_initial_delay_ms": 0,
|
|
"health_check_failure_threshold": 10,
|
|
"object_store_full_delay_ms": 100,
|
|
"local_gc_min_interval_s": 1,
|
|
}
|
|
return system_config
|
|
|
|
|
|
def get_default_fixture_ray_kwargs():
|
|
system_config = get_default_fixure_system_config()
|
|
ray_kwargs = {
|
|
"num_cpus": 1,
|
|
"object_store_memory": 150 * 1024 * 1024,
|
|
"dashboard_port": None,
|
|
"namespace": "default_test_namespace",
|
|
"_system_config": system_config,
|
|
}
|
|
return ray_kwargs
|
|
|
|
|
|
def is_process_listen_to_port(pid, port):
|
|
retry_num = 10
|
|
interval_time = 0.5
|
|
for _ in range(retry_num):
|
|
try:
|
|
proc = psutil.Process(pid)
|
|
for conn in proc.connections():
|
|
if conn.status == "LISTEN" and conn.laddr.port == port:
|
|
return True
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
time.sleep(interval_time)
|
|
print(
|
|
f"Process({pid}) has not listened to port {port} "
|
|
+ f"for more than {retry_num * interval_time}s."
|
|
)
|
|
return False
|
|
|
|
|
|
def find_user_process_by_port_and_status(
|
|
port: int, statuses_to_check: Optional[list[str]]
|
|
):
|
|
"""
|
|
Test helper function to find the processes that have a connection to a provided
|
|
port and with statuses in the provided list.
|
|
|
|
Args:
|
|
port: The port to check.
|
|
statuses_to_check: The list of statuses to check. If None, the function will not
|
|
check the status of the connection.
|
|
|
|
Returns:
|
|
The first process that have a connection
|
|
"""
|
|
# Here the function finds all the processes and checks if each of them is with the
|
|
# provided port and status. This is inefficient comparing to leveraging
|
|
# psutil.net_connections to directly filter the processes by port. However, the
|
|
# method is chosen because the net_connections method will need root access to run
|
|
# on macOS: https://psutil.readthedocs.io/en/latest/#psutil.net_connections.
|
|
# Therefore, the current solution is chosen to make the function work for all
|
|
processes = []
|
|
for pid in psutil.pids():
|
|
process = psutil.Process(pid)
|
|
try:
|
|
conns = process.connections()
|
|
for conn in conns:
|
|
if conn.laddr.port == port:
|
|
if statuses_to_check is None or conn.status in statuses_to_check:
|
|
processes.append(process)
|
|
except (psutil.AccessDenied, psutil.ZombieProcess, psutil.NoSuchProcess):
|
|
continue
|
|
|
|
if not processes:
|
|
print(
|
|
f"Failed to find processes that have connections to the port {port} and "
|
|
f"with connection status in {statuses_to_check}. It could "
|
|
"be because the process needs higher privilege to access its "
|
|
"information or the port is not listened by any processes."
|
|
)
|
|
return processes
|
|
|
|
|
|
def redis_alive(port, enable_tls):
|
|
try:
|
|
# If there is no redis libs installed, skip the check.
|
|
# This could happen In minimal test, where we don't have
|
|
# redis.
|
|
import redis
|
|
except Exception:
|
|
return True
|
|
|
|
params = {}
|
|
if enable_tls:
|
|
from ray._raylet import Config
|
|
|
|
params = {"ssl": True, "ssl_cert_reqs": "required"}
|
|
if Config.REDIS_CA_CERT():
|
|
params["ssl_ca_certs"] = Config.REDIS_CA_CERT()
|
|
if Config.REDIS_CLIENT_CERT():
|
|
params["ssl_certfile"] = Config.REDIS_CLIENT_CERT()
|
|
if Config.REDIS_CLIENT_KEY():
|
|
params["ssl_keyfile"] = Config.REDIS_CLIENT_KEY()
|
|
|
|
cli = redis.Redis("localhost", port, **params)
|
|
|
|
try:
|
|
return cli.ping()
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
|
|
def _find_available_ports(start: int, end: int, *, num: int = 1) -> List[int]:
|
|
ports = []
|
|
for _ in range(num):
|
|
random_port = 0
|
|
with socket.socket() as s:
|
|
s.bind(("", 0))
|
|
random_port = s.getsockname()[1]
|
|
if random_port >= start and random_port <= end and random_port not in ports:
|
|
ports.append(random_port)
|
|
continue
|
|
|
|
for port in range(start, end + 1):
|
|
if port in ports:
|
|
continue
|
|
try:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("", port))
|
|
ports.append(port)
|
|
break
|
|
except OSError:
|
|
pass
|
|
|
|
if len(ports) != num:
|
|
raise RuntimeError(f"Can't find {num} available port from {start} to {end}.")
|
|
|
|
return ports
|
|
|
|
|
|
def start_redis_with_sentinel(db_dir):
|
|
temp_dir = ray._common.utils.get_default_ray_temp_dir()
|
|
|
|
redis_ports = _find_available_ports(49159, 55535, num=redis_sentinel_replicas() + 1)
|
|
sentinel_port = redis_ports[0]
|
|
master_port = redis_ports[1]
|
|
redis_processes = [
|
|
start_redis_instance(temp_dir, p, listen_to_localhost_only=True, db_dir=db_dir)[
|
|
1
|
|
]
|
|
for p in redis_ports[1:]
|
|
]
|
|
|
|
# ensure all redis servers are up
|
|
for port in redis_ports[1:]:
|
|
wait_for_condition(redis_alive, 3, 100, port=port, enable_tls=False)
|
|
|
|
# setup replicas of the master
|
|
for port in redis_ports[2:]:
|
|
redis_cli = get_redis_cli(port, False)
|
|
redis_cli.replicaof("127.0.0.1", master_port)
|
|
sentinel_process = start_redis_sentinel_instance(
|
|
temp_dir, sentinel_port, master_port
|
|
)
|
|
address_str = f"127.0.0.1:{sentinel_port}"
|
|
return address_str, redis_processes + [sentinel_process]
|
|
|
|
|
|
def start_redis(db_dir):
|
|
retry_num = 0
|
|
while True:
|
|
is_need_restart = False
|
|
processes = []
|
|
enable_tls = "RAY_REDIS_CA_CERT" in os.environ
|
|
leader_port = None
|
|
leader_id = None
|
|
redis_ports = []
|
|
while len(redis_ports) != redis_replicas():
|
|
temp_dir = ray._common.utils.get_default_ray_temp_dir()
|
|
port, free_port = _find_available_ports(49159, 55535, num=2)
|
|
try:
|
|
node_id = None
|
|
proc = None
|
|
node_id, proc = start_redis_instance(
|
|
temp_dir,
|
|
port,
|
|
enable_tls=enable_tls,
|
|
replica_of=leader_port,
|
|
leader_id=leader_id,
|
|
db_dir=db_dir,
|
|
free_port=free_port,
|
|
)
|
|
wait_for_condition(
|
|
redis_alive, 3, 100, port=port, enable_tls=enable_tls
|
|
)
|
|
except Exception as e:
|
|
print(f"Fails to start redis on port {port} with exception {e}")
|
|
if (
|
|
proc is not None
|
|
and proc.process is not None
|
|
and proc.process.poll() is None
|
|
):
|
|
proc.process.kill()
|
|
|
|
# TODO (mengjin) Here we added more debug logs here to help further
|
|
# troubleshoot the potential race condition where the available port
|
|
# we found above is taken by another process and the Redis server
|
|
# cannot be started. Here we won't fail the test but we can check the
|
|
# output log of the test to further investigate the issue if needed.
|
|
if "Redis process exited unexpectedly" in str(e):
|
|
# Output the process that listens to the port
|
|
processes = find_user_process_by_port_and_status(
|
|
port, [psutil.CONN_LISTEN]
|
|
)
|
|
|
|
for process in processes:
|
|
print(
|
|
f"Another process({process.pid}) with command"
|
|
f"\"{' '.join(process.args)}\" is listening on the port"
|
|
f"{port}"
|
|
)
|
|
|
|
continue
|
|
|
|
redis_ports.append(port)
|
|
if leader_port is None:
|
|
leader_port = port
|
|
leader_id = node_id
|
|
processes.append(proc)
|
|
# Check if th redis has started successfully and is listening on the port.
|
|
if not is_process_listen_to_port(proc.process.pid, port):
|
|
is_need_restart = True
|
|
break
|
|
|
|
if is_need_restart:
|
|
retry_num += 1
|
|
for proc in processes:
|
|
proc.process.kill()
|
|
|
|
if retry_num > 5:
|
|
raise RuntimeError(
|
|
f"Failed to start Redis on port {port} after {retry_num} attempts."
|
|
)
|
|
print(
|
|
"Retry to start redis because the process failed to "
|
|
+ f"listen to the port({port}), retry num:{retry_num}."
|
|
)
|
|
continue
|
|
|
|
if redis_replicas() > 1:
|
|
|
|
redis_cli = get_redis_cli(str(leader_port), enable_tls)
|
|
while redis_cli.cluster("info")["cluster_state"] != "ok":
|
|
pass
|
|
|
|
scheme = "rediss://" if enable_tls else ""
|
|
address_str = f"{scheme}127.0.0.1:{redis_ports[-1]}"
|
|
return address_str, processes
|
|
|
|
|
|
def kill_all_redis_server():
|
|
"""
|
|
Find all redis server processes running on this host via cmdline
|
|
and kill them.
|
|
Note: killed redis process will raise ResourceWarning
|
|
when the python Subprocess tracking the
|
|
underlying process is garbage collected.
|
|
"""
|
|
# Find Redis server processes
|
|
redis_procs = []
|
|
for proc in psutil.process_iter(["name", "cmdline"]):
|
|
try:
|
|
if proc.name() == "redis-server":
|
|
redis_procs.append(proc)
|
|
except psutil.NoSuchProcess:
|
|
pass
|
|
|
|
# Kill Redis server processes
|
|
for proc in redis_procs:
|
|
try:
|
|
proc.kill()
|
|
except psutil.NoSuchProcess:
|
|
pass
|
|
|
|
|
|
@contextmanager
|
|
def _setup_redis(request, with_sentinel=False):
|
|
with tempfile.TemporaryDirectory() as tmpdirname:
|
|
kill_all_redis_server()
|
|
address_str, processes = (
|
|
start_redis_with_sentinel(tmpdirname)
|
|
if with_sentinel
|
|
else start_redis(tmpdirname)
|
|
)
|
|
old_addr = os.environ.get("RAY_REDIS_ADDRESS")
|
|
os.environ["RAY_REDIS_ADDRESS"] = address_str
|
|
import uuid
|
|
|
|
ns = str(uuid.uuid4())
|
|
old_ns = os.environ.get("RAY_external_storage_namespace")
|
|
os.environ["RAY_external_storage_namespace"] = ns
|
|
|
|
yield
|
|
if old_addr is not None:
|
|
os.environ["RAY_REDIS_ADDRESS"] = old_addr
|
|
else:
|
|
del os.environ["RAY_REDIS_ADDRESS"]
|
|
|
|
if old_ns is not None:
|
|
os.environ["RAY_external_storage_namespace"] = old_ns
|
|
else:
|
|
del os.environ["RAY_external_storage_namespace"]
|
|
|
|
kill_processes(processes)
|
|
|
|
|
|
@contextmanager
|
|
def _setup_rocksdb_gcs(request):
|
|
"""Configure the env so a Ray cluster started inside the fixture uses
|
|
the RocksDB GCS backend (REP-64). The DB lives in a tempdir scoped
|
|
to the fixture; nothing persists beyond the test.
|
|
"""
|
|
with tempfile.TemporaryDirectory() as tmpdirname:
|
|
old_storage = os.environ.get("RAY_gcs_storage")
|
|
old_path = os.environ.get("RAY_gcs_storage_path")
|
|
os.environ["RAY_gcs_storage"] = "rocksdb"
|
|
os.environ["RAY_gcs_storage_path"] = tmpdirname
|
|
try:
|
|
yield
|
|
finally:
|
|
if old_storage is not None:
|
|
os.environ["RAY_gcs_storage"] = old_storage
|
|
else:
|
|
del os.environ["RAY_gcs_storage"]
|
|
if old_path is not None:
|
|
os.environ["RAY_gcs_storage_path"] = old_path
|
|
else:
|
|
del os.environ["RAY_gcs_storage_path"]
|
|
|
|
|
|
@pytest.fixture
|
|
def maybe_setup_external_redis(request):
|
|
# Dispatches the configured GCS storage backend based on CI env
|
|
# vars. Despite the historical name, this fixture also handles the
|
|
# RocksDB GCS backend (REP-64) so existing cluster fixtures pick up
|
|
# rocksdb-mode behavior automatically when TEST_GCS_ROCKSDB=1.
|
|
if external_redis_test_enabled():
|
|
with _setup_redis(request):
|
|
yield
|
|
elif rocksdb_gcs_test_enabled():
|
|
with _setup_rocksdb_gcs(request):
|
|
yield
|
|
else:
|
|
yield
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def maybe_setup_external_redis_shared(request):
|
|
if external_redis_test_enabled():
|
|
with _setup_redis(request):
|
|
yield
|
|
elif rocksdb_gcs_test_enabled():
|
|
with _setup_rocksdb_gcs(request):
|
|
yield
|
|
else:
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def external_redis(request):
|
|
with _setup_redis(request):
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def external_redis_with_sentinel(request):
|
|
with _setup_redis(request, True):
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def local_autoscaling_cluster(request, enable_v2):
|
|
reset_autoscaler_v2_enabled_cache()
|
|
|
|
# Start a mock multi-node autoscaling cluster.
|
|
head_resources, worker_node_types, system_config = copy.deepcopy(request.param)
|
|
cluster = AutoscalingCluster(
|
|
head_resources=head_resources,
|
|
worker_node_types=worker_node_types,
|
|
autoscaler_v2=enable_v2,
|
|
)
|
|
cluster.start(_system_config=system_config)
|
|
|
|
yield None
|
|
|
|
# Shutdown the cluster
|
|
cluster.shutdown()
|
|
|
|
|
|
@pytest.fixture
|
|
def shutdown_only(maybe_setup_external_redis):
|
|
yield None
|
|
# The code after the yield will run as teardown code.
|
|
ray.shutdown()
|
|
# Delete the cluster address just in case.
|
|
ray._common.utils.reset_ray_address()
|
|
|
|
|
|
@pytest.fixture
|
|
def propagate_logs():
|
|
# Ensure that logs are propagated to ancestor handles. This is required if using the
|
|
# caplog or capsys fixtures with Ray's logging.
|
|
# NOTE: This only enables log propagation in the driver process, not the workers!
|
|
logging.getLogger("ray").propagate = True
|
|
logging.getLogger("ray.data").propagate = True
|
|
yield
|
|
logging.getLogger("ray").propagate = False
|
|
logging.getLogger("ray.data").propagate = False
|
|
|
|
|
|
# Provide a shared Ray instance for a test class
|
|
@pytest.fixture(scope="class")
|
|
def class_ray_instance():
|
|
yield ray.init()
|
|
ray.shutdown()
|
|
# Delete the cluster address just in case.
|
|
ray._common.utils.reset_ray_address()
|
|
|
|
|
|
@contextmanager
|
|
def _ray_start(**kwargs):
|
|
init_kwargs = get_default_fixture_ray_kwargs()
|
|
init_kwargs.update(kwargs)
|
|
# Start the Ray processes.
|
|
address_info = ray.init("local", **init_kwargs)
|
|
|
|
yield address_info
|
|
# The code after the yield will run as teardown code.
|
|
ray.shutdown()
|
|
# Delete the cluster address just in case.
|
|
ray._common.utils.reset_ray_address()
|
|
|
|
|
|
@pytest.fixture
|
|
def ray_start_with_dashboard(request, maybe_setup_external_redis):
|
|
param = getattr(request, "param", {})
|
|
if param.get("num_cpus") is None:
|
|
param["num_cpus"] = 1
|
|
with _ray_start(include_dashboard=True, **param) as address_info:
|
|
yield address_info
|
|
|
|
|
|
@pytest.fixture
|
|
def ray_start_with_dashboard_and_proxy(request, httpserver, maybe_setup_external_redis):
|
|
hsurl = httpserver.url_for("/")
|
|
|
|
param = getattr(request, "param", {})
|
|
if param.get("num_cpus") is None:
|
|
param["num_cpus"] = 1
|
|
with _ray_start(include_dashboard=True, proxy_server_url=hsurl, **param) as info:
|
|
yield info
|
|
|
|
|
|
@pytest.fixture
|
|
def make_sure_dashboard_http_port_unused():
|
|
"""Make sure the dashboard agent http port is unused."""
|
|
for process in psutil.process_iter():
|
|
should_kill = False
|
|
try:
|
|
for conn in process.connections():
|
|
if conn.laddr.port == ray_constants.DEFAULT_DASHBOARD_AGENT_LISTEN_PORT:
|
|
should_kill = True
|
|
break
|
|
except Exception:
|
|
continue
|
|
if should_kill:
|
|
try:
|
|
process.kill()
|
|
process.wait()
|
|
except Exception:
|
|
pass
|
|
yield
|
|
|
|
|
|
# The following fixture will start ray with 0 cpu.
|
|
@pytest.fixture
|
|
def ray_start_no_cpu(request, maybe_setup_external_redis):
|
|
param = getattr(request, "param", {})
|
|
with _ray_start(num_cpus=0, **param) as res:
|
|
yield res
|
|
|
|
|
|
# The following fixture will start ray with 1 cpu.
|
|
@pytest.fixture
|
|
def ray_start_regular(request, maybe_setup_external_redis):
|
|
param = getattr(request, "param", {})
|
|
with _ray_start(**param) as res:
|
|
yield res
|
|
|
|
|
|
# We can compose external_redis and ray_start_regular instead of creating this
|
|
# separate fixture, if there is a good way to ensure external_redis runs before
|
|
# ray_start_regular.
|
|
@pytest.fixture
|
|
def ray_start_regular_with_external_redis(request, external_redis):
|
|
param = getattr(request, "param", {})
|
|
with _ray_start(**param) as res:
|
|
yield res
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def ray_start_regular_shared(request):
|
|
param = getattr(request, "param", {})
|
|
with _ray_start(**param) as res:
|
|
yield res
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def ray_start_regular_shared_2_cpus(request):
|
|
param = getattr(request, "param", {})
|
|
with _ray_start(num_cpus=2, **param) as res:
|
|
yield res
|
|
|
|
|
|
@pytest.fixture
|
|
def ray_start_2_cpus(request, maybe_setup_external_redis):
|
|
param = getattr(request, "param", {})
|
|
with _ray_start(num_cpus=2, **param) as res:
|
|
yield res
|
|
|
|
|
|
@pytest.fixture
|
|
def ray_start_10_cpus(request, maybe_setup_external_redis):
|
|
param = getattr(request, "param", {})
|
|
with _ray_start(num_cpus=10, **param) as res:
|
|
yield res
|
|
|
|
|
|
@contextmanager
|
|
def _ray_start_cluster(**kwargs):
|
|
if kwargs.pop("skip_cluster", cluster_not_supported):
|
|
pytest.skip("Cluster not supported")
|
|
|
|
init_kwargs = get_default_fixture_ray_kwargs()
|
|
num_nodes = 0
|
|
do_init = False
|
|
# num_nodes & do_init are not arguments for ray.init, so delete them.
|
|
if "num_nodes" in kwargs:
|
|
num_nodes = kwargs["num_nodes"]
|
|
del kwargs["num_nodes"]
|
|
if "do_init" in kwargs:
|
|
do_init = kwargs["do_init"]
|
|
del kwargs["do_init"]
|
|
elif num_nodes > 0:
|
|
do_init = True
|
|
init_kwargs.update(kwargs)
|
|
namespace = init_kwargs.pop("namespace")
|
|
|
|
cluster = Cluster()
|
|
remote_nodes = []
|
|
for i in range(num_nodes):
|
|
if i > 0 and "_system_config" in init_kwargs:
|
|
del init_kwargs["_system_config"]
|
|
remote_nodes.append(cluster.add_node(**init_kwargs))
|
|
# We assume driver will connect to the head (first node),
|
|
# so ray init will be invoked if do_init is true
|
|
if len(remote_nodes) == 1 and do_init:
|
|
ray.init(address=cluster.address, namespace=namespace)
|
|
yield cluster
|
|
|
|
# The code after the yield will run as teardown code.
|
|
ray.shutdown()
|
|
cluster.shutdown()
|
|
|
|
|
|
# This fixture will start a cluster with empty nodes.
|
|
@pytest.fixture
|
|
def ray_start_cluster(request, maybe_setup_external_redis):
|
|
param = getattr(request, "param", {})
|
|
with _ray_start_cluster(**param) as res:
|
|
yield res
|
|
|
|
|
|
@pytest.fixture
|
|
def ray_start_cluster_enabled(request, maybe_setup_external_redis):
|
|
param = getattr(request, "param", {})
|
|
param["skip_cluster"] = False
|
|
with _ray_start_cluster(**param) as res:
|
|
yield res
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def ray_start_cluster_enabled_shared(request, maybe_setup_external_redis_shared):
|
|
param = getattr(request, "param", {})
|
|
param["skip_cluster"] = False
|
|
with _ray_start_cluster(**param) as res:
|
|
yield res
|
|
|
|
|
|
@pytest.fixture
|
|
def ray_start_cluster_init(request, maybe_setup_external_redis):
|
|
param = getattr(request, "param", {})
|
|
with _ray_start_cluster(do_init=True, **param) as res:
|
|
yield res
|
|
|
|
|
|
@pytest.fixture
|
|
def ray_start_cluster_head(request, maybe_setup_external_redis):
|
|
param = getattr(request, "param", {})
|
|
with _ray_start_cluster(do_init=True, num_nodes=1, **param) as res:
|
|
yield res
|
|
|
|
|
|
# We can compose external_redis and ray_start_cluster_head instead of creating
|
|
# this separate fixture, if there is a good way to ensure external_redis runs
|
|
# before ray_start_cluster_head.
|
|
@pytest.fixture
|
|
def ray_start_cluster_head_with_external_redis(request, external_redis):
|
|
param = getattr(request, "param", {})
|
|
with _ray_start_cluster(do_init=True, num_nodes=1, **param) as res:
|
|
yield res
|
|
|
|
|
|
@pytest.fixture
|
|
def ray_start_cluster_head_with_external_redis_sentinel(
|
|
request, external_redis_with_sentinel
|
|
):
|
|
param = getattr(request, "param", {})
|
|
with _ray_start_cluster(do_init=True, num_nodes=1, **param) as res:
|
|
yield res
|
|
|
|
|
|
@pytest.fixture
|
|
def ray_start_cluster_head_with_env_vars(
|
|
request, maybe_setup_external_redis, monkeypatch
|
|
):
|
|
param = getattr(request, "param", {})
|
|
env_vars = param.get("env_vars", {})
|
|
# Create a copy of param without env_vars to pass to _ray_start_cluster
|
|
cluster_param = {k: v for k, v in param.items() if k != "env_vars"}
|
|
for k, v in env_vars.items():
|
|
monkeypatch.setenv(k, v)
|
|
with _ray_start_cluster(do_init=True, num_nodes=1, **cluster_param) as res:
|
|
yield res
|
|
|
|
|
|
@pytest.fixture
|
|
def ray_start_cluster_2_nodes(request, maybe_setup_external_redis):
|
|
param = getattr(request, "param", {})
|
|
with _ray_start_cluster(do_init=True, num_nodes=2, **param) as res:
|
|
yield res
|
|
|
|
|
|
@pytest.fixture
|
|
def ray_start_object_store_memory(request, maybe_setup_external_redis):
|
|
# Start the Ray processes.
|
|
store_size = request.param
|
|
system_config = get_default_fixure_system_config()
|
|
init_kwargs = {
|
|
"num_cpus": 1,
|
|
"_system_config": system_config,
|
|
"object_store_memory": store_size,
|
|
}
|
|
ray.init("local", **init_kwargs)
|
|
yield store_size
|
|
# The code after the yield will run as teardown code.
|
|
ray.shutdown()
|
|
|
|
|
|
@pytest.fixture
|
|
def call_ray_start(request):
|
|
with call_ray_start_context(request) as address:
|
|
yield address
|
|
|
|
|
|
# This fixture will start an httpserver and use it as the proxy-server-url parameters
|
|
@pytest.fixture
|
|
def call_ray_start_context_with_proxy_server(httpserver):
|
|
hsurl = httpserver.url_for("/")
|
|
cmd = f"ray start --head --num-cpus=1 --proxy-server-url={hsurl} --port 0 --min-worker-port=0 --max-worker-port=0"
|
|
tempObject = type("Temp", (), {"param": cmd})
|
|
|
|
with call_ray_start_context(tempObject) as address:
|
|
yield address
|
|
|
|
|
|
@contextmanager
|
|
def call_ray_start_context(request):
|
|
default_cmd = (
|
|
"ray start --head --num-cpus=1 --min-worker-port=0 "
|
|
"--max-worker-port=0 --port 0"
|
|
)
|
|
parameter = getattr(request, "param", default_cmd)
|
|
env = None
|
|
|
|
if isinstance(parameter, dict):
|
|
if "env" in parameter:
|
|
env = {**os.environ, **parameter.get("env")}
|
|
|
|
parameter = parameter.get("cmd", default_cmd)
|
|
|
|
command_args = parameter.split(" ")
|
|
try:
|
|
out = ray._common.utils.decode(
|
|
subprocess.check_output(command_args, stderr=subprocess.STDOUT, env=env)
|
|
)
|
|
# If the exit code is non-zero subprocess.check_output raises a CalledProcessError
|
|
except subprocess.CalledProcessError as e:
|
|
print("Ray start cmd failed!")
|
|
print(f"Command: {' '.join(e.cmd)}")
|
|
print(f"Exit code: {e.returncode}")
|
|
if e.output:
|
|
print(f"Output:\n{e.output.decode()}")
|
|
raise
|
|
except Exception as e:
|
|
print(type(e), e)
|
|
raise
|
|
|
|
# Get the redis address from the output.
|
|
redis_substring_prefix = "--address='"
|
|
idx = out.find(redis_substring_prefix)
|
|
if idx >= 0:
|
|
address_location = idx + len(redis_substring_prefix)
|
|
address = out[address_location:]
|
|
address = address.split("'")[0]
|
|
else:
|
|
address = None
|
|
|
|
yield address
|
|
|
|
# Disconnect from the Ray cluster.
|
|
ray.shutdown()
|
|
# Kill the Ray cluster.
|
|
subprocess.check_call(["ray", "stop"], env=env)
|
|
# Delete the cluster address just in case.
|
|
ray._common.utils.reset_ray_address()
|
|
|
|
|
|
@pytest.fixture
|
|
def init_and_serve():
|
|
import ray.util.client.server.server as ray_client_server
|
|
|
|
server_handle, _ = ray_client_server.init_and_serve("localhost", 50051)
|
|
yield server_handle
|
|
ray_client_server.shutdown_with_server(server_handle.grpc_server)
|
|
time.sleep(2)
|
|
|
|
|
|
@pytest.fixture
|
|
def call_ray_stop_only():
|
|
yield
|
|
subprocess.check_call(["ray", "stop"])
|
|
# Delete the cluster address just in case.
|
|
ray._common.utils.reset_ray_address()
|
|
|
|
|
|
def _start_cluster(cluster, request):
|
|
cluster.add_node(num_cpus=4, dashboard_agent_listen_port=find_free_port())
|
|
return cluster, cluster.address
|
|
|
|
|
|
# Used to enforce that `start_cluster` and `start_cluster_shared` fixtures aren't mixed.
|
|
_START_CLUSTER_SHARED_USED = False
|
|
|
|
|
|
@pytest.fixture
|
|
def start_cluster(ray_start_cluster_enabled, request):
|
|
if _START_CLUSTER_SHARED_USED:
|
|
pytest.fail(
|
|
"Cannot mix `start_cluster` and `start_cluster_shared` "
|
|
"fixtures in the same session."
|
|
)
|
|
yield _start_cluster(ray_start_cluster_enabled, request)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def _start_cluster_shared(ray_start_cluster_enabled_shared, request):
|
|
global _START_CLUSTER_SHARED_USED
|
|
_START_CLUSTER_SHARED_USED = True
|
|
|
|
yield _start_cluster(ray_start_cluster_enabled_shared, request)
|
|
|
|
|
|
# Same as `start_cluster` but module-scoped (shared across all tests in a file).
|
|
# Runs ray.shutdown after each test.
|
|
@pytest.fixture
|
|
def start_cluster_shared(_start_cluster_shared, request):
|
|
yield _start_cluster_shared
|
|
ray.shutdown()
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def tmp_working_dir():
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
path = Path(tmp_dir)
|
|
|
|
hello_file = path / "hello"
|
|
with hello_file.open(mode="w") as f:
|
|
f.write("world")
|
|
|
|
test_file_module = path / "file_module.py"
|
|
with test_file_module.open(mode="w") as f:
|
|
f.write("def hello():\n")
|
|
f.write(" return 'hello'\n")
|
|
|
|
module_path = path / "test_module"
|
|
module_path.mkdir(parents=True)
|
|
|
|
test_file = module_path / "test.py"
|
|
with test_file.open(mode="w") as f:
|
|
f.write("def one():\n")
|
|
f.write(" return 1\n")
|
|
|
|
init_file = module_path / "__init__.py"
|
|
with init_file.open(mode="w") as f:
|
|
f.write("from test_module.test import one\n")
|
|
|
|
yield tmp_dir
|
|
|
|
|
|
@pytest.fixture
|
|
def enable_pickle_debug():
|
|
os.environ["RAY_PICKLE_VERBOSE_DEBUG"] = "1"
|
|
yield
|
|
del os.environ["RAY_PICKLE_VERBOSE_DEBUG"]
|
|
|
|
|
|
@pytest.fixture
|
|
def set_enable_auto_connect(enable_auto_connect: bool = False):
|
|
from ray._private import auto_init_hook
|
|
|
|
try:
|
|
old_value = auto_init_hook.enable_auto_connect
|
|
auto_init_hook.enable_auto_connect = enable_auto_connect
|
|
yield enable_auto_connect
|
|
finally:
|
|
auto_init_hook.enable_auto_connect = old_value
|
|
|
|
|
|
@pytest.fixture
|
|
def enable_mac_large_object_store():
|
|
os.environ["RAY_ENABLE_MAC_LARGE_OBJECT_STORE"] = "1"
|
|
yield
|
|
del os.environ["RAY_ENABLE_MAC_LARGE_OBJECT_STORE"]
|
|
|
|
|
|
@pytest.fixture()
|
|
def two_node_cluster():
|
|
system_config = {
|
|
"object_timeout_milliseconds": 200,
|
|
}
|
|
if cluster_not_supported:
|
|
pytest.skip("Cluster not supported")
|
|
cluster = ray.cluster_utils.Cluster(
|
|
head_node_args={"_system_config": system_config}
|
|
)
|
|
for _ in range(2):
|
|
remote_node = cluster.add_node(num_cpus=1)
|
|
ray.init(address=cluster.address)
|
|
yield cluster, remote_node
|
|
|
|
# The code after the yield will run as teardown code.
|
|
ray.shutdown()
|
|
cluster.shutdown()
|
|
|
|
|
|
@pytest.fixture()
|
|
def error_pubsub():
|
|
p = init_error_pubsub()
|
|
yield p
|
|
p.close()
|
|
|
|
|
|
@pytest.fixture()
|
|
def log_pubsub():
|
|
p = init_log_pubsub()
|
|
yield p
|
|
p.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def use_tls(request):
|
|
if request.param:
|
|
key_filepath, cert_filepath, temp_dir = setup_tls()
|
|
yield request.param
|
|
if request.param:
|
|
teardown_tls(key_filepath, cert_filepath, temp_dir)
|
|
|
|
|
|
"""
|
|
Object spilling test fixture
|
|
"""
|
|
# -- Smart open param --
|
|
bucket_name = "object-spilling-test"
|
|
|
|
# -- File system param --
|
|
spill_local_path = "/tmp/spill"
|
|
|
|
# -- Spilling configs --
|
|
file_system_object_spilling_config = {
|
|
"type": "filesystem",
|
|
"params": {"directory_path": spill_local_path},
|
|
}
|
|
|
|
buffer_object_spilling_config = {
|
|
"type": "filesystem",
|
|
"params": {"directory_path": spill_local_path, "buffer_size": 1_000_000},
|
|
}
|
|
|
|
# Since we have differet protocol for a local external storage (e.g., fs)
|
|
# and distributed external storage (e.g., S3), we need to test both cases.
|
|
# This mocks the distributed fs with cluster utils.
|
|
mock_distributed_fs_object_spilling_config = {
|
|
"type": "mock_distributed_fs",
|
|
"params": {"directory_path": spill_local_path},
|
|
}
|
|
smart_open_object_spilling_config = {
|
|
"type": "smart_open",
|
|
"params": {"uri": f"s3://{bucket_name}/"},
|
|
}
|
|
buffer_open_object_spilling_config = {
|
|
"type": "smart_open",
|
|
"params": {"uri": f"s3://{bucket_name}/", "buffer_size": 1000},
|
|
}
|
|
multi_smart_open_object_spilling_config = {
|
|
"type": "smart_open",
|
|
"params": {"uri": [f"s3://{bucket_name}/{i}" for i in range(3)]},
|
|
}
|
|
|
|
unstable_object_spilling_config = {
|
|
"type": "unstable_fs",
|
|
"params": {
|
|
"directory_path": spill_local_path,
|
|
},
|
|
}
|
|
slow_object_spilling_config = {
|
|
"type": "slow_fs",
|
|
"params": {
|
|
"directory_path": spill_local_path,
|
|
},
|
|
}
|
|
|
|
|
|
def create_object_spilling_config(request, tmp_path):
|
|
temp_folder = tmp_path / "spill"
|
|
temp_folder.mkdir()
|
|
if (
|
|
request.param["type"] == "filesystem"
|
|
or request.param["type"] == "mock_distributed_fs"
|
|
):
|
|
request.param["params"]["directory_path"] = str(temp_folder)
|
|
return json.dumps(request.param), temp_folder
|
|
|
|
|
|
@pytest.fixture(
|
|
scope="function",
|
|
params=[
|
|
file_system_object_spilling_config,
|
|
],
|
|
)
|
|
def fs_only_object_spilling_config(request, tmp_path):
|
|
yield create_object_spilling_config(request, tmp_path)
|
|
|
|
|
|
@pytest.fixture(
|
|
scope="function",
|
|
params=[
|
|
file_system_object_spilling_config,
|
|
],
|
|
)
|
|
def object_spilling_config(request, tmp_path):
|
|
yield create_object_spilling_config(request, tmp_path)
|
|
|
|
|
|
@pytest.fixture(
|
|
scope="function",
|
|
params=[
|
|
file_system_object_spilling_config,
|
|
mock_distributed_fs_object_spilling_config,
|
|
],
|
|
)
|
|
def multi_node_object_spilling_config(request, tmp_path):
|
|
yield create_object_spilling_config(request, tmp_path)
|
|
|
|
|
|
@pytest.fixture(
|
|
scope="function",
|
|
params=[
|
|
unstable_object_spilling_config,
|
|
],
|
|
)
|
|
def unstable_spilling_config(request, tmp_path):
|
|
yield create_object_spilling_config(request, tmp_path)
|
|
|
|
|
|
@pytest.fixture(
|
|
scope="function",
|
|
params=[
|
|
slow_object_spilling_config,
|
|
],
|
|
)
|
|
def slow_spilling_config(request, tmp_path):
|
|
yield create_object_spilling_config(request, tmp_path)
|
|
|
|
|
|
def _ray_start_chaos_cluster(request):
|
|
param = getattr(request, "param", {})
|
|
kill_interval = param.pop("kill_interval", None)
|
|
config = param.pop("_system_config", {})
|
|
config.update(
|
|
{
|
|
"task_retry_delay_ms": 100,
|
|
}
|
|
)
|
|
# Config of workers that are re-started.
|
|
head_resources = param.pop("head_resources")
|
|
worker_node_types = param.pop("worker_node_types")
|
|
cluster = AutoscalingCluster(
|
|
head_resources,
|
|
worker_node_types,
|
|
idle_timeout_minutes=10, # Don't take down nodes.
|
|
**param,
|
|
)
|
|
cluster.start(_system_config=config)
|
|
ray.init("auto")
|
|
nodes = ray.nodes()
|
|
assert len(nodes) == 1
|
|
|
|
if kill_interval is not None:
|
|
node_killer = get_and_run_resource_killer(RayletKiller, kill_interval)
|
|
|
|
yield cluster
|
|
|
|
if kill_interval is not None:
|
|
ray.get(node_killer.stop_run.remote())
|
|
killed = {
|
|
node_id for node_id, _, _ in ray.get(node_killer.get_killed_nodes.remote())
|
|
}
|
|
assert len(killed) > 0
|
|
died = {node["NodeID"] for node in ray.nodes() if not node["Alive"]}
|
|
assert died.issubset(
|
|
killed
|
|
), f"Raylets {died - killed} that we did not kill crashed"
|
|
|
|
ray.shutdown()
|
|
cluster.shutdown()
|
|
|
|
|
|
@pytest.fixture
|
|
def ray_start_chaos_cluster(request):
|
|
"""Returns the cluster and chaos thread."""
|
|
for x in _ray_start_chaos_cluster(request):
|
|
yield x
|
|
|
|
|
|
# Set scope to "class" to force this to run before start_cluster, whose scope
|
|
# is "function". We need these env vars to be set before Ray is started.
|
|
@pytest.fixture(scope="class")
|
|
def runtime_env_disable_URI_cache():
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"RAY_RUNTIME_ENV_CONDA_CACHE_SIZE_GB": "0",
|
|
"RAY_RUNTIME_ENV_PIP_CACHE_SIZE_GB": "0",
|
|
"RAY_RUNTIME_ENV_WORKING_DIR_CACHE_SIZE_GB": "0",
|
|
"RAY_RUNTIME_ENV_PY_MODULES_CACHE_SIZE_GB": "0",
|
|
},
|
|
):
|
|
print(
|
|
"URI caching disabled (conda, pip, working_dir, py_modules cache "
|
|
"size set to 0)."
|
|
)
|
|
yield
|
|
|
|
|
|
# Use to create virtualenv that clone from current python env.
|
|
# The difference between this fixture and `pytest_virtual.virtual` is that
|
|
# `pytest_virtual.virtual` will not inherit current python env's site-package.
|
|
# Note: Can't use in virtualenv, this must be noted when testing locally.
|
|
@pytest.fixture(scope="function")
|
|
def cloned_virtualenv():
|
|
# Lazy import pytest_virtualenv,
|
|
# aviod import `pytest_virtualenv` in test case `Minimal install`
|
|
from pytest_virtualenv import VirtualEnv
|
|
|
|
if virtualenv_utils.is_in_virtualenv():
|
|
raise RuntimeError("Forbid the use of this fixture in virtualenv")
|
|
|
|
venv = VirtualEnv(
|
|
args=[
|
|
"--system-site-packages",
|
|
"--reset-app-data",
|
|
"--no-periodic-update",
|
|
"--no-download",
|
|
],
|
|
)
|
|
yield venv
|
|
venv.teardown()
|
|
|
|
|
|
@pytest.fixture
|
|
def set_runtime_env_retry_times(request):
|
|
runtime_env_retry_times = getattr(request, "param", "0")
|
|
try:
|
|
os.environ["RUNTIME_ENV_RETRY_TIMES"] = runtime_env_retry_times
|
|
yield runtime_env_retry_times
|
|
finally:
|
|
del os.environ["RUNTIME_ENV_RETRY_TIMES"]
|
|
|
|
|
|
@pytest.fixture
|
|
def listen_port(request):
|
|
port = getattr(request, "param", 0)
|
|
try:
|
|
sock = socket.socket()
|
|
if hasattr(socket, "SO_REUSEPORT"):
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 0)
|
|
|
|
# Try up to 10 times.
|
|
MAX_RETRY = 10
|
|
for i in range(MAX_RETRY):
|
|
try:
|
|
sock.bind(("127.0.0.1", port))
|
|
break
|
|
except OSError as e:
|
|
if i == MAX_RETRY - 1:
|
|
raise e
|
|
else:
|
|
print(f"failed to bind on a port {port}. {e}")
|
|
time.sleep(1)
|
|
yield port
|
|
finally:
|
|
sock.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def set_bad_runtime_env_cache_ttl_seconds(request):
|
|
ttl = getattr(request, "param", "0")
|
|
os.environ["BAD_RUNTIME_ENV_CACHE_TTL_SECONDS"] = ttl
|
|
yield ttl
|
|
del os.environ["BAD_RUNTIME_ENV_CACHE_TTL_SECONDS"]
|
|
|
|
|
|
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
|
def pytest_runtest_makereport(item, call):
|
|
# execute all other hooks to obtain the report object
|
|
outcome = yield
|
|
rep = outcome.get_result()
|
|
|
|
try:
|
|
append_short_test_summary(rep)
|
|
except Exception as e:
|
|
print(f"+++ Error creating PyTest summary\n{e}")
|
|
try:
|
|
create_ray_logs_for_failed_test(rep)
|
|
except Exception as e:
|
|
print(f"+++ Error saving Ray logs for failing test\n{e}")
|
|
|
|
|
|
def append_short_test_summary(rep):
|
|
"""Writes a short summary txt for failed tests to be printed later."""
|
|
if rep.when != "call":
|
|
return
|
|
|
|
summary_dir = os.environ.get("RAY_TEST_SUMMARY_DIR")
|
|
|
|
if platform.system() == "Darwin":
|
|
summary_dir = os.environ.get("RAY_TEST_SUMMARY_DIR_HOST")
|
|
|
|
if not summary_dir:
|
|
return
|
|
|
|
if not os.path.exists(summary_dir):
|
|
os.makedirs(summary_dir, exist_ok=True)
|
|
|
|
test_name = rep.nodeid.replace(os.sep, "::")
|
|
|
|
if platform.system() == "Windows":
|
|
# ":" is not legal in filenames in windows
|
|
test_name = test_name.replace(":", "$")
|
|
|
|
summary_file = os.path.join(summary_dir, test_name + ".txt")
|
|
|
|
if rep.passed and os.path.exists(summary_file):
|
|
# The test succeeded after failing, thus it is flaky.
|
|
# We do not want to annotate flaky tests just now, so remove report.
|
|
os.remove(summary_file)
|
|
return
|
|
|
|
# Only consider failed tests from now on
|
|
if not rep.failed:
|
|
return
|
|
|
|
# No failing test information
|
|
if rep.longrepr is None:
|
|
return
|
|
|
|
# No failing test information
|
|
if not hasattr(rep.longrepr, "chain"):
|
|
return
|
|
|
|
# Use `wt` here to overwrite so we only have one result per test (exclude retries)
|
|
with open(summary_file, "wt") as fp:
|
|
fp.write(_get_markdown_annotation(rep))
|
|
|
|
|
|
def _get_markdown_annotation(rep) -> str:
|
|
# Main traceback is the last in the chain (where the last error is raised)
|
|
main_tb, main_loc, _ = rep.longrepr.chain[-1]
|
|
markdown = ""
|
|
|
|
# Only keep last line of the message
|
|
short_message = list(filter(None, main_loc.message.split("\n")))[-1]
|
|
|
|
# Header: Main error message
|
|
markdown += f"#### {rep.nodeid}\n\n"
|
|
markdown += "<details>\n"
|
|
markdown += f"<summary>{short_message}</summary>\n\n"
|
|
|
|
# Add location to the test definition
|
|
test_file, test_lineno, _test_node = rep.location
|
|
test_path = os.path.abspath(test_file)
|
|
markdown += f"Test location: {test_path}:{test_lineno}\n\n"
|
|
|
|
# Print main traceback
|
|
markdown += "##### Traceback\n\n"
|
|
markdown += "```\n"
|
|
markdown += str(main_tb)
|
|
markdown += "\n```\n\n"
|
|
|
|
# Print test definition location
|
|
markdown += f"{main_loc.path}:{main_loc.lineno}\n\n"
|
|
|
|
# If this is a longer exception chain, users can expand the full traceback
|
|
if len(rep.longrepr.chain) > 1:
|
|
markdown += "<details><summary>Full traceback</summary>\n\n"
|
|
|
|
# Here we just print each traceback and the respective lines.
|
|
for tb, loc, _ in rep.longrepr.chain:
|
|
markdown += "```\n"
|
|
markdown += str(tb)
|
|
markdown += "\n```\n\n"
|
|
if loc:
|
|
markdown += f"{loc.path}:{loc.lineno}\n\n"
|
|
|
|
markdown += "</details>\n"
|
|
|
|
markdown += "<details><summary>PIP packages</summary>\n\n"
|
|
markdown += "```\n"
|
|
markdown += "\n".join(_get_pip_packages())
|
|
markdown += "\n```\n\n"
|
|
markdown += "</details>\n"
|
|
|
|
markdown += "</details>\n\n"
|
|
return markdown
|
|
|
|
|
|
def _get_pip_packages() -> List[str]:
|
|
try:
|
|
from pip._internal.operations import freeze
|
|
|
|
return list(freeze.freeze())
|
|
except Exception:
|
|
return ["invalid"]
|
|
|
|
|
|
def create_ray_logs_for_failed_test(rep):
|
|
"""Creates artifact zip of /tmp/ray/session_latest/logs for failed tests"""
|
|
|
|
# We temporarily restrict to Linux until we have artifact dirs
|
|
# for Windows and Mac
|
|
if platform.system() != "Linux" and platform.system() != "Windows":
|
|
return
|
|
|
|
# Only archive failed tests after the "call" phase of the test
|
|
if rep.when != "call" or not rep.failed:
|
|
return
|
|
|
|
# Get dir to write zipped logs to
|
|
archive_dir = os.environ.get("RAY_TEST_FAILURE_LOGS_ARCHIVE_DIR")
|
|
|
|
if not archive_dir:
|
|
return
|
|
|
|
if not os.path.exists(archive_dir):
|
|
os.makedirs(archive_dir)
|
|
|
|
# Get logs dir from the latest ray session
|
|
tmp_dir = gettempdir()
|
|
logs_dir = os.path.join(tmp_dir, "ray", "session_latest", "logs")
|
|
|
|
if not os.path.exists(logs_dir):
|
|
return
|
|
|
|
# Write zipped logs to logs archive dir
|
|
test_name = rep.nodeid.replace(os.sep, "::")
|
|
if platform.system() == "Windows":
|
|
# ":" is not legal in filenames in windows
|
|
test_name = test_name.replace(":", "$")
|
|
output_file = os.path.join(archive_dir, f"{test_name}_{time.time():.4f}")
|
|
shutil.make_archive(output_file, "zip", logs_dir)
|
|
|
|
|
|
@pytest.fixture(params=[True, False])
|
|
def start_http_proxy(request):
|
|
env = {}
|
|
|
|
proxy = None
|
|
try:
|
|
if request.param:
|
|
# the `proxy` command is from the proxy.py package.
|
|
proxy = subprocess.Popen(
|
|
["proxy", "--port", "8899", "--log-level", "ERROR"]
|
|
)
|
|
env["RAY_grpc_enable_http_proxy"] = "1"
|
|
proxy_url = "http://localhost:8899"
|
|
else:
|
|
proxy_url = "http://example.com"
|
|
env["http_proxy"] = proxy_url
|
|
env["https_proxy"] = proxy_url
|
|
yield env
|
|
finally:
|
|
if proxy:
|
|
proxy.terminate()
|
|
proxy.wait()
|
|
|
|
|
|
@pytest.fixture
|
|
def set_runtime_env_plugins(request):
|
|
runtime_env_plugins = getattr(request, "param", "0")
|
|
try:
|
|
os.environ["RAY_RUNTIME_ENV_PLUGINS"] = runtime_env_plugins
|
|
yield runtime_env_plugins
|
|
finally:
|
|
del os.environ["RAY_RUNTIME_ENV_PLUGINS"]
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def temp_file(request):
|
|
with tempfile.NamedTemporaryFile("r+b") as fp:
|
|
yield fp
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def temp_dir(request):
|
|
with tempfile.TemporaryDirectory("r+b") as d:
|
|
yield d
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def random_ascii_file(request):
|
|
import random
|
|
import string
|
|
|
|
file_size = getattr(request, "param", 1 << 10)
|
|
|
|
with tempfile.NamedTemporaryFile(mode="r+b") as fp:
|
|
fp.write("".join(random.choices(string.ascii_letters, k=file_size)).encode())
|
|
fp.flush()
|
|
|
|
yield fp
|
|
|
|
|
|
# Clean up Ray address file before the test run starts, since sometimes bazel test times out
|
|
# and kill the test process, without cleaning up the Ray address file.
|
|
def pytest_sessionstart(session):
|
|
"""Called after the Session object has been created and before performing collection and entering the run test loop."""
|
|
|
|
# Delete the cluster address file just in case.
|
|
ray._common.utils.reset_ray_address()
|
|
|
|
|
|
"""
|
|
pytest httpserver related test fixtures
|
|
"""
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def make_httpserver(httpserver_listen_address, httpserver_ssl_context):
|
|
"""
|
|
Module-scoped override of pytest-httpserver's make_httpserver fixture.
|
|
Copies the implementation the make_httpserver fixture.
|
|
"""
|
|
# Lazy import pytest_httpserver to avoid import errors in library tests that doesn't
|
|
# have pytest_httpserver installed.
|
|
from pytest_httpserver.httpserver import HTTPServer
|
|
|
|
host, port = httpserver_listen_address
|
|
if not host:
|
|
host = HTTPServer.DEFAULT_LISTEN_HOST
|
|
if not port:
|
|
port = HTTPServer.DEFAULT_LISTEN_PORT
|
|
|
|
server = HTTPServer(host=host, port=port, ssl_context=httpserver_ssl_context)
|
|
server.start()
|
|
yield server
|
|
server.clear()
|
|
if server.is_running():
|
|
server.stop()
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def event_routing_config(request, monkeypatch):
|
|
"""
|
|
fixture to toggle event routing modes.
|
|
Modes:
|
|
- "default": Uses the existing core_worker to gcs code path.
|
|
- "aggregator": Enable publishing events to GCS through the Aggregator agent.
|
|
"""
|
|
mode = getattr(request, "param", "default")
|
|
# clear envs to ensure default behavior
|
|
monkeypatch.delenv(
|
|
"RAY_DASHBOARD_AGGREGATOR_AGENT_PUBLISH_EVENTS_TO_GCS", raising=False
|
|
)
|
|
monkeypatch.delenv("RAY_enable_core_worker_ray_event_to_aggregator", raising=False)
|
|
|
|
if mode == "aggregator":
|
|
print("using aggregator mode")
|
|
# Enable aggregator path in core worker
|
|
monkeypatch.setenv("RAY_enable_core_worker_ray_event_to_aggregator", "1")
|
|
# Explicitly disable core worker to GCS so that all events are only sent to GCS once (through the aggregator pathway)
|
|
monkeypatch.setenv("RAY_enable_core_worker_task_event_to_gcs", "0")
|
|
# Ensure aggregator agent publishes to GCS
|
|
monkeypatch.setenv(
|
|
"RAY_DASHBOARD_AGGREGATOR_AGENT_PUBLISH_EVENTS_TO_GCS", "True"
|
|
)
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def cleanup_auth_token_env():
|
|
"""Reset authentication environment variables, files, and caches."""
|
|
|
|
with authentication_env_guard():
|
|
clear_auth_token_sources(remove_default=True)
|
|
reset_auth_token_state()
|
|
yield
|
|
reset_auth_token_state()
|
|
|
|
|
|
@pytest.fixture(autouse=False)
|
|
def clean_token_sources(cleanup_auth_token_env):
|
|
"""Ensure authentication-related state is clean around each test."""
|
|
clear_auth_token_sources(remove_default=True)
|
|
reset_auth_token_state()
|
|
|
|
yield
|
|
|
|
if ray.is_initialized():
|
|
ray.shutdown()
|
|
|
|
subprocess.run(
|
|
["ray", "stop", "--force"],
|
|
capture_output=True,
|
|
timeout=60,
|
|
check=False,
|
|
)
|
|
|
|
reset_auth_token_state()
|
|
|
|
|
|
@pytest.fixture
|
|
def setup_cluster_with_token_auth(cleanup_auth_token_env):
|
|
"""Spin up a Ray cluster with token authentication enabled."""
|
|
|
|
test_token = "test_token_12345678901234567890123456789012"
|
|
set_auth_mode("token")
|
|
set_env_auth_token(test_token)
|
|
reset_auth_token_state()
|
|
|
|
cluster = Cluster()
|
|
# Use dynamic port to avoid port conflicts on Windows where sockets
|
|
# linger in TIME_WAIT state between tests
|
|
cluster.add_node(dashboard_agent_listen_port=find_free_port())
|
|
|
|
try:
|
|
context = ray.init(address=cluster.address)
|
|
dashboard_url = context.address_info["webui_url"]
|
|
yield {
|
|
"cluster": cluster,
|
|
"dashboard_url": f"http://{dashboard_url}",
|
|
"token": test_token,
|
|
}
|
|
finally:
|
|
ray.shutdown()
|
|
cluster.shutdown()
|
|
|
|
|
|
@pytest.fixture
|
|
def setup_cluster_without_token_auth(cleanup_auth_token_env):
|
|
"""Spin up a Ray cluster with authentication disabled."""
|
|
|
|
set_auth_mode("disabled")
|
|
clear_auth_token_sources(remove_default=True)
|
|
reset_auth_token_state()
|
|
|
|
cluster = Cluster()
|
|
# Use dynamic port to avoid port conflicts on Windows where sockets
|
|
# linger in TIME_WAIT state between tests
|
|
cluster.add_node(dashboard_agent_listen_port=find_free_port())
|
|
|
|
try:
|
|
context = ray.init(address=cluster.address)
|
|
dashboard_url = context.address_info["webui_url"]
|
|
yield {
|
|
"cluster": cluster,
|
|
"dashboard_url": f"http://{dashboard_url}",
|
|
}
|
|
finally:
|
|
ray.shutdown()
|
|
cluster.shutdown()
|
|
|
|
|
|
@pytest.fixture
|
|
def ray_start_cluster_with_zero_copy_tensors(monkeypatch):
|
|
"""Start a Ray cluster with zero-copy PyTorch tensors enabled."""
|
|
with monkeypatch.context() as m:
|
|
# Enable zero-copy sharing of PyTorch tensors in Ray
|
|
m.setenv("RAY_ENABLE_ZERO_COPY_TORCH_TENSORS", "1")
|
|
|
|
# Initialize Ray with the required environment variable.
|
|
ray.init(runtime_env={"env_vars": {"RAY_ENABLE_ZERO_COPY_TORCH_TENSORS": "1"}})
|
|
|
|
# Yield control to the test session
|
|
yield
|
|
|
|
# Shutdown Ray after tests complete
|
|
ray.shutdown()
|