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

775 lines
22 KiB
Python

import asyncio
import json
import logging
import os
import sys
import tempfile
import time
from pathlib import Path
from typing import List
from unittest import mock
import pytest
import ray
from ray._common.test_utils import wait_for_condition
from ray._private import ray_constants
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
from ray._private.test_utils import external_redis_test_enabled
from ray.exceptions import RuntimeEnvSetupError
from ray.runtime_env.runtime_env import RuntimeEnv
MY_PLUGIN_CLASS_PATH = "ray.tests.test_runtime_env_plugin.MyPlugin"
MY_PLUGIN_NAME = "MyPlugin"
class MyPlugin(RuntimeEnvPlugin):
name = MY_PLUGIN_NAME
env_key = "MY_PLUGIN_TEST_ENVIRONMENT_KEY"
@staticmethod
def validate(runtime_env: RuntimeEnv) -> str:
value = runtime_env[MY_PLUGIN_NAME]
if value == "fail":
raise ValueError("not allowed")
return value
def modify_context(
self,
uris: List[str],
runtime_env: RuntimeEnv,
ctx: RuntimeEnvContext,
logger: logging.Logger,
) -> None:
plugin_config_dict = runtime_env[MY_PLUGIN_NAME]
ctx.env_vars[MyPlugin.env_key] = str(plugin_config_dict["env_value"])
ctx.command_prefix += [
"echo",
plugin_config_dict["tmp_content"],
">",
plugin_config_dict["tmp_file"],
"&&",
]
ctx.py_executable = (
plugin_config_dict["prefix_command"] + " " + ctx.py_executable
)
@pytest.mark.parametrize(
"set_runtime_env_plugins",
[
'[{"class":"' + MY_PLUGIN_CLASS_PATH + '"}]',
],
indirect=True,
)
def test_simple_env_modification_plugin(set_runtime_env_plugins, ray_start_regular):
_, tmp_file_path = tempfile.mkstemp()
@ray.remote
def f():
import psutil
with open(tmp_file_path, "r") as f:
content = f.read().strip()
return {
"env_value": os.environ[MyPlugin.env_key],
"tmp_content": content,
"nice": psutil.Process().nice(),
}
with pytest.raises(RuntimeEnvSetupError, match="not allowed"):
ray.get(f.options(runtime_env={MY_PLUGIN_NAME: "fail"}).remote())
if os.name != "nt":
output = ray.get(
f.options(
runtime_env={
MY_PLUGIN_NAME: {
"env_value": 42,
"tmp_file": tmp_file_path,
"tmp_content": "hello",
# See https://en.wikipedia.org/wiki/Nice_(Unix)
"prefix_command": "nice -n 19",
}
}
).remote()
)
assert output == {"env_value": "42", "tmp_content": "hello", "nice": 19}
MY_PLUGIN_FOR_HANG_CLASS_PATH = "ray.tests.test_runtime_env_plugin.MyPluginForHang"
MY_PLUGIN_FOR_HANG_NAME = "MyPluginForHang"
my_plugin_setup_times = 0
# This plugin will hang when first setup, second setup will ok
class MyPluginForHang(RuntimeEnvPlugin):
name = MY_PLUGIN_FOR_HANG_NAME
env_key = "MY_PLUGIN_FOR_HANG_TEST_ENVIRONMENT_KEY"
@staticmethod
def validate(runtime_env_dict: dict) -> str:
return "True"
async def create(
self,
uri: str,
runtime_env: dict,
ctx: RuntimeEnvContext,
logger: logging.Logger,
) -> float:
global my_plugin_setup_times
my_plugin_setup_times += 1
# first setup
if my_plugin_setup_times == 1:
# sleep forever
await asyncio.sleep(3600)
def modify_context(
self,
uris: List[str],
plugin_config_dict: dict,
ctx: RuntimeEnvContext,
logger: logging.Logger,
) -> None:
global my_plugin_setup_times
ctx.env_vars[MyPluginForHang.env_key] = str(my_plugin_setup_times)
@pytest.mark.parametrize(
"set_runtime_env_plugins",
[
'[{"class":"' + MY_PLUGIN_FOR_HANG_CLASS_PATH + '"}]',
],
indirect=True,
)
def test_plugin_hang(set_runtime_env_plugins, ray_start_regular):
env_key = MyPluginForHang.env_key
@ray.remote(num_cpus=0.1)
def f():
return os.environ[env_key]
refs = [
f.options(
# Avoid hitting the cache of runtime_env
runtime_env={MY_PLUGIN_FOR_HANG_NAME: {"name": "f1"}}
).remote(),
f.options(runtime_env={MY_PLUGIN_FOR_HANG_NAME: {"name": "f2"}}).remote(),
]
def condition():
for ref in refs:
try:
res = ray.get(ref, timeout=1)
print("result:", res)
assert int(res) == 2
return True
except Exception as error:
print(f"Got error: {error}")
pass
return False
wait_for_condition(condition, timeout=60)
DUMMY_PLUGIN_CLASS_PATH = "ray.tests.test_runtime_env_plugin.DummyPlugin"
DUMMY_PLUGIN_NAME = "DummyPlugin"
HANG_PLUGIN_CLASS_PATH = "ray.tests.test_runtime_env_plugin.HangPlugin"
HANG_PLUGIN_NAME = "HangPlugin"
class DummyPlugin(RuntimeEnvPlugin):
name = DUMMY_PLUGIN_NAME
@staticmethod
def validate(runtime_env_dict: dict) -> str:
return 1
class HangPlugin(DummyPlugin):
name = HANG_PLUGIN_NAME
async def create(
self,
uri: str,
runtime_env: "RuntimeEnv",
ctx: RuntimeEnvContext,
logger: logging.Logger, # noqa: F821
) -> float:
await asyncio.sleep(3600)
@pytest.mark.parametrize(
"set_runtime_env_plugins",
[
'[{"class":"' + DUMMY_PLUGIN_CLASS_PATH + '"},'
'{"class":"' + HANG_PLUGIN_CLASS_PATH + '"}]',
],
indirect=True,
)
@pytest.mark.skipif(external_redis_test_enabled(), reason="Failing in redis mode.")
def test_plugin_timeout(set_runtime_env_plugins, start_cluster):
@ray.remote(num_cpus=0.1)
def f():
return True
refs = [
f.options(
runtime_env={
HANG_PLUGIN_NAME: {"name": "f1"},
"config": {"setup_timeout_seconds": 1},
}
).remote(),
f.options(runtime_env={DUMMY_PLUGIN_NAME: {"name": "f2"}}).remote(),
f.options(
runtime_env={
HANG_PLUGIN_NAME: {"name": "f3"},
"config": {"setup_timeout_seconds": -1},
}
).remote(),
]
def condition():
good_fun_num = 0
bad_fun_num = 0
for ref in refs:
try:
res = ray.get(ref, timeout=1)
print("result:", res)
if res:
good_fun_num += 1
return True
except RuntimeEnvSetupError:
bad_fun_num += 1
return bad_fun_num == 1 and good_fun_num == 2
wait_for_condition(condition, timeout=60)
FAULT_PLUGIN_CLASS_PATH = "ray.tests.test_runtime_env_plugin.FaultPlugin"
FAULT_PLUGIN_NAME = "FaultPlugin"
FAULT_PLUGIN_KEY = "FAULT_PLUGIN_KEY"
class FaultPlugin(DummyPlugin):
name = FAULT_PLUGIN_NAME
async def create(
self,
uri: str,
runtime_env: "RuntimeEnv",
ctx: RuntimeEnvContext,
logger: logging.Logger, # noqa: F821
) -> float:
action = os.environ.get(FAULT_PLUGIN_KEY, "raise")
if action == "raise":
raise RuntimeError(
"Ever tried. Ever failed. No matter. Try again. Fail again. Fail "
"better. -- Waiting for Godot, Samuel Beckett"
)
elif action == "sleep":
await asyncio.sleep(3600)
elif action == "ok":
return
else:
raise ValueError(f"unknown action {action}")
@pytest.mark.parametrize(
"set_runtime_env_plugins",
[
'[{"class":"' + FAULT_PLUGIN_CLASS_PATH + '"}]',
],
indirect=True,
)
def test_task_fails_on_rt_env_failure(
set_runtime_env_plugins,
monkeypatch,
ray_start_cluster,
):
"""
Simulate runtime env failure on a node. The task should fail but the raylet should
not die. See https://github.com/ray-project/ray/pull/46991
"""
cluster = ray_start_cluster
cluster.add_node(num_cpus=0)
ray.init(address=cluster.address)
with monkeypatch.context() as m:
m.setenv(FAULT_PLUGIN_KEY, "raise")
fault_node = cluster.add_node(num_cpus=1)
@ray.remote(num_cpus=0.1, runtime_env={FAULT_PLUGIN_NAME: {}})
def s(a, b):
return a + b
two = ray.put(2)
three = ray.put(3)
mortal = s.remote(two, three)
immortal = s.options(max_retries=-1).remote(two, three)
with pytest.raises(RuntimeEnvSetupError) as e:
ray.get(mortal)
assert "Samuel Beckett" in str(e.value)
# Note: even immortal task will fail because a rt env failure cancels tasks.
with pytest.raises(RuntimeEnvSetupError) as e:
ray.get(immortal)
assert "Samuel Beckett" in str(e.value)
# Assert that the raylet is still alive.
for _ in range(5):
time.sleep(1)
assert (
fault_node.all_processes[ray_constants.PROCESS_TYPE_RAYLET][
0
].process.poll()
is None
)
@pytest.mark.parametrize(
"set_runtime_env_plugins",
[
'[{"class":"' + FAULT_PLUGIN_CLASS_PATH + '"}]',
],
indirect=True,
)
def test_actor_fails_on_rt_env_failure(
set_runtime_env_plugins,
monkeypatch,
ray_start_cluster,
):
"""
Simulate runtime env failure on a node. The actor should be dead, but the raylet
should not die. See https://github.com/ray-project/ray/pull/46991
"""
cluster = ray_start_cluster
cluster.add_node(num_cpus=0)
ray.init(address=cluster.address)
with monkeypatch.context() as m:
m.setenv(FAULT_PLUGIN_KEY, "raise")
fault_node = cluster.add_node(num_cpus=1)
@ray.remote(num_cpus=0.1, runtime_env={FAULT_PLUGIN_NAME: {}})
class Actor:
def __init__(self, a, b):
self.a = a
self.b = b
def ping(self):
return self.a + self.b
two = ray.put(2)
three = ray.put(3)
mortal = Actor.remote(two, three)
immortal = Actor.options(max_restarts=-1).remote(two, three)
with pytest.raises(RuntimeEnvSetupError) as e:
ray.get(mortal.ping.remote())
assert "Samuel Beckett" in str(e.value)
# Note: even immortal actor will die because a rt env failure cancels tasks.
with pytest.raises(RuntimeEnvSetupError) as e:
ray.get(immortal.ping.remote())
assert "Samuel Beckett" in str(e.value)
# Assert that the raylet is still alive.
for _ in range(5):
time.sleep(1)
assert (
fault_node.all_processes[ray_constants.PROCESS_TYPE_RAYLET][
0
].process.poll()
is None
)
PRIORITY_TEST_PLUGIN1_CLASS_PATH = (
"ray.tests.test_runtime_env_plugin.PriorityTestPlugin1"
)
PRIORITY_TEST_PLUGIN1_NAME = "PriorityTestPlugin1"
PRIORITY_TEST_PLUGIN2_CLASS_PATH = (
"ray.tests.test_runtime_env_plugin.PriorityTestPlugin2"
)
PRIORITY_TEST_PLUGIN2_NAME = "PriorityTestPlugin2"
PRIORITY_TEST_ENV_VAR_NAME = "PriorityTestEnv"
class PriorityTestPlugin1(RuntimeEnvPlugin):
name = PRIORITY_TEST_PLUGIN1_NAME
priority = 11
env_value = " world"
@staticmethod
def validate(runtime_env_dict: dict) -> str:
return None
def modify_context(
self,
uris: List[str],
plugin_config_dict: dict,
ctx: RuntimeEnvContext,
logger: logging.Logger,
) -> None:
if PRIORITY_TEST_ENV_VAR_NAME in ctx.env_vars:
ctx.env_vars[PRIORITY_TEST_ENV_VAR_NAME] += PriorityTestPlugin1.env_value
else:
ctx.env_vars[PRIORITY_TEST_ENV_VAR_NAME] = PriorityTestPlugin1.env_value
class PriorityTestPlugin2(RuntimeEnvPlugin):
name = PRIORITY_TEST_PLUGIN2_NAME
priority = 10
env_value = "hello"
@staticmethod
def validate(runtime_env_dict: dict) -> str:
return None
def modify_context(
self,
uris: List[str],
plugin_config_dict: dict,
ctx: RuntimeEnvContext,
logger: logging.Logger,
) -> None:
if PRIORITY_TEST_ENV_VAR_NAME in ctx.env_vars:
raise RuntimeError(
f"Env var {PRIORITY_TEST_ENV_VAR_NAME} has been set to "
f"{ctx.env_vars[PRIORITY_TEST_ENV_VAR_NAME]}."
)
ctx.env_vars[PRIORITY_TEST_ENV_VAR_NAME] = PriorityTestPlugin2.env_value
priority_test_plugin_config_without_priority = [
{
"class": PRIORITY_TEST_PLUGIN1_CLASS_PATH,
},
{
"class": PRIORITY_TEST_PLUGIN2_CLASS_PATH,
},
]
priority_test_plugin_config = [
{
"class": PRIORITY_TEST_PLUGIN1_CLASS_PATH,
"priority": 1,
},
{
"class": PRIORITY_TEST_PLUGIN2_CLASS_PATH,
"priority": 0,
},
]
priority_test_plugin_bad_config = [
{
"class": PRIORITY_TEST_PLUGIN1_CLASS_PATH,
"priority": 0,
# Only used to distinguish the bad config in test body.
"tag": "bad",
},
{
"class": PRIORITY_TEST_PLUGIN2_CLASS_PATH,
"priority": 1,
},
]
@pytest.mark.parametrize(
"set_runtime_env_plugins",
[
json.dumps(priority_test_plugin_config_without_priority),
json.dumps(priority_test_plugin_config),
json.dumps(priority_test_plugin_bad_config),
],
indirect=True,
)
def test_plugin_priority(set_runtime_env_plugins, ray_start_regular):
config = set_runtime_env_plugins
_, tmp_file_path = tempfile.mkstemp()
@ray.remote
def f():
import os
return os.environ.get(PRIORITY_TEST_ENV_VAR_NAME)
if "bad" in config:
with pytest.raises(RuntimeEnvSetupError, match="has been set"):
value = ray.get(
f.options(
runtime_env={
PRIORITY_TEST_PLUGIN1_NAME: {},
PRIORITY_TEST_PLUGIN2_NAME: {},
}
).remote()
)
else:
value = ray.get(
f.options(
runtime_env={
PRIORITY_TEST_PLUGIN1_NAME: {},
PRIORITY_TEST_PLUGIN2_NAME: {},
}
).remote()
)
assert value is not None
assert value == "hello world"
def test_unexpected_field_warning(shutdown_only):
"""Test that an unexpected runtime_env field doesn't error."""
ray.init(runtime_env={"unexpected_field": "value"})
@ray.remote
def f():
return True
# Run a task to trigger runtime_env creation.
assert ray.get(f.remote())
# Check that the warning is logged.
session_dir = ray._private.worker.global_worker.node.address_info["session_dir"]
log_path = Path(session_dir) / "logs"
# Check that a warning appears in some "runtime_env_setup*.log"
wait_for_condition(
lambda: any(
"unexpected_field is not recognized" in open(f).read()
for f in log_path.glob("runtime_env_setup*.log")
)
)
URI_CACHING_TEST_PLUGIN_CLASS_PATH = (
"ray.tests.test_runtime_env_plugin.UriCachingTestPlugin"
)
URI_CACHING_TEST_PLUGIN_NAME = "UriCachingTestPlugin"
URI_CACHING_TEST_DIR = Path(tempfile.gettempdir()) / "runtime_env_uri_caching_test"
uri_caching_test_file_path = URI_CACHING_TEST_DIR / "uri_caching_test_file.json"
URI_CACHING_TEST_DIR.mkdir(parents=True, exist_ok=True)
uri_caching_test_file_path.write_text("{}")
def get_plugin_usage_data():
with open(uri_caching_test_file_path, "r") as f:
data = json.loads(f.read())
return data
class UriCachingTestPlugin(RuntimeEnvPlugin):
"""A plugin that fakes taking up local disk space when creating its environment.
This plugin is used to test that the URI caching is working correctly.
Example:
runtime_env = {"UriCachingTestPlugin": {"uri": "file:///a", "size_bytes": 10}}
"""
name = URI_CACHING_TEST_PLUGIN_NAME
def __init__(self):
# Keeps track of the "disk space" each URI takes up for the
# UriCachingTestPlugin.
self.uris_to_sizes = {}
self.modify_context_call_count = 0
self.create_call_count = 0
def write_plugin_usage_data(self) -> None:
with open(uri_caching_test_file_path, "w") as f:
data = {
"uris_to_sizes": self.uris_to_sizes,
"modify_context_call_count": self.modify_context_call_count,
"create_call_count": self.create_call_count,
}
f.write(json.dumps(data))
def get_uris(self, runtime_env: "RuntimeEnv") -> List[str]: # noqa: F811
return [runtime_env[self.name]["uri"]]
async def create(
self,
uri,
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: logging.Logger,
) -> float:
self.create_call_count += 1
created_size_bytes = runtime_env[self.name]["size_bytes"]
self.uris_to_sizes[uri] = created_size_bytes
self.write_plugin_usage_data()
return created_size_bytes
def modify_context(
self,
uris: List[str],
runtime_env: "RuntimeEnv",
context: RuntimeEnvContext,
logger: logging.Logger,
) -> None:
self.modify_context_call_count += 1
self.write_plugin_usage_data()
def delete_uri(self, uri: str, logger: logging.Logger) -> int:
size = self.uris_to_sizes.pop(uri)
self.write_plugin_usage_data()
return size
# 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 uri_cache_size_100_gb():
var = f"RAY_RUNTIME_ENV_{URI_CACHING_TEST_PLUGIN_NAME}_CACHE_SIZE_GB".upper()
with mock.patch.dict(
os.environ,
{
var: "100",
},
):
print("Set URI cache size for UriCachingTestPlugin to 100 GB")
yield
def gb_to_bytes(size_gb: int) -> int:
return size_gb * 1024 * 1024 * 1024
class TestGC:
@pytest.mark.parametrize(
"set_runtime_env_plugins",
[
json.dumps([{"class": URI_CACHING_TEST_PLUGIN_CLASS_PATH}]),
],
indirect=True,
)
def test_uri_caching(
self, set_runtime_env_plugins, start_cluster, uri_cache_size_100_gb
):
cluster, address = start_cluster
ray.init(address=address)
def reinit():
ray.shutdown()
# TODO(architkulkarni): Currently, reinit the driver will generate the same
# job id. And if we reinit immediately after shutdown, raylet may
# process new job started before old job finished in some cases. This
# inconsistency could disorder the URI reference and delete a valid
# runtime env. We sleep here to walk around this issue.
time.sleep(5)
ray.init(address=address)
@ray.remote
def f():
return True
# Run a task to trigger runtime_env creation.
ref1 = f.options(
runtime_env={
URI_CACHING_TEST_PLUGIN_NAME: {
"uri": "file:///tmp/test_uri_1",
"size_bytes": gb_to_bytes(50),
}
}
).remote()
ray.get(ref1)
# Check that the URI was "created on disk".
print(get_plugin_usage_data())
wait_for_condition(
lambda: get_plugin_usage_data()
== {
"uris_to_sizes": {"file:///tmp/test_uri_1": gb_to_bytes(50)},
"modify_context_call_count": 1,
"create_call_count": 1,
}
)
# Shutdown ray to stop the worker and remove the runtime_env reference.
reinit()
# Run a task with a different runtime env.
ref2 = f.options(
runtime_env={
URI_CACHING_TEST_PLUGIN_NAME: {
"uri": "file:///tmp/test_uri_2",
"size_bytes": gb_to_bytes(51),
}
}
).remote()
ray.get(ref2)
# This should delete the old URI and create a new one, because 50 + 51 > 100
# and the cache size limit is 100.
wait_for_condition(
lambda: get_plugin_usage_data()
== {
"uris_to_sizes": {"file:///tmp/test_uri_2": gb_to_bytes(51)},
"modify_context_call_count": 2,
"create_call_count": 2,
}
)
reinit()
# Run a task with the cached runtime env, to check that the runtime env is not
# created anew.
ref3 = f.options(
runtime_env={
URI_CACHING_TEST_PLUGIN_NAME: {
"uri": "file:///tmp/test_uri_2",
"size_bytes": gb_to_bytes(51),
}
}
).remote()
ray.get(ref3)
# modify_context should still be called even if create() is not called.
# Example: for a "conda" plugin, even if the conda env is already created
# and cached, we still need to call modify_context to add "conda activate" to
# the RuntimeEnvContext.command_prefix for the worker.
wait_for_condition(
lambda: get_plugin_usage_data()
== {
"uris_to_sizes": {"file:///tmp/test_uri_2": gb_to_bytes(51)},
"modify_context_call_count": 3,
"create_call_count": 2,
}
)
reinit()
# Run a task with a new runtime env
ref4 = f.options(
runtime_env={
URI_CACHING_TEST_PLUGIN_NAME: {
"uri": "file:///tmp/test_uri_3",
"size_bytes": gb_to_bytes(10),
}
}
).remote()
ray.get(ref4)
# The last two URIs should still be present in the cache, because 51 + 10 < 100.
wait_for_condition(
lambda: get_plugin_usage_data()
== {
"uris_to_sizes": {
"file:///tmp/test_uri_2": gb_to_bytes(51),
"file:///tmp/test_uri_3": gb_to_bytes(10),
},
"modify_context_call_count": 4,
"create_call_count": 3,
}
)
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))