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

394 lines
13 KiB
Python

"""runtime_env tests that require their own custom fixture.
The other runtime_env tests use a shared Ray instance across the test module
to reduce overheads & overall test runtime.
"""
import fnmatch
import logging
import os
import sys
import time
from pathlib import Path
from typing import List
import pytest
import ray
from ray._common.test_utils import wait_for_condition
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
from ray._private.test_utils import (
get_error_message,
get_log_sources,
)
from ray.exceptions import RuntimeEnvSetupError
from ray.job_submission import JobStatus, JobSubmissionClient
from ray.runtime_env import RuntimeEnv
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
def test_no_spurious_worker_startup(shutdown_only, monkeypatch):
"""Test that no extra workers start up during a long env installation."""
# Causes agent to sleep for 15 seconds to simulate creating a runtime env.
monkeypatch.setenv("RAY_RUNTIME_ENV_SLEEP_FOR_TESTING_S", "15")
ray.init(num_cpus=1)
@ray.remote
class Counter(object):
def __init__(self):
self.value = 0
def get(self):
return self.value
# Set a nonempty runtime env so that the runtime env setup hook is called.
runtime_env = RuntimeEnv(env_vars={"a": "b"})
# Instantiate an actor that requires the long runtime env installation.
a = Counter.options(runtime_env=runtime_env).remote()
assert ray.get(a.get.remote()) == 0
# Check "debug_state.txt" to ensure no extra workers were started.
session_dir = ray._private.worker.global_worker.node.address_info["session_dir"]
session_path = Path(session_dir)
debug_state_path = session_path / "logs" / "debug_state.txt"
def get_num_workers():
with open(debug_state_path) as f:
for line in f.readlines():
num_workers_prefix = "- num PYTHON workers: "
if num_workers_prefix in line:
return int(line[len(num_workers_prefix) :])
return None
# Wait for "debug_state.txt" to be updated to reflect the started worker.
start = time.time()
wait_for_condition(lambda: get_num_workers() is not None and get_num_workers() > 0)
time_waited = time.time() - start
print(f"Waited {time_waited} for debug_state.txt to be updated")
# If any workers were unnecessarily started during the initial env
# installation, they will bypass the runtime env setup hook (because the
# created env will have been cached) and should be added to num_workers
# within a few seconds. Adjusting the default update period for
# debut_state.txt via this cluster_utils pytest fixture seems to be broken,
# so just check it for the next 10 seconds (the default period).
start = time.time()
got_num_workers = False
while time.time() - start < 10:
# Check that no more than one extra worker is started. We add one
# because Ray will prestart an idle worker for the one available CPU.
num_workers = get_num_workers()
if num_workers is not None:
got_num_workers = True
assert num_workers <= 2
time.sleep(0.1)
assert got_num_workers, "failed to read num workers for 10 seconds"
@pytest.fixture
def runtime_env_local_dev_env_var(monkeypatch):
monkeypatch.setenv("RAY_RUNTIME_ENV_LOCAL_DEV_MODE", "1")
yield
@pytest.mark.skipif(sys.platform == "win32", reason="very slow on Windows.")
def test_runtime_env_no_spurious_resource_deadlock_msg(
runtime_env_local_dev_env_var, ray_start_regular, error_pubsub
):
p = error_pubsub
runtime_env = RuntimeEnv(pip=["tensorflow", "torch"])
@ray.remote(runtime_env=runtime_env)
def f():
pass
# Check no warning printed.
ray.get(f.remote())
errors = get_error_message(
p, 5, ray._private.ray_constants.RESOURCE_DEADLOCK_ERROR, timeout=5
)
assert len(errors) == 0
RT_ENV_AGENT_SLOW_STARTUP_PLUGIN_CLASS_PATH = (
"ray.tests.test_runtime_env_standalone.RtEnvAgentSlowStartupPlugin" # noqa
)
RT_ENV_AGENT_SLOW_STARTUP_PLUGIN_NAME = "RtEnvAgentSlowStartupPlugin"
RT_ENV_AGENT_SLOW_STARTUP_PLUGIN_CLASS_PATH = (
"ray.tests.test_runtime_env_standalone.RtEnvAgentSlowStartupPlugin"
)
class RtEnvAgentSlowStartupPlugin(RuntimeEnvPlugin):
name = RT_ENV_AGENT_SLOW_STARTUP_PLUGIN_NAME
def __init__(self):
# This happens in Runtime Env Agent start up process. Make it slow.
time.sleep(5)
print("starting...")
@pytest.mark.parametrize(
"set_runtime_env_plugins",
[
'[{"class":"' + RT_ENV_AGENT_SLOW_STARTUP_PLUGIN_CLASS_PATH + '"}]',
],
indirect=True,
)
def test_slow_runtime_env_agent_startup_on_task_pressure(
shutdown_only, set_runtime_env_plugins
):
"""
Starts nodes with runtime env agent and a slow plugin. Then when the runtime env
agent is still starting up, we submit a lot of tasks to the cluster. The tasks
should wait for the runtime env agent to start up and then run.
https://github.com/ray-project/ray/issues/45353
"""
@ray.remote(num_cpus=0.1)
def get_foo():
return os.environ.get("foo")
print("Submitting 20 tasks...")
# Each task has a different runtime env to ensure the agent is invoked for each.
vals = ray.get(
[
get_foo.options(runtime_env={"env_vars": {"foo": f"bar{i}"}}).remote()
for i in range(20)
]
)
print("20 tasks done.")
assert vals == [f"bar{i}" for i in range(20)]
MY_PLUGIN_CLASS_PATH = "ray.tests.test_runtime_env_standalone.MyPlugin"
MY_PLUGIN_NAME = "MyPlugin"
success_retry_number = 3
runtime_env_retry_times = 0
# This plugin can make runtime env creation failed before the retry times
# increased to `success_retry_number`.
class MyPlugin(RuntimeEnvPlugin):
name = MY_PLUGIN_NAME
@staticmethod
def validate(runtime_env_dict: dict) -> str:
return runtime_env_dict[MY_PLUGIN_NAME]
@staticmethod
def modify_context(
uris: List[str],
runtime_env: dict,
ctx: RuntimeEnvContext,
logger: logging.Logger,
) -> None:
global runtime_env_retry_times
runtime_env_retry_times += 1
if runtime_env_retry_times != success_retry_number:
raise ValueError(f"Fault injection {runtime_env_retry_times}")
pass
@pytest.mark.parametrize(
"set_runtime_env_retry_times",
[
str(success_retry_number - 1),
str(success_retry_number),
],
indirect=True,
)
@pytest.mark.parametrize(
"set_runtime_env_plugins",
[
'[{"class":"' + MY_PLUGIN_CLASS_PATH + '"}]',
],
indirect=True,
)
def test_runtime_env_retry(
set_runtime_env_retry_times, set_runtime_env_plugins, ray_start_regular
):
@ray.remote
def f():
return "ok"
runtime_env_retry_times = int(set_runtime_env_retry_times)
if runtime_env_retry_times >= success_retry_number:
# Enough retry times
output = ray.get(
f.options(runtime_env={MY_PLUGIN_NAME: {"key": "value"}}).remote()
)
assert output == "ok"
else:
# No enough retry times
with pytest.raises(
RuntimeEnvSetupError, match=f"Fault injection {runtime_env_retry_times}"
):
ray.get(f.options(runtime_env={MY_PLUGIN_NAME: {"key": "value"}}).remote())
@pytest.fixture
def enable_dev_mode(local_env_var_enabled, monkeypatch):
enabled = "1" if local_env_var_enabled else "0"
monkeypatch.setenv("RAY_RUNTIME_ENV_LOG_TO_DRIVER_ENABLED", enabled)
yield
@pytest.mark.skipif(
sys.platform == "win32", reason="conda in runtime_env unsupported on Windows."
)
@pytest.mark.parametrize("local_env_var_enabled", [False, True])
def test_runtime_env_log_msg(
local_env_var_enabled,
enable_dev_mode,
ray_start_cluster_head,
log_pubsub,
):
p = log_pubsub
@ray.remote
def f():
pass
good_env = RuntimeEnv(pip=["requests"])
ray.get(f.options(runtime_env=good_env).remote())
sources = get_log_sources(p, 5)
if local_env_var_enabled:
assert "runtime_env" in sources
else:
assert "runtime_env" not in sources
def assert_no_user_info_in_logs(user_info: str, file_whitelist: List[str] = None):
"""Assert that the user info is not in the logs, except for any file that
glob pattern matches a file in the whitelist.
"""
if file_whitelist is None:
file_whitelist = []
node = ray._private.worker.global_worker.node
log_dir = os.path.join(node.get_session_dir_path(), "logs")
for root, dirs, files in os.walk(log_dir):
for file in files:
if any(fnmatch.fnmatch(file, pattern) for pattern in file_whitelist):
continue
# Some lines contain hex IDs, so ignore the UTF decoding errors.
with open(os.path.join(root, file), "r", errors="ignore") as f:
for line in f:
assert user_info not in line, (file, user_info, line)
class TestNoUserInfoInLogs:
"""Test that no user info (e.g. runtime env env vars) show up in the logs."""
def test_assert_no_user_info_in_logs(self, shutdown_only):
"""Test assert_no_user_info_in_logs does not spuriously pass."""
ray.init()
with pytest.raises(AssertionError):
assert_no_user_info_in_logs("ray")
assert_no_user_info_in_logs("ray", file_whitelist=["*"])
def test_basic(self, tmp_path, shutdown_only):
"""Test that no user info shows up in the logs."""
# Runtime env logs may still appear in debug logs. Check the debug flag is off.
assert os.getenv("RAY_BACKEND_LOG_LEVEL") != "debug"
# Reuse the same "secret" for working_dir, pip, env_vars for convenience.
USER_SECRET = "pip-install-test"
working_dir = tmp_path / USER_SECRET
working_dir.mkdir()
runtime_env = {
"working_dir": str(working_dir),
"pip": [USER_SECRET],
"env_vars": {USER_SECRET: USER_SECRET},
}
ray.init(runtime_env=runtime_env, include_dashboard=True)
# Run a function to ensure the runtime env is set up.
@ray.remote
def f():
return os.environ.get(USER_SECRET)
assert USER_SECRET in ray.get(f.remote())
@ray.remote
class Foo:
def __init__(self):
self.x = os.environ.get(USER_SECRET)
def get_x(self):
return self.x
foo = Foo.remote()
assert USER_SECRET in ray.get(foo.get_x.remote())
# Generate runtime env failure logs too.
bad_runtime_env = {
"pip": ["pkg-which-sadly-does-not-exist"],
"env_vars": {USER_SECRET: USER_SECRET},
}
with pytest.raises(Exception):
ray.get(f.options(runtime_env=bad_runtime_env).remote())
with pytest.raises(Exception):
foo2 = Foo.options(runtime_env=bad_runtime_env).remote()
ray.get(foo2.get_x.remote())
# Test Ray Jobs API codepath.
# Skip for Windows because Dashboard and Ray Jobs are not tested on Windows.
if sys.platform != "win32":
client = JobSubmissionClient()
job_id_good_runtime_env = client.submit_job(
entrypoint="echo 'hello world'", runtime_env=runtime_env
)
job_id_bad_runtime_env = client.submit_job(
entrypoint="echo 'hello world'", runtime_env=bad_runtime_env
)
def job_succeeded(job_id):
job_status = client.get_job_status(job_id)
return job_status == JobStatus.SUCCEEDED
def job_failed(job_id):
job_status = client.get_job_status(job_id)
return job_status == JobStatus.FAILED
wait_for_condition(lambda: job_succeeded(job_id_good_runtime_env))
wait_for_condition(lambda: job_failed(job_id_bad_runtime_env), timeout=30)
with pytest.raises(AssertionError):
assert_no_user_info_in_logs(USER_SECRET)
assert_no_user_info_in_logs(
USER_SECRET, file_whitelist=["runtime_env*.log", "event_EXPORT*.log"]
)
@pytest.mark.skipif(sys.platform == "win32", reason="Hangs on windows.")
def test_failed_job_env_no_hang(shutdown_only):
"""Test that after a failed job-level env, tasks can still be run."""
runtime_env_for_init = RuntimeEnv(pip=["ray-doesnotexist-123"])
ray.init(runtime_env=runtime_env_for_init)
@ray.remote
def f():
import pip_install_test # noqa: F401
return True
runtime_env_for_f = RuntimeEnv(pip=["pip-install-test==0.5"])
assert ray.get(f.options(runtime_env=runtime_env_for_f).remote())
# Task with no runtime env should inherit the bad job env.
with pytest.raises(RuntimeEnvSetupError):
ray.get(f.remote())
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))