Files
ray-project--ray/python/ray/dashboard/modules/job/tests/test_sdk.py
T
2026-07-13 13:17:40 +08:00

436 lines
16 KiB
Python

import os
import sys
import tempfile
import time
from pathlib import Path
from typing import Dict, Optional, Tuple
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import aiohttp
import pytest
import ray.experimental.internal_kv as kv
from ray._common.test_utils import wait_for_condition
from ray._private import worker
from ray._private.ray_constants import (
KV_NAMESPACE_DASHBOARD,
PROCESS_TYPE_DASHBOARD,
)
from ray._private.test_utils import (
format_web_url,
wait_until_server_available,
)
from ray._raylet import GcsClient
from ray.dashboard.consts import (
DASHBOARD_AGENT_ADDR_NODE_ID_PREFIX,
GCS_RPC_TIMEOUT_SECONDS,
RAY_JOB_ALLOW_DRIVER_ON_WORKER_NODES_ENV_VAR,
)
from ray.dashboard.modules.dashboard_sdk import (
DEFAULT_DASHBOARD_ADDRESS,
ClusterInfo,
parse_cluster_info,
)
from ray.dashboard.modules.job.pydantic_models import JobType
from ray.dashboard.modules.job.sdk import JobStatus, JobSubmissionClient
from ray.dashboard.tests.conftest import * # noqa
from ray.runtime_env.runtime_env import RuntimeEnv
from ray.tests.conftest import _ray_start
from ray.util.state import list_nodes
import psutil
def _check_job_succeeded(client: JobSubmissionClient, job_id: str) -> bool:
status = client.get_job_status(job_id)
if status == JobStatus.FAILED:
logs = client.get_job_logs(job_id)
raise RuntimeError(f"Job failed\nlogs:\n{logs}")
return status == JobStatus.SUCCEEDED
def check_internal_kv_gced():
return len(kv._internal_kv_list("gcs://")) == 0
@pytest.mark.parametrize(
"address_param",
[
("ray://1.2.3.4:10001", "ray", "1.2.3.4:10001"),
("other_module://", "other_module", ""),
("other_module://address", "other_module", "address"),
],
)
@pytest.mark.parametrize("create_cluster_if_needed", [True, False])
@pytest.mark.parametrize("cookies", [None, {"test_cookie_key": "test_cookie_val"}])
@pytest.mark.parametrize("metadata", [None, {"test_metadata_key": "test_metadata_val"}])
@pytest.mark.parametrize("headers", [None, {"test_headers_key": "test_headers_val"}])
@pytest.mark.parametrize("extra_kwargs", [{}, {"cloud": "my-cloud"}])
def test_parse_cluster_info(
address_param: Tuple[str, str, str],
create_cluster_if_needed: bool,
cookies: Optional[Dict[str, str]],
metadata: Optional[Dict[str, str]],
headers: Optional[Dict[str, str]],
extra_kwargs: Dict[str, str],
):
"""
Test ray.dashboard.modules.dashboard_sdk.parse_cluster_info for different
format of addresses.
"""
mock_get_job_submission_client_cluster = Mock(return_value="Ray ClusterInfo")
mock_module = Mock()
mock_module.get_job_submission_client_cluster_info = Mock(
return_value="Other module ClusterInfo"
)
mock_import_module = Mock(return_value=mock_module)
address, module_string, inner_address = address_param
with (
patch.multiple(
"ray.dashboard.modules.dashboard_sdk",
get_job_submission_client_cluster_info=mock_get_job_submission_client_cluster,
),
patch.multiple("importlib", import_module=mock_import_module),
):
if module_string == "ray":
with pytest.raises(ValueError, match="ray://"):
parse_cluster_info(
address,
create_cluster_if_needed=create_cluster_if_needed,
cookies=cookies,
metadata=metadata,
headers=headers,
**extra_kwargs,
)
elif module_string == "other_module":
assert (
parse_cluster_info(
address,
create_cluster_if_needed=create_cluster_if_needed,
cookies=cookies,
metadata=metadata,
headers=headers,
**extra_kwargs,
)
== "Other module ClusterInfo"
)
mock_import_module.assert_called_once_with(module_string)
mock_module.get_job_submission_client_cluster_info.assert_called_once_with(
inner_address,
create_cluster_if_needed=create_cluster_if_needed,
cookies=cookies,
metadata=metadata,
headers=headers,
**extra_kwargs,
)
def test_parse_cluster_info_default_address():
assert parse_cluster_info(
address=None,
) == ClusterInfo(address=DEFAULT_DASHBOARD_ADDRESS)
def test_submit_job_does_not_mutate_runtime_env():
class TestClient(JobSubmissionClient):
def __init__(self):
self._default_metadata = {}
def _upload_working_dir_if_needed(self, runtime_env):
runtime_env["working_dir"] = "gcs://test.zip"
def _upload_py_modules_if_needed(self, runtime_env):
runtime_env["py_modules"] = ["gcs://test_module.zip"]
def _do_request(self, method, endpoint, **kwargs):
return MagicMock(
status_code=200,
json=lambda: {"job_id": "test_job", "submission_id": "test_job"},
)
runtime_env = {"working_dir": "/tmp/test", "py_modules": ["/tmp/test_module"]}
original_runtime_env = {
"working_dir": runtime_env["working_dir"],
"py_modules": list(runtime_env["py_modules"]),
}
assert (
TestClient().submit_job(entrypoint="echo hi", runtime_env=runtime_env)
== "test_job"
)
assert runtime_env == original_runtime_env
@pytest.mark.parametrize("expiration_s", [0, 10])
def test_temporary_uri_reference(monkeypatch, expiration_s):
"""Test that temporary GCS URI references are deleted after expiration_s."""
monkeypatch.setenv(
"RAY_RUNTIME_ENV_TEMPORARY_REFERENCE_EXPIRATION_S", str(expiration_s)
)
# We can't use a fixture with a shared Ray runtime because we need to set the
# expiration_s env var before Ray starts.
with _ray_start(include_dashboard=True, num_cpus=1) as ctx:
headers = {"Connection": "keep-alive", "Authorization": "TOK:<MY_TOKEN>"}
address = ctx.address_info["webui_url"]
assert wait_until_server_available(address)
client = JobSubmissionClient(format_web_url(address), headers=headers)
with tempfile.TemporaryDirectory() as tmp_dir:
path = Path(tmp_dir)
hello_file = path / "hi.txt"
with hello_file.open(mode="w") as f:
f.write("hi\n")
start = time.time()
runtime_env = {"working_dir": tmp_dir}
job_id = client.submit_job(entrypoint="echo hi", runtime_env=runtime_env)
assert runtime_env == {"working_dir": tmp_dir}
wait_for_condition(
_check_job_succeeded, client=client, job_id=job_id, timeout=30
)
# Give time for deletion to occur if expiration_s is 0.
time.sleep(2)
# Need to connect to Ray to check internal_kv.
# ray.init(address="auto")
print("Starting Internal KV checks at time ", time.time() - start)
if expiration_s > 0:
assert not check_internal_kv_gced()
wait_for_condition(check_internal_kv_gced, timeout=2 * expiration_s)
assert expiration_s < time.time() - start < 2 * expiration_s
print("Internal KV was GC'ed at time ", time.time() - start)
else:
wait_for_condition(check_internal_kv_gced)
print("Internal KV was GC'ed at time ", time.time() - start)
# Regression test for #46625: reusing the same runtime_env after
# the package has been GC'ed should re-upload the local working_dir.
job_id = client.submit_job(
entrypoint="echo hi", runtime_env=runtime_env
)
wait_for_condition(
_check_job_succeeded, client=client, job_id=job_id, timeout=30
)
def get_register_agents_number(gcs_client):
keys = gcs_client.internal_kv_keys(
prefix=DASHBOARD_AGENT_ADDR_NODE_ID_PREFIX,
namespace=KV_NAMESPACE_DASHBOARD,
timeout=GCS_RPC_TIMEOUT_SECONDS,
)
return len(keys)
@pytest.mark.parametrize(
"ray_start_cluster_head_with_env_vars",
[
{
"include_dashboard": True,
"env_vars": {RAY_JOB_ALLOW_DRIVER_ON_WORKER_NODES_ENV_VAR: "1"},
},
{
"include_dashboard": True,
"env_vars": {RAY_JOB_ALLOW_DRIVER_ON_WORKER_NODES_ENV_VAR: "0"},
},
],
indirect=True,
)
def test_jobs_run_on_head_by_default_E2E(ray_start_cluster_head_with_env_vars):
allow_driver_on_worker_nodes = (
os.environ.get(RAY_JOB_ALLOW_DRIVER_ON_WORKER_NODES_ENV_VAR) == "1"
)
# Cluster setup
cluster = ray_start_cluster_head_with_env_vars
cluster.add_node(dashboard_agent_listen_port=52366)
cluster.add_node(dashboard_agent_listen_port=52367)
assert wait_until_server_available(cluster.webui_url) is True
webui_url = cluster.webui_url
webui_url = format_web_url(webui_url)
client = JobSubmissionClient(webui_url)
gcs_client = GcsClient(address=cluster.gcs_address)
def _check_nodes(num_nodes):
try:
assert len(list_nodes()) == num_nodes
return True
except Exception as ex:
print(ex)
return False
wait_for_condition(lambda: _check_nodes(num_nodes=3), timeout=15)
wait_for_condition(lambda: get_register_agents_number(gcs_client) == 3, timeout=20)
# Submit 20 simple jobs.
for i in range(20):
client.submit_job(entrypoint="echo hi", submission_id=f"job_{i}")
import pprint
def check_all_jobs_succeeded():
submission_jobs = [
job for job in client.list_jobs() if job.type == JobType.SUBMISSION
]
for job in submission_jobs:
pprint.pprint(job)
if job.status != JobStatus.SUCCEEDED:
return False
return True
# Wait until all jobs have finished.
wait_for_condition(check_all_jobs_succeeded, timeout=60, retry_interval_ms=1000)
# Check driver_node_id of all jobs.
submission_jobs = [
job for job in client.list_jobs() if job.type == JobType.SUBMISSION
]
driver_node_ids = [job.driver_node_id for job in submission_jobs]
# Spuriously fails with probability (1/3)^20.
pprint.pprint(driver_node_ids)
num_ids = len(set(driver_node_ids))
assert (num_ids > 1) if allow_driver_on_worker_nodes else (num_ids == 1), [
id[:5] for id in driver_node_ids
]
@pytest.fixture
def runtime_env_working_dir():
with tempfile.TemporaryDirectory() as tmp_dir:
path = Path(tmp_dir)
working_dir = path / "working_dir"
working_dir.mkdir(parents=True)
yield working_dir
@pytest.fixture
def py_module_whl():
with tempfile.NamedTemporaryFile(suffix=".whl") as tmp_file:
yield tmp_file.name
def test_job_submission_with_runtime_env_as_dict(
runtime_env_working_dir, py_module_whl
):
working_dir_str = str(runtime_env_working_dir)
with _ray_start(num_cpus=1):
client = JobSubmissionClient()
runtime_env = {"working_dir": working_dir_str, "py_modules": [py_module_whl]}
job_id = client.submit_job(entrypoint="echo hi", runtime_env=runtime_env)
job_details = client.get_job_info(job_id)
parsed_runtime_env = job_details.runtime_env
assert "gcs://" in parsed_runtime_env["working_dir"]
assert len(parsed_runtime_env["py_modules"]) == 1
assert "gcs://" in parsed_runtime_env["py_modules"][0]
def test_job_submission_with_runtime_env_as_object(
runtime_env_working_dir, py_module_whl
):
working_dir_str = str(runtime_env_working_dir)
with _ray_start(num_cpus=1):
client = JobSubmissionClient()
runtime_env = RuntimeEnv(
working_dir=working_dir_str, py_modules=[py_module_whl]
)
job_id = client.submit_job(entrypoint="echo hi", runtime_env=runtime_env)
job_details = client.get_job_info(job_id)
parsed_runtime_env = job_details.runtime_env
assert "gcs://" in parsed_runtime_env["working_dir"]
assert len(parsed_runtime_env["py_modules"]) == 1
assert "gcs://" in parsed_runtime_env["py_modules"][0]
@pytest.mark.asyncio
async def test_tail_job_logs_passes_headers_to_websocket(ray_start_regular):
"""
Test that authentication headers are passed to WebSocket connections.
This test verifies that headers provided to JobSubmissionClient are
explicitly passed to the ws_connect() method, not just to the ClientSession.
This is required because aiohttp's ClientSession does not automatically
include session headers in WebSocket upgrade requests.
"""
dashboard_url = ray_start_regular.dashboard_url
test_headers = {"Authorization": "Bearer test-token"}
client = JobSubmissionClient(format_web_url(dashboard_url), headers=test_headers)
# Submit a simple job
job_id = client.submit_job(entrypoint="echo hello")
# Mock the aiohttp ClientSession and WebSocket
mock_ws = AsyncMock()
mock_ws.receive = AsyncMock()
mock_ws.receive.side_effect = [
# First call returns a text message
MagicMock(type=aiohttp.WSMsgType.TEXT, data="test log line\n"),
# Second call indicates WebSocket is closed
MagicMock(type=aiohttp.WSMsgType.CLOSED),
]
mock_ws.close_code = 1000 # Normal closure
mock_session = AsyncMock()
mock_session.ws_connect = AsyncMock(return_value=mock_ws)
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
# Patch ClientSession to use our mock
with patch("aiohttp.ClientSession", return_value=mock_session):
# Tail logs
log_lines = []
async for lines in client.tail_job_logs(job_id):
log_lines.append(lines)
# Verify ws_connect was called with headers
mock_session.ws_connect.assert_called_once()
call_args = mock_session.ws_connect.call_args
assert "headers" in call_args.kwargs
assert call_args.kwargs["headers"] == test_headers
@pytest.mark.asyncio
async def test_tail_job_logs_websocket_abnormal_closure(ray_start_regular):
"""
Test that ABNORMAL_CLOSURE raises RuntimeError when tailing logs.
This test uses its own Ray cluster and kills the dashboard while tailing logs
to simulate an abnormal WebSocket closure.
"""
dashboard_url = ray_start_regular.dashboard_url
client = JobSubmissionClient(format_web_url(dashboard_url))
# Submit a long-running job
driver_script = """
import time
for i in range(100):
print("Hello", i)
time.sleep(0.5)
"""
entrypoint = f"python -c '{driver_script}'"
job_id = client.submit_job(entrypoint=entrypoint)
# Start tailing logs and stop Ray while tailing
# Expect RuntimeError when WebSocket closes abnormally
with pytest.raises(
RuntimeError,
match="WebSocket connection closed unexpectedly with close code",
):
i = 0
async for lines in client.tail_job_logs(job_id):
print(lines, end="")
i += 1
# Kill the dashboard after receiving a few log lines
if i == 3:
print("\nKilling the dashboard to close websocket abnormally...")
dash_info = worker._global_node.all_processes[PROCESS_TYPE_DASHBOARD][0]
psutil.Process(dash_info.process.pid).kill()
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))